@musnows/scriverse 0.4.4 → 0.4.5

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.
@@ -1,4 +1,4 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260724-relationship-density";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260725-galaxy-ripple";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260722-inline-code";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
@@ -723,11 +723,14 @@ let settingEditorVditor = null;
723
723
  let knowledgeSectionVditor = null;
724
724
  let characterSectionVditor = null;
725
725
  let entityHistoryContext = null;
726
+ let moduleContentInteractionsBound = false;
726
727
 
727
728
  function showEntityEditorPage(type, { readOnly = false } = {}) {
729
+ const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
730
+ const viewOnly = readOnly || !canEditModule(module);
728
731
  entityEditorType = type;
729
732
  entityEditorDirty = false;
730
- entityEditorReadOnly = readOnly;
733
+ entityEditorReadOnly = viewOnly;
731
734
  characterSectionEditorDirty = false;
732
735
  knowledgeSectionEditorDirty = false;
733
736
  $("#entity-editor-view").classList.remove("hidden");
@@ -736,6 +739,7 @@ function showEntityEditorPage(type, { readOnly = false } = {}) {
736
739
  $("#knowledge-editor-form").classList.toggle("hidden", !["race", "organization"].includes(type));
737
740
  $("#character-section-editor-view").classList.add("hidden");
738
741
  $("#knowledge-section-editor-view").classList.add("hidden");
742
+ ["setting", "character", "knowledge"].forEach((editor) => $(`#${editor}-editor-readonly-badge`).classList.toggle("hidden", !(viewOnly && (editor === "knowledge" ? ["race", "organization"].includes(type) : editor === type))));
739
743
  $("#app").inert = true;
740
744
  document.body.classList.add("entity-editor-open");
741
745
  replacePageRoute(currentPageRoute());
@@ -1782,7 +1786,9 @@ async function api(path, options = {}) {
1782
1786
  state.csrfToken = null;
1783
1787
  showAuth(false);
1784
1788
  }
1785
- throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
1789
+ const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
1790
+ error.code = payload.error?.code;
1791
+ throw error;
1786
1792
  }
1787
1793
  if (response.status === 204) return null;
1788
1794
  const payload = await response.json();
@@ -2264,11 +2270,8 @@ async function initializePage() {
2264
2270
  }
2265
2271
  if (route.view === "module") return;
2266
2272
  if (route.view === "entity-editor") {
2267
- const records = route.entity === "setting" ? state.settings : route.entity === "character" ? state.characters : route.entity === "race" ? state.races : state.organizations;
2268
2273
  const item = route.entityId
2269
- ? route.entity === "character"
2270
- ? await api(`/api/characters/${encodeURIComponent(route.entityId)}`)
2271
- : records.find((record) => record.id === route.entityId)
2274
+ ? await api(`/api/${route.entity === "setting" ? "settings" : route.entity === "character" ? "characters" : route.entity === "race" ? "races" : "organizations"}/${encodeURIComponent(route.entityId)}`)
2272
2275
  : null;
2273
2276
  if (route.entityId && !item) {
2274
2277
  toast(({ setting: "未找到要编辑的设定", character: "未找到要编辑的角色", race: "未找到要编辑的种族", organization: "未找到要编辑的组织" }[route.entity] ?? "未找到要编辑的档案"), "error");
@@ -2903,6 +2906,7 @@ async function showModule(module) {
2903
2906
  $("#module-create-button").textContent = meta[3];
2904
2907
  $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2905
2908
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2909
+ bindModuleContentInteractions();
2906
2910
  try {
2907
2911
  if (module === "settings") await renderSettings();
2908
2912
  if (module === "characters") await renderCharacters(characterListPage);
@@ -3128,7 +3132,7 @@ function bindModuleLayoutToggle(refresh) {
3128
3132
 
3129
3133
  function bindRecordPreview(selector, open) {
3130
3134
  $("#module-content").querySelectorAll(selector).forEach((card) => {
3131
- const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openReview;
3135
+ const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
3132
3136
  card.addEventListener("click", (event) => {
3133
3137
  if (!event.target.closest("button, a, summary")) void open(id);
3134
3138
  });
@@ -3141,6 +3145,36 @@ function bindRecordPreview(selector, open) {
3141
3145
  });
3142
3146
  }
3143
3147
 
3148
+ function bindModuleContentInteractions() {
3149
+ if (moduleContentInteractionsBound) return;
3150
+ moduleContentInteractionsBound = true;
3151
+ const host = $("#module-content");
3152
+ const open = async (card) => {
3153
+ const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization;
3154
+ if (!id) return;
3155
+ if (card.dataset.openSetting) return openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly: true });
3156
+ if (card.dataset.openCharacter) return openCharacterEditor(await api(`/api/characters/${encodeURIComponent(id)}`), { readOnly: true });
3157
+ if (card.dataset.openRace) return openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly: true });
3158
+ if (card.dataset.openOrganization) return openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly: true });
3159
+ };
3160
+ const findCard = (target) => target instanceof Element
3161
+ ? target.closest("[data-open-setting], [data-open-character], [data-open-race], [data-open-organization]")
3162
+ : null;
3163
+ host.addEventListener("click", (event) => {
3164
+ if (event.target instanceof Element && event.target.closest("button, a, summary")) return;
3165
+ const card = findCard(event.target);
3166
+ if (card) void open(card);
3167
+ });
3168
+ host.addEventListener("keydown", (event) => {
3169
+ if (event.key !== "Enter" && event.key !== " ") return;
3170
+ if (event.target instanceof Element && event.target.closest("button, a, summary")) return;
3171
+ const card = findCard(event.target);
3172
+ if (!card) return;
3173
+ event.preventDefault();
3174
+ void open(card);
3175
+ });
3176
+ }
3177
+
3144
3178
  function openReviewDetailDialog(item) {
3145
3179
  if (!item) return;
3146
3180
  const evidence = Array.isArray(item.evidence) ? item.evidence : [];
@@ -3185,13 +3219,13 @@ function settingRecordActions(item) {
3185
3219
  function renderSettingCards(records) {
3186
3220
  return `<div class="card-grid">${records.map((item) => `
3187
3221
  <article class="record-card preview-record-card" data-open-setting="${esc(item.id)}" role="button" tabindex="0" aria-label="查看设定 ${esc(item.title)}"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(settingStatusLabel(item.status))}</small>
3188
- <h3>${esc(item.title)}</h3><div class="record-markdown-preview message-body">${renderMarkdown(item.content) || '<p class="markdown-editor-empty">暂无正文</p>'}</div>
3222
+ <h3>${esc(item.title)}</h3><div class="record-markdown-preview">${esc(item.contentPreview || "暂无正文预览")}</div>
3189
3223
  <div class="card-actions">${settingRecordActions(item)}</div></article>`).join("")}</div>`;
3190
3224
  }
3191
3225
 
3192
3226
  function renderSettingRows(records) {
3193
3227
  return `<div class="module-row-list">${records.map((item) => {
3194
- const preview = moduleRowPreview(item.content);
3228
+ const preview = moduleRowPreview(item.contentPreview);
3195
3229
  return `
3196
3230
  <article class="record-card module-row preview-record-card" data-open-setting="${esc(item.id)}" role="button" tabindex="0" aria-label="查看设定 ${esc(item.title)}"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(settingStatusLabel(item.status))}</small>
3197
3231
  <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
@@ -3208,20 +3242,16 @@ async function renderSettings() {
3208
3242
  ? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
3209
3243
  : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
3210
3244
  bindModuleLayoutToggle(renderSettings);
3211
- bindRecordPreview("[data-open-setting]", (id) => openSettingEditor(records.find((item) => item.id === id), { readOnly: true }));
3212
- $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
3245
+ const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
3246
+ $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
3213
3247
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
3214
3248
  }
3215
3249
 
3216
3250
  async function renderCharacters(page = characterListPage) {
3217
- const [characterPage, races, organizations] = await Promise.all([
3218
- apiPage(`/api/works/${state.work.id}/characters`, page),
3219
- canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
3220
- canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
3221
- ]);
3251
+ const characterPage = await apiPage(`/api/works/${state.work.id}/characters`, page);
3222
3252
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3223
3253
  characterListPage = characterPage.page;
3224
- [state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
3254
+ state.characters = characterPage.items;
3225
3255
  const layout = readModuleLayout();
3226
3256
  const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
3227
3257
  const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
@@ -3287,15 +3317,12 @@ async function renderCharacters(page = characterListPage) {
3287
3317
  button.disabled = false;
3288
3318
  }
3289
3319
  });
3290
- bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(state.characters.find((item) => item.id === id), { readOnly: true }));
3291
- $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
3320
+ const openCharacter = async (id, readOnly) => openCharacterEditor(await api(`/api/characters/${encodeURIComponent(id)}`), { readOnly });
3321
+ $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => { void openCharacter(button.dataset.editCharacter, false); }));
3292
3322
  }
3293
3323
 
3294
3324
  async function renderRaces() {
3295
- [state.races, state.characters] = await Promise.all([
3296
- apiAllPages(`/api/works/${state.work.id}/races`),
3297
- canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
3298
- ]);
3325
+ state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
3299
3326
  const layout = readModuleLayout();
3300
3327
  const canEditRaces = canEditModule("races");
3301
3328
  const raceActions = (item) => canEditRaces
@@ -3307,10 +3334,10 @@ async function renderRaces() {
3307
3334
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
3308
3335
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
3309
3336
  <div class="race-tree-branch">
3310
- <article class="record-card race-card preview-record-card${canEditRaces ? " has-card-edit" : ""}" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
3337
+ <article class="record-card race-card preview-record-card${canEditRaces ? " has-card-edit" : ""}" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}"><small>${item.memberIds.length} 位直接角色 · ${item.settingsCount ?? item.settings?.length ?? 0} 条自身设定</small>
3311
3338
  <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
3312
3339
  <p>${esc(item.description || "尚未填写种族简介")}</p>
3313
- <div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<section class="knowledge-markdown-block${setting.inherited ? " inherited" : ""}"><div class="knowledge-markdown-block-heading"><h4>${esc(setting.title || "未命名章节")}</h4><small>${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}</small></div><div class="message-body">${renderMarkdown(setting.value) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></section>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
3340
+ <div class="race-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条共同设定,打开查看详情</span>` : '<span class="pill">暂无共同设定</span>'}</div>
3314
3341
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
3315
3342
  ${raceCardActions(item)}
3316
3343
  </article>
@@ -3319,7 +3346,7 @@ async function renderRaces() {
3319
3346
  </details>`;
3320
3347
  const raceRows = () => `<div class="module-row-list">${state.races.map((item) => {
3321
3348
  const preview = moduleRowPreview(item.description || "尚未填写种族简介");
3322
- const meta = `${item.memberIds.length} 位直接角色 · ${item.settings.length ? "已填写共同设定" : "暂无共同设定"}`;
3349
+ const meta = `${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
3323
3350
  return `
3324
3351
  <article class="record-card module-row race-card preview-record-card" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}">
3325
3352
  <small>${esc(meta)}</small>
@@ -3333,16 +3360,13 @@ async function renderRaces() {
3333
3360
  ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
3334
3361
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
3335
3362
  bindModuleLayoutToggle(renderRaces);
3336
- bindRecordPreview("[data-open-race]", (id) => openRaceDialog(state.races.find((item) => item.id === id), { readOnly: true }));
3337
- $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
3363
+ const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
3364
+ $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
3338
3365
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
3339
3366
  }
3340
3367
 
3341
3368
  async function renderOrganizations() {
3342
- [state.organizations, state.characters] = await Promise.all([
3343
- apiAllPages(`/api/works/${state.work.id}/organizations`),
3344
- canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
3345
- ]);
3369
+ state.organizations = await apiAllPages(`/api/works/${state.work.id}/organizations`);
3346
3370
  const layout = readModuleLayout();
3347
3371
  const canEditOrganizations = canEditModule("organizations");
3348
3372
  const organizationActions = (item) => canEditOrganizations
@@ -3352,9 +3376,9 @@ async function renderOrganizations() {
3352
3376
  ? organizationActions(item)
3353
3377
  : `<div class="card-actions">${organizationActions(item)}</div>`;
3354
3378
  const organizationCards = () => `<div class="card-grid organization-grid">${state.organizations.map((item) => `
3355
- <article class="record-card organization-card${canEditOrganizations ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
3379
+ <article class="record-card organization-card preview-record-card${canEditOrganizations ? " has-card-edit" : ""}" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}"><small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
3356
3380
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
3357
- <div class="organization-settings">${item.settingsSections?.length ? item.settingsSections.map((section) => `<article class="knowledge-markdown-block"><div class="knowledge-markdown-block-heading"><h4>${esc(section.title || "未命名章节")}</h4></div><div class="message-body">${renderMarkdown(section.contentMarkdown) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></article>`).join("") : '<span class="pill">暂无组织设定</span>'}</div>
3381
+ <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
3358
3382
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
3359
3383
  ${organizationCardActions(item)}
3360
3384
  </article>`).join("")}</div>`;
@@ -3362,8 +3386,8 @@ async function renderOrganizations() {
3362
3386
  const preview = moduleRowPreview(item.description || "尚未填写组织简介");
3363
3387
  const members = item.members.length ? item.members.map((member) => member.name).join("、") : "暂无绑定角色";
3364
3388
  return `
3365
- <article class="record-card module-row organization-card">
3366
- <small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
3389
+ <article class="record-card module-row organization-card" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}">
3390
+ <small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
3367
3391
  <h3>${esc(item.name)}</h3>
3368
3392
  <p class="module-row-preview" title="${esc(`${preview} · 成员:${members}`)}">${esc(preview)} · ${esc(members)}</p>
3369
3393
  <div class="card-actions">${organizationActions(item)}</div>
@@ -3374,7 +3398,8 @@ async function renderOrganizations() {
3374
3398
  ? `${layout === "rows" ? organizationRows() : organizationCards()}`
3375
3399
  : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
3376
3400
  bindModuleLayoutToggle(renderOrganizations);
3377
- $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
3401
+ const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
3402
+ $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => { void openOrganization(button.dataset.editOrganization, false); }));
3378
3403
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
3379
3404
  }
3380
3405
 
@@ -4063,7 +4088,7 @@ async function loadAiReferences() {
4063
4088
  const generation = workScopedUiGeneration;
4064
4089
  const [characters, settings] = await Promise.all([
4065
4090
  canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
4066
- canReadModule("settings") ? apiAllPages(`/api/works/${workId}/settings`) : Promise.resolve([])
4091
+ canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([])
4067
4092
  ]);
4068
4093
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
4069
4094
  state.characters = characters;
@@ -4740,7 +4765,14 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4740
4765
  placeholder,
4741
4766
  preview: { transform: transformVditorPreview },
4742
4767
  cache: { enable: false },
4743
- toolbar: ["headings", "bold", "italic", "strike", "|", "line", "quote", "list", "ordered-list", "check", "|", "code", "inline-code", "link", "table", "upload", "|", "undo", "redo", "edit-mode", "fullscreen"],
4768
+ toolbar: ["headings", "bold", "italic", "strike", "|", "line", "quote", "list", "ordered-list", "check", "|", "code", "inline-code", "link", "table", "upload", "|", "undo", "redo", {
4769
+ name: "line-number",
4770
+ tip: "显示/隐藏行号",
4771
+ tipPosition: "s",
4772
+ className: "vditor-line-number-button",
4773
+ icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h3M4 9h3M4 13h3M4 17h3M10 5h10M10 9h10M10 13h10M10 17h10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/></svg>',
4774
+ click: () => toggleVditorLineNumbers(editor)
4775
+ }, "edit-mode", "fullscreen"],
4744
4776
  upload: {
4745
4777
  accept: "image/*",
4746
4778
  max: 10 * 1024 * 1024,
@@ -4750,11 +4782,13 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4750
4782
  input: (markdown) => {
4751
4783
  normalizeVditorAttachmentImages(editor);
4752
4784
  updateVditorWordCount(editor, markdown);
4785
+ updateVditorLineNumbers(editor, markdown);
4753
4786
  onInput(markdown);
4754
4787
  },
4755
4788
  after: () => {
4756
4789
  normalizeVditorAttachmentImages(editor);
4757
4790
  updateVditorWordCount(editor, value);
4791
+ updateVditorLineNumbers(editor, value);
4758
4792
  if (readOnly) editor?.disabled();
4759
4793
  }
4760
4794
  });
@@ -4765,6 +4799,34 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4765
4799
  return editor;
4766
4800
  }
4767
4801
 
4802
+ function toggleVditorLineNumbers(editor) {
4803
+ const host = editor?.vditor?.element;
4804
+ if (!host) return;
4805
+ const enabled = host.classList.toggle("show-line-numbers");
4806
+ const button = host.querySelector(".vditor-line-number-button");
4807
+ if (button) button.setAttribute("aria-pressed", String(enabled));
4808
+ }
4809
+
4810
+ function updateVditorLineNumbers(editor, markdown) {
4811
+ const content = editor?.vditor?.element?.querySelector(".vditor-content");
4812
+ if (!content) return;
4813
+ let gutter = content.querySelector(".vditor-line-numbers");
4814
+ if (!gutter) {
4815
+ gutter = document.createElement("div");
4816
+ gutter.className = "vditor-line-numbers";
4817
+ gutter.setAttribute("aria-hidden", "true");
4818
+ content.prepend(gutter);
4819
+ }
4820
+ const lineCount = Math.max(1, String(markdown ?? "").split(/\r?\n/gu).length);
4821
+ const fragment = document.createDocumentFragment();
4822
+ for (let line = 1; line <= lineCount; line += 1) {
4823
+ const number = document.createElement("span");
4824
+ number.textContent = String(line);
4825
+ fragment.append(number);
4826
+ }
4827
+ gutter.replaceChildren(fragment);
4828
+ }
4829
+
4768
4830
  function updateVditorWordCount(editor, markdown) {
4769
4831
  const toolbar = editor?.vditor?.toolbar?.element;
4770
4832
  if (!toolbar) return;
@@ -4861,13 +4923,10 @@ function knowledgeSectionEditorHtml(section = null) {
4861
4923
  const label = knowledgeEditorKind === "race" ? "种族" : "组织";
4862
4924
  return `<div class="character-section-editor-shell knowledge-section-editor-shell">
4863
4925
  <header class="character-section-editor-header">
4864
- <div><span class="eyebrow">${label} Markdown 设定</span><h2 id="knowledge-section-editor-title">${section ? `编辑“${esc(section.title)}”` : "新建设定"}</h2></div>
4926
+ <div><span class="eyebrow">${label} Markdown 设定</span><input id="knowledge-section-title" class="character-section-editor-title-input" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="新建设定" aria-label="设定标题" required></div>
4865
4927
  <button class="entity-editor-back" type="button" data-knowledge-section-edit-close>返回设定列表</button>
4866
4928
  </header>
4867
4929
  <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}${label} Markdown 设定">
4868
- <div class="character-markdown-editor-meta">
4869
- <label>设定标题<input id="knowledge-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:组织章程、种族特征" required></label>
4870
- </div>
4871
4930
  <div id="knowledge-section-markdown" class="vditor-editor-host" data-vditor-editor aria-label="Markdown 编辑器"></div>
4872
4931
  <div class="character-markdown-editor-footer">
4873
4932
  <div class="character-markdown-editor-actions"><button type="button" data-knowledge-section-edit-cancel>取消</button><button type="button" class="primary-button" data-knowledge-section-edit-save>${section ? "保存设定" : "添加设定"}</button></div>
@@ -4939,13 +4998,12 @@ function characterSectionEditorHtml(section = null) {
4939
4998
  const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
4940
4999
  return `<div class="character-section-editor-shell">
4941
5000
  <header class="character-section-editor-header">
4942
- <div><span class="eyebrow">人物 Markdown 档案</span><h2 id="character-section-editor-title">${section ? `编辑“${esc(section.title)}”` : "新建档案章节"}</h2></div>
5001
+ <div><span class="eyebrow">人物 Markdown 档案</span><input id="character-section-title" class="character-section-editor-title-input" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="新建档案章节" aria-label="章节标题" required></div>
4943
5002
  <button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
4944
5003
  </header>
4945
5004
  <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
4946
5005
  <div class="character-markdown-editor-meta">
4947
5006
  <label>章节类型<select id="character-section-type">${options}</select></label>
4948
- <label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
4949
5007
  <label class="character-markdown-summary-field">章节摘要<textarea id="character-section-summary" maxlength="20000" placeholder="用于角色列表和 AI 快速定位,不会替代正文">${esc(section?.summary ?? "")}</textarea></label>
4950
5008
  </div>
4951
5009
  <div id="character-section-markdown" class="vditor-editor-host" data-vditor-editor aria-label="Markdown 编辑器"></div>
@@ -6176,7 +6234,9 @@ $("#profile-form").addEventListener("submit", async (event) => {
6176
6234
  state.user = updated;
6177
6235
  applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
6178
6236
  toast("显示名称已更新");
6179
- } catch (error) { toast(error.message, "error"); }
6237
+ } catch (error) {
6238
+ toast(error.code === "INVALID_CURRENT_PASSWORD" ? "当前密码错误,请重新输入" : error.message, "error");
6239
+ }
6180
6240
  });
6181
6241
  $("#password-form").addEventListener("submit", async (event) => {
6182
6242
  event.preventDefault();
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260725-ui-mobile-drawer-logo">
13
+ <link rel="stylesheet" href="/styles.css?v=20260725-develop-galaxy-ripple">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -376,6 +376,7 @@
376
376
  <button id="setting-editor-back" class="entity-editor-back" type="button">返回设定库</button>
377
377
  <div class="entity-editor-heading">
378
378
  <span id="setting-editor-eyebrow" class="eyebrow">新建设定</span>
379
+ <span id="setting-editor-readonly-badge" class="entity-editor-readonly-badge hidden">只读模式</span>
379
380
  <input id="setting-editor-name" class="setting-editor-title-input" name="title" maxlength="200" placeholder="新建设定" aria-label="设定标题">
380
381
  <div class="setting-editor-header-fields">
381
382
  <label>分类<select id="setting-editor-category" name="category">
@@ -408,7 +409,7 @@
408
409
  <button id="character-editor-close" class="entity-editor-back" type="button">返回角色列表</button>
409
410
  <div>
410
411
  <span id="character-editor-eyebrow" class="eyebrow">人物主档案</span>
411
- <div class="character-editor-title-row"><h1 id="character-editor-title">新建角色</h1><span id="character-editor-version" class="character-version-badge">新档案</span></div>
412
+ <div class="character-editor-title-row"><h1 id="character-editor-title">新建角色</h1><span id="character-editor-version" class="character-version-badge">新档案</span><span id="character-editor-readonly-badge" class="entity-editor-readonly-badge hidden">只读模式</span></div>
412
413
  </div>
413
414
  <div class="character-editor-header-actions">
414
415
  <button id="character-editor-edit" class="primary-button hidden" type="button">编辑角色</button>
@@ -442,7 +443,7 @@
442
443
  <button id="knowledge-editor-close" class="entity-editor-back" type="button">返回列表</button>
443
444
  <div>
444
445
  <span id="knowledge-editor-eyebrow" class="eyebrow">世界档案</span>
445
- <div class="character-editor-title-row"><h1 id="knowledge-editor-title">新建档案</h1><span id="knowledge-editor-version" class="character-version-badge">新档案</span></div>
446
+ <div class="character-editor-title-row"><h1 id="knowledge-editor-title">新建档案</h1><span id="knowledge-editor-version" class="character-version-badge">新档案</span><span id="knowledge-editor-readonly-badge" class="entity-editor-readonly-badge hidden">只读模式</span></div>
446
447
  </div>
447
448
  <div class="character-editor-header-actions">
448
449
  <span id="knowledge-editor-header-note" class="knowledge-editor-header-note">独立资料页</span>
@@ -682,6 +683,7 @@
682
683
  <output id="galaxy-stats" class="galaxy-stats" aria-live="polite" data-testid="galaxy-stats"></output>
683
684
  <aside class="galaxy-controls" aria-label="银河图控制">
684
685
  <button id="galaxy-stars" type="button" aria-pressed="true" data-testid="galaxy-stars">隐藏背景星点</button>
686
+ <button id="galaxy-grid" type="button" aria-pressed="false" data-testid="galaxy-grid">显示空间网格</button>
685
687
  <button id="galaxy-rotation" type="button" aria-pressed="false" data-testid="galaxy-rotation">暂停旋转</button>
686
688
  <button id="galaxy-zoom-out" type="button" aria-label="缩小">−</button>
687
689
  <button id="galaxy-zoom-in" type="button" aria-label="放大">+</button>
@@ -697,6 +699,6 @@
697
699
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
698
700
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
699
701
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
700
- <script type="module" src="/app.js?v=20260725-ui-mobile-drawer-logo"></script>
702
+ <script type="module" src="/app.js?v=20260725-develop-galaxy-ripple"></script>
701
703
  </body>
702
704
  </html>
@@ -1551,29 +1551,87 @@ export function layoutGalaxy(graph, seed) {
1551
1551
  return { nodes, byId };
1552
1552
  }
1553
1553
 
1554
- export function createGalaxyStarfield(seed, count = 3600) {
1554
+ export function createGalaxyStarfield(seed, count = 7200) {
1555
1555
  const random = seededRandom(hashString(seed));
1556
1556
  const stars = [];
1557
1557
  const armCount = 4;
1558
1558
  for (let index = 0; index < count; index += 1) {
1559
- const radius = 55 + Math.pow(random(), 0.62) * 1380;
1559
+ const population = random();
1560
+ const isCore = population < 0.62;
1561
+ const isHalo = !isCore && population > 0.9;
1562
+ const radius = isCore
1563
+ ? 32 + Math.pow(random(), 1.72) * 720
1564
+ : 160 + Math.pow(random(), 0.68) * 1510;
1560
1565
  const arm = index % armCount;
1561
1566
  const armAngle = arm / armCount * Math.PI * 2;
1562
- const angle = armAngle + radius * 0.0065 + (random() - 0.5) * (0.42 + radius / 1100);
1563
- const thickness = 22 + radius * 0.105;
1567
+ const angle = isHalo
1568
+ ? random() * Math.PI * 2
1569
+ : armAngle + radius * 0.0065 + (random() - 0.5) * (isCore ? 0.72 : 0.42 + radius / 1100);
1570
+ const thickness = isHalo ? 70 + radius * 0.2 : (isCore ? 8 + radius * 0.045 : 22 + radius * 0.105);
1564
1571
  const temperature = random();
1572
+ const x = Math.cos(angle) * radius + (random() - 0.5) * (isCore ? 42 : 78);
1573
+ const z = Math.sin(angle) * radius + (random() - 0.5) * (isCore ? 42 : 78);
1565
1574
  stars.push({
1566
- x: Math.cos(angle) * radius + (random() - 0.5) * 62,
1575
+ x,
1567
1576
  y: (random() + random() + random() - 1.5) * thickness,
1568
- z: Math.sin(angle) * radius + (random() - 0.5) * 62,
1569
- size: random() > 0.965 ? 1.7 + random() * 1.4 : 0.45 + random() * 0.85,
1570
- brightness: 0.22 + random() * 0.78,
1577
+ z,
1578
+ originX: x,
1579
+ originZ: z,
1580
+ vx: 0,
1581
+ vz: 0,
1582
+ size: isCore && random() > 0.94 ? 1.25 + random() * 1.25 : 0.38 + random() * 0.82,
1583
+ brightness: isCore ? 0.3 + random() * 0.7 : 0.16 + random() * 0.66,
1571
1584
  color: temperature < 0.2 ? "255,218,176" : temperature > 0.78 ? "174,211,255" : "226,237,255"
1572
1585
  });
1573
1586
  }
1574
1587
  return stars;
1575
1588
  }
1576
1589
 
1590
+ export function stepGalaxyStarfieldPhysics(stars, attractor = null, options = {}) {
1591
+ const deltaMs = clamp(Number(options.deltaMs) || 16.667, 1, 50);
1592
+ const timeScale = deltaMs / 16.667;
1593
+ const influenceRadius = Math.max(1, Number(options.influenceRadius) || 210);
1594
+ const repulsionStrength = Number(options.repulsionStrength) || 5.5;
1595
+ const springStrength = Number(options.springStrength) || 0.006;
1596
+ const damping = clamp(Number(options.damping) || 0.84, 0, 1);
1597
+ let energy = 0;
1598
+
1599
+ for (const star of stars) {
1600
+ const originX = Number(star.originX ?? star.x);
1601
+ const originZ = Number(star.originZ ?? star.z);
1602
+ star.originX = originX;
1603
+ star.originZ = originZ;
1604
+ star.vx = Number(star.vx) || 0;
1605
+ star.vz = Number(star.vz) || 0;
1606
+
1607
+ if (attractor) {
1608
+ const deltaX = star.x - attractor.x;
1609
+ const deltaZ = star.z - attractor.z;
1610
+ const distance = Math.hypot(deltaX, deltaZ);
1611
+ if (distance < influenceRadius) {
1612
+ const angle = Math.atan2(star.originZ, star.originX);
1613
+ const normalX = distance > 0.001 ? deltaX / distance : Math.cos(angle);
1614
+ const normalZ = distance > 0.001 ? deltaZ / distance : Math.sin(angle);
1615
+ const falloff = 1 - distance / influenceRadius;
1616
+ const force = falloff * falloff * repulsionStrength * timeScale;
1617
+ star.vx += normalX * force;
1618
+ star.vz += normalZ * force;
1619
+ }
1620
+ }
1621
+
1622
+ star.vx += (originX - star.x) * springStrength * timeScale;
1623
+ star.vz += (originZ - star.z) * springStrength * timeScale;
1624
+ const frameDamping = Math.pow(damping, timeScale);
1625
+ star.vx *= frameDamping;
1626
+ star.vz *= frameDamping;
1627
+ star.x += star.vx * timeScale;
1628
+ star.z += star.vz * timeScale;
1629
+ energy += Math.abs(star.vx) + Math.abs(star.vz) + Math.abs(originX - star.x) + Math.abs(originZ - star.z);
1630
+ }
1631
+
1632
+ return energy;
1633
+ }
1634
+
1577
1635
  export function projectGalaxyPoint(point, camera, viewport) {
1578
1636
  const relativeX = point.x - Number(camera.targetX ?? 0);
1579
1637
  const relativeY = point.y - Number(camera.targetY ?? 0);
@@ -1697,13 +1755,18 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1697
1755
  let animationFrame = 0;
1698
1756
  let previousFrameTime = 0;
1699
1757
  let cameraFocus = null;
1758
+ let draggedNode = null;
1759
+ let starPhysicsEnergy = 0;
1700
1760
  let paused = false;
1701
1761
  let starsVisible = true;
1762
+ let gridVisible = false;
1702
1763
  let destroyed = false;
1703
1764
 
1704
1765
  shell.classList.add("is-three-dimensional");
1705
1766
  shell.dataset.sceneDimension = "3";
1706
1767
  shell.dataset.starCount = String(stars.length);
1768
+ shell.dataset.gridVisible = "false";
1769
+ shell.dataset.starPhysicsEnergy = "0";
1707
1770
  shell.dataset.rotationSpeed = String(GALAXY_ROTATION_RADIANS_PER_MS);
1708
1771
  shell.dataset.layoutMinimumRadius = String(GALAXY_LAYOUT_CONFIG.minimumRadius);
1709
1772
  shell.dataset.layoutRadialSpan = String(GALAXY_LAYOUT_CONFIG.radialSpan);
@@ -1743,24 +1806,26 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1743
1806
  context.fillStyle = backdrop;
1744
1807
  context.fillRect(0, 0, width, height);
1745
1808
 
1746
- context.lineWidth = 1;
1747
- context.strokeStyle = "rgba(105,142,182,.06)";
1748
- for (let offset = -1200; offset <= 1200; offset += 160) {
1749
- const horizontalStart = project({ x: -1200, y: 0, z: offset }, width, height);
1750
- const horizontalEnd = project({ x: 1200, y: 0, z: offset }, width, height);
1751
- const verticalStart = project({ x: offset, y: 0, z: -1200 }, width, height);
1752
- const verticalEnd = project({ x: offset, y: 0, z: 1200 }, width, height);
1753
- if (horizontalStart.visible && horizontalEnd.visible) {
1754
- context.beginPath();
1755
- context.moveTo(horizontalStart.x, horizontalStart.y);
1756
- context.lineTo(horizontalEnd.x, horizontalEnd.y);
1757
- context.stroke();
1758
- }
1759
- if (verticalStart.visible && verticalEnd.visible) {
1760
- context.beginPath();
1761
- context.moveTo(verticalStart.x, verticalStart.y);
1762
- context.lineTo(verticalEnd.x, verticalEnd.y);
1763
- context.stroke();
1809
+ if (gridVisible) {
1810
+ context.lineWidth = 1;
1811
+ context.strokeStyle = "rgba(105,142,182,.06)";
1812
+ for (let offset = -1200; offset <= 1200; offset += 160) {
1813
+ const horizontalStart = project({ x: -1200, y: 0, z: offset }, width, height);
1814
+ const horizontalEnd = project({ x: 1200, y: 0, z: offset }, width, height);
1815
+ const verticalStart = project({ x: offset, y: 0, z: -1200 }, width, height);
1816
+ const verticalEnd = project({ x: offset, y: 0, z: 1200 }, width, height);
1817
+ if (horizontalStart.visible && horizontalEnd.visible) {
1818
+ context.beginPath();
1819
+ context.moveTo(horizontalStart.x, horizontalStart.y);
1820
+ context.lineTo(horizontalEnd.x, horizontalEnd.y);
1821
+ context.stroke();
1822
+ }
1823
+ if (verticalStart.visible && verticalEnd.visible) {
1824
+ context.beginPath();
1825
+ context.moveTo(verticalStart.x, verticalStart.y);
1826
+ context.lineTo(verticalEnd.x, verticalEnd.y);
1827
+ context.stroke();
1828
+ }
1764
1829
  }
1765
1830
  }
1766
1831
 
@@ -1938,6 +2003,10 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1938
2003
  }
1939
2004
  }
1940
2005
  if (!paused && !cameraDrag) camera.yaw += elapsed * GALAXY_ROTATION_RADIANS_PER_MS;
2006
+ if (draggedNode || starPhysicsEnergy > 0.01) {
2007
+ starPhysicsEnergy = stepGalaxyStarfieldPhysics(stars, draggedNode, { deltaMs: elapsed || 16.667 });
2008
+ shell.dataset.starPhysicsEnergy = starPhysicsEnergy.toFixed(3);
2009
+ }
1941
2010
  drawScene();
1942
2011
  animationFrame = window.requestAnimationFrame(renderFrame);
1943
2012
  };
@@ -2072,6 +2141,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2072
2141
  scale: Number(button.dataset.projectedScale) || 1,
2073
2142
  dragged: false
2074
2143
  };
2144
+ draggedNode = node;
2075
2145
  button.setPointerCapture(event.pointerId);
2076
2146
  button.classList.add("is-dragging");
2077
2147
  button.setAttribute("aria-grabbed", "true");
@@ -2099,6 +2169,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2099
2169
  if (!nodeDrag || event.pointerId !== nodeDrag.pointerId) return;
2100
2170
  suppressClick = nodeDrag.dragged;
2101
2171
  nodeDrag = null;
2172
+ if (draggedNode === node) draggedNode = null;
2102
2173
  button.classList.remove("is-dragging");
2103
2174
  button.setAttribute("aria-grabbed", "false");
2104
2175
  if (button.hasPointerCapture(event.pointerId)) button.releasePointerCapture(event.pointerId);
@@ -2156,9 +2227,13 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2156
2227
  if (!dialog.open) dialog.showModal();
2157
2228
  paused = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2158
2229
  starsVisible = true;
2230
+ gridVisible = false;
2159
2231
  background.classList.remove("hidden-stars");
2232
+ shell.dataset.gridVisible = "false";
2160
2233
  dialog.querySelector("#galaxy-stars").setAttribute("aria-pressed", "true");
2161
2234
  dialog.querySelector("#galaxy-stars").textContent = "隐藏背景星点";
2235
+ dialog.querySelector("#galaxy-grid").setAttribute("aria-pressed", "false");
2236
+ dialog.querySelector("#galaxy-grid").textContent = "显示空间网格";
2162
2237
  updateRotationControl();
2163
2238
  stats.value = `${graph.stats.nodeCount} 个节点 / ${graph.stats.edgeCount} 条关系`;
2164
2239
  renderNodes();
@@ -2182,6 +2257,13 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2182
2257
  event.currentTarget.textContent = starsVisible ? "隐藏背景星点" : "显示背景星点";
2183
2258
  drawScene();
2184
2259
  });
2260
+ listen(dialog.querySelector("#galaxy-grid"), "click", (event) => {
2261
+ gridVisible = !gridVisible;
2262
+ shell.dataset.gridVisible = String(gridVisible);
2263
+ event.currentTarget.setAttribute("aria-pressed", String(gridVisible));
2264
+ event.currentTarget.textContent = gridVisible ? "隐藏空间网格" : "显示空间网格";
2265
+ drawScene();
2266
+ });
2185
2267
  listen(dialog.querySelector("#galaxy-rotation"), "click", () => {
2186
2268
  paused = !paused;
2187
2269
  updateRotationControl();