@musnows/scriverse 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.js +59 -0
- package/dist/ai.js.map +1 -1
- package/dist/app.js +253 -100
- 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/http-logging.js +2 -2
- package/dist/http-logging.js.map +1 -1
- package/dist/logger.js +2 -2
- package/dist/logger.js.map +1 -1
- package/dist/pagination.js +42 -0
- package/dist/pagination.js.map +1 -0
- package/dist/public/app.js +483 -115
- package/dist/public/index.html +61 -9
- package/dist/public/markdown.js +35 -1
- package/dist/public/page-route.js +12 -0
- package/dist/public/styles.css +98 -22
- package/dist/security.js +41 -4
- package/dist/security.js.map +1 -1
- package/dist/store.js +720 -109
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +75 -11
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/public/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260721-release-0.3.6";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
|
-
import { renderMarkdown } from "/markdown.js?v=
|
|
3
|
+
import { renderMarkdown } from "/markdown.js?v=20260722-inline-code";
|
|
4
4
|
import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
|
|
5
5
|
import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
|
|
6
6
|
import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
@@ -14,7 +14,7 @@ import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "
|
|
|
14
14
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
15
15
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
|
|
16
16
|
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260714-all-knowledge-history";
|
|
17
|
-
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=
|
|
17
|
+
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260722-entity-editor-page";
|
|
18
18
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
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";
|
|
@@ -81,18 +81,36 @@ function analysisTaskStatusLabel(status) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function canEditWork(work = state.work) {
|
|
84
|
+
return ["admin", "owner", "editor", "settings-editor"].includes(String(work?.accessRole));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function canEditProse(work = state.work) {
|
|
84
88
|
return ["admin", "owner", "editor"].includes(String(work?.accessRole));
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
function canManageWork(work = state.work) {
|
|
92
|
+
return ["admin", "owner"].includes(String(work?.accessRole));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function canEditModule(module, work = state.work) {
|
|
96
|
+
if (canEditProse(work)) return true;
|
|
97
|
+
return canEditWork(work) && ["settings", "characters", "races", "organizations", "timeline", "outlines", "relationships"].includes(module);
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
function applyWorkAccessMode() {
|
|
88
101
|
const viewOnly = Boolean(state.work) && !canEditWork();
|
|
102
|
+
const settingsOnly = String(state.work?.accessRole) === "settings-editor";
|
|
103
|
+
const proseReadOnly = Boolean(state.work) && !canEditProse();
|
|
89
104
|
$("#app").classList.toggle("view-only-mode", viewOnly);
|
|
105
|
+
$("#app").classList.toggle("settings-only-mode", settingsOnly);
|
|
106
|
+
$("#app").classList.toggle("prose-read-only-mode", proseReadOnly);
|
|
90
107
|
document.body.classList.toggle("work-viewer-mode", viewOnly);
|
|
91
|
-
|
|
92
|
-
$("#chapter-
|
|
93
|
-
$("#chapter-
|
|
94
|
-
$("#chapter-
|
|
95
|
-
|
|
108
|
+
document.body.classList.toggle("work-settings-editor-mode", settingsOnly);
|
|
109
|
+
$("#chapter-title").readOnly = proseReadOnly;
|
|
110
|
+
$("#chapter-content").readOnly = proseReadOnly;
|
|
111
|
+
$("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
|
|
112
|
+
$("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
|
|
113
|
+
if (proseReadOnly) {
|
|
96
114
|
cancelChapterAutoSave();
|
|
97
115
|
state.dirty = false;
|
|
98
116
|
}
|
|
@@ -352,6 +370,10 @@ function replacePageRoute(route) {
|
|
|
352
370
|
|
|
353
371
|
function currentPageRoute() {
|
|
354
372
|
const workId = state.work?.id ?? null;
|
|
373
|
+
if (!$("#entity-editor-view").classList.contains("hidden") && workId && entityEditorType) {
|
|
374
|
+
const entityId = entityEditorType === "setting" ? settingEditorItem?.id : characterEditorItem?.id;
|
|
375
|
+
return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null };
|
|
376
|
+
}
|
|
355
377
|
if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
|
|
356
378
|
if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
|
|
357
379
|
if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
|
|
@@ -461,6 +483,9 @@ const chapterAutoSaveDelay = 800;
|
|
|
461
483
|
let aiMentionMatch = null;
|
|
462
484
|
let aiMentionRange = null;
|
|
463
485
|
let settingsReturnContext = null;
|
|
486
|
+
let entityEditorType = null;
|
|
487
|
+
let entityEditorDirty = false;
|
|
488
|
+
let settingEditorItem = null;
|
|
464
489
|
let characterEditorItem = null;
|
|
465
490
|
let characterEditorVersions = [];
|
|
466
491
|
let characterEditorRelationships = [];
|
|
@@ -468,8 +493,49 @@ let characterEditorRelationshipsLoading = false;
|
|
|
468
493
|
let characterEditorSections = [];
|
|
469
494
|
let characterSectionPreviewTimer = null;
|
|
470
495
|
let characterSectionPendingAttachments = [];
|
|
496
|
+
let characterSectionEditorDirty = false;
|
|
471
497
|
let entityHistoryContext = null;
|
|
472
498
|
|
|
499
|
+
function showEntityEditorPage(type) {
|
|
500
|
+
entityEditorType = type;
|
|
501
|
+
entityEditorDirty = false;
|
|
502
|
+
characterSectionEditorDirty = false;
|
|
503
|
+
$("#entity-editor-view").classList.remove("hidden");
|
|
504
|
+
$("#setting-editor-form").classList.toggle("hidden", type !== "setting");
|
|
505
|
+
$("#character-editor-form").classList.toggle("hidden", type !== "character");
|
|
506
|
+
$("#character-section-editor-view").classList.add("hidden");
|
|
507
|
+
$("#app").inert = true;
|
|
508
|
+
document.body.classList.add("entity-editor-open");
|
|
509
|
+
replacePageRoute(currentPageRoute());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function markEntityEditorDirty() {
|
|
513
|
+
if (entityEditorType && canEditWork()) entityEditorDirty = true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function confirmEntityEditorDiscard(message) {
|
|
517
|
+
if (!entityEditorDirty) return true;
|
|
518
|
+
return window.confirm(message ?? "当前资料有未保存修改,返回列表将丢弃这些修改。是否继续?");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function closeEntityEditor({ force = false } = {}) {
|
|
522
|
+
if (!$("#character-section-editor-view").classList.contains("hidden")) return closeCharacterSectionEditor({ force });
|
|
523
|
+
if (!force && !confirmEntityEditorDiscard()) return false;
|
|
524
|
+
await discardPendingCharacterAttachments();
|
|
525
|
+
const module = entityEditorType === "setting" ? "settings" : "characters";
|
|
526
|
+
entityEditorType = null;
|
|
527
|
+
entityEditorDirty = false;
|
|
528
|
+
settingEditorItem = null;
|
|
529
|
+
characterEditorItem = null;
|
|
530
|
+
$("#entity-editor-view").classList.add("hidden");
|
|
531
|
+
$("#setting-editor-form").classList.add("hidden");
|
|
532
|
+
$("#character-editor-form").classList.add("hidden");
|
|
533
|
+
$("#app").inert = false;
|
|
534
|
+
document.body.classList.remove("entity-editor-open");
|
|
535
|
+
await showModule(module);
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
473
539
|
function setModuleNavExpanded(expanded) {
|
|
474
540
|
moduleNavExpanded = expanded;
|
|
475
541
|
$("#module-more-button .nav-label").textContent = expanded ? "收起" : "更多";
|
|
@@ -1016,7 +1082,7 @@ function renderAiConversationHistory() {
|
|
|
1016
1082
|
async function loadAiConversations(openLatest = true) {
|
|
1017
1083
|
const workId = state.work?.id;
|
|
1018
1084
|
if (!workId) return;
|
|
1019
|
-
const conversations = await
|
|
1085
|
+
const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
|
|
1020
1086
|
if (state.work?.id !== workId) return;
|
|
1021
1087
|
state.aiConversations = conversations;
|
|
1022
1088
|
loadedAiConversationsWorkId = workId;
|
|
@@ -1045,7 +1111,7 @@ async function ensureAiConversationsLoaded() {
|
|
|
1045
1111
|
}
|
|
1046
1112
|
|
|
1047
1113
|
async function openAiConversation(conversationId, hideHistory = true) {
|
|
1048
|
-
const conversation = await api(`/api/ai-conversations/${conversationId}`);
|
|
1114
|
+
const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
|
|
1049
1115
|
state.aiConversationId = conversation.id;
|
|
1050
1116
|
state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
|
|
1051
1117
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
@@ -1376,15 +1442,75 @@ applyTypographySettings(typographySettings);
|
|
|
1376
1442
|
applyColorTheme(currentColorTheme());
|
|
1377
1443
|
applyPanelLayout();
|
|
1378
1444
|
|
|
1445
|
+
function optimisticVersionForPath(path) {
|
|
1446
|
+
const normalizedPath = String(path).split("?")[0];
|
|
1447
|
+
const find = (items, id) => items.find((item) => String(item?.id ?? item?.chapterId ?? "") === id)?.versionNo;
|
|
1448
|
+
const workMatch = normalizedPath.match(/^\/api\/works\/([^/]+)(?:\/(?:cover|import|file-versions\/[^/]+\/restore))?$/u);
|
|
1449
|
+
if (workMatch) {
|
|
1450
|
+
const workId = decodeURIComponent(workMatch[1]);
|
|
1451
|
+
return state.works.find((item) => item.id === workId)?.versionNo ?? (state.work?.id === workId ? state.work.versionNo : undefined);
|
|
1452
|
+
}
|
|
1453
|
+
const resourceMatch = normalizedPath.match(/^\/api\/(volumes|chapters|settings|races|organizations|timeline-tracks|timeline|relationships|foreshadows|characters|character-sections)\/([^/]+)(?:\/(?:restore|move|split))?$/u);
|
|
1454
|
+
if (resourceMatch) {
|
|
1455
|
+
const resourceId = decodeURIComponent(resourceMatch[2]);
|
|
1456
|
+
const collection = {
|
|
1457
|
+
settings: state.settings,
|
|
1458
|
+
races: state.races,
|
|
1459
|
+
organizations: state.organizations,
|
|
1460
|
+
"timeline-tracks": state.timelineTracks,
|
|
1461
|
+
characters: state.characters
|
|
1462
|
+
}[resourceMatch[1]] ?? [];
|
|
1463
|
+
if (resourceMatch[1] === "chapters" && state.chapter?.id === resourceId) return state.chapter.versionNo;
|
|
1464
|
+
if (resourceMatch[1] === "volumes") return find(state.work?.volumes ?? [], resourceId);
|
|
1465
|
+
if (resourceMatch[1] === "character-sections") return find(characterEditorSections, resourceId);
|
|
1466
|
+
if (resourceMatch[1] === "characters" && characterEditorItem?.id === resourceId) return characterEditorItem.versionNo;
|
|
1467
|
+
return find(collection, resourceId);
|
|
1468
|
+
}
|
|
1469
|
+
const outlineMatch = normalizedPath.match(/^\/api\/chapters\/([^/]+)\/outline$/u);
|
|
1470
|
+
if (outlineMatch) return find(state.outlines ?? [], decodeURIComponent(outlineMatch[1]));
|
|
1471
|
+
const entityRestoreMatch = normalizedPath.match(/^\/api\/entity-versions\/(work|volume|setting|race|organization|timeline-track|timeline-event|relationship|chapter-outline|foreshadow)\/([^/]+)\/restore$/u);
|
|
1472
|
+
if (entityRestoreMatch) {
|
|
1473
|
+
const entityType = entityRestoreMatch[1];
|
|
1474
|
+
const entityId = decodeURIComponent(entityRestoreMatch[2]);
|
|
1475
|
+
if (entityType === "work") return state.works.find((item) => item.id === entityId)?.versionNo ?? (state.work?.id === entityId ? state.work.versionNo : undefined);
|
|
1476
|
+
if (entityType === "volume") return find(state.work?.volumes ?? [], entityId);
|
|
1477
|
+
if (entityType === "chapter-outline") return find(state.outlines ?? [], entityId);
|
|
1478
|
+
const collection = {
|
|
1479
|
+
setting: state.settings,
|
|
1480
|
+
race: state.races,
|
|
1481
|
+
organization: state.organizations,
|
|
1482
|
+
"timeline-track": state.timelineTracks
|
|
1483
|
+
}[entityType] ?? [];
|
|
1484
|
+
return find(collection, entityId);
|
|
1485
|
+
}
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function attachOptimisticVersion(path, method, body) {
|
|
1490
|
+
if (!["PATCH", "PUT", "DELETE", "POST"].includes(method)) return body;
|
|
1491
|
+
if (body instanceof FormData) {
|
|
1492
|
+
if (!body.has("expectedVersionNo")) {
|
|
1493
|
+
const versionNo = optimisticVersionForPath(path);
|
|
1494
|
+
if (Number.isInteger(versionNo) && versionNo > 0) body.append("expectedVersionNo", String(versionNo));
|
|
1495
|
+
}
|
|
1496
|
+
return body;
|
|
1497
|
+
}
|
|
1498
|
+
const currentBody = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
1499
|
+
if (currentBody.expectedVersionNo !== undefined) return currentBody;
|
|
1500
|
+
const versionNo = optimisticVersionForPath(path);
|
|
1501
|
+
return Number.isInteger(versionNo) && versionNo > 0 ? { ...currentBody, expectedVersionNo: versionNo } : body;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1379
1504
|
async function api(path, options = {}) {
|
|
1380
1505
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
1506
|
+
const body = attachOptimisticVersion(path, method, options.body);
|
|
1381
1507
|
const headers = { ...(options.headers ?? {}) };
|
|
1382
1508
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
1383
|
-
if (!(
|
|
1384
|
-
const response = await fetch(path,
|
|
1509
|
+
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
1510
|
+
const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
|
|
1385
1511
|
...options,
|
|
1386
1512
|
headers,
|
|
1387
|
-
body:
|
|
1513
|
+
body: body && typeof body !== "string" ? JSON.stringify(body) : body
|
|
1388
1514
|
});
|
|
1389
1515
|
if (!response.ok) {
|
|
1390
1516
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
@@ -1396,6 +1522,24 @@ async function api(path, options = {}) {
|
|
|
1396
1522
|
return payload.data;
|
|
1397
1523
|
}
|
|
1398
1524
|
|
|
1525
|
+
async function apiPage(path, page = 1, limit = 50) {
|
|
1526
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
1527
|
+
const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
|
|
1528
|
+
if (Array.isArray(result)) return { items: result, page, limit, hasMore: false, nextPage: null };
|
|
1529
|
+
return result;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function apiAllPages(path, limit = 100) {
|
|
1533
|
+
const items = [];
|
|
1534
|
+
let page = 1;
|
|
1535
|
+
while (true) {
|
|
1536
|
+
const result = await apiPage(path, page, limit);
|
|
1537
|
+
items.push(...(result.items ?? []));
|
|
1538
|
+
if (!result.hasMore || !result.nextPage) return items;
|
|
1539
|
+
page = result.nextPage;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1399
1543
|
function selectAuthMode(mode) {
|
|
1400
1544
|
const registerTab = $("#auth-register-tab");
|
|
1401
1545
|
const login = mode === "login" || registerTab.disabled;
|
|
@@ -1550,7 +1694,7 @@ function cancelChapterAutoSave() {
|
|
|
1550
1694
|
}
|
|
1551
1695
|
|
|
1552
1696
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
1553
|
-
if (!state.chapter || !
|
|
1697
|
+
if (!state.chapter || !canEditProse()) return;
|
|
1554
1698
|
cancelChapterAutoSave();
|
|
1555
1699
|
setSaveState("等待自动保存", true);
|
|
1556
1700
|
chapterAutoSaveTimer = setTimeout(() => {
|
|
@@ -1560,7 +1704,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
|
1560
1704
|
}
|
|
1561
1705
|
|
|
1562
1706
|
async function persistChapter({ automatic = false } = {}) {
|
|
1563
|
-
if (!
|
|
1707
|
+
if (!canEditProse()) return null;
|
|
1564
1708
|
if (!state.chapter) {
|
|
1565
1709
|
if (!automatic) toast("请先选择章节", "error");
|
|
1566
1710
|
return null;
|
|
@@ -1628,13 +1772,26 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
|
|
|
1628
1772
|
return window.confirm(message);
|
|
1629
1773
|
}
|
|
1630
1774
|
|
|
1775
|
+
function chooseExistingWorkImportMode(file) {
|
|
1776
|
+
const dialog = $("#import-mode-dialog");
|
|
1777
|
+
$("#import-mode-file-summary").textContent = `文件:${file.name};当前作品:《${state.work.title}》`;
|
|
1778
|
+
$("#import-mode-unsaved-warning").classList.toggle("hidden", !state.dirty);
|
|
1779
|
+
dialog.returnValue = "cancel";
|
|
1780
|
+
dialog.showModal();
|
|
1781
|
+
return new Promise((resolve) => {
|
|
1782
|
+
dialog.addEventListener("close", () => {
|
|
1783
|
+
resolve(["append", "overwrite"].includes(dialog.returnValue) ? dialog.returnValue : null);
|
|
1784
|
+
}, { once: true });
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1631
1788
|
function updateDocumentTitle(work = null) {
|
|
1632
1789
|
const workTitle = String(work?.title ?? "").trim();
|
|
1633
1790
|
document.title = workTitle ? `${workTitle} · 叙界` : platformDocumentTitle;
|
|
1634
1791
|
}
|
|
1635
1792
|
|
|
1636
1793
|
async function loadWorks(preferredId) {
|
|
1637
|
-
state.works = await
|
|
1794
|
+
state.works = (await apiPage("/api/works")).items;
|
|
1638
1795
|
if (preferredId) {
|
|
1639
1796
|
await selectWork(preferredId);
|
|
1640
1797
|
return;
|
|
@@ -1658,7 +1815,7 @@ async function initializePage() {
|
|
|
1658
1815
|
return;
|
|
1659
1816
|
}
|
|
1660
1817
|
const route = parsePageRoute(window.location.hash);
|
|
1661
|
-
state.works = await
|
|
1818
|
+
state.works = (await apiPage("/api/works")).items;
|
|
1662
1819
|
try {
|
|
1663
1820
|
if (route.view === "shelf") {
|
|
1664
1821
|
showShelf();
|
|
@@ -1672,16 +1829,30 @@ async function initializePage() {
|
|
|
1672
1829
|
}
|
|
1673
1830
|
|
|
1674
1831
|
if (requestedWork) {
|
|
1675
|
-
state.module = route.view === "module"
|
|
1832
|
+
state.module = route.view === "module"
|
|
1833
|
+
? route.module
|
|
1834
|
+
: route.view === "entity-editor"
|
|
1835
|
+
? (route.entity === "setting" ? "settings" : "characters")
|
|
1836
|
+
: "editor";
|
|
1676
1837
|
await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
|
|
1677
1838
|
}
|
|
1678
1839
|
|
|
1679
1840
|
if (route.view === "editor") {
|
|
1680
|
-
|
|
1681
|
-
if (route.chapterId && chapterExists && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
|
|
1841
|
+
if (route.chapterId && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
|
|
1682
1842
|
return;
|
|
1683
1843
|
}
|
|
1684
1844
|
if (route.view === "module") return;
|
|
1845
|
+
if (route.view === "entity-editor") {
|
|
1846
|
+
const records = route.entity === "setting" ? state.settings : state.characters;
|
|
1847
|
+
const item = route.entityId ? records.find((record) => record.id === route.entityId) : null;
|
|
1848
|
+
if (route.entityId && !item) {
|
|
1849
|
+
toast(route.entity === "setting" ? "未找到要编辑的设定" : "未找到要编辑的角色", "error");
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
if (route.entity === "setting") openSettingEditor(item);
|
|
1853
|
+
else await openCharacterEditor(item);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1685
1856
|
if (route.view === "welcome") {
|
|
1686
1857
|
showWelcome(true);
|
|
1687
1858
|
return;
|
|
@@ -1784,7 +1955,7 @@ async function openUsersDialog() {
|
|
|
1784
1955
|
}
|
|
1785
1956
|
$("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
|
|
1786
1957
|
$("#users-dialog").showModal();
|
|
1787
|
-
try { renderUsers(await
|
|
1958
|
+
try { renderUsers((await apiPage("/api/users")).items); }
|
|
1788
1959
|
catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
|
|
1789
1960
|
}
|
|
1790
1961
|
|
|
@@ -1806,8 +1977,8 @@ function renderMembers(members) {
|
|
|
1806
1977
|
const work = memberDialogWork ?? state.work;
|
|
1807
1978
|
const canManage = ["admin", "owner"].includes(String(work?.accessRole));
|
|
1808
1979
|
$("#members-list").innerHTML = members.map((member) => `<article class="access-row">
|
|
1809
|
-
<div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small>${member.role === "owner" ? "作品创建者" : member.role === "viewer" ? "查看者" : "
|
|
1810
|
-
${member.role === "owner" ? "<span>所有者</span>" : `<select data-member-role="${esc(member.userId)}" aria-label="${esc(member.displayName)}的作品权限" ${canManage ? "" : "disabled"}><option value="viewer" ${member.role === "viewer" ? "selected" : ""}>仅查看</option><option value="editor" ${member.role === "editor" ? "selected" : ""}
|
|
1980
|
+
<div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small>${member.role === "owner" ? "作品创建者" : member.role === "viewer" ? "查看者" : member.role === "settings-editor" ? "设定编辑" : "完整协作者"}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
|
|
1981
|
+
${member.role === "owner" ? "<span>所有者</span>" : `<select data-member-role="${esc(member.userId)}" aria-label="${esc(member.displayName)}的作品权限" ${canManage ? "" : "disabled"}><option value="viewer" ${member.role === "viewer" ? "selected" : ""}>仅查看</option><option value="settings-editor" ${member.role === "settings-editor" ? "selected" : ""}>仅编辑设定</option><option value="editor" ${member.role === "editor" ? "selected" : ""}>编辑正文与设定</option></select>`}
|
|
1811
1982
|
${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
|
|
1812
1983
|
</article>`).join("");
|
|
1813
1984
|
bindUserAvatarFallbacks($("#members-list"));
|
|
@@ -1921,13 +2092,13 @@ async function openSearchResult(result) {
|
|
|
1921
2092
|
if (result.type === "character") {
|
|
1922
2093
|
await showModule("characters");
|
|
1923
2094
|
const character = state.characters.find((item) => item.id === result.id);
|
|
1924
|
-
if (character)
|
|
2095
|
+
if (character) openCharacterEditor(character);
|
|
1925
2096
|
return;
|
|
1926
2097
|
}
|
|
1927
2098
|
if (result.type === "setting") {
|
|
1928
2099
|
await showModule("settings");
|
|
1929
2100
|
const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
|
|
1930
|
-
|
|
2101
|
+
openSettingEditor(setting);
|
|
1931
2102
|
return;
|
|
1932
2103
|
}
|
|
1933
2104
|
if (result.type === "race") {
|
|
@@ -2010,9 +2181,9 @@ function renderShelf() {
|
|
|
2010
2181
|
<span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
|
|
2011
2182
|
${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
|
|
2012
2183
|
</span>
|
|
2013
|
-
<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 === "editor" ? "
|
|
2184
|
+
<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 === "admin" ? "管理员访问" : "我的作品"}</em></span>
|
|
2014
2185
|
</button>
|
|
2015
|
-
${
|
|
2186
|
+
${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
|
|
2016
2187
|
</article>`).join("")}
|
|
2017
2188
|
<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>`;
|
|
2018
2189
|
shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
|
|
@@ -2026,7 +2197,7 @@ function renderShelf() {
|
|
|
2026
2197
|
async function selectWork(workId, preferredChapterId = null) {
|
|
2027
2198
|
const discarding = state.work?.id !== workId && state.dirty;
|
|
2028
2199
|
if (discarding && !confirmDiscardChanges()) return false;
|
|
2029
|
-
const nextWork = await api(`/api/works/${workId}`);
|
|
2200
|
+
const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
|
|
2030
2201
|
if (state.work?.id !== nextWork.id) {
|
|
2031
2202
|
loadedAiModelsWorkId = null;
|
|
2032
2203
|
loadedAiReferencesWorkId = null;
|
|
@@ -2067,7 +2238,8 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
2067
2238
|
renderTree();
|
|
2068
2239
|
const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
|
|
2069
2240
|
const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
|
|
2070
|
-
if (state.module === "editor" &&
|
|
2241
|
+
if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
|
|
2242
|
+
else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
|
|
2071
2243
|
else if (state.module === "editor") showWelcome(true);
|
|
2072
2244
|
else await showModule(state.module);
|
|
2073
2245
|
return true;
|
|
@@ -2096,7 +2268,7 @@ function renderTree() {
|
|
|
2096
2268
|
renderTree();
|
|
2097
2269
|
});
|
|
2098
2270
|
button.addEventListener("contextmenu", (event) => {
|
|
2099
|
-
if (!
|
|
2271
|
+
if (!canEditProse()) return;
|
|
2100
2272
|
event.preventDefault();
|
|
2101
2273
|
openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
|
|
2102
2274
|
});
|
|
@@ -2104,7 +2276,7 @@ function renderTree() {
|
|
|
2104
2276
|
$("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
|
|
2105
2277
|
button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
|
|
2106
2278
|
button.addEventListener("contextmenu", (event) => {
|
|
2107
|
-
if (!
|
|
2279
|
+
if (!canEditProse()) return;
|
|
2108
2280
|
event.preventDefault();
|
|
2109
2281
|
openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
|
|
2110
2282
|
});
|
|
@@ -2154,7 +2326,7 @@ async function selectChapter(chapterId) {
|
|
|
2154
2326
|
scheduleChapterLineNumbers();
|
|
2155
2327
|
$("#chapter-insight").classList.add("hidden");
|
|
2156
2328
|
updateChapterStats();
|
|
2157
|
-
if (!
|
|
2329
|
+
if (!canEditProse()) setSaveState(canEditWork() ? "正文只读" : "仅查看");
|
|
2158
2330
|
else if (spacingChanged) scheduleChapterAutoSave(120);
|
|
2159
2331
|
else setSaveState("已保存");
|
|
2160
2332
|
renderTree();
|
|
@@ -2212,7 +2384,7 @@ const moduleMeta = {
|
|
|
2212
2384
|
|
|
2213
2385
|
async function showModule(module) {
|
|
2214
2386
|
if (!state.work) return showWelcome();
|
|
2215
|
-
if (!
|
|
2387
|
+
if (!canEditProse() && ["tasks", "ai-settings"].includes(module)) module = "editor";
|
|
2216
2388
|
if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
|
|
2217
2389
|
if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
|
|
2218
2390
|
state.module = module;
|
|
@@ -2234,7 +2406,7 @@ async function showModule(module) {
|
|
|
2234
2406
|
$("#module-title").textContent = meta[1];
|
|
2235
2407
|
$("#module-description").textContent = meta[2];
|
|
2236
2408
|
$("#module-create-button").textContent = meta[3];
|
|
2237
|
-
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !
|
|
2409
|
+
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
|
|
2238
2410
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
2239
2411
|
try {
|
|
2240
2412
|
if (module === "settings") await renderSettings();
|
|
@@ -2323,8 +2495,38 @@ function bindEntityHistoryButtons(refresh) {
|
|
|
2323
2495
|
}));
|
|
2324
2496
|
}
|
|
2325
2497
|
|
|
2498
|
+
function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
|
|
2499
|
+
const targetOptions = candidates
|
|
2500
|
+
.filter((candidate) => candidate.id !== source.id)
|
|
2501
|
+
.map((candidate) => [candidate.id, candidate.name]);
|
|
2502
|
+
openDialog(`合并${typeLabel}`,
|
|
2503
|
+
`<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
|
|
2504
|
+
field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions),
|
|
2505
|
+
async (form) => {
|
|
2506
|
+
const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
|
|
2507
|
+
if (!target) throw new Error(`请选择目标${typeLabel}`);
|
|
2508
|
+
await api(endpoint(source), { method: "POST", body: body(target) });
|
|
2509
|
+
await refresh();
|
|
2510
|
+
await loadAiReferences();
|
|
2511
|
+
toast(`已将“${source.name}”合并到“${target.name}”`);
|
|
2512
|
+
}, "人工资料管理", { submitLabel: "确认合并" });
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "" }) {
|
|
2516
|
+
const detail = warning ? `\n${warning}` : "";
|
|
2517
|
+
if (!window.confirm(`确认删除${typeLabel}“${item.name}”吗?${detail}`)) return;
|
|
2518
|
+
try {
|
|
2519
|
+
await api(endpoint(item), { method: "DELETE" });
|
|
2520
|
+
await refresh();
|
|
2521
|
+
await loadAiReferences();
|
|
2522
|
+
toast(`已删除${typeLabel}“${item.name}”`);
|
|
2523
|
+
} catch (error) {
|
|
2524
|
+
toast(error.message, "error");
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2326
2528
|
async function renderSettings() {
|
|
2327
|
-
const records = await
|
|
2529
|
+
const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
|
|
2328
2530
|
state.settings = records;
|
|
2329
2531
|
$("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
|
|
2330
2532
|
<article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
|
|
@@ -2336,15 +2538,15 @@ async function renderSettings() {
|
|
|
2336
2538
|
await renderSettings();
|
|
2337
2539
|
await loadAiReferences();
|
|
2338
2540
|
}));
|
|
2339
|
-
$("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () =>
|
|
2541
|
+
$("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
|
|
2340
2542
|
bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
|
|
2341
2543
|
}
|
|
2342
2544
|
|
|
2343
2545
|
async function renderCharacters() {
|
|
2344
2546
|
[state.characters, state.races, state.organizations] = await Promise.all([
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2547
|
+
apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
|
|
2548
|
+
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
2549
|
+
apiAllPages(`/api/works/${state.work.id}/organizations`)
|
|
2348
2550
|
]);
|
|
2349
2551
|
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>`;
|
|
2350
2552
|
$("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
|
|
@@ -2358,7 +2560,7 @@ async function renderCharacters() {
|
|
|
2358
2560
|
<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>
|
|
2359
2561
|
${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>`}
|
|
2360
2562
|
${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
|
|
2361
|
-
<div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button
|
|
2563
|
+
<div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button>${canEditWork() && state.characters.length > 1 ? `<button data-merge-character="${esc(item.id)}">合并</button>` : ""}${canEditWork() ? `<button class="danger-button" data-delete-character="${esc(item.id)}">删除</button>` : ""}</div></article>`;
|
|
2362
2564
|
}).join("")}</div>`
|
|
2363
2565
|
: emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
|
|
2364
2566
|
$("#create-character-audit-task")?.addEventListener("click", async () => {
|
|
@@ -2374,7 +2576,7 @@ async function renderCharacters() {
|
|
|
2374
2576
|
}
|
|
2375
2577
|
});
|
|
2376
2578
|
$("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
|
|
2377
|
-
const open = () =>
|
|
2579
|
+
const open = () => openCharacterEditor(state.characters.find((item) => item.id === card.dataset.openCharacter));
|
|
2378
2580
|
card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
|
|
2379
2581
|
card.addEventListener("keydown", (event) => {
|
|
2380
2582
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
@@ -2382,13 +2584,41 @@ async function renderCharacters() {
|
|
|
2382
2584
|
open();
|
|
2383
2585
|
});
|
|
2384
2586
|
});
|
|
2385
|
-
$("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () =>
|
|
2587
|
+
$("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
|
|
2588
|
+
$("#module-content").querySelectorAll("[data-merge-character]").forEach((button) => button.addEventListener("click", () => {
|
|
2589
|
+
const source = state.characters.find((item) => item.id === button.dataset.mergeCharacter);
|
|
2590
|
+
if (!source) return;
|
|
2591
|
+
openEntityMergeDialog({
|
|
2592
|
+
typeLabel: "角色",
|
|
2593
|
+
source,
|
|
2594
|
+
candidates: state.characters,
|
|
2595
|
+
endpoint: (item) => `/api/characters/${encodeURIComponent(item.id)}/merge`,
|
|
2596
|
+
body: (target) => ({
|
|
2597
|
+
targetCharacterId: target.id,
|
|
2598
|
+
expectedTargetVersionNo: target.versionNo,
|
|
2599
|
+
expectedSourceVersionNo: source.versionNo
|
|
2600
|
+
}),
|
|
2601
|
+
refresh: renderCharacters,
|
|
2602
|
+
impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
|
|
2603
|
+
});
|
|
2604
|
+
}));
|
|
2605
|
+
$("#module-content").querySelectorAll("[data-delete-character]").forEach((button) => button.addEventListener("click", () => {
|
|
2606
|
+
const item = state.characters.find((character) => character.id === button.dataset.deleteCharacter);
|
|
2607
|
+
if (!item) return;
|
|
2608
|
+
void deleteManagedEntity({
|
|
2609
|
+
typeLabel: "角色",
|
|
2610
|
+
item,
|
|
2611
|
+
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
|
|
2612
|
+
refresh: renderCharacters,
|
|
2613
|
+
warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
|
|
2614
|
+
});
|
|
2615
|
+
}));
|
|
2386
2616
|
}
|
|
2387
2617
|
|
|
2388
2618
|
async function renderRaces() {
|
|
2389
2619
|
[state.races, state.characters] = await Promise.all([
|
|
2390
|
-
|
|
2391
|
-
|
|
2620
|
+
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
2621
|
+
apiAllPages(`/api/works/${state.work.id}/characters`)
|
|
2392
2622
|
]);
|
|
2393
2623
|
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
2394
2624
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
@@ -2398,36 +2628,84 @@ async function renderRaces() {
|
|
|
2398
2628
|
<p>${esc(item.description || "尚未填写种族简介")}</p>
|
|
2399
2629
|
<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>
|
|
2400
2630
|
<p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
|
|
2401
|
-
<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
|
|
2631
|
+
<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>${canEditWork() && state.races.length > 1 ? `<button data-merge-race="${esc(item.id)}">合并</button>` : ""}${canEditWork() ? `<button class="danger-button" data-delete-race="${esc(item.id)}">删除</button>` : ""}</div>
|
|
2402
2632
|
</article>
|
|
2403
2633
|
${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
|
|
2404
2634
|
</div>
|
|
2405
2635
|
</details>`;
|
|
2406
2636
|
$("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
2407
2637
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
|
|
2638
|
+
$("#module-content").querySelectorAll("[data-merge-race]").forEach((button) => button.addEventListener("click", () => {
|
|
2639
|
+
const source = state.races.find((item) => item.id === button.dataset.mergeRace);
|
|
2640
|
+
if (!source) return;
|
|
2641
|
+
openEntityMergeDialog({
|
|
2642
|
+
typeLabel: "种族",
|
|
2643
|
+
source,
|
|
2644
|
+
candidates: state.races,
|
|
2645
|
+
endpoint: (item) => `/api/races/${encodeURIComponent(item.id)}/merge`,
|
|
2646
|
+
body: (target) => ({ targetRaceId: target.id }),
|
|
2647
|
+
refresh: renderRaces,
|
|
2648
|
+
impact: "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。"
|
|
2649
|
+
});
|
|
2650
|
+
}));
|
|
2651
|
+
$("#module-content").querySelectorAll("[data-delete-race]").forEach((button) => button.addEventListener("click", () => {
|
|
2652
|
+
const item = state.races.find((race) => race.id === button.dataset.deleteRace);
|
|
2653
|
+
if (!item) return;
|
|
2654
|
+
void deleteManagedEntity({
|
|
2655
|
+
typeLabel: "种族",
|
|
2656
|
+
item,
|
|
2657
|
+
endpoint: (race) => `/api/races/${encodeURIComponent(race.id)}`,
|
|
2658
|
+
refresh: renderRaces,
|
|
2659
|
+
warning: "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。"
|
|
2660
|
+
});
|
|
2661
|
+
}));
|
|
2408
2662
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
2409
2663
|
}
|
|
2410
2664
|
|
|
2411
2665
|
async function renderOrganizations() {
|
|
2412
2666
|
[state.organizations, state.characters] = await Promise.all([
|
|
2413
|
-
|
|
2414
|
-
|
|
2667
|
+
apiAllPages(`/api/works/${state.work.id}/organizations`),
|
|
2668
|
+
apiAllPages(`/api/works/${state.work.id}/characters`)
|
|
2415
2669
|
]);
|
|
2416
2670
|
$("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
|
|
2417
2671
|
<article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
|
|
2418
2672
|
<h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
|
|
2419
2673
|
<div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
|
|
2420
2674
|
<p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
|
|
2421
|
-
<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
|
|
2675
|
+
<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>${canEditWork() && state.organizations.length > 1 ? `<button data-merge-organization="${esc(item.id)}">合并</button>` : ""}${canEditWork() ? `<button class="danger-button" data-delete-organization="${esc(item.id)}">删除</button>` : ""}</div>
|
|
2422
2676
|
</article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
|
|
2423
2677
|
$("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
|
|
2678
|
+
$("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
|
|
2679
|
+
const source = state.organizations.find((item) => item.id === button.dataset.mergeOrganization);
|
|
2680
|
+
if (!source) return;
|
|
2681
|
+
openEntityMergeDialog({
|
|
2682
|
+
typeLabel: "组织",
|
|
2683
|
+
source,
|
|
2684
|
+
candidates: state.organizations,
|
|
2685
|
+
endpoint: (item) => `/api/organizations/${encodeURIComponent(item.id)}/merge`,
|
|
2686
|
+
body: (target) => ({ targetOrganizationId: target.id }),
|
|
2687
|
+
refresh: renderOrganizations,
|
|
2688
|
+
impact: "来源组织的成员、简介与组织设定会迁移到目标组织。"
|
|
2689
|
+
});
|
|
2690
|
+
}));
|
|
2691
|
+
$("#module-content").querySelectorAll("[data-delete-organization]").forEach((button) => button.addEventListener("click", () => {
|
|
2692
|
+
const item = state.organizations.find((organization) => organization.id === button.dataset.deleteOrganization);
|
|
2693
|
+
if (!item) return;
|
|
2694
|
+
void deleteManagedEntity({
|
|
2695
|
+
typeLabel: "组织",
|
|
2696
|
+
item,
|
|
2697
|
+
endpoint: (organization) => `/api/organizations/${encodeURIComponent(organization.id)}`,
|
|
2698
|
+
refresh: renderOrganizations,
|
|
2699
|
+
warning: "角色与该组织的成员关系会一并移除。"
|
|
2700
|
+
});
|
|
2701
|
+
}));
|
|
2424
2702
|
bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
|
|
2425
2703
|
}
|
|
2426
2704
|
|
|
2427
2705
|
async function renderTimeline() {
|
|
2428
2706
|
const [events, tracks] = await Promise.all([
|
|
2429
|
-
|
|
2430
|
-
|
|
2707
|
+
apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
|
|
2708
|
+
apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
|
|
2431
2709
|
]);
|
|
2432
2710
|
state.timelineTracks = tracks;
|
|
2433
2711
|
const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
|
|
@@ -2446,7 +2724,12 @@ async function renderTimeline() {
|
|
|
2446
2724
|
const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
|
|
2447
2725
|
if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
|
|
2448
2726
|
openDialog("合并时间事件", field("name", "合并后的事件名称") + field("description", "合并说明(留空则拼接原说明)", "textarea"), async (form) => {
|
|
2449
|
-
await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: {
|
|
2727
|
+
await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: {
|
|
2728
|
+
eventIds,
|
|
2729
|
+
name: form.get("name"),
|
|
2730
|
+
description: form.get("description") || undefined,
|
|
2731
|
+
expectedVersionNos: Object.fromEntries(eventIds.map((eventId) => [eventId, Number(events.find((event) => event.id === eventId)?.versionNo)]))
|
|
2732
|
+
} });
|
|
2450
2733
|
await renderTimeline();
|
|
2451
2734
|
}, "保留参与者与证据");
|
|
2452
2735
|
});
|
|
@@ -2455,8 +2738,8 @@ async function renderTimeline() {
|
|
|
2455
2738
|
async function renderOutlines() {
|
|
2456
2739
|
const currentChapterId = state.chapter?.id;
|
|
2457
2740
|
const [outlines, foreshadows] = await Promise.all([
|
|
2458
|
-
|
|
2459
|
-
|
|
2741
|
+
apiPage(`/api/works/${state.work.id}/outlines`).then((result) => result.items),
|
|
2742
|
+
apiPage(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `¤tChapterId=${encodeURIComponent(currentChapterId)}` : ""}`).then((result) => result.items)
|
|
2460
2743
|
]);
|
|
2461
2744
|
const unresolved = foreshadows.filter((item) => item.unresolved);
|
|
2462
2745
|
const overdue = unresolved.filter((item) => item.overdue);
|
|
@@ -2484,8 +2767,8 @@ async function renderOutlines() {
|
|
|
2484
2767
|
}
|
|
2485
2768
|
|
|
2486
2769
|
async function renderRelationships() {
|
|
2487
|
-
state.characters = await
|
|
2488
|
-
const relationships = await
|
|
2770
|
+
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
2771
|
+
const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
|
|
2489
2772
|
const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
|
|
2490
2773
|
state.galaxy?.destroy();
|
|
2491
2774
|
state.relationshipExpandedMap?.destroy?.();
|
|
@@ -2516,8 +2799,8 @@ async function renderRelationships() {
|
|
|
2516
2799
|
|
|
2517
2800
|
async function renderReviews() {
|
|
2518
2801
|
const [reviews, characters] = await Promise.all([
|
|
2519
|
-
|
|
2520
|
-
|
|
2802
|
+
apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
|
|
2803
|
+
apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
|
|
2521
2804
|
]);
|
|
2522
2805
|
const characterById = new Map(characters.map((character) => [character.id, character]));
|
|
2523
2806
|
const duplicateCard = (item) => {
|
|
@@ -2577,7 +2860,7 @@ async function renderReviews() {
|
|
|
2577
2860
|
|
|
2578
2861
|
async function renderTasks() {
|
|
2579
2862
|
const [tasks, settings] = await Promise.all([
|
|
2580
|
-
|
|
2863
|
+
apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
|
|
2581
2864
|
api(`/api/works/${state.work.id}/ai-settings`)
|
|
2582
2865
|
]);
|
|
2583
2866
|
const pendingCount = tasks.filter((item) => item.status === "pending").length;
|
|
@@ -3012,8 +3295,8 @@ async function loadAiReferences() {
|
|
|
3012
3295
|
const workId = state.work?.id;
|
|
3013
3296
|
if (!workId) return;
|
|
3014
3297
|
const [characters, settings] = await Promise.all([
|
|
3015
|
-
|
|
3016
|
-
|
|
3298
|
+
apiAllPages(`/api/works/${workId}/characters`),
|
|
3299
|
+
apiAllPages(`/api/works/${workId}/settings`)
|
|
3017
3300
|
]);
|
|
3018
3301
|
if (state.work?.id !== workId) return;
|
|
3019
3302
|
state.characters = characters;
|
|
@@ -3230,7 +3513,7 @@ function bindWorkCoverControls(work) {
|
|
|
3230
3513
|
$("#work-cover-remove")?.addEventListener("click", async () => {
|
|
3231
3514
|
try {
|
|
3232
3515
|
await api(`/api/works/${work.id}/cover`, { method: "DELETE" });
|
|
3233
|
-
state.works = await
|
|
3516
|
+
state.works = (await apiPage("/api/works")).items;
|
|
3234
3517
|
const updated = state.works.find((item) => item.id === work.id) ?? { ...work, coverUrl: null };
|
|
3235
3518
|
Object.assign(work, updated);
|
|
3236
3519
|
const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
|
|
@@ -3257,7 +3540,7 @@ function openWorkSettingsDialog(work) {
|
|
|
3257
3540
|
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
|
|
3258
3541
|
async (form) => {
|
|
3259
3542
|
await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
|
|
3260
|
-
state.works = await
|
|
3543
|
+
state.works = (await apiPage("/api/works")).items;
|
|
3261
3544
|
const updated = state.works.find((item) => item.id === work.id);
|
|
3262
3545
|
if (updated) Object.assign(work, updated);
|
|
3263
3546
|
if (state.work?.id === work.id) {
|
|
@@ -3280,6 +3563,7 @@ function openWorkSettingsDialog(work) {
|
|
|
3280
3563
|
|
|
3281
3564
|
async function openChapterDialog() {
|
|
3282
3565
|
if (!state.work) return openWorkDialog();
|
|
3566
|
+
if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
|
|
3283
3567
|
if (!state.work.volumes.length) {
|
|
3284
3568
|
await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
|
|
3285
3569
|
state.work = await api(`/api/works/${state.work.id}`);
|
|
@@ -3294,6 +3578,7 @@ async function openChapterDialog() {
|
|
|
3294
3578
|
|
|
3295
3579
|
function openVolumeDialog(item) {
|
|
3296
3580
|
if (!state.work) return openWorkDialog();
|
|
3581
|
+
if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
|
|
3297
3582
|
const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
|
|
3298
3583
|
openDialog(item ? "编辑分卷" : "新建分卷",
|
|
3299
3584
|
field("title", "分卷名称", "text", item?.title) +
|
|
@@ -3314,17 +3599,50 @@ function openVolumeDialog(item) {
|
|
|
3314
3599
|
}, "分卷设置");
|
|
3315
3600
|
}
|
|
3316
3601
|
|
|
3317
|
-
function
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3602
|
+
function openSettingEditor(item = null) {
|
|
3603
|
+
settingEditorItem = item;
|
|
3604
|
+
$("#setting-editor-eyebrow").textContent = item ? "人工修正" : "作者事实";
|
|
3605
|
+
$("#setting-editor-title").textContent = item ? `编辑“${item.title}”` : "新建设定";
|
|
3606
|
+
$("#setting-editor-name").value = item?.title ?? "";
|
|
3607
|
+
$("#setting-editor-category").value = item?.category ?? "世界规则";
|
|
3608
|
+
$("#setting-editor-locked").checked = Boolean(item?.locked);
|
|
3609
|
+
$("#setting-editor-body").value = item?.content ?? "";
|
|
3610
|
+
$("#setting-change-note").value = "";
|
|
3611
|
+
$("#setting-change-note-field").classList.toggle("hidden", !item);
|
|
3612
|
+
$("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
|
|
3613
|
+
const viewOnly = !canEditWork();
|
|
3614
|
+
$("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
|
|
3615
|
+
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
3616
|
+
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
3617
|
+
$("#setting-editor-form").onsubmit = async (event) => {
|
|
3618
|
+
event.preventDefault();
|
|
3619
|
+
if (!canEditWork()) return;
|
|
3620
|
+
const form = new FormData(event.currentTarget);
|
|
3621
|
+
const submit = $("#setting-editor-submit");
|
|
3622
|
+
submit.disabled = true;
|
|
3623
|
+
try {
|
|
3624
|
+
const locked = form.get("locked") === "on";
|
|
3625
|
+
const body = {
|
|
3626
|
+
title: String(form.get("title") ?? "").trim(),
|
|
3627
|
+
category: String(form.get("category") ?? "世界规则"),
|
|
3628
|
+
content: String(form.get("content") ?? ""),
|
|
3629
|
+
locked,
|
|
3630
|
+
status: locked ? "confirmed" : (item?.status ?? "draft"),
|
|
3631
|
+
...(item ? { changeNote: String(form.get("changeNote") ?? "").trim() } : {})
|
|
3632
|
+
};
|
|
3324
3633
|
await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
|
|
3325
|
-
|
|
3634
|
+
entityEditorDirty = false;
|
|
3326
3635
|
await loadAiReferences();
|
|
3327
|
-
|
|
3636
|
+
await closeEntityEditor({ force: true });
|
|
3637
|
+
toast(item ? "设定新版本已保存" : "设定已创建");
|
|
3638
|
+
} catch (error) {
|
|
3639
|
+
toast(error.message, "error");
|
|
3640
|
+
} finally {
|
|
3641
|
+
submit.disabled = false;
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
showEntityEditorPage("setting");
|
|
3645
|
+
$("#setting-editor-name").focus();
|
|
3328
3646
|
}
|
|
3329
3647
|
|
|
3330
3648
|
function characterEditorSection(key, title, description, content) {
|
|
@@ -3400,8 +3718,8 @@ async function loadCharacterEditorRelationships(characterId) {
|
|
|
3400
3718
|
let loaded = false;
|
|
3401
3719
|
try {
|
|
3402
3720
|
const [characters, relationships] = await Promise.all([
|
|
3403
|
-
|
|
3404
|
-
|
|
3721
|
+
apiAllPages(`/api/works/${workId}/characters`),
|
|
3722
|
+
apiAllPages(`/api/works/${workId}/relationships`)
|
|
3405
3723
|
]);
|
|
3406
3724
|
if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
|
|
3407
3725
|
state.characters = characters;
|
|
@@ -3422,7 +3740,7 @@ async function loadCharacterEditorRelationships(characterId) {
|
|
|
3422
3740
|
async function refreshRelationshipSurfaces(characterId = null) {
|
|
3423
3741
|
const tasks = [];
|
|
3424
3742
|
if (state.module === "relationships") tasks.push(renderRelationships());
|
|
3425
|
-
if (characterId &&
|
|
3743
|
+
if (characterId && entityEditorType === "character" && !$("#entity-editor-view").classList.contains("hidden") && characterEditorItem?.id === characterId) {
|
|
3426
3744
|
tasks.push(loadCharacterEditorRelationships(characterId));
|
|
3427
3745
|
}
|
|
3428
3746
|
await Promise.all(tasks);
|
|
@@ -3459,9 +3777,29 @@ function scheduleCharacterSectionPreview() {
|
|
|
3459
3777
|
}, 260);
|
|
3460
3778
|
}
|
|
3461
3779
|
|
|
3780
|
+
async function closeCharacterSectionEditor({ force = false } = {}) {
|
|
3781
|
+
if (!force && characterSectionEditorDirty && !window.confirm("当前 Markdown 章节有未保存修改,返回人物档案将丢弃这些修改。是否继续?")) return false;
|
|
3782
|
+
if (characterSectionPreviewTimer !== null) {
|
|
3783
|
+
clearTimeout(characterSectionPreviewTimer);
|
|
3784
|
+
characterSectionPreviewTimer = null;
|
|
3785
|
+
}
|
|
3786
|
+
await discardPendingCharacterAttachments();
|
|
3787
|
+
characterSectionEditorDirty = false;
|
|
3788
|
+
$("#character-section-editor-view").classList.add("hidden");
|
|
3789
|
+
$("#character-editor-form").classList.remove("hidden");
|
|
3790
|
+
$("#character-section-editor-host").innerHTML = "";
|
|
3791
|
+
replacePageRoute(currentPageRoute());
|
|
3792
|
+
return true;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3462
3795
|
function characterSectionEditorHtml(section = null) {
|
|
3463
3796
|
const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
|
|
3464
|
-
return `<
|
|
3797
|
+
return `<div class="character-section-editor-shell">
|
|
3798
|
+
<header class="character-section-editor-header">
|
|
3799
|
+
<div><span class="eyebrow">人物 Markdown 档案</span><h2 id="character-section-editor-title">${section ? `编辑“${esc(section.title)}”` : "新建档案章节"}</h2></div>
|
|
3800
|
+
<button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
|
|
3801
|
+
</header>
|
|
3802
|
+
<section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
|
|
3465
3803
|
<div class="character-markdown-editor-meta">
|
|
3466
3804
|
<label>章节类型<select id="character-section-type">${options}</select></label>
|
|
3467
3805
|
<label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
|
|
@@ -3476,17 +3814,23 @@ function characterSectionEditorHtml(section = null) {
|
|
|
3476
3814
|
<label>Markdown 原文<textarea id="character-section-markdown" maxlength="500000" spellcheck="true" placeholder="支持标题、列表、引用、表格、链接和图片">${esc(section?.contentMarkdown ?? "")}</textarea></label>
|
|
3477
3815
|
<div><span class="character-markdown-preview-label">安全预览</span><article id="character-section-preview" class="character-markdown-document message-body">${renderMarkdown(section?.contentMarkdown ?? "") || '<p class="character-markdown-empty">预览区域暂无内容。</p>'}</article></div>
|
|
3478
3816
|
</div>
|
|
3479
|
-
<
|
|
3480
|
-
|
|
3481
|
-
|
|
3817
|
+
<div class="character-markdown-editor-footer">
|
|
3818
|
+
<label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
|
|
3819
|
+
<div class="character-markdown-editor-actions"><button type="button" data-character-section-edit-cancel>取消</button><button type="button" class="primary-button" data-character-section-edit-save>${section ? "保存章节版本" : "创建章节"}</button></div>
|
|
3820
|
+
</div>
|
|
3821
|
+
</section>
|
|
3822
|
+
</div>`;
|
|
3482
3823
|
}
|
|
3483
3824
|
|
|
3484
3825
|
async function openCharacterSectionEditor(section = null) {
|
|
3485
3826
|
await discardPendingCharacterAttachments();
|
|
3486
|
-
const host = $("#character-
|
|
3487
|
-
if (!host) return;
|
|
3827
|
+
const host = $("#character-section-editor-host");
|
|
3488
3828
|
host.innerHTML = characterSectionEditorHtml(section);
|
|
3829
|
+
characterSectionEditorDirty = false;
|
|
3830
|
+
$("#character-editor-form").classList.add("hidden");
|
|
3831
|
+
$("#character-section-editor-view").classList.remove("hidden");
|
|
3489
3832
|
const textarea = $("#character-section-markdown");
|
|
3833
|
+
host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => { characterSectionEditorDirty = true; }));
|
|
3490
3834
|
textarea.addEventListener("input", scheduleCharacterSectionPreview);
|
|
3491
3835
|
$("#character-section-attachment").addEventListener("change", async (event) => {
|
|
3492
3836
|
const file = event.target.files?.[0];
|
|
@@ -3506,6 +3850,7 @@ async function openCharacterSectionEditor(section = null) {
|
|
|
3506
3850
|
const suffix = end < textarea.value.length && !textarea.value.slice(end).startsWith("\n") ? "\n\n" : "";
|
|
3507
3851
|
textarea.setRangeText(`${prefix}${insertion}${suffix}`, start, end, "end");
|
|
3508
3852
|
textarea.focus();
|
|
3853
|
+
characterSectionEditorDirty = true;
|
|
3509
3854
|
scheduleCharacterSectionPreview();
|
|
3510
3855
|
toast(attachment.storedMimeType === "image/webp" ? "图片已转换为无损 WebP 并插入" : "图片已插入;转换后未变小,因此保留原格式");
|
|
3511
3856
|
} catch (error) {
|
|
@@ -3515,10 +3860,8 @@ async function openCharacterSectionEditor(section = null) {
|
|
|
3515
3860
|
input.value = "";
|
|
3516
3861
|
}
|
|
3517
3862
|
});
|
|
3518
|
-
host.querySelector("[data-character-section-edit-
|
|
3519
|
-
|
|
3520
|
-
renderCharacterMarkdownSections();
|
|
3521
|
-
});
|
|
3863
|
+
host.querySelector("[data-character-section-edit-close]").addEventListener("click", () => void closeCharacterSectionEditor());
|
|
3864
|
+
host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
|
|
3522
3865
|
host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
|
|
3523
3866
|
const button = event.currentTarget;
|
|
3524
3867
|
const title = $("#character-section-title").value.trim();
|
|
@@ -3547,6 +3890,8 @@ async function openCharacterSectionEditor(section = null) {
|
|
|
3547
3890
|
characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
|
|
3548
3891
|
renderCharacterMarkdownSections();
|
|
3549
3892
|
await Promise.all([renderCharacters(), loadAiReferences()]);
|
|
3893
|
+
characterSectionEditorDirty = false;
|
|
3894
|
+
await closeCharacterSectionEditor({ force: true });
|
|
3550
3895
|
toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
|
|
3551
3896
|
} catch (error) {
|
|
3552
3897
|
toast(error.message, "error");
|
|
@@ -3565,7 +3910,7 @@ async function showCharacterSectionVersions(sectionId) {
|
|
|
3565
3910
|
host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
|
|
3566
3911
|
button.disabled = true;
|
|
3567
3912
|
try {
|
|
3568
|
-
|
|
3913
|
+
await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
|
|
3569
3914
|
characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
|
|
3570
3915
|
renderCharacterMarkdownSections();
|
|
3571
3916
|
await Promise.all([renderCharacters(), loadAiReferences()]);
|
|
@@ -3780,10 +4125,10 @@ async function showCharacterHistory() {
|
|
|
3780
4125
|
}
|
|
3781
4126
|
}
|
|
3782
4127
|
|
|
3783
|
-
async function
|
|
4128
|
+
async function openCharacterEditor(item = null) {
|
|
3784
4129
|
[state.races, state.organizations] = await Promise.all([
|
|
3785
|
-
|
|
3786
|
-
|
|
4130
|
+
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
4131
|
+
apiAllPages(`/api/works/${state.work.id}/organizations`)
|
|
3787
4132
|
]);
|
|
3788
4133
|
characterEditorItem = item ?? null;
|
|
3789
4134
|
characterEditorVersions = [];
|
|
@@ -3805,13 +4150,14 @@ async function openCharacterDialog(item) {
|
|
|
3805
4150
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
3806
4151
|
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
3807
4152
|
}
|
|
4153
|
+
$("#character-change-note").readOnly = viewOnly;
|
|
4154
|
+
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
3808
4155
|
document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
|
|
3809
4156
|
button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
|
|
3810
4157
|
});
|
|
3811
4158
|
const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
|
|
3812
4159
|
relationshipTab.disabled = !item;
|
|
3813
4160
|
relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
|
|
3814
|
-
const dialog = $("#character-editor-dialog");
|
|
3815
4161
|
const form = $("#character-editor-form");
|
|
3816
4162
|
form.onsubmit = async (event) => {
|
|
3817
4163
|
event.preventDefault();
|
|
@@ -3825,8 +4171,9 @@ async function openCharacterDialog(item) {
|
|
|
3825
4171
|
const previousVersion = characterEditorItem?.versionNo;
|
|
3826
4172
|
if (!wasEditing) delete body.changeNote;
|
|
3827
4173
|
const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
|
|
3828
|
-
|
|
3829
|
-
await
|
|
4174
|
+
entityEditorDirty = false;
|
|
4175
|
+
await loadAiReferences();
|
|
4176
|
+
await closeEntityEditor({ force: true });
|
|
3830
4177
|
toast(!wasEditing ? "人物档案已创建" : saved.versionNo === previousVersion ? "没有检测到人物档案变更" : `人物档案已保存为 v${saved.versionNo}`);
|
|
3831
4178
|
} catch (error) {
|
|
3832
4179
|
toast(error.message, "error");
|
|
@@ -3834,7 +4181,7 @@ async function openCharacterDialog(item) {
|
|
|
3834
4181
|
submit.disabled = false;
|
|
3835
4182
|
}
|
|
3836
4183
|
};
|
|
3837
|
-
|
|
4184
|
+
showEntityEditorPage("character");
|
|
3838
4185
|
if (item) {
|
|
3839
4186
|
void loadCharacterEditorRelationships(item.id);
|
|
3840
4187
|
void loadCharacterMarkdownSections(item.id);
|
|
@@ -3842,7 +4189,7 @@ async function openCharacterDialog(item) {
|
|
|
3842
4189
|
}
|
|
3843
4190
|
|
|
3844
4191
|
async function openRaceDialog(item) {
|
|
3845
|
-
state.characters = await
|
|
4192
|
+
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
3846
4193
|
const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
|
|
3847
4194
|
const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
|
|
3848
4195
|
.sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
|
|
@@ -3863,7 +4210,7 @@ async function openRaceDialog(item) {
|
|
|
3863
4210
|
}
|
|
3864
4211
|
|
|
3865
4212
|
async function openOrganizationDialog(item) {
|
|
3866
|
-
state.characters = await
|
|
4213
|
+
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
3867
4214
|
const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
|
|
3868
4215
|
openDialog(item ? "编辑组织" : "新建组织",
|
|
3869
4216
|
field("name", "组织名称", "text", item?.name) +
|
|
@@ -3890,7 +4237,7 @@ function openTimelineDialog(item, preferredTrackId = null) {
|
|
|
3890
4237
|
const trackOptions = [["", "未分组"], ...state.timelineTracks.map((track) => [track.id, track.name])];
|
|
3891
4238
|
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) => {
|
|
3892
4239
|
const rawSort = String(form.get("timeSort") ?? "").trim();
|
|
3893
|
-
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" };
|
|
4240
|
+
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 } : {}) };
|
|
3894
4241
|
await api(item ? `/api/timeline/${item.id}` : `/api/works/${state.work.id}/timeline`, { method: item ? "PATCH" : "POST", body });
|
|
3895
4242
|
await renderTimeline();
|
|
3896
4243
|
}, item ? "人工调整" : "作者确认事件");
|
|
@@ -3906,7 +4253,7 @@ function openOutlineDialog(item) {
|
|
|
3906
4253
|
field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
|
|
3907
4254
|
async (form) => {
|
|
3908
4255
|
await api(`/api/chapters/${item.chapterId}/outline`, { method: "PUT", body: {
|
|
3909
|
-
goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status")
|
|
4256
|
+
goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status"), expectedVersionNo: item.versionNo
|
|
3910
4257
|
} });
|
|
3911
4258
|
await renderOutlines();
|
|
3912
4259
|
toast("章节规划已保存");
|
|
@@ -3949,7 +4296,8 @@ function openForeshadowDialog(item) {
|
|
|
3949
4296
|
const occurrences = [...preservedOccurrences, ...editedOccurrences];
|
|
3950
4297
|
const body = {
|
|
3951
4298
|
title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
|
|
3952
|
-
plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences
|
|
4299
|
+
plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
|
|
4300
|
+
...(item ? { expectedVersionNo: item.versionNo } : {})
|
|
3953
4301
|
};
|
|
3954
4302
|
await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
|
|
3955
4303
|
await renderOutlines();
|
|
@@ -3962,20 +4310,20 @@ function openTimelineSplitDialog(item) {
|
|
|
3962
4310
|
await api(`/api/timeline/${item.id}/split`, { method: "POST", body: { parts: [
|
|
3963
4311
|
{ name: form.get("firstName"), description: form.get("firstDescription") },
|
|
3964
4312
|
{ name: form.get("secondName"), description: form.get("secondDescription") }
|
|
3965
|
-
] } });
|
|
4313
|
+
], expectedVersionNo: item.versionNo } });
|
|
3966
4314
|
await renderTimeline();
|
|
3967
4315
|
}, "原证据同步保留");
|
|
3968
4316
|
}
|
|
3969
4317
|
|
|
3970
4318
|
async function openRelationshipDialog(item, options = {}) {
|
|
3971
|
-
state.characters = await
|
|
4319
|
+
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
3972
4320
|
if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
|
|
3973
4321
|
const characterOptions = state.characters.map((item) => [item.id, item.name]);
|
|
3974
4322
|
const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
|
|
3975
4323
|
const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
|
|
3976
4324
|
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) => {
|
|
3977
4325
|
const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
|
|
3978
|
-
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" } });
|
|
4326
|
+
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 } : {}) } });
|
|
3979
4327
|
await refreshRelationshipSurfaces(options.characterId ?? null);
|
|
3980
4328
|
}, item ? "关系档案" : "人工确认关系");
|
|
3981
4329
|
}
|
|
@@ -4278,7 +4626,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
4278
4626
|
async function showVersions() {
|
|
4279
4627
|
if (!state.chapter) return;
|
|
4280
4628
|
const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
|
|
4281
|
-
$("#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
|
|
4629
|
+
$("#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("");
|
|
4282
4630
|
$("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
|
|
4283
4631
|
if (!window.confirm(`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`)) return;
|
|
4284
4632
|
state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
|
|
@@ -4581,7 +4929,7 @@ $("#member-invite-form").addEventListener("submit", async (event) => {
|
|
|
4581
4929
|
const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
|
|
4582
4930
|
renderMembers(members);
|
|
4583
4931
|
await fillMemberCandidates(members);
|
|
4584
|
-
toast(role === "viewer" ? "仅查看成员已邀请" : "
|
|
4932
|
+
toast(role === "viewer" ? "仅查看成员已邀请" : role === "settings-editor" ? "设定编辑已邀请" : "完整协作者已邀请");
|
|
4585
4933
|
} catch (error) { toast(error.message, "error"); }
|
|
4586
4934
|
});
|
|
4587
4935
|
$("#platform-new-provider").addEventListener("click", () => openProviderDialog());
|
|
@@ -4596,9 +4944,16 @@ $("#versions-button").addEventListener("click", showVersions);
|
|
|
4596
4944
|
$("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
|
|
4597
4945
|
$("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
|
|
4598
4946
|
$("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
|
|
4599
|
-
$("#
|
|
4600
|
-
$("#character-editor-
|
|
4601
|
-
$("#character-editor-
|
|
4947
|
+
$("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
|
|
4948
|
+
$("#character-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
|
|
4949
|
+
$("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
|
|
4950
|
+
$("#setting-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
4951
|
+
$("#setting-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
4952
|
+
$("#character-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
4953
|
+
$("#character-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
4954
|
+
$("#character-editor-fields").addEventListener("click", (event) => {
|
|
4955
|
+
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
|
|
4956
|
+
});
|
|
4602
4957
|
$("#character-history-button").addEventListener("click", () => {
|
|
4603
4958
|
if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
|
|
4604
4959
|
else setCharacterHistoryVisible(false);
|
|
@@ -4697,7 +5052,7 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
4697
5052
|
if (button.dataset.module) showModule(button.dataset.module);
|
|
4698
5053
|
});
|
|
4699
5054
|
$("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
|
|
4700
|
-
$("#module-create-button").addEventListener("click", () => ({ settings:
|
|
5055
|
+
$("#module-create-button").addEventListener("click", () => ({ settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
4701
5056
|
$("#ai-prompt").addEventListener("input", async () => {
|
|
4702
5057
|
updateAiMentionMenu();
|
|
4703
5058
|
scheduleAiContextUsage();
|
|
@@ -4723,22 +5078,35 @@ $("#ai-mention-menu").addEventListener("click", (event) => {
|
|
|
4723
5078
|
if (button) selectAiMention(button);
|
|
4724
5079
|
});
|
|
4725
5080
|
$("#import-file").addEventListener("change", async (event) => {
|
|
4726
|
-
|
|
4727
|
-
if (!
|
|
5081
|
+
const file = event.target.files[0];
|
|
5082
|
+
if (!state.work || !file) return;
|
|
5083
|
+
if (!canEditProse()) {
|
|
5084
|
+
event.target.value = "";
|
|
5085
|
+
toast("当前权限只能编辑设定资料,不能导入正文", "error");
|
|
5086
|
+
return;
|
|
5087
|
+
}
|
|
5088
|
+
const mode = await chooseExistingWorkImportMode(file);
|
|
5089
|
+
if (!mode) {
|
|
4728
5090
|
event.target.value = "";
|
|
4729
5091
|
return;
|
|
4730
5092
|
}
|
|
5093
|
+
cancelChapterAutoSave();
|
|
4731
5094
|
const body = new FormData();
|
|
4732
|
-
body.append("file",
|
|
5095
|
+
body.append("file", file);
|
|
5096
|
+
body.append("mode", mode);
|
|
5097
|
+
body.append("expectedVersionNo", String(state.work.versionNo));
|
|
4733
5098
|
try {
|
|
4734
5099
|
const result = await api(`/api/works/${state.work.id}/import`, { method: "POST", body });
|
|
4735
|
-
setSaveState("
|
|
5100
|
+
setSaveState(mode === "append" ? "已追加" : "已覆盖");
|
|
4736
5101
|
state.work = result.tree;
|
|
4737
5102
|
renderTree();
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
if (
|
|
4741
|
-
} catch (error) {
|
|
5103
|
+
const completion = mode === "append" ? "正文追加完成" : "正文覆盖完成";
|
|
5104
|
+
toast(result.warnings.length ? `${completion}:${result.warnings.join(";")}` : completion);
|
|
5105
|
+
if (result.firstImportedChapterId) await selectChapter(result.firstImportedChapterId);
|
|
5106
|
+
} catch (error) {
|
|
5107
|
+
toast(error.message, "error");
|
|
5108
|
+
if (state.dirty) scheduleChapterAutoSave();
|
|
5109
|
+
}
|
|
4742
5110
|
event.target.value = "";
|
|
4743
5111
|
});
|
|
4744
5112
|
$("#new-import-file").addEventListener("change", async (event) => {
|
|
@@ -4769,7 +5137,7 @@ $("#cover-file").addEventListener("change", async (event) => {
|
|
|
4769
5137
|
body.append("file", file);
|
|
4770
5138
|
try {
|
|
4771
5139
|
await api(`/api/works/${workId}/cover`, { method: "PUT", body });
|
|
4772
|
-
state.works = await
|
|
5140
|
+
state.works = (await apiPage("/api/works")).items;
|
|
4773
5141
|
const updated = state.works.find((item) => item.id === workId);
|
|
4774
5142
|
const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
|
|
4775
5143
|
if (updated && coverField && $("#form-dialog")?.open) {
|
|
@@ -4908,7 +5276,7 @@ $("#search-form").addEventListener("submit", async (event) => {
|
|
|
4908
5276
|
$("#export-button").addEventListener("click", () => {
|
|
4909
5277
|
if (state.work) window.location.href = `/api/works/${state.work.id}/export?format=markdown`;
|
|
4910
5278
|
});
|
|
4911
|
-
window.addEventListener("beforeunload", (event) => { if (state.dirty) event.preventDefault(); });
|
|
5279
|
+
window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
|
|
4912
5280
|
|
|
4913
5281
|
initializePage().catch((error) => {
|
|
4914
5282
|
restoringPageRoute = false;
|