@musnows/scriverse 0.4.6 → 0.4.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 +33 -15
- package/dist/ai.js.map +1 -1
- package/dist/app.js +5 -2
- package/dist/app.js.map +1 -1
- package/dist/database.js +30 -1
- package/dist/database.js.map +1 -1
- package/dist/pagination.js +3 -2
- package/dist/pagination.js.map +1 -1
- package/dist/public/app.js +474 -43
- package/dist/public/avatar-crop.d.ts +57 -0
- package/dist/public/avatar-crop.js +201 -0
- package/dist/public/index.html +38 -6
- package/dist/public/styles.css +113 -9
- package/dist/store.js +5 -2
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +12 -2
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -40,6 +40,16 @@ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canW
|
|
|
40
40
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
41
41
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
42
42
|
import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
|
|
43
|
+
import {
|
|
44
|
+
clampCropRect,
|
|
45
|
+
containImageRect,
|
|
46
|
+
cropOutputSize,
|
|
47
|
+
defaultCropRect,
|
|
48
|
+
mapDisplayPointToImage,
|
|
49
|
+
mapImageRectToDisplay,
|
|
50
|
+
moveCropRect,
|
|
51
|
+
resizeCropRect
|
|
52
|
+
} from "/avatar-crop.js?v=20260725-avatar-crop";
|
|
43
53
|
|
|
44
54
|
const state = {
|
|
45
55
|
user: null,
|
|
@@ -68,6 +78,7 @@ const state = {
|
|
|
68
78
|
relationshipMindMap: null,
|
|
69
79
|
relationshipExpandedMap: null,
|
|
70
80
|
collapsedVolumeIds: new Set(),
|
|
81
|
+
collapsedRaceIds: new Set(),
|
|
71
82
|
contextChapterId: null
|
|
72
83
|
};
|
|
73
84
|
|
|
@@ -179,17 +190,15 @@ function applyWorkAccessMode() {
|
|
|
179
190
|
$("#import-file-button").setAttribute("title", proseReadOnly ? "当前权限不能导入正文" : "导入 TXT / DOCX");
|
|
180
191
|
$("#import-file").disabled = proseReadOnly;
|
|
181
192
|
$(".ai-panel").classList.toggle("permission-hidden", aiHidden);
|
|
182
|
-
$("#chapter-title").readOnly = proseReadOnly;
|
|
183
|
-
$("#chapter-content").readOnly = proseReadOnly;
|
|
184
|
-
$("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
|
|
185
|
-
$("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
|
|
186
193
|
$("#ai-prompt").readOnly = aiReadOnly;
|
|
187
194
|
$("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
|
|
188
195
|
$("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
|
|
189
196
|
if (proseReadOnly) {
|
|
197
|
+
chapterEditorReadOnly = true;
|
|
190
198
|
cancelChapterAutoSave();
|
|
191
199
|
state.dirty = false;
|
|
192
200
|
}
|
|
201
|
+
applyChapterEditorMode();
|
|
193
202
|
}
|
|
194
203
|
|
|
195
204
|
const $ = (selector) => document.querySelector(selector);
|
|
@@ -493,12 +502,12 @@ function syncChapterAutoSaveWithPresence() {
|
|
|
493
502
|
collaborationAutoSaveDisabled = hasOtherCollaborators();
|
|
494
503
|
if (collaborationAutoSaveDisabled) {
|
|
495
504
|
cancelChapterAutoSave();
|
|
496
|
-
if (state.chapter && canEditProse()) {
|
|
505
|
+
if (state.chapter && canEditProse() && !chapterEditorReadOnly) {
|
|
497
506
|
setSaveState(state.dirty ? "多人协作,自动保存已关闭" : "自动保存已关闭", state.dirty);
|
|
498
507
|
}
|
|
499
508
|
return;
|
|
500
509
|
}
|
|
501
|
-
if (wasDisabled && state.dirty && state.chapter && canEditProse()) scheduleChapterAutoSave(250);
|
|
510
|
+
if (wasDisabled && state.dirty && state.chapter && canEditProse() && !chapterEditorReadOnly) scheduleChapterAutoSave(250);
|
|
502
511
|
}
|
|
503
512
|
|
|
504
513
|
function renderPresence() {
|
|
@@ -704,6 +713,7 @@ let settingsReturnContext = null;
|
|
|
704
713
|
let entityEditorType = null;
|
|
705
714
|
let entityEditorDirty = false;
|
|
706
715
|
let entityEditorReadOnly = false;
|
|
716
|
+
let chapterEditorReadOnly = true;
|
|
707
717
|
let characterListPage = 1;
|
|
708
718
|
const characterFilters = { raceIds: [], organizationIds: [] };
|
|
709
719
|
let characterFiltersPanelOpen = false;
|
|
@@ -727,6 +737,28 @@ let characterSectionVditor = null;
|
|
|
727
737
|
let entityHistoryContext = null;
|
|
728
738
|
let moduleContentInteractionsBound = false;
|
|
729
739
|
|
|
740
|
+
function applyChapterEditorMode() {
|
|
741
|
+
const permissionBlocked = Boolean(state.work) && !canEditProse();
|
|
742
|
+
const viewOnly = permissionBlocked || chapterEditorReadOnly;
|
|
743
|
+
$("#editor-view").classList.toggle("is-read-only", viewOnly);
|
|
744
|
+
$("#chapter-title").readOnly = viewOnly;
|
|
745
|
+
$("#chapter-content").readOnly = viewOnly;
|
|
746
|
+
$("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
|
|
747
|
+
$("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
|
|
748
|
+
$("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
|
|
749
|
+
if (viewOnly) cancelChapterAutoSave();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function enterChapterEditMode() {
|
|
753
|
+
if (!state.chapter || !canEditProse()) return;
|
|
754
|
+
chapterEditorReadOnly = false;
|
|
755
|
+
applyChapterEditorMode();
|
|
756
|
+
const draft = chapterDraftSnapshot();
|
|
757
|
+
if (!sameChapterSnapshot(draft, lastSavedChapterSnapshot)) scheduleChapterAutoSave(120);
|
|
758
|
+
else setSaveState("已保存");
|
|
759
|
+
$("#chapter-content").focus();
|
|
760
|
+
}
|
|
761
|
+
|
|
730
762
|
function showEntityEditorPage(type, { readOnly = false } = {}) {
|
|
731
763
|
const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
|
|
732
764
|
const viewOnly = readOnly || !canEditModule(module);
|
|
@@ -1165,7 +1197,7 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
1165
1197
|
story_index: "作品目录与章节概要",
|
|
1166
1198
|
read_chapters: "读取章节",
|
|
1167
1199
|
grep: "查询正文关键字",
|
|
1168
|
-
|
|
1200
|
+
search_story_entities: "搜索作品实体",
|
|
1169
1201
|
read_character_sections: "读取人物 Markdown 章节"
|
|
1170
1202
|
};
|
|
1171
1203
|
|
|
@@ -1173,7 +1205,7 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
1173
1205
|
story_index: "分页读取当前作品的卷章目录和章节概要。",
|
|
1174
1206
|
read_chapters: "读取指定章节的概要、正文或两者。",
|
|
1175
1207
|
grep: "查询正文关键字所在的完整段落及章节信息。",
|
|
1176
|
-
|
|
1208
|
+
search_story_entities: "按实体名或关键词子串匹配设定、人物、组织等结构化记录;非语义检索。",
|
|
1177
1209
|
read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
|
|
1178
1210
|
};
|
|
1179
1211
|
|
|
@@ -1783,7 +1815,8 @@ async function api(path, options = {}) {
|
|
|
1783
1815
|
});
|
|
1784
1816
|
if (!response.ok) {
|
|
1785
1817
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
1786
|
-
|
|
1818
|
+
// Presence is best-effort; a heartbeat 401 must not force the login wall.
|
|
1819
|
+
if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
|
|
1787
1820
|
state.user = null;
|
|
1788
1821
|
state.csrfToken = null;
|
|
1789
1822
|
showAuth(false);
|
|
@@ -2084,7 +2117,7 @@ function cancelChapterAutoSave() {
|
|
|
2084
2117
|
}
|
|
2085
2118
|
|
|
2086
2119
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
2087
|
-
if (!state.chapter || !canEditProse()) return;
|
|
2120
|
+
if (!state.chapter || !canEditProse() || chapterEditorReadOnly) return;
|
|
2088
2121
|
cancelChapterAutoSave();
|
|
2089
2122
|
if (collaborationAutoSaveDisabled) {
|
|
2090
2123
|
setSaveState("多人协作,自动保存已关闭", true);
|
|
@@ -2098,7 +2131,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
|
2098
2131
|
}
|
|
2099
2132
|
|
|
2100
2133
|
async function persistChapter({ automatic = false } = {}) {
|
|
2101
|
-
if (!canEditProse()) return null;
|
|
2134
|
+
if (!canEditProse() || chapterEditorReadOnly) return null;
|
|
2102
2135
|
if (!state.chapter) {
|
|
2103
2136
|
if (!automatic) toast("请先选择章节", "error");
|
|
2104
2137
|
return null;
|
|
@@ -2679,6 +2712,7 @@ function resetWorkScopedUiCaches() {
|
|
|
2679
2712
|
state.settings = [];
|
|
2680
2713
|
characterListPage = 1;
|
|
2681
2714
|
state.collapsedVolumeIds.clear();
|
|
2715
|
+
state.collapsedRaceIds.clear();
|
|
2682
2716
|
lastSavedChapterSnapshot = null;
|
|
2683
2717
|
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
2684
2718
|
aiContextUsageTimer = null;
|
|
@@ -2712,6 +2746,7 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
2712
2746
|
settingsReturnContext = null;
|
|
2713
2747
|
state.work = nextWork;
|
|
2714
2748
|
state.chapter = null;
|
|
2749
|
+
chapterEditorReadOnly = true;
|
|
2715
2750
|
if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
|
|
2716
2751
|
applyWorkAccessMode();
|
|
2717
2752
|
updateDocumentTitle(state.work);
|
|
@@ -2764,7 +2799,13 @@ function renderTree() {
|
|
|
2764
2799
|
button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
|
|
2765
2800
|
});
|
|
2766
2801
|
$("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
|
|
2767
|
-
button.addEventListener("click", () =>
|
|
2802
|
+
button.addEventListener("click", () => {
|
|
2803
|
+
selectChapter(button.dataset.chapterId);
|
|
2804
|
+
if (isMobileViewport()) {
|
|
2805
|
+
panelLayout.leftCollapsed = true;
|
|
2806
|
+
applyPanelLayout(true);
|
|
2807
|
+
}
|
|
2808
|
+
});
|
|
2768
2809
|
button.addEventListener("contextmenu", (event) => {
|
|
2769
2810
|
if (!canEditProse()) return;
|
|
2770
2811
|
event.preventDefault();
|
|
@@ -2794,11 +2835,12 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
|
|
|
2794
2835
|
menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
|
|
2795
2836
|
}
|
|
2796
2837
|
|
|
2797
|
-
async function selectChapter(chapterId) {
|
|
2838
|
+
async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
2798
2839
|
if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
|
|
2799
2840
|
cancelChapterAutoSave();
|
|
2800
2841
|
state.chapter = await api(`/api/chapters/${chapterId}`);
|
|
2801
2842
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
2843
|
+
chapterEditorReadOnly = !canEditProse() || !editMode;
|
|
2802
2844
|
state.module = "editor";
|
|
2803
2845
|
applyWorkAccessMode();
|
|
2804
2846
|
markActiveModule("editor");
|
|
@@ -2818,6 +2860,7 @@ async function selectChapter(chapterId) {
|
|
|
2818
2860
|
$("#chapter-insight").classList.add("hidden");
|
|
2819
2861
|
updateChapterStats();
|
|
2820
2862
|
if (!canEditProse()) setSaveState("正文只读");
|
|
2863
|
+
else if (chapterEditorReadOnly) setSaveState("阅读模式");
|
|
2821
2864
|
else if (spacingChanged) scheduleChapterAutoSave(120);
|
|
2822
2865
|
else setSaveState("已保存");
|
|
2823
2866
|
renderTree();
|
|
@@ -2837,6 +2880,7 @@ async function saveChapter() {
|
|
|
2837
2880
|
|
|
2838
2881
|
function tidyChapterBlankLines() {
|
|
2839
2882
|
if (!state.chapter) return toast("请先选择章节", "error");
|
|
2883
|
+
if (chapterEditorReadOnly || !canEditProse()) return toast("请先进入编辑模式", "error");
|
|
2840
2884
|
const input = $("#chapter-content");
|
|
2841
2885
|
const normalized = normalizeParagraphSpacing(input.value);
|
|
2842
2886
|
if (normalized === input.value) return toast("正文空行已经符合要求");
|
|
@@ -3125,6 +3169,12 @@ function mountModuleLayoutToggle(layout, ariaLabel) {
|
|
|
3125
3169
|
$("#module-header-actions").insertAdjacentHTML("beforeend", renderModuleLayoutToggle(layout, ariaLabel));
|
|
3126
3170
|
}
|
|
3127
3171
|
|
|
3172
|
+
function mountModuleCount(count) {
|
|
3173
|
+
$("#module-header-actions").querySelector('[data-module-header-action="count"]')?.remove();
|
|
3174
|
+
const safeCount = Math.max(0, Number(count) || 0);
|
|
3175
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<span class="module-count-badge" data-module-header-action="count" aria-label="列表数量 ${safeCount}">${safeCount}</span>`);
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3128
3178
|
function bindModuleLayoutToggle(refresh) {
|
|
3129
3179
|
$("#module-header-actions").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
|
|
3130
3180
|
saveModuleLayout(button.dataset.moduleLayout);
|
|
@@ -3132,6 +3182,72 @@ function bindModuleLayoutToggle(refresh) {
|
|
|
3132
3182
|
}));
|
|
3133
3183
|
}
|
|
3134
3184
|
|
|
3185
|
+
function raceTreeExpandIconMarkup(action) {
|
|
3186
|
+
if (action === "collapse") {
|
|
3187
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 15 6-6 6 6"></path></svg>';
|
|
3188
|
+
}
|
|
3189
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg>';
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
function raceTreeExpandAction() {
|
|
3193
|
+
return state.collapsedRaceIds.size === 0 ? "collapse" : "expand";
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
function renderRaceTreeExpandToggle() {
|
|
3197
|
+
const action = raceTreeExpandAction();
|
|
3198
|
+
const label = action === "collapse" ? "全部折叠" : "全部展开";
|
|
3199
|
+
return `<div class="module-layout-toolbar race-tree-expand-toolbar" data-module-header-action="race-tree-expand">
|
|
3200
|
+
<div class="module-layout-toggle" role="group" aria-label="种族树展开折叠">
|
|
3201
|
+
<button type="button" data-race-tree-expand="${action}" aria-label="${label}" title="${label}">${raceTreeExpandIconMarkup(action)}</button>
|
|
3202
|
+
</div>
|
|
3203
|
+
</div>`;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
function mountRaceTreeExpandToggle() {
|
|
3207
|
+
$("#module-header-actions").querySelector('[data-module-header-action="race-tree-expand"]')?.remove();
|
|
3208
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", renderRaceTreeExpandToggle());
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
function syncRaceTreeExpandToggle() {
|
|
3212
|
+
const button = $("#module-header-actions").querySelector("[data-race-tree-expand]");
|
|
3213
|
+
if (!button) return;
|
|
3214
|
+
const action = raceTreeExpandAction();
|
|
3215
|
+
const label = action === "collapse" ? "全部折叠" : "全部展开";
|
|
3216
|
+
button.dataset.raceTreeExpand = action;
|
|
3217
|
+
button.setAttribute("aria-label", label);
|
|
3218
|
+
button.title = label;
|
|
3219
|
+
button.innerHTML = raceTreeExpandIconMarkup(action);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function setAllRaceTreeNodesOpen(open) {
|
|
3223
|
+
const nodes = $("#module-content").querySelectorAll("details.race-tree-node[data-race-node]");
|
|
3224
|
+
if (open) state.collapsedRaceIds.clear();
|
|
3225
|
+
else nodes.forEach((node) => state.collapsedRaceIds.add(node.dataset.raceNode));
|
|
3226
|
+
nodes.forEach((node) => {
|
|
3227
|
+
node.open = open;
|
|
3228
|
+
});
|
|
3229
|
+
syncRaceTreeExpandToggle();
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function bindRaceTreeNodeToggles() {
|
|
3233
|
+
$("#module-content").querySelectorAll("details.race-tree-node[data-race-node]").forEach((node) => {
|
|
3234
|
+
node.addEventListener("toggle", () => {
|
|
3235
|
+
const raceId = node.dataset.raceNode;
|
|
3236
|
+
if (!raceId) return;
|
|
3237
|
+
if (node.open) state.collapsedRaceIds.delete(raceId);
|
|
3238
|
+
else state.collapsedRaceIds.add(raceId);
|
|
3239
|
+
syncRaceTreeExpandToggle();
|
|
3240
|
+
});
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
function bindRaceTreeExpandToggle() {
|
|
3245
|
+
$("#module-header-actions").querySelector("[data-race-tree-expand]")?.addEventListener("click", (event) => {
|
|
3246
|
+
const button = event.currentTarget;
|
|
3247
|
+
setAllRaceTreeNodesOpen(button.dataset.raceTreeExpand === "expand");
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3135
3251
|
function mountCharacterFilterToggle() {
|
|
3136
3252
|
$("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.remove();
|
|
3137
3253
|
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="character-filter-toggle" aria-label="筛选角色" aria-controls="character-filter-panel" aria-expanded="${characterFiltersPanelOpen}" title="筛选角色"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
|
|
@@ -3245,8 +3361,9 @@ function renderSettingRows(records) {
|
|
|
3245
3361
|
}
|
|
3246
3362
|
|
|
3247
3363
|
async function renderSettings() {
|
|
3248
|
-
const records =
|
|
3364
|
+
const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
|
|
3249
3365
|
state.settings = records;
|
|
3366
|
+
mountModuleCount(records.length);
|
|
3250
3367
|
const layout = readModuleLayout();
|
|
3251
3368
|
if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
|
|
3252
3369
|
$("#module-content").innerHTML = records.length
|
|
@@ -3270,16 +3387,20 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3270
3387
|
: characterSource;
|
|
3271
3388
|
if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
|
|
3272
3389
|
characterListPage = characterPage.page;
|
|
3273
|
-
state.characters = characterPage.items;
|
|
3390
|
+
[state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
|
|
3391
|
+
mountModuleCount(characterPage.total);
|
|
3274
3392
|
const layout = readModuleLayout();
|
|
3275
3393
|
const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
|
|
3394
|
+
const characterLockBadge = (item) => item.lockedFields.length
|
|
3395
|
+
? `<span class="character-lock-badge" aria-label="${item.lockedFields.length} 个锁定字段" title="锁定字段:${esc(item.lockedFields.join("、"))}"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg><span>${item.lockedFields.length}</span></span>`
|
|
3396
|
+
: "";
|
|
3276
3397
|
const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
|
|
3277
3398
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
3278
3399
|
return `
|
|
3279
|
-
<article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
|
|
3280
|
-
<h3>${esc(item.name)}</h3>
|
|
3400
|
+
<article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
|
|
3401
|
+
<div class="character-card-heading"><h3>${esc(item.name)}</h3>${characterLockBadge(item)}</div>
|
|
3281
3402
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
3282
|
-
${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
3403
|
+
${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
3283
3404
|
${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
|
|
3284
3405
|
${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
|
|
3285
3406
|
${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(detail.label)}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
|
|
@@ -3298,9 +3419,8 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3298
3419
|
].filter(Boolean).join(" · ");
|
|
3299
3420
|
const line = meta ? `${meta} · ${preview}` : preview;
|
|
3300
3421
|
return `
|
|
3301
|
-
<article class="record-card module-row character-card preview-record-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
|
|
3302
|
-
|
|
3303
|
-
<h3>${esc(item.name)}</h3>
|
|
3422
|
+
<article class="record-card module-row character-row character-card preview-record-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
|
|
3423
|
+
<div class="character-card-heading"><h3>${esc(item.name)}</h3>${characterLockBadge(item)}</div>
|
|
3304
3424
|
<p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
|
|
3305
3425
|
<div class="card-actions">${characterActions(item)}</div>
|
|
3306
3426
|
</article>`;
|
|
@@ -3308,7 +3428,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3308
3428
|
const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
|
|
3309
3429
|
? `<nav class="module-pagination" aria-label="角色列表分页">
|
|
3310
3430
|
<button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
|
|
3311
|
-
<span>第 ${characterPage.page} 页 · 本页 ${state.characters.length} 个角色</span>
|
|
3431
|
+
<span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${state.characters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
|
|
3312
3432
|
<button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
|
|
3313
3433
|
</nav>`
|
|
3314
3434
|
: "";
|
|
@@ -3362,7 +3482,11 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3362
3482
|
}
|
|
3363
3483
|
|
|
3364
3484
|
async function renderRaces() {
|
|
3365
|
-
state.races = await
|
|
3485
|
+
[state.races, state.characters] = await Promise.all([
|
|
3486
|
+
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
3487
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
3488
|
+
]);
|
|
3489
|
+
mountModuleCount(state.races.length);
|
|
3366
3490
|
const layout = readModuleLayout();
|
|
3367
3491
|
const canEditRaces = canEditModule("races");
|
|
3368
3492
|
const raceActions = (item) => canEditRaces
|
|
@@ -3371,7 +3495,7 @@ async function renderRaces() {
|
|
|
3371
3495
|
const raceCardActions = (item) => canEditRaces
|
|
3372
3496
|
? raceActions(item)
|
|
3373
3497
|
: `<div class="card-actions">${raceActions(item)}</div>`;
|
|
3374
|
-
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
3498
|
+
const renderRaceNode = (item) => `<details class="race-tree-node"${state.collapsedRaceIds.has(item.id) ? "" : " open"} data-race-node="${esc(item.id)}">
|
|
3375
3499
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
3376
3500
|
<div class="race-tree-branch">
|
|
3377
3501
|
<article class="record-card race-card preview-record-card${canEditRaces ? " has-card-edit" : ""}" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}"><small>${item.memberIds.length} 位直接角色 · ${item.settingsCount ?? item.settings?.length ?? 0} 条自身设定</small>
|
|
@@ -3396,17 +3520,24 @@ async function renderRaces() {
|
|
|
3396
3520
|
</article>`;
|
|
3397
3521
|
}).join("")}</div>`;
|
|
3398
3522
|
if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
|
|
3523
|
+
if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
|
|
3399
3524
|
$("#module-content").innerHTML = state.races.length
|
|
3400
3525
|
? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
|
|
3401
3526
|
: emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
3402
3527
|
bindModuleLayoutToggle(renderRaces);
|
|
3528
|
+
bindRaceTreeExpandToggle();
|
|
3529
|
+
bindRaceTreeNodeToggles();
|
|
3403
3530
|
const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
|
|
3404
3531
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
|
|
3405
3532
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
3406
3533
|
}
|
|
3407
3534
|
|
|
3408
3535
|
async function renderOrganizations() {
|
|
3409
|
-
state.organizations = await
|
|
3536
|
+
[state.organizations, state.characters] = await Promise.all([
|
|
3537
|
+
apiAllPages(`/api/works/${state.work.id}/organizations`),
|
|
3538
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
3539
|
+
]);
|
|
3540
|
+
mountModuleCount(state.organizations.length);
|
|
3410
3541
|
const layout = readModuleLayout();
|
|
3411
3542
|
const canEditOrganizations = canEditModule("organizations");
|
|
3412
3543
|
const organizationActions = (item) => canEditOrganizations
|
|
@@ -3467,9 +3598,10 @@ function setTimelineMultiSelectMode(enabled) {
|
|
|
3467
3598
|
|
|
3468
3599
|
async function renderTimeline() {
|
|
3469
3600
|
const [events, tracks] = await Promise.all([
|
|
3470
|
-
|
|
3601
|
+
apiAllPages(`/api/works/${state.work.id}/timeline`),
|
|
3471
3602
|
apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
|
|
3472
3603
|
]);
|
|
3604
|
+
mountModuleCount(events.length);
|
|
3473
3605
|
timelineMultiSelectEnabled = false;
|
|
3474
3606
|
$("#timeline-tools")?.remove();
|
|
3475
3607
|
$("#module-header-actions").insertAdjacentHTML("beforeend", `<div id="timeline-tools" class="timeline-tools" data-module-header-action="timeline-tools" role="group" aria-label="时间轴操作"><button id="create-timeline-track" class="ghost-button" type="button">新建独立时间轴</button><button id="timeline-multi-select-toggle" class="ghost-button" type="button" aria-pressed="false">多选</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button" hidden>合并所选事件</button>' : ""}</div>`);
|
|
@@ -3507,9 +3639,10 @@ async function renderTimeline() {
|
|
|
3507
3639
|
async function renderOutlines() {
|
|
3508
3640
|
const currentChapterId = state.chapter?.id;
|
|
3509
3641
|
const [outlines, foreshadows] = await Promise.all([
|
|
3510
|
-
|
|
3511
|
-
|
|
3642
|
+
apiAllPages(`/api/works/${state.work.id}/outlines`),
|
|
3643
|
+
apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `¤tChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
|
|
3512
3644
|
]);
|
|
3645
|
+
mountModuleCount(outlines.length + foreshadows.length);
|
|
3513
3646
|
const layout = readModuleLayout();
|
|
3514
3647
|
const unresolved = foreshadows.filter((item) => item.unresolved);
|
|
3515
3648
|
const overdue = unresolved.filter((item) => item.overdue);
|
|
@@ -3555,7 +3688,8 @@ async function renderOutlines() {
|
|
|
3555
3688
|
|
|
3556
3689
|
async function renderRelationships() {
|
|
3557
3690
|
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
3558
|
-
const relationships =
|
|
3691
|
+
const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
|
|
3692
|
+
mountModuleCount(relationships.length);
|
|
3559
3693
|
const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
|
|
3560
3694
|
state.galaxy?.destroy();
|
|
3561
3695
|
state.relationshipExpandedMap?.destroy?.();
|
|
@@ -3590,9 +3724,10 @@ async function renderReviews() {
|
|
|
3590
3724
|
const canMergeCharacters = canResolveReview
|
|
3591
3725
|
&& ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
|
|
3592
3726
|
const [reviews, characters] = await Promise.all([
|
|
3593
|
-
|
|
3727
|
+
apiAllPages(`/api/works/${state.work.id}/reviews`),
|
|
3594
3728
|
canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
|
|
3595
3729
|
]);
|
|
3730
|
+
mountModuleCount(reviews.length);
|
|
3596
3731
|
const characterById = new Map(characters.map((character) => [character.id, character]));
|
|
3597
3732
|
const duplicateCard = (item) => {
|
|
3598
3733
|
const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
|
|
@@ -3677,11 +3812,12 @@ async function renderReviews() {
|
|
|
3677
3812
|
|
|
3678
3813
|
async function renderTasks() {
|
|
3679
3814
|
const [tasks, settings] = await Promise.all([
|
|
3680
|
-
|
|
3815
|
+
apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
|
|
3681
3816
|
canReadModule("ai-settings")
|
|
3682
3817
|
? api(`/api/works/${state.work.id}/ai-settings`)
|
|
3683
3818
|
: Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
|
|
3684
3819
|
]);
|
|
3820
|
+
mountModuleCount(tasks.length);
|
|
3685
3821
|
const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
|
|
3686
3822
|
const pendingCount = tasks.filter((item) => item.status === "pending").length;
|
|
3687
3823
|
const runningCount = tasks.filter((item) => item.status === "running").length;
|
|
@@ -3898,19 +4034,19 @@ async function renderBookAiSettings() {
|
|
|
3898
4034
|
api(`/api/works/${state.work.id}/task-defaults`)
|
|
3899
4035
|
]);
|
|
3900
4036
|
const host = $("#module-content");
|
|
3901
|
-
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "
|
|
3902
|
-
host.innerHTML = `<section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="
|
|
3903
|
-
host.querySelector('input[name="agent-tool"][value="
|
|
4037
|
+
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
|
|
4038
|
+
host.innerHTML = `<section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名或关键词子串匹配设定、人物、组织、时间线、关系、大纲和伏笔;非语义检索。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
|
|
4039
|
+
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
3904
4040
|
"beforebegin",
|
|
3905
4041
|
`<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
|
|
3906
4042
|
);
|
|
3907
|
-
host.querySelector('input[name="agent-tool"][value="
|
|
4043
|
+
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
3908
4044
|
"afterend",
|
|
3909
4045
|
`<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>`
|
|
3910
4046
|
);
|
|
3911
4047
|
if (!canEditModule("ai-settings")) {
|
|
3912
4048
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
3913
|
-
host.querySelectorAll(".
|
|
4049
|
+
host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
|
|
3914
4050
|
}
|
|
3915
4051
|
$("#save-work-system-prompt").addEventListener("click", async () => {
|
|
3916
4052
|
const button = $("#save-work-system-prompt");
|
|
@@ -3943,7 +4079,7 @@ async function renderBookAiSettings() {
|
|
|
3943
4079
|
button.disabled = true;
|
|
3944
4080
|
try {
|
|
3945
4081
|
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
|
|
3946
|
-
toast("
|
|
4082
|
+
toast("对话上下文 compact 阈值已保存");
|
|
3947
4083
|
scheduleAiContextUsage();
|
|
3948
4084
|
} catch (error) {
|
|
3949
4085
|
toast(error.message, "error");
|
|
@@ -4480,7 +4616,7 @@ async function openChapterDialog(volumeId = null) {
|
|
|
4480
4616
|
openDialog("新建章节", field("title", "章节标题") + field("volumeId", "所属卷", "select", selectedVolumeId, state.work.volumes.map((volume) => [volume.id, volume.title])) + field("chapterType", "章节类型", "select", "正文", chapterTypes.map((value) => [value, value])), async (form) => {
|
|
4481
4617
|
const chapter = await api(`/api/works/${state.work.id}/chapters`, { method: "POST", body: { title: form.get("title"), volumeId: form.get("volumeId"), chapterType: form.get("chapterType"), content: "" } });
|
|
4482
4618
|
state.work = await api(`/api/works/${state.work.id}`);
|
|
4483
|
-
await selectChapter(chapter.id);
|
|
4619
|
+
await selectChapter(chapter.id, { editMode: true });
|
|
4484
4620
|
});
|
|
4485
4621
|
}
|
|
4486
4622
|
|
|
@@ -5714,16 +5850,28 @@ function openTaskDialog() {
|
|
|
5714
5850
|
const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
|
|
5715
5851
|
const defaultTaskType = ANALYSIS_TYPES[0].value;
|
|
5716
5852
|
const taskTypeField = `<div class="form-field analysis-type-field"><label>分析类型<select name="taskType" aria-describedby="analysis-type-description">${ANALYSIS_TYPES.map(({ value, label }) => `<option value="${esc(value)}" ${value === defaultTaskType ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label><p id="analysis-type-description" class="analysis-type-description" aria-live="polite">${esc(analysisTypeDescription(defaultTaskType))}</p></div>`;
|
|
5717
|
-
|
|
5853
|
+
const chapterField = `<label class="task-chapter-field">章节<select name="chapterId">${chapterOptions.map(([key, text], index) => `<option value="${esc(key)}" ${index === 0 ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
|
|
5854
|
+
openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField, async (form) => {
|
|
5718
5855
|
const scope = form.get("taskType") === "character-identity-audit" || form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
|
|
5719
5856
|
await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
|
|
5720
5857
|
await renderTasks();
|
|
5721
5858
|
});
|
|
5722
5859
|
const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
|
|
5860
|
+
const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
|
|
5861
|
+
const chapterSelect = $("#dialog-fields").querySelector('select[name="chapterId"]');
|
|
5862
|
+
const chapterFieldElement = chapterSelect.closest(".task-chapter-field");
|
|
5723
5863
|
const description = $("#analysis-type-description");
|
|
5864
|
+
const syncChapterField = () => {
|
|
5865
|
+
const disabled = scopeTypeSelect.value === "book";
|
|
5866
|
+
chapterSelect.disabled = disabled;
|
|
5867
|
+
chapterFieldElement.classList.toggle("is-disabled", disabled);
|
|
5868
|
+
chapterFieldElement.setAttribute("aria-disabled", String(disabled));
|
|
5869
|
+
};
|
|
5724
5870
|
taskTypeSelect.addEventListener("change", () => {
|
|
5725
5871
|
description.textContent = analysisTypeDescription(taskTypeSelect.value);
|
|
5726
5872
|
});
|
|
5873
|
+
scopeTypeSelect.addEventListener("change", syncChapterField);
|
|
5874
|
+
syncChapterField();
|
|
5727
5875
|
}
|
|
5728
5876
|
|
|
5729
5877
|
function openProviderDialog(item) {
|
|
@@ -6223,6 +6371,196 @@ $("#account-settings-button").addEventListener("click", () => {
|
|
|
6223
6371
|
});
|
|
6224
6372
|
$("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
|
|
6225
6373
|
$("#avatar-upload-button").addEventListener("click", () => $("#avatar-file").click());
|
|
6374
|
+
|
|
6375
|
+
const avatarCropSession = {
|
|
6376
|
+
objectUrl: null,
|
|
6377
|
+
imageWidth: 0,
|
|
6378
|
+
imageHeight: 0,
|
|
6379
|
+
crop: { x: 0, y: 0, size: 0 },
|
|
6380
|
+
display: { x: 0, y: 0, width: 0, height: 0, scale: 0 },
|
|
6381
|
+
drag: null,
|
|
6382
|
+
uploading: false
|
|
6383
|
+
};
|
|
6384
|
+
|
|
6385
|
+
function releaseAvatarCropObjectUrl() {
|
|
6386
|
+
if (!avatarCropSession.objectUrl) return;
|
|
6387
|
+
URL.revokeObjectURL(avatarCropSession.objectUrl);
|
|
6388
|
+
avatarCropSession.objectUrl = null;
|
|
6389
|
+
}
|
|
6390
|
+
|
|
6391
|
+
function closeAvatarCropDialog() {
|
|
6392
|
+
const dialog = $("#avatar-crop-dialog");
|
|
6393
|
+
if (dialog?.open) dialog.close();
|
|
6394
|
+
}
|
|
6395
|
+
|
|
6396
|
+
function resetAvatarCropDialog() {
|
|
6397
|
+
releaseAvatarCropObjectUrl();
|
|
6398
|
+
avatarCropSession.drag = null;
|
|
6399
|
+
avatarCropSession.imageWidth = 0;
|
|
6400
|
+
avatarCropSession.imageHeight = 0;
|
|
6401
|
+
avatarCropSession.crop = { x: 0, y: 0, size: 0 };
|
|
6402
|
+
const image = $("#avatar-crop-image");
|
|
6403
|
+
if (image) image.removeAttribute("src");
|
|
6404
|
+
$("#avatar-crop-selection")?.setAttribute("hidden", "");
|
|
6405
|
+
$("#avatar-crop-preview")?.replaceChildren();
|
|
6406
|
+
const fileInput = $("#avatar-file");
|
|
6407
|
+
if (fileInput) fileInput.value = "";
|
|
6408
|
+
}
|
|
6409
|
+
|
|
6410
|
+
function stagePointFromEvent(event) {
|
|
6411
|
+
const stage = $("#avatar-crop-stage");
|
|
6412
|
+
const bounds = stage.getBoundingClientRect();
|
|
6413
|
+
return {
|
|
6414
|
+
x: event.clientX - bounds.left,
|
|
6415
|
+
y: event.clientY - bounds.top
|
|
6416
|
+
};
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6419
|
+
function paintAvatarCropPreview() {
|
|
6420
|
+
const preview = $("#avatar-crop-preview");
|
|
6421
|
+
const image = $("#avatar-crop-image");
|
|
6422
|
+
if (!preview || !image?.naturalWidth || avatarCropSession.crop.size < 1) {
|
|
6423
|
+
preview?.replaceChildren();
|
|
6424
|
+
return;
|
|
6425
|
+
}
|
|
6426
|
+
const output = cropOutputSize(avatarCropSession.crop.size);
|
|
6427
|
+
let canvas = preview.querySelector("canvas");
|
|
6428
|
+
if (!canvas) {
|
|
6429
|
+
preview.replaceChildren();
|
|
6430
|
+
canvas = document.createElement("canvas");
|
|
6431
|
+
preview.append(canvas);
|
|
6432
|
+
}
|
|
6433
|
+
canvas.width = output;
|
|
6434
|
+
canvas.height = output;
|
|
6435
|
+
const context = canvas.getContext("2d");
|
|
6436
|
+
if (!context) return;
|
|
6437
|
+
context.clearRect(0, 0, output, output);
|
|
6438
|
+
context.beginPath();
|
|
6439
|
+
context.arc(output / 2, output / 2, output / 2, 0, Math.PI * 2);
|
|
6440
|
+
context.closePath();
|
|
6441
|
+
context.clip();
|
|
6442
|
+
context.drawImage(
|
|
6443
|
+
image,
|
|
6444
|
+
avatarCropSession.crop.x,
|
|
6445
|
+
avatarCropSession.crop.y,
|
|
6446
|
+
avatarCropSession.crop.size,
|
|
6447
|
+
avatarCropSession.crop.size,
|
|
6448
|
+
0,
|
|
6449
|
+
0,
|
|
6450
|
+
output,
|
|
6451
|
+
output
|
|
6452
|
+
);
|
|
6453
|
+
}
|
|
6454
|
+
|
|
6455
|
+
function renderAvatarCropSelection() {
|
|
6456
|
+
const stage = $("#avatar-crop-stage");
|
|
6457
|
+
const selection = $("#avatar-crop-selection");
|
|
6458
|
+
const shade = $("#avatar-crop-shade");
|
|
6459
|
+
const image = $("#avatar-crop-image");
|
|
6460
|
+
if (!stage || !selection || !shade || !image?.naturalWidth) return;
|
|
6461
|
+
|
|
6462
|
+
avatarCropSession.display = containImageRect(
|
|
6463
|
+
avatarCropSession.imageWidth,
|
|
6464
|
+
avatarCropSession.imageHeight,
|
|
6465
|
+
stage.clientWidth,
|
|
6466
|
+
stage.clientHeight
|
|
6467
|
+
);
|
|
6468
|
+
const shown = mapImageRectToDisplay(avatarCropSession.crop, avatarCropSession.display);
|
|
6469
|
+
if (shown.size < 1) {
|
|
6470
|
+
selection.setAttribute("hidden", "");
|
|
6471
|
+
shade.style.webkitMaskSize = "100% 100%, 0 0";
|
|
6472
|
+
shade.style.maskSize = "100% 100%, 0 0";
|
|
6473
|
+
return;
|
|
6474
|
+
}
|
|
6475
|
+
selection.hidden = false;
|
|
6476
|
+
selection.style.left = `${shown.x}px`;
|
|
6477
|
+
selection.style.top = `${shown.y}px`;
|
|
6478
|
+
selection.style.width = `${shown.size}px`;
|
|
6479
|
+
selection.style.height = `${shown.size}px`;
|
|
6480
|
+
const hole = `${Math.max(0, shown.size)}px ${Math.max(0, shown.size)}px`;
|
|
6481
|
+
const position = `${shown.x}px ${shown.y}px`;
|
|
6482
|
+
shade.style.webkitMaskSize = `100% 100%, ${hole}`;
|
|
6483
|
+
shade.style.maskSize = `100% 100%, ${hole}`;
|
|
6484
|
+
shade.style.webkitMaskPosition = `0 0, ${position}`;
|
|
6485
|
+
shade.style.maskPosition = `0 0, ${position}`;
|
|
6486
|
+
paintAvatarCropPreview();
|
|
6487
|
+
}
|
|
6488
|
+
|
|
6489
|
+
function applyAvatarCropRect(next) {
|
|
6490
|
+
avatarCropSession.crop = clampCropRect(next, avatarCropSession.imageWidth, avatarCropSession.imageHeight);
|
|
6491
|
+
renderAvatarCropSelection();
|
|
6492
|
+
}
|
|
6493
|
+
|
|
6494
|
+
async function openAvatarCropDialog(file) {
|
|
6495
|
+
releaseAvatarCropObjectUrl();
|
|
6496
|
+
const objectUrl = URL.createObjectURL(file);
|
|
6497
|
+
avatarCropSession.objectUrl = objectUrl;
|
|
6498
|
+
const image = $("#avatar-crop-image");
|
|
6499
|
+
const dialog = $("#avatar-crop-dialog");
|
|
6500
|
+
await new Promise((resolve, reject) => {
|
|
6501
|
+
const onLoad = () => {
|
|
6502
|
+
image.removeEventListener("error", onError);
|
|
6503
|
+
resolve();
|
|
6504
|
+
};
|
|
6505
|
+
const onError = () => {
|
|
6506
|
+
image.removeEventListener("load", onLoad);
|
|
6507
|
+
reject(new Error("无法读取所选图片"));
|
|
6508
|
+
};
|
|
6509
|
+
image.addEventListener("load", onLoad, { once: true });
|
|
6510
|
+
image.addEventListener("error", onError, { once: true });
|
|
6511
|
+
image.src = objectUrl;
|
|
6512
|
+
});
|
|
6513
|
+
avatarCropSession.imageWidth = image.naturalWidth;
|
|
6514
|
+
avatarCropSession.imageHeight = image.naturalHeight;
|
|
6515
|
+
if (avatarCropSession.imageWidth > 4096 || avatarCropSession.imageHeight > 4096) {
|
|
6516
|
+
resetAvatarCropDialog();
|
|
6517
|
+
throw new Error("图片尺寸不能超过 4096 × 4096 像素");
|
|
6518
|
+
}
|
|
6519
|
+
avatarCropSession.crop = defaultCropRect(avatarCropSession.imageWidth, avatarCropSession.imageHeight);
|
|
6520
|
+
if (avatarCropSession.crop.size < 1) {
|
|
6521
|
+
resetAvatarCropDialog();
|
|
6522
|
+
throw new Error("图片尺寸无效");
|
|
6523
|
+
}
|
|
6524
|
+
if (!dialog.open) dialog.showModal();
|
|
6525
|
+
requestAnimationFrame(() => {
|
|
6526
|
+
renderAvatarCropSelection();
|
|
6527
|
+
$("#avatar-crop-confirm")?.focus();
|
|
6528
|
+
});
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6531
|
+
function exportAvatarCropBlob() {
|
|
6532
|
+
const image = $("#avatar-crop-image");
|
|
6533
|
+
if (!image?.naturalWidth || avatarCropSession.crop.size < 1) {
|
|
6534
|
+
return Promise.reject(new Error("请先完成头像裁剪"));
|
|
6535
|
+
}
|
|
6536
|
+
const output = cropOutputSize(avatarCropSession.crop.size);
|
|
6537
|
+
const canvas = document.createElement("canvas");
|
|
6538
|
+
canvas.width = output;
|
|
6539
|
+
canvas.height = output;
|
|
6540
|
+
const context = canvas.getContext("2d");
|
|
6541
|
+
if (!context) return Promise.reject(new Error("当前浏览器无法裁剪图片"));
|
|
6542
|
+
context.drawImage(
|
|
6543
|
+
image,
|
|
6544
|
+
avatarCropSession.crop.x,
|
|
6545
|
+
avatarCropSession.crop.y,
|
|
6546
|
+
avatarCropSession.crop.size,
|
|
6547
|
+
avatarCropSession.crop.size,
|
|
6548
|
+
0,
|
|
6549
|
+
0,
|
|
6550
|
+
output,
|
|
6551
|
+
output
|
|
6552
|
+
);
|
|
6553
|
+
return new Promise((resolve, reject) => {
|
|
6554
|
+
canvas.toBlob((blob) => {
|
|
6555
|
+
if (!blob) {
|
|
6556
|
+
reject(new Error("头像裁剪失败"));
|
|
6557
|
+
return;
|
|
6558
|
+
}
|
|
6559
|
+
resolve(blob);
|
|
6560
|
+
}, "image/png");
|
|
6561
|
+
});
|
|
6562
|
+
}
|
|
6563
|
+
|
|
6226
6564
|
$("#avatar-file").addEventListener("change", async (event) => {
|
|
6227
6565
|
const file = event.target.files[0];
|
|
6228
6566
|
if (!file) return;
|
|
@@ -6231,23 +6569,115 @@ $("#avatar-file").addEventListener("change", async (event) => {
|
|
|
6231
6569
|
event.target.value = "";
|
|
6232
6570
|
return;
|
|
6233
6571
|
}
|
|
6234
|
-
|
|
6235
|
-
|
|
6572
|
+
try {
|
|
6573
|
+
await openAvatarCropDialog(file);
|
|
6574
|
+
} catch (error) {
|
|
6575
|
+
toast(error.message, "error");
|
|
6576
|
+
event.target.value = "";
|
|
6577
|
+
}
|
|
6578
|
+
});
|
|
6579
|
+
|
|
6580
|
+
$("#avatar-crop-stage").addEventListener("pointerdown", (event) => {
|
|
6581
|
+
if (!$("#avatar-crop-dialog")?.open || avatarCropSession.uploading) return;
|
|
6582
|
+
const handle = event.target.closest("[data-avatar-crop-handle]")?.dataset.avatarCropHandle;
|
|
6583
|
+
const onSelection = event.target.closest("#avatar-crop-selection");
|
|
6584
|
+
if (!handle && !onSelection) return;
|
|
6585
|
+
event.preventDefault();
|
|
6586
|
+
const point = stagePointFromEvent(event);
|
|
6587
|
+
const imagePoint = mapDisplayPointToImage(point, avatarCropSession.display);
|
|
6588
|
+
avatarCropSession.drag = {
|
|
6589
|
+
mode: handle ? "resize" : "move",
|
|
6590
|
+
handle: handle || null,
|
|
6591
|
+
originX: imagePoint.x,
|
|
6592
|
+
originY: imagePoint.y,
|
|
6593
|
+
startCrop: { ...avatarCropSession.crop }
|
|
6594
|
+
};
|
|
6595
|
+
$("#avatar-crop-selection").classList.add("is-dragging");
|
|
6596
|
+
$("#avatar-crop-stage").setPointerCapture(event.pointerId);
|
|
6597
|
+
});
|
|
6598
|
+
|
|
6599
|
+
$("#avatar-crop-stage").addEventListener("pointermove", (event) => {
|
|
6600
|
+
if (!avatarCropSession.drag) return;
|
|
6601
|
+
const point = stagePointFromEvent(event);
|
|
6602
|
+
const imagePoint = mapDisplayPointToImage(point, avatarCropSession.display);
|
|
6603
|
+
if (avatarCropSession.drag.mode === "move") {
|
|
6604
|
+
applyAvatarCropRect(moveCropRect(
|
|
6605
|
+
avatarCropSession.drag.startCrop,
|
|
6606
|
+
imagePoint.x - avatarCropSession.drag.originX,
|
|
6607
|
+
imagePoint.y - avatarCropSession.drag.originY,
|
|
6608
|
+
avatarCropSession.imageWidth,
|
|
6609
|
+
avatarCropSession.imageHeight
|
|
6610
|
+
));
|
|
6611
|
+
return;
|
|
6612
|
+
}
|
|
6613
|
+
applyAvatarCropRect(resizeCropRect(
|
|
6614
|
+
avatarCropSession.drag.startCrop,
|
|
6615
|
+
avatarCropSession.drag.handle,
|
|
6616
|
+
imagePoint.x,
|
|
6617
|
+
imagePoint.y,
|
|
6618
|
+
avatarCropSession.imageWidth,
|
|
6619
|
+
avatarCropSession.imageHeight
|
|
6620
|
+
));
|
|
6621
|
+
});
|
|
6622
|
+
|
|
6623
|
+
function endAvatarCropDrag(event) {
|
|
6624
|
+
if (!avatarCropSession.drag) return;
|
|
6625
|
+
avatarCropSession.drag = null;
|
|
6626
|
+
$("#avatar-crop-selection")?.classList.remove("is-dragging");
|
|
6627
|
+
if (event?.pointerId != null && $("#avatar-crop-stage")?.hasPointerCapture?.(event.pointerId)) {
|
|
6628
|
+
$("#avatar-crop-stage").releasePointerCapture(event.pointerId);
|
|
6629
|
+
}
|
|
6630
|
+
}
|
|
6631
|
+
|
|
6632
|
+
$("#avatar-crop-stage").addEventListener("pointerup", endAvatarCropDrag);
|
|
6633
|
+
$("#avatar-crop-stage").addEventListener("pointercancel", endAvatarCropDrag);
|
|
6634
|
+
|
|
6635
|
+
window.addEventListener("resize", () => {
|
|
6636
|
+
if ($("#avatar-crop-dialog")?.open) renderAvatarCropSelection();
|
|
6637
|
+
});
|
|
6638
|
+
|
|
6639
|
+
$("#avatar-crop-close").addEventListener("click", () => {
|
|
6640
|
+
if (!avatarCropSession.uploading) closeAvatarCropDialog();
|
|
6641
|
+
});
|
|
6642
|
+
$("#avatar-crop-cancel").addEventListener("click", () => {
|
|
6643
|
+
if (!avatarCropSession.uploading) closeAvatarCropDialog();
|
|
6644
|
+
});
|
|
6645
|
+
$("#avatar-crop-dialog").addEventListener("cancel", (event) => {
|
|
6646
|
+
if (avatarCropSession.uploading) event.preventDefault();
|
|
6647
|
+
});
|
|
6648
|
+
$("#avatar-crop-dialog").addEventListener("close", () => {
|
|
6649
|
+
if (avatarCropSession.uploading) return;
|
|
6650
|
+
resetAvatarCropDialog();
|
|
6651
|
+
});
|
|
6652
|
+
|
|
6653
|
+
$("#avatar-crop-confirm").addEventListener("click", async () => {
|
|
6654
|
+
if (avatarCropSession.uploading) return;
|
|
6655
|
+
avatarCropSession.uploading = true;
|
|
6656
|
+
$("#avatar-crop-confirm").disabled = true;
|
|
6657
|
+
$("#avatar-crop-cancel").disabled = true;
|
|
6236
6658
|
$("#avatar-upload-button").disabled = true;
|
|
6237
6659
|
$("#avatar-remove-button").disabled = true;
|
|
6238
6660
|
try {
|
|
6661
|
+
const blob = await exportAvatarCropBlob();
|
|
6662
|
+
const body = new FormData();
|
|
6663
|
+
body.append("file", blob, "avatar.png");
|
|
6239
6664
|
const updated = await api("/api/auth/avatar", { method: "PUT", body });
|
|
6240
6665
|
applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
|
|
6241
6666
|
renderProfileAvatar();
|
|
6667
|
+
avatarCropSession.uploading = false;
|
|
6668
|
+
closeAvatarCropDialog();
|
|
6242
6669
|
toast("头像已更新");
|
|
6243
6670
|
} catch (error) {
|
|
6244
6671
|
toast(error.message, "error");
|
|
6245
6672
|
} finally {
|
|
6673
|
+
avatarCropSession.uploading = false;
|
|
6674
|
+
$("#avatar-crop-confirm").disabled = false;
|
|
6675
|
+
$("#avatar-crop-cancel").disabled = false;
|
|
6246
6676
|
$("#avatar-upload-button").disabled = false;
|
|
6247
6677
|
$("#avatar-remove-button").disabled = false;
|
|
6248
|
-
event.target.value = "";
|
|
6249
6678
|
}
|
|
6250
6679
|
});
|
|
6680
|
+
|
|
6251
6681
|
$("#avatar-remove-button").addEventListener("click", async () => {
|
|
6252
6682
|
if (!state.user?.avatarUrl || !(await confirmToast("确定移除当前头像吗?", { title: "移除头像", confirmLabel: "确认移除" }))) return;
|
|
6253
6683
|
$("#avatar-upload-button").disabled = true;
|
|
@@ -6487,6 +6917,7 @@ $("#platform-new-provider").addEventListener("click", () => openProviderDialog()
|
|
|
6487
6917
|
$("#shelf-new-work").addEventListener("click", openWorkDialog);
|
|
6488
6918
|
$("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
|
|
6489
6919
|
$("#save-button").addEventListener("click", saveChapter);
|
|
6920
|
+
$("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
|
|
6490
6921
|
$("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
|
|
6491
6922
|
$("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
6492
6923
|
$("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
|
|
@@ -6615,7 +7046,7 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
6615
7046
|
}
|
|
6616
7047
|
if (button.dataset.module) {
|
|
6617
7048
|
void showModule(button.dataset.module).finally(() => {
|
|
6618
|
-
if (
|
|
7049
|
+
if (isMobileViewport()) {
|
|
6619
7050
|
panelLayout.leftCollapsed = true;
|
|
6620
7051
|
applyPanelLayout(true);
|
|
6621
7052
|
}
|