@musnows/scriverse 1.0.10 → 1.0.12

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.
@@ -186,9 +186,15 @@ export function normalizeAiQuestionItems(question) {
186
186
  * 会话内的最新审批详情缓存:确认/撤销之后写回,避免卡片在重渲染时回退到提交时刻的快照。
187
187
  */
188
188
  const livePlanDetails = new Map();
189
+ const livePlanCardDefinitions = new WeakMap();
189
190
 
190
191
  export function cacheAiWritePlanDetail(detail) {
191
- if (detail?.id) livePlanDetails.set(String(detail.id), detail);
192
+ if (!detail?.id) return;
193
+ const planId = String(detail.id);
194
+ const previous = livePlanDetails.get(planId);
195
+ // 审批中心列表只返回摘要,不能覆盖已加载详情中的操作明细。
196
+ livePlanDetails.set(planId, previous ? { ...previous, ...detail } : detail);
197
+ refreshAiWritePlanCards(planId);
192
198
  }
193
199
 
194
200
  export function cachedAiWritePlanDetail(planId) {
@@ -268,6 +274,18 @@ function actionButton(labelText, className, onClick) {
268
274
  return button;
269
275
  }
270
276
 
277
+ /** 将审批中心或详情接口的最新状态同步到当前已显示的会话卡片。 */
278
+ function refreshAiWritePlanCards(planId) {
279
+ if (typeof document === "undefined") return;
280
+ for (const card of document.querySelectorAll("[data-ai-write-plan-id]")) {
281
+ if (card.dataset.aiWritePlanId !== String(planId)) continue;
282
+ const definition = livePlanCardDefinitions.get(card);
283
+ if (!definition) continue;
284
+ const replacement = buildPlanCard(definition.model, definition.actions);
285
+ card.replaceWith(replacement);
286
+ }
287
+ }
288
+
271
289
  function buildPlanCard(model, actions) {
272
290
  // 优先使用缓存中的最新详情(用户刚刚确认/撤销过),否则回退到工具结果摘要。
273
291
  const liveDetail = model.plan ? cachedAiWritePlanDetail(model.plan.id) : null;
@@ -312,6 +330,7 @@ function buildPlanCard(model, actions) {
312
330
  const actionsBar = document.createElement("div");
313
331
  actionsBar.className = "ai-interactive-actions";
314
332
  const planId = model.plan?.id ?? liveDetail?.id ?? "";
333
+ if (planId) card.dataset.aiWritePlanId = String(planId);
315
334
  actionsBar.append(actionButton("完整修改明细", "ghost-button ai-card-action", () => actions.openPlanDetail(planId)));
316
335
  if (status === "pending") {
317
336
  actionsBar.append(
@@ -329,6 +348,7 @@ function buildPlanCard(model, actions) {
329
348
  ? "确认后系统会重新校验权限、开关与目标版本,全部通过才原子执行;拒绝或过期都不会产生任何写入。"
330
349
  : (model.message || "详情与审计记录见 AI 操作审批中心。");
331
350
  card.append(note);
351
+ livePlanCardDefinitions.set(card, { model, actions });
332
352
  return card;
333
353
  }
334
354
 
@@ -21,6 +21,13 @@ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManage
21
21
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
22
22
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
23
23
  import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
24
+ import {
25
+ calculateChapterEditorContentHeight,
26
+ chapterLineNumberLayerTransform,
27
+ needsChapterLineVirtualWindowRefresh,
28
+ readChapterEditorScrollMetrics,
29
+ transferNestedEditorScroll
30
+ } from "/chapter-editor-scroll.js?v=20260916-line-scroll-bind-v1";
24
31
  import {
25
32
  FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
26
33
  foreshadowReminderRequestTargetsState,
@@ -54,7 +61,7 @@ import {
54
61
  renderWritePlanDetailMarkup,
55
62
  isInteractiveToolPending,
56
63
  aiFormatDateTime
57
- } from "/ai-interactive.js?v=20260906-question-recovery-v1";
64
+ } from "/ai-interactive.js?v=20260915-plan-status-sync-v1";
58
65
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
59
66
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
60
67
  import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
@@ -202,6 +209,7 @@ function isSelectableModel(model) {
202
209
  const state = {
203
210
  user: null,
204
211
  csrfToken: null,
212
+ registrationMode: "disabled",
205
213
  works: [],
206
214
  work: null,
207
215
  chapter: null,
@@ -501,6 +509,7 @@ function applyWorkAccessMode() {
501
509
  $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
502
510
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
503
511
  $("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
512
+ $("#writing-progress-button").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("editor"));
504
513
  $("#ai-assistant-entry").classList.toggle("permission-hidden", !state.work || aiHidden);
505
514
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
506
515
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -1739,24 +1748,27 @@ function setModuleNavExpanded(expanded) {
1739
1748
  $("#module-nav").querySelectorAll(".module-nav-secondary").forEach((button) => button.classList.toggle("hidden", !expanded));
1740
1749
  }
1741
1750
 
1751
+ function getChapterEditorScroller() {
1752
+ // 行号与正文只允许这个容器滚动。
1753
+ return $("#chapter-editor-scroll");
1754
+ }
1755
+
1742
1756
  function syncChapterLineNumberScroll() {
1757
+ const scroller = getChapterEditorScroller();
1743
1758
  const input = $("#chapter-content");
1744
1759
  const inner = $("#chapter-line-numbers-inner");
1745
1760
  const whitespace = $("#chapter-whitespace-inner");
1746
- if (!input || !inner) return;
1747
- inner.style.transform = `translateY(${-input.scrollTop}px)`;
1748
- inner.dataset.scrollTop = String(input.scrollTop);
1761
+ if (!scroller || !inner) return;
1762
+ if (input) transferNestedEditorScroll(input, scroller);
1763
+ const metrics = readChapterEditorScrollMetrics(scroller);
1764
+ const layerTransform = chapterLineNumberLayerTransform();
1765
+ inner.style.transform = layerTransform;
1766
+ inner.dataset.scrollTop = String(metrics.scrollTop);
1749
1767
  if (whitespace) {
1750
- whitespace.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
1751
- whitespace.dataset.scrollTop = String(input.scrollTop);
1768
+ whitespace.style.transform = layerTransform;
1769
+ whitespace.dataset.scrollTop = String(metrics.scrollTop);
1752
1770
  }
1753
- if (!chapterLineVirtualWindow) return;
1754
- const buffer = input.clientHeight * 0.35;
1755
- const viewportBottom = input.scrollTop + input.clientHeight;
1756
- const needsPreviousLines = chapterLineVirtualWindow.start > 0 && input.scrollTop < chapterLineVirtualWindow.top + buffer;
1757
- const needsNextLines = chapterLineVirtualWindow.end < chapterLineVirtualWindow.lineCount
1758
- && viewportBottom > chapterLineVirtualWindow.bottom - buffer;
1759
- if (needsPreviousLines || needsNextLines) scheduleChapterLineNumbers();
1771
+ if (needsChapterLineVirtualWindowRefresh(chapterLineVirtualWindow, metrics)) scheduleChapterLineNumbers();
1760
1772
  }
1761
1773
 
1762
1774
  function syncChapterWhitespaceControls() {
@@ -1908,7 +1920,8 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1908
1920
  const input = $("#chapter-content");
1909
1921
  const inner = $("#chapter-line-numbers-inner");
1910
1922
  const measure = $("#chapter-line-measure");
1911
- if (!input || !inner || !measure || input.clientWidth === 0) return;
1923
+ const scroller = getChapterEditorScroller();
1924
+ if (!input || !inner || !measure || !scroller || input.clientWidth === 0) return;
1912
1925
  const style = getComputedStyle(input);
1913
1926
  const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
1914
1927
  const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
@@ -1919,20 +1932,31 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1919
1932
  const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
1920
1933
  const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
1921
1934
  const paddingBottom = parseFloat(style.paddingBottom) || 0;
1922
- const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
1923
- const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
1924
- const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
1935
+ const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight);
1936
+ const nextHeight = calculateChapterEditorContentHeight({
1937
+ measureHeight,
1938
+ paddingTop,
1939
+ paddingBottom,
1940
+ viewportHeight: scroller.clientHeight
1941
+ });
1942
+ const nextHeightPx = `${nextHeight}px`;
1943
+ if (input.style.height !== nextHeightPx) {
1944
+ const savedScrollTop = scroller.scrollTop;
1945
+ input.style.height = nextHeightPx;
1946
+ scroller.scrollTop = savedScrollTop;
1947
+ }
1948
+ const metrics = readChapterEditorScrollMetrics(scroller);
1925
1949
  if (Number.isInteger(targetLineIndex)) {
1926
1950
  const safeTarget = Math.max(0, Math.min(targetLineIndex, layout.lines.length - 1));
1927
- input.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - input.clientHeight / 3);
1951
+ scroller.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - metrics.clientHeight / 3);
1928
1952
  }
1929
- const viewportTop = Math.max(0, input.scrollTop - paddingTop);
1930
- const overscan = Math.max(input.clientHeight, lineHeight * 8);
1953
+ const viewportTop = Math.max(0, scroller.scrollTop - paddingTop);
1954
+ const overscan = Math.max(metrics.clientHeight, lineHeight * 8);
1931
1955
  const lineWindow = findChapterLineWindow(
1932
1956
  layout.lines.length,
1933
1957
  getLineBounds,
1934
1958
  viewportTop - overscan,
1935
- viewportTop + input.clientHeight + overscan
1959
+ viewportTop + metrics.clientHeight + overscan
1936
1960
  );
1937
1961
  const numbers = document.createDocumentFragment();
1938
1962
  for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
@@ -2018,26 +2042,25 @@ function scheduleChapterCaretScroll() {
2018
2042
  chapterCaretScrollFrame = null;
2019
2043
  const input = $("#chapter-content");
2020
2044
  const measure = $("#chapter-line-measure");
2021
- if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
2045
+ const scroller = getChapterEditorScroller();
2046
+ if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || !scroller || input.readOnly || input.clientWidth === 0 || scroller.clientHeight === 0) return;
2022
2047
  const style = getComputedStyle(input);
2023
2048
  const paddingTop = parseFloat(style.paddingTop) || 0;
2024
- const paddingBottom = parseFloat(style.paddingBottom) || 0;
2025
2049
  const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
2026
2050
  const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
2027
2051
  const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
2028
- const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
2029
- const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
2030
- const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
2052
+ const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight);
2031
2053
  const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
2032
2054
  const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
2055
+ const metrics = readChapterEditorScrollMetrics(scroller);
2033
2056
  const nextScrollTop = calculateChapterCaretScroll({
2034
2057
  caretBottom,
2035
- scrollTop: input.scrollTop,
2036
- clientHeight: input.clientHeight,
2037
- scrollHeight: input.scrollHeight
2058
+ scrollTop: metrics.scrollTop,
2059
+ clientHeight: metrics.clientHeight,
2060
+ scrollHeight: metrics.scrollHeight
2038
2061
  });
2039
- if (nextScrollTop === input.scrollTop) return;
2040
- input.scrollTop = nextScrollTop;
2062
+ if (nextScrollTop === metrics.scrollTop) return;
2063
+ scroller.scrollTop = nextScrollTop;
2041
2064
  syncChapterLineNumberScroll();
2042
2065
  scheduleChapterLineNumbers();
2043
2066
  });
@@ -3605,7 +3628,17 @@ async function loadAiApprovalCenterPlans() {
3605
3628
  ]);
3606
3629
  const plans = Array.isArray(planPayload) ? planPayload : (Array.isArray(planPayload?.plans) ? planPayload.plans : []);
3607
3630
  const questions = Array.isArray(questionPayload) ? questionPayload : (Array.isArray(questionPayload?.questions) ? questionPayload.questions : []);
3631
+ plans.forEach(cacheAiWritePlanDetail);
3608
3632
  $("#ai-approval-list-host").innerHTML = renderApprovalCenterRows(plans, questions);
3633
+ await syncVisibleAiWritePlanCardStatuses();
3634
+ }
3635
+
3636
+ /** 审批中心的筛选结果不一定覆盖当前会话中的旧卡片,逐项读取其权威状态。 */
3637
+ async function syncVisibleAiWritePlanCardStatuses() {
3638
+ const planIds = [...document.querySelectorAll("[data-ai-write-plan-id]")]
3639
+ .map((card) => String(card.dataset.aiWritePlanId ?? ""))
3640
+ .filter(Boolean);
3641
+ await Promise.allSettled([...new Set(planIds)].map((planId) => fetchAiWritePlanDetail(planId)));
3609
3642
  }
3610
3643
 
3611
3644
  function openAiApprovalCenter() {
@@ -6514,7 +6547,7 @@ function invalidateAuthentication() {
6514
6547
  showAuth(false);
6515
6548
  }
6516
6549
 
6517
- function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
6550
+ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false, registrationMode = "disabled") {
6518
6551
  if (state.user) return;
6519
6552
  document.documentElement.classList.remove("dev-auth-bypass");
6520
6553
  document.documentElement.classList.add("login-route");
@@ -6523,14 +6556,17 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
6523
6556
  document.body.classList.add("auth-pending");
6524
6557
  $("#auth-view").classList.remove("hidden");
6525
6558
  const canRegister = registrationOpen === true;
6559
+ const inviteRequired = registrationMode === "invite" && setupRequired !== true;
6526
6560
  $("#auth-title").textContent = setupRequired
6527
6561
  ? canRegister ? "创建首个管理员账户" : "注册已禁用"
6528
6562
  : "登录后继续创作";
6529
6563
  $("#auth-description").textContent = setupRequired
6530
6564
  ? canRegister
6531
6565
  ? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
6532
- : "请将 APP_ALLOW_REGISTRATION 设置为 true 后创建首个管理员账户。"
6533
- : "你的作品、协作权限和每一次修改都会绑定到账户。";
6566
+ : "请将 APP_ALLOW_REGISTRATION 设置为 true 或 invite 后创建首个管理员账户。"
6567
+ : inviteRequired
6568
+ ? "当前部署需要邀请码才能注册。请向系统管理员获取一次性邀请码。"
6569
+ : "你的作品、协作权限和每一次修改都会绑定到账户。";
6534
6570
  const registerTab = $("#auth-register-tab");
6535
6571
  registerTab.disabled = !canRegister;
6536
6572
  registerTab.setAttribute("aria-disabled", String(!canRegister));
@@ -6539,12 +6575,19 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
6539
6575
  const setupTokenInput = setupTokenField.querySelector('input[name="setupToken"]');
6540
6576
  setupTokenField.classList.toggle("hidden", !setupTokenRequired);
6541
6577
  setupTokenInput.required = setupTokenRequired;
6578
+ const inviteField = $("#register-invite-code-field");
6579
+ const inviteInput = inviteField.querySelector('input[name="inviteCode"]');
6580
+ inviteField.classList.toggle("hidden", !inviteRequired);
6581
+ inviteInput.required = inviteRequired;
6542
6582
  selectAuthMode(setupRequired && canRegister ? "register" : "login");
6543
6583
  }
6544
6584
 
6545
6585
  function applyAuthenticatedUser(session) {
6546
6586
  state.user = session.user;
6547
6587
  state.csrfToken = session.csrfToken;
6588
+ state.registrationMode = session.registrationMode === "invite" || session.registrationMode === "open"
6589
+ ? session.registrationMode
6590
+ : "disabled";
6548
6591
  const isSystemAdmin = session.user.isSystemAdmin === true;
6549
6592
  const accountButton = $("#account-button");
6550
6593
  $("#account-name").textContent = session.user.displayName;
@@ -6627,7 +6670,7 @@ async function initializeAuthentication() {
6627
6670
  if (!session.authenticated) {
6628
6671
  // 未登录时一律转到登录页路由;登录页本身则保持原样
6629
6672
  if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
6630
- showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true);
6673
+ showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true, session.registrationMode);
6631
6674
  return false;
6632
6675
  }
6633
6676
  // 已登录却停在登录页路由时,回到书架首页
@@ -7726,9 +7769,61 @@ async function openUsersDialog() {
7726
7769
  return;
7727
7770
  }
7728
7771
  $("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
7772
+ $("#invite-codes-list").innerHTML = '<p class="empty-state">正在读取邀请码……</p>';
7773
+ $("#invite-code-latest").classList.add("hidden");
7729
7774
  $("#users-dialog").showModal();
7730
- try { renderUsers((await apiPage("/api/users")).items); }
7731
- catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
7775
+ try {
7776
+ const [usersPage] = await Promise.all([apiPage("/api/users"), loadRegistrationInvites()]);
7777
+ renderUsers(usersPage.items);
7778
+ } catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
7779
+ }
7780
+
7781
+ function registrationModeLabel(mode) {
7782
+ if (mode === "invite") return "需要邀请码才能注册";
7783
+ if (mode === "open") return "完全开放注册";
7784
+ return "彻底禁止注册";
7785
+ }
7786
+
7787
+ function renderRegistrationInvites(payload) {
7788
+ const mode = payload?.registrationMode === "invite" || payload?.registrationMode === "open"
7789
+ ? payload.registrationMode
7790
+ : "disabled";
7791
+ state.registrationMode = mode;
7792
+ $("#invite-codes-mode").textContent = `当前注册策略:${registrationModeLabel(mode)}。邀请码由系统管理员生成,每个只能使用一次。`;
7793
+ const invites = Array.isArray(payload?.items) ? payload.items : [];
7794
+ $("#invite-codes-list").innerHTML = invites.length
7795
+ ? invites.map((invite) => {
7796
+ const used = Boolean(invite.usedAt);
7797
+ const usedBy = invite.usedBy ? `${invite.usedBy.displayName} · @${invite.usedBy.username}` : "已失效";
7798
+ return `<article class="access-row invite-code-row">
7799
+ <div class="access-person-copy"><strong>${used ? "已使用" : "未使用"}</strong><small>${esc(formatDateTime(invite.createdAt))} 由 ${esc(invite.createdBy.displayName)} 生成${used ? ` · ${esc(usedBy)} 于 ${esc(formatDateTime(invite.usedAt))}` : ""}</small></div>
7800
+ <span>${used ? "已核销" : "待使用"}</span>
7801
+ </article>`;
7802
+ }).join("")
7803
+ : '<p class="empty-state">还没有邀请码。需要邀请码注册时,请先生成。</p>';
7804
+ }
7805
+
7806
+ async function loadRegistrationInvites() {
7807
+ const payload = await api("/api/registration-invites");
7808
+ renderRegistrationInvites(payload);
7809
+ return payload;
7810
+ }
7811
+
7812
+ async function generateRegistrationInvite() {
7813
+ const button = $("#invite-code-generate");
7814
+ button.disabled = true;
7815
+ try {
7816
+ const created = await api("/api/registration-invites", { method: "POST", body: {} });
7817
+ const latest = $("#invite-code-latest");
7818
+ latest.innerHTML = `新邀请码(请立即复制,页面不会再次完整显示):<strong>${esc(created.code)}</strong>`;
7819
+ latest.classList.remove("hidden");
7820
+ await loadRegistrationInvites();
7821
+ toast("邀请码已生成");
7822
+ } catch (error) {
7823
+ toast(error.message, "error");
7824
+ } finally {
7825
+ button.disabled = false;
7826
+ }
7732
7827
  }
7733
7828
 
7734
7829
  function renderAdminAiConversationFilterOptions(works, users) {
@@ -20323,6 +20418,7 @@ $("#register-form").addEventListener("submit", async (event) => {
20323
20418
  password: form.get("password"),
20324
20419
  passwordConfirmation: form.get("passwordConfirmation"),
20325
20420
  setupToken: form.get("setupToken") || undefined,
20421
+ inviteCode: form.get("inviteCode") || undefined,
20326
20422
  captchaId: form.get("captchaId"),
20327
20423
  captchaAnswer: form.get("captchaAnswer")
20328
20424
  }
@@ -20365,8 +20461,15 @@ $("#platform-usage-pricing-refresh").addEventListener("click", async () => {
20365
20461
  }
20366
20462
  });
20367
20463
  $("#user-management-button").addEventListener("click", openUsersDialog);
20464
+ $("#invite-code-generate").addEventListener("click", () => generateRegistrationInvite().catch((error) => toast(error.message, "error")));
20368
20465
  $("#admin-ai-conversations-button").addEventListener("click", () => openAdminAiConversationsDialog().catch((error) => toast(error.message, "error")));
20369
- $("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
20466
+ $("#writing-progress-button").addEventListener("click", () => {
20467
+ void openWritingProgressDialog().catch((error) => toast(error.message, "error"));
20468
+ if (isMobileViewport()) {
20469
+ panelLayout.leftCollapsed = true;
20470
+ applyPanelLayout(true);
20471
+ }
20472
+ });
20370
20473
  $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
20371
20474
  $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
20372
20475
  $("#writing-goal-form").addEventListener("submit", saveWritingGoal);
@@ -20749,6 +20852,7 @@ $("#chapter-content").addEventListener("input", (event) => {
20749
20852
  });
20750
20853
  $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
20751
20854
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
20855
+ $("#chapter-editor-scroll").addEventListener("scroll", syncChapterLineNumberScroll, { passive: true });
20752
20856
  $("#chapter-content").addEventListener("contextmenu", (event) => {
20753
20857
  if (!state.chapter) return;
20754
20858
  showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
@@ -20840,7 +20944,7 @@ $("#ai-semantic-query").addEventListener("keydown", (event) => {
20840
20944
  $("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
20841
20945
  setupPanelResize($("#left-panel-resize"), "left");
20842
20946
  setupPanelResize($("#ai-panel-resize"), "ai");
20843
- if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
20947
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-editor-scroll"));
20844
20948
  if (typeof ResizeObserver !== "undefined") new ResizeObserver(syncMobileAiPanelSafeTop).observe($("#chapter-foreshadow-reminder"));
20845
20949
  window.addEventListener("resize", () => {
20846
20950
  if (isMobileViewport() && $("#onboarding-dialog").open) completeOnboarding();
@@ -0,0 +1,37 @@
1
+ export type ChapterEditorScrollMetrics = {
2
+ scrollTop: number;
3
+ scrollLeft: number;
4
+ clientHeight: number;
5
+ clientWidth: number;
6
+ scrollHeight: number;
7
+ };
8
+
9
+ export type ChapterLineVirtualWindow = {
10
+ start: number;
11
+ end: number;
12
+ lineCount: number;
13
+ top: number;
14
+ bottom: number;
15
+ };
16
+
17
+ export function readChapterEditorScrollMetrics(scroller?: { scrollTop?: number; scrollLeft?: number; clientHeight?: number; clientWidth?: number; scrollHeight?: number } | null): ChapterEditorScrollMetrics;
18
+
19
+ export function calculateChapterEditorContentHeight(input?: {
20
+ measureHeight?: number;
21
+ paddingTop?: number;
22
+ paddingBottom?: number;
23
+ viewportHeight?: number;
24
+ }): number;
25
+
26
+ export function needsChapterLineVirtualWindowRefresh(
27
+ virtualWindow?: ChapterLineVirtualWindow | null,
28
+ metrics?: Pick<ChapterEditorScrollMetrics, "scrollTop" | "clientHeight"> | null,
29
+ bufferRatio?: number
30
+ ): boolean;
31
+
32
+ export function chapterLineNumberLayerTransform(): "none";
33
+
34
+ export function transferNestedEditorScroll(
35
+ source?: { scrollTop?: number; scrollLeft?: number } | null,
36
+ destination?: { scrollTop?: number; scrollLeft?: number } | null
37
+ ): { transferredTop: number; transferredLeft: number };
@@ -0,0 +1,52 @@
1
+ /** 正文行号与文字共用同一滚动容器,行号层不再独立滚动。 */
2
+
3
+ export function readChapterEditorScrollMetrics(scroller) {
4
+ return {
5
+ scrollTop: Math.max(0, Number(scroller?.scrollTop) || 0),
6
+ scrollLeft: Number(scroller?.scrollLeft) || 0,
7
+ clientHeight: Math.max(0, Number(scroller?.clientHeight) || 0),
8
+ clientWidth: Math.max(0, Number(scroller?.clientWidth) || 0),
9
+ scrollHeight: Math.max(0, Number(scroller?.scrollHeight) || 0)
10
+ };
11
+ }
12
+
13
+ export function calculateChapterEditorContentHeight({
14
+ measureHeight,
15
+ paddingTop,
16
+ paddingBottom,
17
+ viewportHeight
18
+ } = {}) {
19
+ const contentHeight = Math.max(0, Number(measureHeight) || 0)
20
+ + Math.max(0, Number(paddingTop) || 0)
21
+ + Math.max(0, Number(paddingBottom) || 0);
22
+ return Math.max(contentHeight, Math.max(0, Number(viewportHeight) || 0));
23
+ }
24
+
25
+ export function needsChapterLineVirtualWindowRefresh(virtualWindow, metrics, bufferRatio = 0.35) {
26
+ if (!virtualWindow) return false;
27
+ const scrollTop = Math.max(0, Number(metrics?.scrollTop) || 0);
28
+ const clientHeight = Math.max(0, Number(metrics?.clientHeight) || 0);
29
+ const buffer = clientHeight * (Number.isFinite(Number(bufferRatio)) ? Number(bufferRatio) : 0.35);
30
+ const viewportBottom = scrollTop + clientHeight;
31
+ const needsPreviousLines = virtualWindow.start > 0 && scrollTop < virtualWindow.top + buffer;
32
+ const needsNextLines = virtualWindow.end < virtualWindow.lineCount
33
+ && viewportBottom > virtualWindow.bottom - buffer;
34
+ return needsPreviousLines || needsNextLines;
35
+ }
36
+
37
+ export function chapterLineNumberLayerTransform() {
38
+ return "none";
39
+ }
40
+
41
+ export function transferNestedEditorScroll(source, destination) {
42
+ const transferredTop = Number(source?.scrollTop) || 0;
43
+ const transferredLeft = Number(source?.scrollLeft) || 0;
44
+ if (!source || !destination || (transferredTop === 0 && transferredLeft === 0)) {
45
+ return { transferredTop: 0, transferredLeft: 0 };
46
+ }
47
+ destination.scrollTop += transferredTop;
48
+ destination.scrollLeft += transferredLeft;
49
+ source.scrollTop = 0;
50
+ source.scrollLeft = 0;
51
+ return { transferredTop, transferredLeft };
52
+ }
@@ -9,7 +9,7 @@
9
9
  <script src="/theme-init.js?v=20260827-reader-prefetch-v1"></script>
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
- <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=ai-composer-compact-controls-v1&feature=ai-context-meter-ring-only-v2&feature=ai-context-cache-hit-v1&feature=annotation-line-counts-v1&feature=chapter-comment-filters-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=ai-stream-character-count-five-digit-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1&feature=admin-account-identity-v2&feature=task-detail-failure-orange-v1&feature=character-card-header-alignment-v6&feature=character-card-title-fit-v2&feature=book-import-progress-v1&feature=editor-toolbar-compact-v2&feature=reader-first-frame-v1&feature=auth-compact-v2&feature=editor-preview-toggle-v2&feature=setting-title-chrome-v2&feature=entity-pin-icon-center-v1&feature=chapter-center-bottom-space-v1&feature=work-editor-preferences-v1&feature=work-editor-preference-checkbox-v1&feature=ai-roleplay-memory-v2&feature=ai-roleplay-memory-v3&feature=ai-roleplay-memory-v5&feature=character-editor-header-actions-v1&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=roleplay-memory-pin-border-v1&feature=ai-write-tools-v2&feature=ai-write-card-actions-compact-v1&feature=ai-write-plan-actions-footer-v1&feature=ai-question-actions-footer-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-answer-limit-v1&feature=ai-question-batch-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v1&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=ai-settings-textarea-font-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=ai-skill-slash-menu-v1&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=im-narration-contrast-v1&feature=im-member-add-plus-v2&feature=im-button-hierarchy-v1&feature=im-icon-button-size-v1&feature=compact-sidebar-directory-v5&feature=ai-model-config-dialog-v1&feature=system-prompt-override-v3&feature=entity-editor-back-icon-v1&feature=toast-stack-v1&feature=chapter-header-compact-v4&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1">
12
+ <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=ai-composer-compact-controls-v1&feature=ai-context-meter-ring-only-v2&feature=ai-context-cache-hit-v1&feature=annotation-line-counts-v1&feature=chapter-comment-filters-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=ai-stream-character-count-five-digit-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1&feature=admin-account-identity-v2&feature=task-detail-failure-orange-v1&feature=character-card-header-alignment-v6&feature=character-card-title-fit-v2&feature=book-import-progress-v1&feature=editor-toolbar-compact-v2&feature=reader-first-frame-v1&feature=auth-compact-v2&feature=editor-preview-toggle-v2&feature=setting-title-chrome-v2&feature=entity-pin-icon-center-v1&feature=chapter-center-bottom-space-v1&feature=work-editor-preferences-v1&feature=work-editor-preference-checkbox-v1&feature=ai-roleplay-memory-v2&feature=ai-roleplay-memory-v3&feature=ai-roleplay-memory-v5&feature=character-editor-header-actions-v1&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=roleplay-memory-pin-border-v1&feature=ai-write-tools-v2&feature=ai-write-card-actions-compact-v1&feature=ai-write-plan-actions-footer-v1&feature=ai-question-actions-footer-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-answer-limit-v1&feature=ai-question-batch-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v1&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=ai-settings-textarea-font-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=ai-skill-slash-menu-v1&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=im-narration-contrast-v1&feature=im-member-add-plus-v2&feature=im-button-hierarchy-v1&feature=im-icon-button-size-v1&feature=compact-sidebar-directory-v5&feature=ai-model-config-dialog-v1&feature=system-prompt-override-v3&feature=entity-editor-back-icon-v1&feature=toast-stack-v1&feature=chapter-header-compact-v4&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1&feature=chapter-line-scroll-bind-v1&feature=writing-goal-module-navigation-v2&feature=invite-registration-v1">
13
13
  </head>
14
14
  <body class="auth-pending">
15
15
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -40,6 +40,7 @@
40
40
  <form id="register-form" class="auth-form hidden" method="post" action="/api/auth/register">
41
41
  <label>用户名<input name="username" autocomplete="username" minlength="3" maxlength="40" pattern="[A-Za-z0-9_.\-\u4e00-\u9fff]+" required></label>
42
42
  <label id="register-setup-token-field" class="hidden">初始化令牌<input name="setupToken" type="password" autocomplete="off" minlength="32" maxlength="500" aria-describedby="register-setup-token-hint"><small id="register-setup-token-hint">请输入服务端 APP_SETUP_TOKEN 的值。</small></label>
43
+ <label id="register-invite-code-field" class="hidden">邀请码<input name="inviteCode" type="text" autocomplete="off" maxlength="32" spellcheck="false" autocapitalize="characters" aria-describedby="register-invite-code-hint"><small id="register-invite-code-hint">请输入系统管理员提供的一次性邀请码。</small></label>
43
44
  <label>密码<span class="password-field"><input id="register-password" name="password" type="password" autocomplete="new-password" minlength="10" maxlength="200" required><button class="password-toggle" type="button" data-password-toggle="register-password" aria-label="显示密码" aria-pressed="false" title="显示密码"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 12s3.3-5.5 9.5-5.5S21.5 12 21.5 12 18.2 17.5 12 17.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/></svg></button></span></label>
44
45
  <label>确认密码<span class="password-field"><input id="register-password-confirmation" name="passwordConfirmation" type="password" autocomplete="new-password" minlength="10" maxlength="200" required><button class="password-toggle" type="button" data-password-toggle="register-password-confirmation" aria-label="显示确认密码" aria-pressed="false" title="显示确认密码"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 12s3.3-5.5 9.5-5.5S21.5 12 21.5 12 18.2 17.5 12 17.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/></svg></button></span></label>
45
46
  <div class="auth-captcha">
@@ -144,6 +145,7 @@
144
145
  <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
145
146
  <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
146
147
  <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
148
+ <button id="writing-progress-button" class="module-nav-secondary hidden" type="button"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2"/></svg><span class="nav-label">写作目标</span></button>
147
149
  </nav>
148
150
  <div class="panel-heading">
149
151
  <span class="panel-heading-label"><span class="panel-heading-label-full">作品目录</span><span class="panel-heading-label-compact">目录</span></span>
@@ -239,12 +241,11 @@
239
241
  <div class="settings-hub-grid">
240
242
  <button id="platform-ai-button" class="settings-hub-card hidden" type="button" data-settings-action="ai"><span class="settings-card-mark">AI</span><strong>AI 管理</strong><small>供应商、模型、上下文与全局提示词</small></button>
241
243
  <button id="platform-usage-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">T</span><strong>Token 用量</strong><small>总消耗、缓存命中率、每日网格与作品明细</small></button>
242
- <button id="user-management-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">人</span><strong>用户管理</strong><small>管理员、普通用户与账户状态</small></button>
244
+ <button id="user-management-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">人</span><strong>用户管理</strong><small>管理员、普通用户、邀请码与账户状态</small></button>
243
245
  <button id="admin-ai-conversations-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">话</span><strong>Chat 对话</strong><small>查看所有作品、所有用户的对话记录</small></button>
244
246
  <button id="platform-ui-settings-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">界</span><strong>界面与分页</strong><small>设置通知位置、银河图性能及各模块的单页数量</small></button>
245
247
  <button id="s3-backup-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">S3</span><strong>S3 备份</strong><small>将整个系统的数据库和图片同步到多个目标</small></button>
246
248
  <button id="collaboration-button" class="settings-hub-card" type="button"><span class="settings-card-mark">协</span><strong>作品协作</strong><small>邀请注册用户共同编辑当前作品</small></button>
247
- <button id="writing-progress-button" class="settings-hub-card" type="button"><span class="settings-card-mark">字</span><strong>写作目标</strong><small>每日目标、总字数目标与近 30 天趋势</small></button>
248
249
  <button id="work-audit-button" class="settings-hub-card" type="button"><span class="settings-card-mark">录</span><strong>操作记录</strong><small>查看作品修改、操作者、对象与时间</small></button>
249
250
  <button id="global-replace-button" class="settings-hub-card" type="button"><span class="settings-card-mark">替</span><strong>全局替换</strong><small>替换已保存正文、设定库或两者内容</small></button>
250
251
  <button id="appearance-button" class="settings-hub-card" type="button" data-settings-action="appearance"><span class="settings-card-mark">Aa</span><strong>显示设置</strong><small>中文字体、等宽英文字体、字号与行距</small></button>
@@ -324,10 +325,12 @@
324
325
  </dl>
325
326
  </section>
326
327
  <div class="editor-body">
327
- <div class="chapter-editor-frame">
328
- <aside id="chapter-line-numbers" class="chapter-line-numbers" aria-label="正文行号,可拖拽引用多行"><div id="chapter-line-numbers-inner"></div></aside>
329
- <textarea id="chapter-content" class="chapter-content" aria-label="章节正文" placeholder="在这里开始你的故事……"></textarea>
330
- <div id="chapter-whitespace-overlay" class="chapter-whitespace-overlay is-visible" aria-hidden="true"><div id="chapter-whitespace-inner"></div></div>
328
+ <div id="chapter-editor-frame" class="chapter-editor-frame">
329
+ <div id="chapter-editor-scroll" class="chapter-editor-scroll">
330
+ <aside id="chapter-line-numbers" class="chapter-line-numbers" aria-label="正文行号,可拖拽引用多行"><div id="chapter-line-numbers-inner"></div></aside>
331
+ <textarea id="chapter-content" class="chapter-content" aria-label="章节正文" placeholder="在这里开始你的故事……"></textarea>
332
+ <div id="chapter-whitespace-overlay" class="chapter-whitespace-overlay is-visible" aria-hidden="true"><div id="chapter-whitespace-inner"></div></div>
333
+ </div>
331
334
  <div id="chapter-line-measure" class="chapter-line-measure" aria-hidden="true"></div>
332
335
  </div>
333
336
  </div>
@@ -986,6 +989,17 @@
986
989
  <dialog id="users-dialog" class="dialog wide-dialog" aria-labelledby="users-dialog-title">
987
990
  <div class="dialog-header"><div><span class="eyebrow">系统管理员</span><h2 id="users-dialog-title">用户管理</h2></div><div class="settings-dialog-header-actions"><button id="users-settings-return" class="ghost-button settings-parent-button" type="button">返回设置</button><button id="users-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div></div>
988
991
  <div class="access-dialog-body">
992
+ <section id="invite-codes-panel" class="invite-codes-panel" aria-labelledby="invite-codes-title">
993
+ <div class="invite-codes-heading">
994
+ <div>
995
+ <h3 id="invite-codes-title">注册邀请码</h3>
996
+ <p id="invite-codes-mode" class="dialog-header-meta">正在读取注册策略……</p>
997
+ </div>
998
+ <button id="invite-code-generate" class="primary-button" type="button">生成邀请码</button>
999
+ </div>
1000
+ <p id="invite-code-latest" class="invite-code-latest hidden" role="status"></p>
1001
+ <div id="invite-codes-list" class="access-list" aria-live="polite"></div>
1002
+ </section>
989
1003
  <div id="users-list" class="access-list"></div>
990
1004
  </div>
991
1005
  </dialog>
@@ -1427,7 +1441,7 @@
1427
1441
  </dialog>
1428
1442
 
1429
1443
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1430
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-history-rename-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-roleplay-message-reference-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v2&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=entity-pin-icon-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2&feature=admin-account-identity-v2&feature=ai-provider-analysis-timeout-v1&feature=task-detail-failure-orange-v1&feature=book-import-progress-v1&feature=presence-multiple-users-v1&feature=ai-context-input-output-v1&feature=editor-blank-lines-preserved-v1&feature=reader-first-frame-v1&feature=vditor-lazy-load-v1&feature=ui-module-preload-v1&feature=reader-initial-prefetch-v1&feature=editor-preview-toggle-v2&feature=vditor-fullscreen-disabled-v1&feature=chapter-auto-indent-v1&feature=chapter-centered-scroll-v1&feature=work-editor-preferences-v1&feature=ai-context-output-usage-v1&feature=ai-context-meter-ring-only-v1&feature=ai-context-cache-hit-v1&feature=ai-roleplay-memory-v4&feature=ai-roleplay-memory-v5&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-header-toolbar-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=annotation-line-anchor-v1&feature=stable-line-ids-v1&feature=live-annotation-anchors-v1&feature=chapter-comment-filters-v1&feature=ai-write-tools-v3&feature=ai-question-option-supplement-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-continuation-ui-v3&feature=ai-background-stream-v1&feature=ai-question-tool-result-v1&feature=ai-question-tool-summary-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-batch-v1&feature=ai-question-answer-limit-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v3&feature=ai-cancel-preserve-process-v2&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=chapter-batch-editor-refresh-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=semantic-search-rag-label-v1&feature=ai-skill-slash-menu-v1&feature=ai-roleplay-scene-bubble-v1&feature=ai-roleplay-scene-collapse-v1&feature=markdown-adjacent-blockquotes-v1&feature=chapter-save-shortcut-v2&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=ai-fork-progress-toast-v1&feature=im-settings-gear-v1&feature=compact-sidebar-directory-v2&feature=chapter-word-count-consistency-v1&feature=ai-all-message-references-v2&feature=ai-question-render-recovery-v3&feature=setting-category-preservation-v1&feature=toast-stack-v1&feature=toast-stack-label-v2&feature=agent-tool-limits-300-v1&feature=server-dev-logo-v1&feature=chapter-directory-ai-reference-v3&feature=ai-error-origin-v1&feature=ai-stream-render-performance-v2&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1&feature=ai-inline-message-references-v1"></script>
1444
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-history-rename-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-roleplay-message-reference-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v2&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=entity-pin-icon-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2&feature=admin-account-identity-v2&feature=ai-provider-analysis-timeout-v1&feature=task-detail-failure-orange-v1&feature=book-import-progress-v1&feature=presence-multiple-users-v1&feature=ai-context-input-output-v1&feature=editor-blank-lines-preserved-v1&feature=reader-first-frame-v1&feature=vditor-lazy-load-v1&feature=ui-module-preload-v1&feature=reader-initial-prefetch-v1&feature=editor-preview-toggle-v2&feature=vditor-fullscreen-disabled-v1&feature=chapter-auto-indent-v1&feature=chapter-centered-scroll-v1&feature=work-editor-preferences-v1&feature=ai-context-output-usage-v1&feature=ai-context-meter-ring-only-v1&feature=ai-context-cache-hit-v1&feature=ai-roleplay-memory-v4&feature=ai-roleplay-memory-v5&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-header-toolbar-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=annotation-line-anchor-v1&feature=stable-line-ids-v1&feature=live-annotation-anchors-v1&feature=chapter-comment-filters-v1&feature=ai-write-tools-v3&feature=ai-question-option-supplement-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-continuation-ui-v3&feature=ai-background-stream-v1&feature=ai-question-tool-result-v1&feature=ai-question-tool-summary-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-batch-v1&feature=ai-question-answer-limit-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v3&feature=ai-cancel-preserve-process-v2&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=chapter-batch-editor-refresh-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=semantic-search-rag-label-v1&feature=ai-skill-slash-menu-v1&feature=ai-roleplay-scene-bubble-v1&feature=ai-roleplay-scene-collapse-v1&feature=markdown-adjacent-blockquotes-v1&feature=chapter-save-shortcut-v2&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=ai-fork-progress-toast-v1&feature=im-settings-gear-v1&feature=compact-sidebar-directory-v2&feature=chapter-word-count-consistency-v1&feature=ai-all-message-references-v2&feature=ai-question-render-recovery-v3&feature=setting-category-preservation-v1&feature=toast-stack-v1&feature=toast-stack-label-v2&feature=agent-tool-limits-300-v1&feature=server-dev-logo-v1&feature=chapter-directory-ai-reference-v3&feature=ai-error-origin-v1&feature=ai-stream-render-performance-v2&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1&feature=ai-inline-message-references-v1&feature=ai-write-plan-status-sync-v1&feature=writing-goal-module-navigation-v2&feature=chapter-line-scroll-bind-v1&feature=invite-registration-v1"></script>
1431
1445
 
1432
1446
  </body>
1433
1447
  </html>