@musnows/scriverse 0.3.2 → 0.3.4

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.
@@ -15,6 +15,7 @@ import { buildCharacterDetails, buildCharacterSections, buildCharacterState, cha
15
15
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
16
16
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260714-all-knowledge-history";
17
17
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260714-refresh-restore";
18
+ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
18
19
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
19
20
 
20
21
  const state = {
@@ -459,6 +460,8 @@ let aiMentionRange = null;
459
460
  let settingsReturnContext = null;
460
461
  let characterEditorItem = null;
461
462
  let characterEditorVersions = [];
463
+ let characterEditorRelationships = [];
464
+ let characterEditorRelationshipsLoading = false;
462
465
  let entityHistoryContext = null;
463
466
 
464
467
  function setModuleNavExpanded(expanded) {
@@ -1393,7 +1396,19 @@ function selectAuthMode(mode) {
1393
1396
  $("#login-form").classList.toggle("hidden", !login);
1394
1397
  $("#register-form").classList.toggle("hidden", login);
1395
1398
  $("#auth-error").textContent = "";
1396
- refreshAuthCaptcha(login ? "login" : "register").catch(() => {});
1399
+ // 验证码默认不加载,等用户点击“点击显示验证码”按钮后再请求,避免首屏空白等待
1400
+ resetAuthCaptcha(login ? "login" : "register");
1401
+ }
1402
+
1403
+ function resetAuthCaptcha(target = "login") {
1404
+ const prefix = target === "register" ? "register" : "login";
1405
+ const image = $(`#${prefix}-captcha-image`);
1406
+ image.hidden = true;
1407
+ image.removeAttribute("src");
1408
+ $(`#${prefix}-captcha-placeholder`).hidden = false;
1409
+ $(`#${prefix}-captcha-id`).value = "";
1410
+ const answerInput = $(`#${prefix}-form`).querySelector('input[name="captchaAnswer"]');
1411
+ if (answerInput) answerInput.value = "";
1397
1412
  }
1398
1413
 
1399
1414
  async function refreshAuthCaptcha(target = "login") {
@@ -1402,24 +1417,31 @@ async function refreshAuthCaptcha(target = "login") {
1402
1417
  const challenge = (await response.json()).data;
1403
1418
  const prefix = target === "register" ? "register" : "login";
1404
1419
  $(`#${prefix}-captcha-id`).value = challenge.captchaId;
1405
- $(`#${prefix}-captcha-image`).src = challenge.imageDataUrl;
1420
+ const image = $(`#${prefix}-captcha-image`);
1421
+ image.src = challenge.imageDataUrl;
1422
+ image.hidden = false;
1423
+ $(`#${prefix}-captcha-placeholder`).hidden = true;
1406
1424
  const answerInput = $(`#${prefix}-form`).querySelector('input[name="captchaAnswer"]');
1407
1425
  if (answerInput) answerInput.value = "";
1408
1426
  }
1409
1427
 
1410
- function showAuth(setupRequired, registrationOpen = true) {
1428
+ function showAuth(setupRequired, registrationOpen = false) {
1411
1429
  document.body.classList.add("auth-pending");
1412
1430
  $("#auth-view").classList.remove("hidden");
1413
- $("#auth-title").textContent = setupRequired ? "创建首个管理员账户" : "登录后继续创作";
1431
+ const canRegister = registrationOpen === true;
1432
+ $("#auth-title").textContent = setupRequired
1433
+ ? canRegister ? "创建首个管理员账户" : "注册已禁用"
1434
+ : "登录后继续创作";
1414
1435
  $("#auth-description").textContent = setupRequired
1415
- ? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
1436
+ ? canRegister
1437
+ ? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
1438
+ : "请将 APP_ALLOW_REGISTRATION 设置为 true 后创建首个管理员账户。"
1416
1439
  : "你的作品、协作权限和每一次修改都会绑定到账户。";
1417
- const canRegister = setupRequired || registrationOpen;
1418
1440
  const registerTab = $("#auth-register-tab");
1419
1441
  registerTab.disabled = !canRegister;
1420
1442
  registerTab.setAttribute("aria-disabled", String(!canRegister));
1421
1443
  registerTab.textContent = canRegister ? "注册" : "注册已禁用";
1422
- selectAuthMode(setupRequired ? "register" : "login");
1444
+ selectAuthMode(setupRequired && canRegister ? "register" : "login");
1423
1445
  }
1424
1446
 
1425
1447
  function applyAuthenticatedUser(session) {
@@ -1430,7 +1452,9 @@ function applyAuthenticatedUser(session) {
1430
1452
  $("#account-menu-name").textContent = `${session.user.displayName} · @${session.user.username}`;
1431
1453
  $("#account-menu-role").textContent = session.user.role === "admin" ? "系统管理员" : "普通用户";
1432
1454
  $("#auth-view").classList.add("hidden");
1433
- document.body.classList.remove("auth-pending");
1455
+ document.documentElement.classList.remove("login-route");
1456
+ // 注意:auth-pending 由 initializePage 路由完成后才移除,
1457
+ // 避免会话确认后、目标视图渲染前露出无内容的编辑器外壳
1434
1458
  }
1435
1459
 
1436
1460
  function applyPlatformUiSettings(settings) {
@@ -1447,13 +1471,18 @@ async function loadPlatformUiSettings() {
1447
1471
  }
1448
1472
 
1449
1473
  async function initializeAuthentication() {
1474
+ const route = parsePageRoute(window.location.hash);
1450
1475
  const response = await fetch("/api/auth/session", { headers: { Accept: "application/json" } });
1451
1476
  if (!response.ok) throw new Error("无法读取登录状态");
1452
1477
  const session = (await response.json()).data;
1453
1478
  if (!session.authenticated) {
1454
- showAuth(session.setupRequired, session.registrationOpen !== false);
1479
+ // 未登录时一律转到登录页路由;登录页本身则保持原样
1480
+ if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
1481
+ showAuth(session.setupRequired, session.registrationOpen === true);
1455
1482
  return false;
1456
1483
  }
1484
+ // 已登录却停在登录页路由时,回到书架首页
1485
+ if (route.view === "login") window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
1457
1486
  applyAuthenticatedUser(session);
1458
1487
  await loadPlatformUiSettings();
1459
1488
  return true;
@@ -1660,6 +1689,7 @@ async function initializePage() {
1660
1689
  settingsReturnContext = restoredSettingsReturnContext(route);
1661
1690
  }
1662
1691
  } finally {
1692
+ document.body.classList.remove("auth-pending");
1663
1693
  restoringPageRoute = false;
1664
1694
  replacePageRoute(currentPageRoute());
1665
1695
  scheduleFirstUseOnboarding();
@@ -2934,6 +2964,11 @@ function field(name, label, type = "text", value = "", options = []) {
2934
2964
  const values = Array.isArray(value) && value.length ? value : [""];
2935
2965
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
2936
2966
  }
2967
+ if (type === "keyword-chips") {
2968
+ const values = uniqueRelationshipKeywords(Array.isArray(value) ? value : []);
2969
+ const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除关键词:${esc(keyword)}">×</button></span>`).join("");
2970
+ return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入关键词后按回车添加;也可用逗号一次添加多个。</small></div>`;
2971
+ }
2937
2972
  if (type === "key-value-list") {
2938
2973
  const config = Array.isArray(options) ? {} : options;
2939
2974
  const keyName = config.keyName ?? "detailLabel";
@@ -3000,6 +3035,53 @@ function bindDynamicListControls(container) {
3000
3035
  };
3001
3036
  }
3002
3037
 
3038
+ function appendRelationshipKeywordChips(editor, values) {
3039
+ const input = editor.querySelector("[data-keyword-input]");
3040
+ if (!input) return;
3041
+ const existing = new Set([...editor.querySelectorAll("[data-keyword-value]")].map((control) => String(control.value).toLocaleLowerCase("zh-CN")));
3042
+ const name = editor.dataset.name || "keywords";
3043
+ for (const keyword of uniqueRelationshipKeywords(values)) {
3044
+ const key = keyword.toLocaleLowerCase("zh-CN");
3045
+ if (existing.has(key)) continue;
3046
+ existing.add(key);
3047
+ input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除关键词:${esc(keyword)}">×</button></span>`);
3048
+ }
3049
+ }
3050
+
3051
+ function commitRelationshipKeywordInput(editor) {
3052
+ const input = editor.querySelector("[data-keyword-input]");
3053
+ if (!input) return;
3054
+ appendRelationshipKeywordChips(editor, splitRelationshipKeywords(input.value));
3055
+ input.value = "";
3056
+ }
3057
+
3058
+ function bindRelationshipKeywordControls(container) {
3059
+ container.querySelectorAll("[data-keyword-chips]").forEach((editor) => {
3060
+ const input = editor.querySelector("[data-keyword-input]");
3061
+ if (!input) return;
3062
+ input.addEventListener("keydown", (event) => {
3063
+ if (event.isComposing || event.key !== "Enter") return;
3064
+ event.preventDefault();
3065
+ commitRelationshipKeywordInput(editor);
3066
+ });
3067
+ input.addEventListener("input", () => {
3068
+ const { completed, remainder } = splitRelationshipKeywordInput(input.value);
3069
+ if (!completed.length) return;
3070
+ appendRelationshipKeywordChips(editor, completed);
3071
+ input.value = remainder;
3072
+ });
3073
+ editor.addEventListener("click", (event) => {
3074
+ const remove = event.target.closest("[data-keyword-chip-remove]");
3075
+ if (!remove) return;
3076
+ remove.closest("[data-keyword-chip]")?.remove();
3077
+ });
3078
+ });
3079
+ }
3080
+
3081
+ function commitRelationshipKeywordInputs(container) {
3082
+ container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
3083
+ }
3084
+
3003
3085
  function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
3004
3086
  $("#dialog-title").textContent = title;
3005
3087
  $("#dialog-eyebrow").textContent = eyebrow;
@@ -3007,6 +3089,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
3007
3089
  $("#dialog-submit").textContent = options.submitLabel ?? "保存";
3008
3090
  $("#form-dialog").classList.toggle("wide-dialog", Boolean(options.wide));
3009
3091
  bindDynamicListControls($("#dialog-fields"));
3092
+ bindRelationshipKeywordControls($("#dialog-fields"));
3010
3093
  const form = $("#dynamic-form");
3011
3094
  form.onsubmit = async (event) => {
3012
3095
  if (event.submitter?.value === "cancel") return;
@@ -3014,6 +3097,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
3014
3097
  const submit = $("#dialog-submit");
3015
3098
  submit.disabled = true;
3016
3099
  try {
3100
+ commitRelationshipKeywordInputs(form);
3017
3101
  await onSubmit(new FormData(form));
3018
3102
  $("#form-dialog").close();
3019
3103
  } catch (error) {
@@ -3188,6 +3272,83 @@ function setCharacterHistoryVisible(visible) {
3188
3272
  $("#character-history-button").setAttribute("aria-expanded", String(visible));
3189
3273
  }
3190
3274
 
3275
+ const relationshipCategoryLabels = {
3276
+ family: "亲属",
3277
+ social: "社交",
3278
+ emotional: "情感",
3279
+ conflict: "冲突",
3280
+ uncertain: "未确定"
3281
+ };
3282
+
3283
+ function renderCharacterEditorRelationships() {
3284
+ const host = $("#character-editor-relationships");
3285
+ if (!host) return;
3286
+ if (!characterEditorItem?.id) {
3287
+ host.innerHTML = '<div class="character-editor-empty-field"><b>人物关系</b><span>保存人物档案后即可添加与其他人物的关系。</span></div>';
3288
+ return;
3289
+ }
3290
+ if (characterEditorRelationshipsLoading) {
3291
+ host.innerHTML = '<p class="character-relationship-status">正在读取人物关系……</p>';
3292
+ return;
3293
+ }
3294
+ const characterId = String(characterEditorItem.id);
3295
+ const nameOf = (id) => state.characters.find((character) => character.id === id)?.name ?? "未知角色";
3296
+ const rows = characterEditorRelationships.map((relationship) => {
3297
+ const isSource = relationship.fromCharacterId === characterId;
3298
+ const otherCharacterId = isSource ? relationship.toCharacterId : relationship.fromCharacterId;
3299
+ const direction = relationship.directed ? (isSource ? "→" : "←") : "↔";
3300
+ const category = relationshipCategoryLabels[relationship.category] ?? relationship.category;
3301
+ const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
3302
+ const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
3303
+ return `<article class="character-relationship-row">
3304
+ <div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${canEditWork() ? `<button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button>` : ""}</div>
3305
+ <div class="character-relationship-keywords"><small>关系关键词</small><div>${keywords.map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || '<span class="character-relationship-empty-keywords">未填写关键词</span>'}</div></div>
3306
+ </article>`;
3307
+ }).join("");
3308
+ host.innerHTML = `<div class="character-relationship-toolbar"><p>与 ${esc(characterEditorItem.name)} 有关的其他人物及关系关键词。</p>${canEditWork() ? '<button type="button" class="ghost-button" data-character-relationship-create>新建关系</button>' : ""}</div>${rows || '<p class="character-relationship-status">暂未记录与其他人物的关系。</p>'}`;
3309
+ host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
3310
+ const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
3311
+ if (relationship) void openRelationshipDialog(relationship, { characterId });
3312
+ }));
3313
+ host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
3314
+ }
3315
+
3316
+ async function loadCharacterEditorRelationships(characterId) {
3317
+ const workId = state.work?.id;
3318
+ if (!workId || characterEditorItem?.id !== characterId) return;
3319
+ characterEditorRelationshipsLoading = true;
3320
+ renderCharacterEditorRelationships();
3321
+ let loaded = false;
3322
+ try {
3323
+ const [characters, relationships] = await Promise.all([
3324
+ api(`/api/works/${workId}/characters`),
3325
+ api(`/api/works/${workId}/relationships`)
3326
+ ]);
3327
+ if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
3328
+ state.characters = characters;
3329
+ characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
3330
+ loaded = true;
3331
+ } catch (error) {
3332
+ if (state.work?.id === workId && characterEditorItem?.id === characterId) {
3333
+ $("#character-editor-relationships").innerHTML = `<p class="character-relationship-status">关系载入失败:${esc(error.message)}</p>`;
3334
+ }
3335
+ } finally {
3336
+ if (state.work?.id === workId && characterEditorItem?.id === characterId) {
3337
+ characterEditorRelationshipsLoading = false;
3338
+ if (loaded) renderCharacterEditorRelationships();
3339
+ }
3340
+ }
3341
+ }
3342
+
3343
+ async function refreshRelationshipSurfaces(characterId = null) {
3344
+ const tasks = [];
3345
+ if (state.module === "relationships") tasks.push(renderRelationships());
3346
+ if (characterId && $("#character-editor-dialog").open && characterEditorItem?.id === characterId) {
3347
+ tasks.push(loadCharacterEditorRelationships(characterId));
3348
+ }
3349
+ await Promise.all(tasks);
3350
+ }
3351
+
3191
3352
  function renderCharacterEditorFields(item) {
3192
3353
  const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, race.name])];
3193
3354
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
@@ -3224,11 +3385,14 @@ function renderCharacterEditorFields(item) {
3224
3385
  addLabel: "添加状态"
3225
3386
  }) +
3226
3387
  '<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
3227
- field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? []))
3388
+ field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
3389
+ characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
3390
+ '<div id="character-editor-relationships" class="character-editor-relationships-field"></div>')
3228
3391
  ].join("");
3229
3392
  const name = $("#character-editor-fields [name='name']");
3230
3393
  if (name) name.required = true;
3231
3394
  bindDynamicListControls($("#character-editor-fields"));
3395
+ renderCharacterEditorRelationships();
3232
3396
  activateCharacterEditorTab("basic");
3233
3397
  }
3234
3398
 
@@ -3332,6 +3496,8 @@ async function openCharacterDialog(item) {
3332
3496
  ]);
3333
3497
  characterEditorItem = item ?? null;
3334
3498
  characterEditorVersions = [];
3499
+ characterEditorRelationships = [];
3500
+ characterEditorRelationshipsLoading = Boolean(item);
3335
3501
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
3336
3502
  $("#character-editor-title").textContent = item?.name || "新建角色";
3337
3503
  $("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
@@ -3350,6 +3516,9 @@ async function openCharacterDialog(item) {
3350
3516
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3351
3517
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3352
3518
  });
3519
+ const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
3520
+ relationshipTab.disabled = !item;
3521
+ relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
3353
3522
  const dialog = $("#character-editor-dialog");
3354
3523
  const form = $("#character-editor-form");
3355
3524
  form.onsubmit = async (event) => {
@@ -3374,6 +3543,7 @@ async function openCharacterDialog(item) {
3374
3543
  }
3375
3544
  };
3376
3545
  dialog.showModal();
3546
+ if (item) void loadCharacterEditorRelationships(item.id);
3377
3547
  }
3378
3548
 
3379
3549
  async function openRaceDialog(item) {
@@ -3498,14 +3668,16 @@ function openTimelineSplitDialog(item) {
3498
3668
  }, "原证据同步保留");
3499
3669
  }
3500
3670
 
3501
- async function openRelationshipDialog(item) {
3671
+ async function openRelationshipDialog(item, options = {}) {
3502
3672
  state.characters = await api(`/api/works/${state.work.id}/characters`);
3503
3673
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
3504
- const options = state.characters.map((item) => [item.id, item.name]);
3505
- openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? options[0][0], options) + field("to", "终点人物", "select", item?.toCharacterId ?? options[1][0], options) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词(用逗号分隔)", "text", item?.keywords?.join("、") ?? "") + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false), async (form) => {
3506
- const keywords = String(form.get("keywords") ?? "").split(/[,,、;;]/u).map((value) => value.trim()).filter(Boolean);
3674
+ const characterOptions = state.characters.map((item) => [item.id, item.name]);
3675
+ const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
3676
+ const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
3677
+ openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? defaultFrom, characterOptions) + field("to", "终点人物", "select", item?.toCharacterId ?? defaultTo, characterOptions) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词", "keyword-chips", item?.keywords ?? []) + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false), async (form) => {
3678
+ const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
3507
3679
  await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed" } });
3508
- await renderRelationships();
3680
+ await refreshRelationshipSurfaces(options.characterId ?? null);
3509
3681
  }, item ? "关系档案" : "人工确认关系");
3510
3682
  }
3511
3683
 
@@ -4034,7 +4206,8 @@ $("#login-form").addEventListener("submit", async (event) => {
4034
4206
  window.location.reload();
4035
4207
  } catch (error) {
4036
4208
  $("#auth-error").textContent = error.message;
4037
- refreshAuthCaptcha("login").catch(() => {});
4209
+ // 仅在验证码已显示时自动换一张,未加载过则保持默认隐藏状态
4210
+ if (!$("#login-captcha-image").hidden) refreshAuthCaptcha("login").catch(() => {});
4038
4211
  }
4039
4212
  });
4040
4213
  $("#register-form").addEventListener("submit", async (event) => {
@@ -4060,7 +4233,8 @@ $("#register-form").addEventListener("submit", async (event) => {
4060
4233
  window.location.reload();
4061
4234
  } catch (error) {
4062
4235
  $("#auth-error").textContent = error.message;
4063
- refreshAuthCaptcha("register").catch(() => {});
4236
+ // 仅在验证码已显示时自动换一张,未加载过则保持默认隐藏状态
4237
+ if (!$("#register-captcha-image").hidden) refreshAuthCaptcha("register").catch(() => {});
4064
4238
  }
4065
4239
  });
4066
4240
  $("#settings-return").addEventListener("click", () => returnFromSettings().catch((error) => toast(error.message, "error")));
@@ -6,13 +6,13 @@
6
6
  <meta name="description" content="面向长篇小说创作的 AI 协作工作台">
7
7
  <meta name="theme-color" content="#8b3d2c">
8
8
  <title>叙界 · 小说 AI 创作工作台</title>
9
- <script src="/theme-init.js?v=20260713-dark-mode"></script>
9
+ <script src="/theme-init.js?v=20260720-route-skeleton"></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=20260719-registration-disabled">
12
+ <link rel="stylesheet" href="/styles.css?v=20260720-captcha-lazy-load">
13
13
  </head>
14
14
  <body class="auth-pending">
15
- <section id="auth-view" class="auth-view" aria-labelledby="auth-title">
15
+ <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
16
16
  <div class="auth-card">
17
17
  <div class="auth-brand"><span class="brand-mark">叙</span><div><strong>叙界</strong><small>小说 AI 创作工作台</small></div></div>
18
18
  <span class="eyebrow">账户与作品空间</span>
@@ -20,7 +20,7 @@
20
20
  <p id="auth-description">你的作品、协作权限和每一次修改都会绑定到账户。</p>
21
21
  <div class="auth-tabs" role="tablist" aria-label="登录或注册">
22
22
  <button id="auth-login-tab" type="button" role="tab" aria-selected="true">登录</button>
23
- <button id="auth-register-tab" type="button" role="tab" aria-selected="false">注册</button>
23
+ <button id="auth-register-tab" type="button" role="tab" aria-selected="false" aria-disabled="true" disabled>注册已禁用</button>
24
24
  </div>
25
25
  <form id="login-form" class="auth-form">
26
26
  <label>用户名<input name="username" autocomplete="username" maxlength="100" required></label>
@@ -28,11 +28,12 @@
28
28
  <div class="auth-captcha">
29
29
  <label>验证码<input name="captchaAnswer" autocomplete="off" inputmode="text" maxlength="8" spellcheck="false" required aria-describedby="login-captcha-hint"></label>
30
30
  <button id="login-captcha-refresh" class="auth-captcha-image" type="button" aria-label="刷新验证码" title="刷新验证码">
31
- <img id="login-captcha-image" alt="验证码图片">
31
+ <img id="login-captcha-image" alt="验证码图片" hidden>
32
+ <span id="login-captcha-placeholder" class="auth-captcha-placeholder">点击显示验证码</span>
32
33
  </button>
33
34
  <input id="login-captcha-id" name="captchaId" type="hidden" required>
34
35
  </div>
35
- <p id="login-captcha-hint" class="auth-captcha-hint">点击图片可刷新</p>
36
+ <p id="login-captcha-hint" class="auth-captcha-hint">点击按钮显示验证码,显示后点击图片可刷新</p>
36
37
  <button class="primary-button" type="submit">登录</button>
37
38
  </form>
38
39
  <form id="register-form" class="auth-form hidden">
@@ -42,11 +43,12 @@
42
43
  <div class="auth-captcha">
43
44
  <label>验证码<input name="captchaAnswer" autocomplete="off" inputmode="text" maxlength="8" spellcheck="false" required aria-describedby="register-captcha-hint"></label>
44
45
  <button id="register-captcha-refresh" class="auth-captcha-image" type="button" aria-label="刷新验证码" title="刷新验证码">
45
- <img id="register-captcha-image" alt="验证码图片">
46
+ <img id="register-captcha-image" alt="验证码图片" hidden>
47
+ <span id="register-captcha-placeholder" class="auth-captcha-placeholder">点击显示验证码</span>
46
48
  </button>
47
49
  <input id="register-captcha-id" name="captchaId" type="hidden" required>
48
50
  </div>
49
- <p id="register-captcha-hint" class="auth-captcha-hint">点击图片可刷新</p>
51
+ <p id="register-captcha-hint" class="auth-captcha-hint">点击按钮显示验证码,显示后点击图片可刷新</p>
50
52
  <small>至少 10 个字符。系统中的第一个注册用户会自动成为管理员。</small>
51
53
  <button class="primary-button" type="submit">创建账户</button>
52
54
  </form>
@@ -332,6 +334,7 @@
332
334
  <button type="button" role="tab" data-character-editor-tab="profile" aria-selected="false">人物档案<small>简介、身份与动机</small></button>
333
335
  <button type="button" role="tab" data-character-editor-tab="settings" aria-selected="false">扩展设定<small>属性与长篇章节</small></button>
334
336
  <button type="button" role="tab" data-character-editor-tab="state" aria-selected="false">状态与约束<small>当前状态与锁定项</small></button>
337
+ <button type="button" role="tab" data-character-editor-tab="relationships" aria-selected="false">人物关系<small>关联人物与关系关键词</small></button>
335
338
  </nav>
336
339
  <main id="character-editor-fields" class="character-editor-fields"></main>
337
340
  <aside id="character-history-panel" class="character-history-panel hidden" aria-label="人物版本历史">
@@ -542,6 +545,7 @@
542
545
  </section>
543
546
  </dialog>
544
547
 
545
- <script type="module" src="/app.js?v=20260719-registration-disabled"></script>
548
+ <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
549
+ <script type="module" src="/app.js?v=20260720-captcha-lazy-load"></script>
546
550
  </body>
547
551
  </html>
@@ -31,7 +31,9 @@ export function serializePageRoute(route = {}) {
31
31
  const view = String(route.view ?? "shelf");
32
32
  const workId = String(route.workId ?? "").trim();
33
33
 
34
- if (view === "editor" && workId) {
34
+ if (view === "login") {
35
+ params.set("view", "login");
36
+ } else if (view === "editor" && workId) {
35
37
  params.set("view", "editor");
36
38
  params.set("work", workId);
37
39
  if (route.chapterId) params.set("chapter", String(route.chapterId));
@@ -58,6 +60,8 @@ export function parsePageRoute(hash = "") {
58
60
  const view = value(params, "view");
59
61
  const workId = value(params, "work");
60
62
 
63
+ if (view === "login") return { view: "login" };
64
+
61
65
  if (view === "editor" && workId) {
62
66
  const chapterId = value(params, "chapter");
63
67
  return { view, workId, chapterId: chapterId || null };
@@ -0,0 +1,34 @@
1
+ const relationshipKeywordSeparator = /[,,、;;\n]/u;
2
+
3
+ function normalizeRelationshipKeyword(value) {
4
+ return String(value ?? "").normalize("NFKC").trim().replace(/\s+/gu, " ");
5
+ }
6
+
7
+ export function splitRelationshipKeywords(value) {
8
+ return String(value ?? "")
9
+ .split(relationshipKeywordSeparator)
10
+ .map(normalizeRelationshipKeyword)
11
+ .filter(Boolean);
12
+ }
13
+
14
+ export function uniqueRelationshipKeywords(values) {
15
+ const keywords = [];
16
+ const seen = new Set();
17
+ for (const value of values) {
18
+ for (const keyword of splitRelationshipKeywords(value)) {
19
+ const key = keyword.toLocaleLowerCase("zh-CN");
20
+ if (seen.has(key)) continue;
21
+ seen.add(key);
22
+ keywords.push(keyword);
23
+ }
24
+ }
25
+ return keywords;
26
+ }
27
+
28
+ export function splitRelationshipKeywordInput(value) {
29
+ const parts = String(value ?? "").split(relationshipKeywordSeparator);
30
+ return {
31
+ completed: uniqueRelationshipKeywords(parts.slice(0, -1)),
32
+ remainder: parts.at(-1) ?? ""
33
+ };
34
+ }
@@ -31,7 +31,26 @@
31
31
  background: var(--paper);
32
32
  }
33
33
 
34
- .auth-pending .app-shell { visibility: hidden; }
34
+ .auth-pending .app-shell { pointer-events: none; user-select: none; }
35
+ .auth-loading { display: none; }
36
+ .auth-pending .auth-loading { position: fixed; inset: 0; z-index: 900; display: grid; place-items: center; pointer-events: none; }
37
+ .auth-loading::before { content: ""; width: 32px; height: 32px; border: 3px solid color-mix(in srgb, var(--accent) 25%, transparent); border-top-color: var(--accent); border-radius: 50%; animation: auth-loading-spin .8s linear infinite; }
38
+ @keyframes auth-loading-spin { to { transform: rotate(360deg); } }
39
+ @media (prefers-reduced-motion: reduce) { .auth-loading::before { animation-duration: 2s; } }
40
+ .login-route .auth-view.hidden { display: grid !important; }
41
+ .login-route .auth-loading { display: none; }
42
+ /* 会话恢复期间按 data-pending-view 预显示目标视图的骨架(自然 display 与各视图定义一致) */
43
+ [data-pending-view="shelf"] .auth-pending #shelf-view.hidden,
44
+ [data-pending-view="settings"] .auth-pending #settings-hub-view.hidden,
45
+ [data-pending-view="platform-ai"] .auth-pending #platform-ai-view.hidden,
46
+ [data-pending-view="module"] .auth-pending #module-view.hidden { display: block !important; }
47
+ [data-pending-view="welcome"] .auth-pending #welcome-view.hidden,
48
+ [data-pending-view="editor"] .auth-pending #editor-view.hidden { display: grid !important; }
49
+ /* 书架/设置/平台 AI 路由的骨架使用整宽布局,与 shelf-mode 一致 */
50
+ .pending-shelf-mode .auth-pending .app-shell { grid-template-columns: 1fr; }
51
+ .pending-shelf-mode .auth-pending .topbar { grid-template-columns: 280px 1fr auto; }
52
+ .pending-shelf-mode .auth-pending .left-panel, .pending-shelf-mode .auth-pending .ai-panel { display: none; }
53
+ .pending-shelf-mode .auth-pending .main-panel { grid-column: 1 / -1; }
35
54
  .auth-view { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; align-content: safe center; padding: 24px; overflow-y: auto; overscroll-behavior: contain; background: radial-gradient(circle at 18% 16%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 34%), var(--paper); }
36
55
  .auth-view.hidden { display: none; }
37
56
  .auth-card { box-sizing: border-box; width: min(440px, 100%); padding: 34px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); box-shadow: var(--shadow); }
@@ -163,6 +182,8 @@
163
182
  cursor: pointer;
164
183
  }
165
184
  .auth-captcha-image img { display: block; width: 148px; height: 48px; border-radius: 6px; }
185
+ .auth-captcha-image img[hidden], .auth-captcha-placeholder[hidden] { display: none; }
186
+ .auth-captcha-placeholder { color: var(--muted); font-size: 13px; padding: 0 14px; white-space: nowrap; }
166
187
  .auth-captcha-hint { margin: -8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.4; }
167
188
  .auth-error { min-height: 20px; margin: 14px 0 0 !important; color: var(--accent-dark) !important; }
168
189
 
@@ -471,6 +492,8 @@ body.work-viewer-mode #merge-events,
471
492
  body.work-viewer-mode [data-edit-foreshadow],
472
493
  body.work-viewer-mode [data-edit-outline],
473
494
  body.work-viewer-mode [data-edit-relationship],
495
+ body.work-viewer-mode [data-character-relationship-edit],
496
+ body.work-viewer-mode [data-character-relationship-create],
474
497
  body.work-viewer-mode [data-review-id],
475
498
  body.work-viewer-mode .task-auto-run-panel,
476
499
  body.work-viewer-mode [data-run-task],
@@ -1282,6 +1305,14 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1282
1305
  .item-list-row button, .item-list-add { border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--muted); font-size: 10px; }
1283
1306
  .item-list-row button { padding: 0 10px; }
1284
1307
  .item-list-add { justify-self: start; padding: 7px 11px; }
1308
+ .keyword-chip-field > small { color: var(--muted); font-size: 10px; line-height: 1.5; }
1309
+ .keyword-chip-editor { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 42px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
1310
+ .keyword-chip-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent); }
1311
+ .keyword-chip-editor > input[data-keyword-input] { flex: 1 1 160px; width: auto; min-width: 140px; padding: 6px 3px; border: 0; background: transparent; color: var(--ink); outline: none; }
1312
+ .keyword-chip-editor > input[data-keyword-input]:focus { border: 0; box-shadow: none; }
1313
+ .keyword-chip { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 3px 5px 3px 10px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); border-radius: 16px; background: var(--paper-deep); color: var(--ink); font-size: 11px; line-height: 1.3; }
1314
+ .keyword-chip button { display: inline-grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--muted); font-size: 15px; line-height: 1; }
1315
+ .keyword-chip button:hover, .keyword-chip button:focus-visible { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-dark); outline: none; }
1285
1316
  .structured-list-field > small { color: var(--muted); font-size: 9px; line-height: 1.5; }
1286
1317
  .structured-list-rows { display: grid; gap: 9px; }
1287
1318
  .structured-list-row { display: grid; gap: 7px; padding: 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface-soft); }
@@ -1338,6 +1369,22 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1338
1369
  .character-editor-section-fields .section-list-row textarea { min-height: 180px; }
1339
1370
  .character-editor-section-fields .member-chip { position: relative; display: inline-flex; width: auto; cursor: pointer; }
1340
1371
  .character-editor-section-fields .item-list-row button, .character-editor-section-fields .structured-list-row button { min-width: 54px; }
1372
+ .character-editor-relationships-field { display: grid; grid-column: 1 / -1; gap: 12px; }
1373
+ .character-relationship-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
1374
+ .character-relationship-toolbar p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
1375
+ .character-relationship-toolbar .ghost-button { flex: 0 0 auto; min-height: 32px; }
1376
+ .character-relationship-row { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
1377
+ .character-relationship-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
1378
+ .character-relationship-heading > div { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; min-width: 0; }
1379
+ .character-relationship-heading strong { font-size: 14px; }
1380
+ .character-relationship-heading span { color: var(--muted); font-size: 10px; }
1381
+ .character-relationship-heading button { flex: 0 0 auto; padding: 6px 9px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font-size: 10px; }
1382
+ .character-relationship-heading button:hover { border-color: var(--accent); color: var(--accent-dark); }
1383
+ .character-relationship-keywords { display: grid; gap: 6px; }
1384
+ .character-relationship-keywords > small { color: var(--muted); font-size: 9px; }
1385
+ .character-relationship-keywords > div { display: flex; flex-wrap: wrap; gap: 5px; }
1386
+ .character-relationship-empty-keywords, .character-relationship-status { color: var(--muted); font-size: 10px; }
1387
+ .character-relationship-status { margin: 0; padding: 18px 12px; border: 1px dashed var(--line); border-radius: 4px; text-align: center; }
1341
1388
  .character-editor-empty-field { display: grid; align-content: start; gap: 7px; color: var(--muted); font-size: 11px; }
1342
1389
  .character-editor-empty-field span { padding: 11px; border: 1px dashed var(--line); border-radius: 4px; font-size: 10px; }
1343
1390
  .character-editor-field-help { margin: -8px 0 0; color: var(--muted); font-size: 9px; }
@@ -5,4 +5,19 @@
5
5
  const theme = storedTheme === "dark" || storedTheme === "light" ? storedTheme : prefersDark ? "dark" : "light";
6
6
  document.documentElement.dataset.theme = theme;
7
7
  document.documentElement.style.colorScheme = theme;
8
+ // 登录页路由首帧直接显示登录卡片,避免出现"骨架屏 → 登录页"的跳变
9
+ const routeParams = new URLSearchParams(window.location.hash.replace(/^#/, ""));
10
+ const routeView = routeParams.get("view") ?? "";
11
+ if (routeView === "login") {
12
+ document.documentElement.classList.add("login-route");
13
+ } else {
14
+ // 会话恢复期间按目标路由预显示对应视图的骨架屏
15
+ const pendingView = ["editor", "module", "welcome"].includes(routeView) && routeParams.get("work")
16
+ ? routeView
17
+ : ["settings", "platform-ai"].includes(routeView) ? routeView : "shelf";
18
+ document.documentElement.dataset.pendingView = pendingView;
19
+ if (["shelf", "settings", "platform-ai"].includes(pendingView)) {
20
+ document.documentElement.classList.add("pending-shelf-mode");
21
+ }
22
+ }
8
23
  })();
package/dist/security.js CHANGED
@@ -241,7 +241,7 @@ export function resolveRuntimeSecurity(environment, requireAuthentication = fals
241
241
  trustProxy,
242
242
  enforceSameOrigin: true,
243
243
  allowPrivateAiEndpoints: environment.APP_ALLOW_PRIVATE_AI_ENDPOINTS === "true" || !production,
244
- allowRegistration: environment.APP_ALLOW_REGISTRATION !== "false"
244
+ allowRegistration: environment.APP_ALLOW_REGISTRATION === "true"
245
245
  };
246
246
  }
247
247
  //# sourceMappingURL=security.js.map