@musnows/scriverse 0.4.7 → 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.
@@ -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
- query_story_knowledge: "查询作品知识",
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
- query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。",
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
- if (response.status === 401 && !path.startsWith("/api/auth/")) {
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);
@@ -2800,11 +2835,12 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
2800
2835
  menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
2801
2836
  }
2802
2837
 
2803
- async function selectChapter(chapterId) {
2838
+ async function selectChapter(chapterId, { editMode = false } = {}) {
2804
2839
  if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
2805
2840
  cancelChapterAutoSave();
2806
2841
  state.chapter = await api(`/api/chapters/${chapterId}`);
2807
2842
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
2843
+ chapterEditorReadOnly = !canEditProse() || !editMode;
2808
2844
  state.module = "editor";
2809
2845
  applyWorkAccessMode();
2810
2846
  markActiveModule("editor");
@@ -2824,6 +2860,7 @@ async function selectChapter(chapterId) {
2824
2860
  $("#chapter-insight").classList.add("hidden");
2825
2861
  updateChapterStats();
2826
2862
  if (!canEditProse()) setSaveState("正文只读");
2863
+ else if (chapterEditorReadOnly) setSaveState("阅读模式");
2827
2864
  else if (spacingChanged) scheduleChapterAutoSave(120);
2828
2865
  else setSaveState("已保存");
2829
2866
  renderTree();
@@ -2843,6 +2880,7 @@ async function saveChapter() {
2843
2880
 
2844
2881
  function tidyChapterBlankLines() {
2845
2882
  if (!state.chapter) return toast("请先选择章节", "error");
2883
+ if (chapterEditorReadOnly || !canEditProse()) return toast("请先进入编辑模式", "error");
2846
2884
  const input = $("#chapter-content");
2847
2885
  const normalized = normalizeParagraphSpacing(input.value);
2848
2886
  if (normalized === input.value) return toast("正文空行已经符合要求");
@@ -3131,6 +3169,12 @@ function mountModuleLayoutToggle(layout, ariaLabel) {
3131
3169
  $("#module-header-actions").insertAdjacentHTML("beforeend", renderModuleLayoutToggle(layout, ariaLabel));
3132
3170
  }
3133
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
+
3134
3178
  function bindModuleLayoutToggle(refresh) {
3135
3179
  $("#module-header-actions").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
3136
3180
  saveModuleLayout(button.dataset.moduleLayout);
@@ -3138,6 +3182,72 @@ function bindModuleLayoutToggle(refresh) {
3138
3182
  }));
3139
3183
  }
3140
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
+
3141
3251
  function mountCharacterFilterToggle() {
3142
3252
  $("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.remove();
3143
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>`);
@@ -3251,8 +3361,9 @@ function renderSettingRows(records) {
3251
3361
  }
3252
3362
 
3253
3363
  async function renderSettings() {
3254
- const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
3364
+ const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
3255
3365
  state.settings = records;
3366
+ mountModuleCount(records.length);
3256
3367
  const layout = readModuleLayout();
3257
3368
  if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
3258
3369
  $("#module-content").innerHTML = records.length
@@ -3276,7 +3387,8 @@ async function renderCharacters(page = characterListPage) {
3276
3387
  : characterSource;
3277
3388
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3278
3389
  characterListPage = characterPage.page;
3279
- state.characters = characterPage.items;
3390
+ [state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
3391
+ mountModuleCount(characterPage.total);
3280
3392
  const layout = readModuleLayout();
3281
3393
  const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
3282
3394
  const characterLockBadge = (item) => item.lockedFields.length
@@ -3316,7 +3428,7 @@ async function renderCharacters(page = characterListPage) {
3316
3428
  const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
3317
3429
  ? `<nav class="module-pagination" aria-label="角色列表分页">
3318
3430
  <button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
3319
- <span>第 ${characterPage.page} 页 · 本页 ${state.characters.length} 个角色</span>
3431
+ <span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${state.characters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
3320
3432
  <button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
3321
3433
  </nav>`
3322
3434
  : "";
@@ -3370,7 +3482,11 @@ async function renderCharacters(page = characterListPage) {
3370
3482
  }
3371
3483
 
3372
3484
  async function renderRaces() {
3373
- state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
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);
3374
3490
  const layout = readModuleLayout();
3375
3491
  const canEditRaces = canEditModule("races");
3376
3492
  const raceActions = (item) => canEditRaces
@@ -3379,7 +3495,7 @@ async function renderRaces() {
3379
3495
  const raceCardActions = (item) => canEditRaces
3380
3496
  ? raceActions(item)
3381
3497
  : `<div class="card-actions">${raceActions(item)}</div>`;
3382
- 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)}">
3383
3499
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
3384
3500
  <div class="race-tree-branch">
3385
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>
@@ -3404,17 +3520,24 @@ async function renderRaces() {
3404
3520
  </article>`;
3405
3521
  }).join("")}</div>`;
3406
3522
  if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
3523
+ if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
3407
3524
  $("#module-content").innerHTML = state.races.length
3408
3525
  ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
3409
3526
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
3410
3527
  bindModuleLayoutToggle(renderRaces);
3528
+ bindRaceTreeExpandToggle();
3529
+ bindRaceTreeNodeToggles();
3411
3530
  const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
3412
3531
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
3413
3532
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
3414
3533
  }
3415
3534
 
3416
3535
  async function renderOrganizations() {
3417
- state.organizations = await apiAllPages(`/api/works/${state.work.id}/organizations`);
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);
3418
3541
  const layout = readModuleLayout();
3419
3542
  const canEditOrganizations = canEditModule("organizations");
3420
3543
  const organizationActions = (item) => canEditOrganizations
@@ -3475,9 +3598,10 @@ function setTimelineMultiSelectMode(enabled) {
3475
3598
 
3476
3599
  async function renderTimeline() {
3477
3600
  const [events, tracks] = await Promise.all([
3478
- apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
3601
+ apiAllPages(`/api/works/${state.work.id}/timeline`),
3479
3602
  apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
3480
3603
  ]);
3604
+ mountModuleCount(events.length);
3481
3605
  timelineMultiSelectEnabled = false;
3482
3606
  $("#timeline-tools")?.remove();
3483
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>`);
@@ -3515,9 +3639,10 @@ async function renderTimeline() {
3515
3639
  async function renderOutlines() {
3516
3640
  const currentChapterId = state.chapter?.id;
3517
3641
  const [outlines, foreshadows] = await Promise.all([
3518
- apiPage(`/api/works/${state.work.id}/outlines`).then((result) => result.items),
3519
- apiPage(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`).then((result) => result.items)
3642
+ apiAllPages(`/api/works/${state.work.id}/outlines`),
3643
+ apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
3520
3644
  ]);
3645
+ mountModuleCount(outlines.length + foreshadows.length);
3521
3646
  const layout = readModuleLayout();
3522
3647
  const unresolved = foreshadows.filter((item) => item.unresolved);
3523
3648
  const overdue = unresolved.filter((item) => item.overdue);
@@ -3563,7 +3688,8 @@ async function renderOutlines() {
3563
3688
 
3564
3689
  async function renderRelationships() {
3565
3690
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3566
- const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
3691
+ const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
3692
+ mountModuleCount(relationships.length);
3567
3693
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
3568
3694
  state.galaxy?.destroy();
3569
3695
  state.relationshipExpandedMap?.destroy?.();
@@ -3598,9 +3724,10 @@ async function renderReviews() {
3598
3724
  const canMergeCharacters = canResolveReview
3599
3725
  && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
3600
3726
  const [reviews, characters] = await Promise.all([
3601
- apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
3727
+ apiAllPages(`/api/works/${state.work.id}/reviews`),
3602
3728
  canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
3603
3729
  ]);
3730
+ mountModuleCount(reviews.length);
3604
3731
  const characterById = new Map(characters.map((character) => [character.id, character]));
3605
3732
  const duplicateCard = (item) => {
3606
3733
  const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
@@ -3685,11 +3812,12 @@ async function renderReviews() {
3685
3812
 
3686
3813
  async function renderTasks() {
3687
3814
  const [tasks, settings] = await Promise.all([
3688
- apiPage(`/api/works/${state.work.id}/tasks?view=summary`).then((result) => result.items),
3815
+ apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
3689
3816
  canReadModule("ai-settings")
3690
3817
  ? api(`/api/works/${state.work.id}/ai-settings`)
3691
3818
  : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
3692
3819
  ]);
3820
+ mountModuleCount(tasks.length);
3693
3821
  const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
3694
3822
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
3695
3823
  const runningCount = tasks.filter((item) => item.status === "running").length;
@@ -3906,19 +4034,19 @@ async function renderBookAiSettings() {
3906
4034
  api(`/api/works/${state.work.id}/task-defaults`)
3907
4035
  ]);
3908
4036
  const host = $("#module-content");
3909
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge", "read_character_sections"]);
3910
- 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="primary-button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="field-label"><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></div><div class="card-actions"><button id="save-book-summary-context-percent" class="primary-button">保存概要配额</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话长期记忆</h2><p>对话历史使用独立预算;达到阈值时先提醒,继续发送会把较早消息整理成带来源的结构化长期记忆,并尽量保留最近八条原文。</p></div></div><div class="field-label"><label class="context-compact-threshold-field">整理提醒阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话长期记忆整理提醒阈值"></label></div><div class="card-actions"><button id="save-context-compact-threshold" class="primary-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="query_story_knowledge" ${agentTools.has("query_story_knowledge") ? "checked" : ""}><span><strong>查询作品知识</strong><small>按关键词查询设定、人物、组织、时间线、关系、大纲和伏笔。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="primary-button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
3911
- host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
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(
3912
4040
  "beforebegin",
3913
4041
  `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
3914
4042
  );
3915
- host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
4043
+ host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
3916
4044
  "afterend",
3917
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>`
3918
4046
  );
3919
4047
  if (!canEditModule("ai-settings")) {
3920
4048
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
3921
- host.querySelectorAll(".primary-button").forEach((button) => button.classList.add("permission-hidden"));
4049
+ host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
3922
4050
  }
3923
4051
  $("#save-work-system-prompt").addEventListener("click", async () => {
3924
4052
  const button = $("#save-work-system-prompt");
@@ -3951,7 +4079,7 @@ async function renderBookAiSettings() {
3951
4079
  button.disabled = true;
3952
4080
  try {
3953
4081
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
3954
- toast("对话长期记忆整理阈值已保存");
4082
+ toast("对话上下文 compact 阈值已保存");
3955
4083
  scheduleAiContextUsage();
3956
4084
  } catch (error) {
3957
4085
  toast(error.message, "error");
@@ -4488,7 +4616,7 @@ async function openChapterDialog(volumeId = null) {
4488
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) => {
4489
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: "" } });
4490
4618
  state.work = await api(`/api/works/${state.work.id}`);
4491
- await selectChapter(chapter.id);
4619
+ await selectChapter(chapter.id, { editMode: true });
4492
4620
  });
4493
4621
  }
4494
4622
 
@@ -6243,6 +6371,196 @@ $("#account-settings-button").addEventListener("click", () => {
6243
6371
  });
6244
6372
  $("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
6245
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
+
6246
6564
  $("#avatar-file").addEventListener("change", async (event) => {
6247
6565
  const file = event.target.files[0];
6248
6566
  if (!file) return;
@@ -6251,23 +6569,115 @@ $("#avatar-file").addEventListener("change", async (event) => {
6251
6569
  event.target.value = "";
6252
6570
  return;
6253
6571
  }
6254
- const body = new FormData();
6255
- body.append("file", file);
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;
6256
6658
  $("#avatar-upload-button").disabled = true;
6257
6659
  $("#avatar-remove-button").disabled = true;
6258
6660
  try {
6661
+ const blob = await exportAvatarCropBlob();
6662
+ const body = new FormData();
6663
+ body.append("file", blob, "avatar.png");
6259
6664
  const updated = await api("/api/auth/avatar", { method: "PUT", body });
6260
6665
  applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
6261
6666
  renderProfileAvatar();
6667
+ avatarCropSession.uploading = false;
6668
+ closeAvatarCropDialog();
6262
6669
  toast("头像已更新");
6263
6670
  } catch (error) {
6264
6671
  toast(error.message, "error");
6265
6672
  } finally {
6673
+ avatarCropSession.uploading = false;
6674
+ $("#avatar-crop-confirm").disabled = false;
6675
+ $("#avatar-crop-cancel").disabled = false;
6266
6676
  $("#avatar-upload-button").disabled = false;
6267
6677
  $("#avatar-remove-button").disabled = false;
6268
- event.target.value = "";
6269
6678
  }
6270
6679
  });
6680
+
6271
6681
  $("#avatar-remove-button").addEventListener("click", async () => {
6272
6682
  if (!state.user?.avatarUrl || !(await confirmToast("确定移除当前头像吗?", { title: "移除头像", confirmLabel: "确认移除" }))) return;
6273
6683
  $("#avatar-upload-button").disabled = true;
@@ -6507,6 +6917,7 @@ $("#platform-new-provider").addEventListener("click", () => openProviderDialog()
6507
6917
  $("#shelf-new-work").addEventListener("click", openWorkDialog);
6508
6918
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
6509
6919
  $("#save-button").addEventListener("click", saveChapter);
6920
+ $("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
6510
6921
  $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
6511
6922
  $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
6512
6923
  $("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));