@musnows/scriverse 0.4.4 → 0.4.6

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";
@@ -12,11 +12,10 @@ import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-lay
12
12
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
13
13
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
14
14
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
15
- import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
15
+ import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
16
16
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260725-enum-labels-zh";
17
17
  import {
18
18
  chapterVersionSourceLabel,
19
- characterVisibilityLabel,
20
19
  foreshadowStatusLabel,
21
20
  levelLabel,
22
21
  occurrenceRoleLabel,
@@ -31,7 +30,7 @@ import {
31
30
  settingStatusLabel,
32
31
  taskScopeLabel,
33
32
  timelineStatusLabel
34
- } from "/display-labels.js?v=20260725-enum-labels-zh";
33
+ } from "/display-labels.js?v=20260725-unified-permissions";
35
34
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
36
35
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
37
36
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
@@ -40,6 +39,7 @@ import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20
40
39
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
41
40
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
42
41
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
42
+ import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
43
43
 
44
44
  const state = {
45
45
  user: null,
@@ -705,6 +705,8 @@ let entityEditorType = null;
705
705
  let entityEditorDirty = false;
706
706
  let entityEditorReadOnly = false;
707
707
  let characterListPage = 1;
708
+ const characterFilters = { raceIds: [], organizationIds: [] };
709
+ let characterFiltersPanelOpen = false;
708
710
  let settingEditorItem = null;
709
711
  let characterEditorItem = null;
710
712
  let knowledgeEditorItem = null;
@@ -723,11 +725,14 @@ let settingEditorVditor = null;
723
725
  let knowledgeSectionVditor = null;
724
726
  let characterSectionVditor = null;
725
727
  let entityHistoryContext = null;
728
+ let moduleContentInteractionsBound = false;
726
729
 
727
730
  function showEntityEditorPage(type, { readOnly = false } = {}) {
731
+ const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
732
+ const viewOnly = readOnly || !canEditModule(module);
728
733
  entityEditorType = type;
729
734
  entityEditorDirty = false;
730
- entityEditorReadOnly = readOnly;
735
+ entityEditorReadOnly = viewOnly;
731
736
  characterSectionEditorDirty = false;
732
737
  knowledgeSectionEditorDirty = false;
733
738
  $("#entity-editor-view").classList.remove("hidden");
@@ -736,6 +741,7 @@ function showEntityEditorPage(type, { readOnly = false } = {}) {
736
741
  $("#knowledge-editor-form").classList.toggle("hidden", !["race", "organization"].includes(type));
737
742
  $("#character-section-editor-view").classList.add("hidden");
738
743
  $("#knowledge-section-editor-view").classList.add("hidden");
744
+ ["setting", "character", "knowledge"].forEach((editor) => $(`#${editor}-editor-readonly-badge`).classList.toggle("hidden", !(viewOnly && (editor === "knowledge" ? ["race", "organization"].includes(type) : editor === type))));
739
745
  $("#app").inert = true;
740
746
  document.body.classList.add("entity-editor-open");
741
747
  replacePageRoute(currentPageRoute());
@@ -1782,7 +1788,9 @@ async function api(path, options = {}) {
1782
1788
  state.csrfToken = null;
1783
1789
  showAuth(false);
1784
1790
  }
1785
- throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
1791
+ const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
1792
+ error.code = payload.error?.code;
1793
+ throw error;
1786
1794
  }
1787
1795
  if (response.status === 204) return null;
1788
1796
  const payload = await response.json();
@@ -2264,11 +2272,8 @@ async function initializePage() {
2264
2272
  }
2265
2273
  if (route.view === "module") return;
2266
2274
  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
2275
  const item = route.entityId
2269
- ? route.entity === "character"
2270
- ? await api(`/api/characters/${encodeURIComponent(route.entityId)}`)
2271
- : records.find((record) => record.id === route.entityId)
2276
+ ? await api(`/api/${route.entity === "setting" ? "settings" : route.entity === "character" ? "characters" : route.entity === "race" ? "races" : "organizations"}/${encodeURIComponent(route.entityId)}`)
2272
2277
  : null;
2273
2278
  if (route.entityId && !item) {
2274
2279
  toast(({ setting: "未找到要编辑的设定", character: "未找到要编辑的角色", race: "未找到要编辑的种族", organization: "未找到要编辑的组织" }[route.entity] ?? "未找到要编辑的档案"), "error");
@@ -2903,6 +2908,7 @@ async function showModule(module) {
2903
2908
  $("#module-create-button").textContent = meta[3];
2904
2909
  $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2905
2910
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2911
+ bindModuleContentInteractions();
2906
2912
  try {
2907
2913
  if (module === "settings") await renderSettings();
2908
2914
  if (module === "characters") await renderCharacters(characterListPage);
@@ -3126,9 +3132,18 @@ function bindModuleLayoutToggle(refresh) {
3126
3132
  }));
3127
3133
  }
3128
3134
 
3135
+ function mountCharacterFilterToggle() {
3136
+ $("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.remove();
3137
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="character-filter-toggle" aria-label="筛选角色" aria-controls="character-filter-panel" aria-expanded="${characterFiltersPanelOpen}" title="筛选角色"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
3138
+ $("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.addEventListener("click", async () => {
3139
+ characterFiltersPanelOpen = !characterFiltersPanelOpen;
3140
+ await renderCharacters(characterListPage);
3141
+ });
3142
+ }
3143
+
3129
3144
  function bindRecordPreview(selector, open) {
3130
3145
  $("#module-content").querySelectorAll(selector).forEach((card) => {
3131
- const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openReview;
3146
+ const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
3132
3147
  card.addEventListener("click", (event) => {
3133
3148
  if (!event.target.closest("button, a, summary")) void open(id);
3134
3149
  });
@@ -3141,6 +3156,36 @@ function bindRecordPreview(selector, open) {
3141
3156
  });
3142
3157
  }
3143
3158
 
3159
+ function bindModuleContentInteractions() {
3160
+ if (moduleContentInteractionsBound) return;
3161
+ moduleContentInteractionsBound = true;
3162
+ const host = $("#module-content");
3163
+ const open = async (card) => {
3164
+ const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization;
3165
+ if (!id) return;
3166
+ if (card.dataset.openSetting) return openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly: true });
3167
+ if (card.dataset.openCharacter) return openCharacterEditor(await api(`/api/characters/${encodeURIComponent(id)}`), { readOnly: true });
3168
+ if (card.dataset.openRace) return openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly: true });
3169
+ if (card.dataset.openOrganization) return openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly: true });
3170
+ };
3171
+ const findCard = (target) => target instanceof Element
3172
+ ? target.closest("[data-open-setting], [data-open-character], [data-open-race], [data-open-organization]")
3173
+ : null;
3174
+ host.addEventListener("click", (event) => {
3175
+ if (event.target instanceof Element && event.target.closest("button, a, summary")) return;
3176
+ const card = findCard(event.target);
3177
+ if (card) void open(card);
3178
+ });
3179
+ host.addEventListener("keydown", (event) => {
3180
+ if (event.key !== "Enter" && event.key !== " ") return;
3181
+ if (event.target instanceof Element && event.target.closest("button, a, summary")) return;
3182
+ const card = findCard(event.target);
3183
+ if (!card) return;
3184
+ event.preventDefault();
3185
+ void open(card);
3186
+ });
3187
+ }
3188
+
3144
3189
  function openReviewDetailDialog(item) {
3145
3190
  if (!item) return;
3146
3191
  const evidence = Array.isArray(item.evidence) ? item.evidence : [];
@@ -3185,13 +3230,13 @@ function settingRecordActions(item) {
3185
3230
  function renderSettingCards(records) {
3186
3231
  return `<div class="card-grid">${records.map((item) => `
3187
3232
  <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>
3233
+ <h3>${esc(item.title)}</h3><div class="record-markdown-preview">${esc(item.contentPreview || "暂无正文预览")}</div>
3189
3234
  <div class="card-actions">${settingRecordActions(item)}</div></article>`).join("")}</div>`;
3190
3235
  }
3191
3236
 
3192
3237
  function renderSettingRows(records) {
3193
3238
  return `<div class="module-row-list">${records.map((item) => {
3194
- const preview = moduleRowPreview(item.content);
3239
+ const preview = moduleRowPreview(item.contentPreview);
3195
3240
  return `
3196
3241
  <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
3242
  <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
@@ -3208,26 +3253,30 @@ async function renderSettings() {
3208
3253
  ? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
3209
3254
  : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
3210
3255
  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))));
3256
+ const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
3257
+ $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
3213
3258
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
3214
3259
  }
3215
3260
 
3216
3261
  async function renderCharacters(page = characterListPage) {
3217
- const [characterPage, races, organizations] = await Promise.all([
3218
- apiPage(`/api/works/${state.work.id}/characters`, page),
3262
+ const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
3263
+ const [characterSource, races, organizations] = await Promise.all([
3264
+ hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page),
3219
3265
  canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
3220
3266
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
3221
3267
  ]);
3268
+ const characterPage = hasCharacterFilters
3269
+ ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, 50)
3270
+ : characterSource;
3222
3271
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3223
3272
  characterListPage = characterPage.page;
3224
- [state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
3273
+ state.characters = characterPage.items;
3225
3274
  const layout = readModuleLayout();
3226
3275
  const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
3227
3276
  const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
3228
3277
  const details = normalizeCharacterDetails(item.attributes?.details);
3229
3278
  return `
3230
- <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(characterVisibilityLabel(item.visibility))}</small>
3279
+ <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}${item.lockedFields.length ? `<small>锁定 ${item.lockedFields.length} 项</small>` : ""}
3231
3280
  <h3>${esc(item.name)}</h3>
3232
3281
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
3233
3282
  ${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
@@ -3250,14 +3299,12 @@ async function renderCharacters(page = characterListPage) {
3250
3299
  const line = meta ? `${meta} · ${preview}` : preview;
3251
3300
  return `
3252
3301
  <article class="record-card module-row character-card preview-record-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
3253
- <small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(characterVisibilityLabel(item.visibility))}</small>
3302
+ ${item.lockedFields.length ? `<small>锁定 ${item.lockedFields.length} 项</small>` : ""}
3254
3303
  <h3>${esc(item.name)}</h3>
3255
3304
  <p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
3256
3305
  <div class="card-actions">${characterActions(item)}</div>
3257
3306
  </article>`;
3258
3307
  }).join("")}</div>`;
3259
- const hasMultipleCharacters = characterPage.page > 1 || characterPage.hasMore || state.characters.length > 1;
3260
- const auditPanel = canEditModule("tasks") ? `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${hasMultipleCharacters ? "" : "disabled"}>AI 角色查重</button></section>` : "";
3261
3308
  const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
3262
3309
  ? `<nav class="module-pagination" aria-label="角色列表分页">
3263
3310
  <button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
@@ -3265,37 +3312,57 @@ async function renderCharacters(page = characterListPage) {
3265
3312
  <button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
3266
3313
  </nav>`
3267
3314
  : "";
3315
+ const selectedRaceIds = new Set(characterFilters.raceIds);
3316
+ const selectedOrganizationIds = new Set(characterFilters.organizationIds);
3317
+ const selectedRaceNames = races.filter((race) => selectedRaceIds.has(String(race.id))).map((race) => racePathLabel(race) || race.name);
3318
+ const selectedOrganizationNames = organizations.filter((organization) => selectedOrganizationIds.has(String(organization.id))).map((organization) => organization.name);
3319
+ const filterOptionList = (items, selectedIds, valueKey = "id") => items.map((item) => {
3320
+ const value = String(item[valueKey]);
3321
+ const label = valueKey === "id" ? (racePathLabel(item) || item.name) : item.name;
3322
+ return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
3323
+ }).join("");
3324
+ const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
3325
+ <details class="character-filter-dropdown"><summary><span>按种族筛选</span><strong>${selectedRaceNames.length ? `已选 ${selectedRaceNames.length} 项` : "全部种族"}</strong></summary><div id="character-race-filter" class="character-filter-options">${filterOptionList(races, selectedRaceIds)}</div></details>
3326
+ <details class="character-filter-dropdown"><summary><span>按组织筛选</span><strong>${selectedOrganizationNames.length ? `已选 ${selectedOrganizationNames.length} 项` : "全部组织"}</strong></summary><div id="character-organization-filter" class="character-filter-options">${filterOptionList(organizations, selectedOrganizationIds, "id")}</div></details>
3327
+ <div class="character-filter-toolbar-actions">${hasCharacterFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${characterPage.total} 个角色</span>` : ""}<button id="clear-character-filters" class="ghost-button" type="button" ${hasCharacterFilters ? "" : "disabled"}>重置筛选</button></div>
3328
+ </section>`;
3329
+ mountCharacterFilterToggle();
3268
3330
  if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
3269
- $("#module-content").innerHTML = auditPanel + (state.characters.length
3331
+ $("#module-content").innerHTML = filterToolbar + (state.characters.length
3270
3332
  ? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
3271
3333
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
3272
3334
  bindModuleLayoutToggle(renderCharacters);
3335
+ const readSelectedValues = (selector) => [...$(selector).querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
3336
+ $("#character-race-filter").addEventListener("change", async () => {
3337
+ characterFiltersPanelOpen = true;
3338
+ characterFilters.raceIds = readSelectedValues("#character-race-filter");
3339
+ characterListPage = 1;
3340
+ await renderCharacters(1);
3341
+ });
3342
+ $("#character-organization-filter").addEventListener("change", async () => {
3343
+ characterFiltersPanelOpen = true;
3344
+ characterFilters.organizationIds = readSelectedValues("#character-organization-filter");
3345
+ characterListPage = 1;
3346
+ await renderCharacters(1);
3347
+ });
3348
+ $("#clear-character-filters")?.addEventListener("click", async () => {
3349
+ characterFiltersPanelOpen = true;
3350
+ characterFilters.raceIds = [];
3351
+ characterFilters.organizationIds = [];
3352
+ characterListPage = 1;
3353
+ await renderCharacters(1);
3354
+ });
3273
3355
  $("#module-content").querySelectorAll("[data-character-page]").forEach((button) => button.addEventListener("click", async () => {
3274
3356
  if (button.disabled) return;
3275
3357
  $("#module-content").querySelectorAll("[data-character-page]").forEach((control) => { control.disabled = true; });
3276
3358
  await renderCharacters(Number(button.dataset.characterPage));
3277
3359
  }));
3278
- $("#create-character-audit-task")?.addEventListener("click", async () => {
3279
- const button = $("#create-character-audit-task");
3280
- button.disabled = true;
3281
- try {
3282
- await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: "character-identity-audit", scope: { type: "book" } } });
3283
- toast("角色查重任务已加入分析队列");
3284
- await showModule("tasks");
3285
- } catch (error) {
3286
- toast(error.message, "error");
3287
- button.disabled = false;
3288
- }
3289
- });
3290
3360
  bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(state.characters.find((item) => item.id === id), { readOnly: true }));
3291
3361
  $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
3292
3362
  }
3293
3363
 
3294
3364
  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
- ]);
3365
+ state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
3299
3366
  const layout = readModuleLayout();
3300
3367
  const canEditRaces = canEditModule("races");
3301
3368
  const raceActions = (item) => canEditRaces
@@ -3307,10 +3374,10 @@ async function renderRaces() {
3307
3374
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
3308
3375
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
3309
3376
  <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>
3377
+ <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
3378
  <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
3312
3379
  <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>
3380
+ <div class="race-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条共同设定,打开查看详情</span>` : '<span class="pill">暂无共同设定</span>'}</div>
3314
3381
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
3315
3382
  ${raceCardActions(item)}
3316
3383
  </article>
@@ -3319,7 +3386,7 @@ async function renderRaces() {
3319
3386
  </details>`;
3320
3387
  const raceRows = () => `<div class="module-row-list">${state.races.map((item) => {
3321
3388
  const preview = moduleRowPreview(item.description || "尚未填写种族简介");
3322
- const meta = `${item.memberIds.length} 位直接角色 · ${item.settings.length ? "已填写共同设定" : "暂无共同设定"}`;
3389
+ const meta = `${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
3323
3390
  return `
3324
3391
  <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
3392
  <small>${esc(meta)}</small>
@@ -3333,16 +3400,13 @@ async function renderRaces() {
3333
3400
  ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
3334
3401
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
3335
3402
  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))));
3403
+ const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
3404
+ $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
3338
3405
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
3339
3406
  }
3340
3407
 
3341
3408
  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
- ]);
3409
+ state.organizations = await apiAllPages(`/api/works/${state.work.id}/organizations`);
3346
3410
  const layout = readModuleLayout();
3347
3411
  const canEditOrganizations = canEditModule("organizations");
3348
3412
  const organizationActions = (item) => canEditOrganizations
@@ -3352,9 +3416,9 @@ async function renderOrganizations() {
3352
3416
  ? organizationActions(item)
3353
3417
  : `<div class="card-actions">${organizationActions(item)}</div>`;
3354
3418
  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>
3419
+ <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
3420
  <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>
3421
+ <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
3358
3422
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
3359
3423
  ${organizationCardActions(item)}
3360
3424
  </article>`).join("")}</div>`;
@@ -3362,8 +3426,8 @@ async function renderOrganizations() {
3362
3426
  const preview = moduleRowPreview(item.description || "尚未填写组织简介");
3363
3427
  const members = item.members.length ? item.members.map((member) => member.name).join("、") : "暂无绑定角色";
3364
3428
  return `
3365
- <article class="record-card module-row organization-card">
3366
- <small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
3429
+ <article class="record-card module-row organization-card" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}">
3430
+ <small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
3367
3431
  <h3>${esc(item.name)}</h3>
3368
3432
  <p class="module-row-preview" title="${esc(`${preview} · 成员:${members}`)}">${esc(preview)} · ${esc(members)}</p>
3369
3433
  <div class="card-actions">${organizationActions(item)}</div>
@@ -3374,7 +3438,8 @@ async function renderOrganizations() {
3374
3438
  ? `${layout === "rows" ? organizationRows() : organizationCards()}`
3375
3439
  : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
3376
3440
  bindModuleLayoutToggle(renderOrganizations);
3377
- $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
3441
+ const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
3442
+ $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => { void openOrganization(button.dataset.editOrganization, false); }));
3378
3443
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
3379
3444
  }
3380
3445
 
@@ -4063,7 +4128,7 @@ async function loadAiReferences() {
4063
4128
  const generation = workScopedUiGeneration;
4064
4129
  const [characters, settings] = await Promise.all([
4065
4130
  canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
4066
- canReadModule("settings") ? apiAllPages(`/api/works/${workId}/settings`) : Promise.resolve([])
4131
+ canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([])
4067
4132
  ]);
4068
4133
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
4069
4134
  state.characters = characters;
@@ -4740,7 +4805,14 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4740
4805
  placeholder,
4741
4806
  preview: { transform: transformVditorPreview },
4742
4807
  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"],
4808
+ toolbar: ["headings", "bold", "italic", "strike", "|", "line", "quote", "list", "ordered-list", "check", "|", "code", "inline-code", "link", "table", "upload", "|", "undo", "redo", {
4809
+ name: "line-number",
4810
+ tip: "显示/隐藏行号",
4811
+ tipPosition: "s",
4812
+ className: "vditor-line-number-button",
4813
+ 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>',
4814
+ click: () => toggleVditorLineNumbers(editor)
4815
+ }, "edit-mode", "fullscreen"],
4744
4816
  upload: {
4745
4817
  accept: "image/*",
4746
4818
  max: 10 * 1024 * 1024,
@@ -4750,11 +4822,13 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4750
4822
  input: (markdown) => {
4751
4823
  normalizeVditorAttachmentImages(editor);
4752
4824
  updateVditorWordCount(editor, markdown);
4825
+ updateVditorLineNumbers(editor, markdown);
4753
4826
  onInput(markdown);
4754
4827
  },
4755
4828
  after: () => {
4756
4829
  normalizeVditorAttachmentImages(editor);
4757
4830
  updateVditorWordCount(editor, value);
4831
+ updateVditorLineNumbers(editor, value);
4758
4832
  if (readOnly) editor?.disabled();
4759
4833
  }
4760
4834
  });
@@ -4765,6 +4839,34 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4765
4839
  return editor;
4766
4840
  }
4767
4841
 
4842
+ function toggleVditorLineNumbers(editor) {
4843
+ const host = editor?.vditor?.element;
4844
+ if (!host) return;
4845
+ const enabled = host.classList.toggle("show-line-numbers");
4846
+ const button = host.querySelector(".vditor-line-number-button");
4847
+ if (button) button.setAttribute("aria-pressed", String(enabled));
4848
+ }
4849
+
4850
+ function updateVditorLineNumbers(editor, markdown) {
4851
+ const content = editor?.vditor?.element?.querySelector(".vditor-content");
4852
+ if (!content) return;
4853
+ let gutter = content.querySelector(".vditor-line-numbers");
4854
+ if (!gutter) {
4855
+ gutter = document.createElement("div");
4856
+ gutter.className = "vditor-line-numbers";
4857
+ gutter.setAttribute("aria-hidden", "true");
4858
+ content.prepend(gutter);
4859
+ }
4860
+ const lineCount = Math.max(1, String(markdown ?? "").split(/\r?\n/gu).length);
4861
+ const fragment = document.createDocumentFragment();
4862
+ for (let line = 1; line <= lineCount; line += 1) {
4863
+ const number = document.createElement("span");
4864
+ number.textContent = String(line);
4865
+ fragment.append(number);
4866
+ }
4867
+ gutter.replaceChildren(fragment);
4868
+ }
4869
+
4768
4870
  function updateVditorWordCount(editor, markdown) {
4769
4871
  const toolbar = editor?.vditor?.toolbar?.element;
4770
4872
  if (!toolbar) return;
@@ -4861,13 +4963,10 @@ function knowledgeSectionEditorHtml(section = null) {
4861
4963
  const label = knowledgeEditorKind === "race" ? "种族" : "组织";
4862
4964
  return `<div class="character-section-editor-shell knowledge-section-editor-shell">
4863
4965
  <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>
4966
+ <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
4967
  <button class="entity-editor-back" type="button" data-knowledge-section-edit-close>返回设定列表</button>
4866
4968
  </header>
4867
4969
  <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
4970
  <div id="knowledge-section-markdown" class="vditor-editor-host" data-vditor-editor aria-label="Markdown 编辑器"></div>
4872
4971
  <div class="character-markdown-editor-footer">
4873
4972
  <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 +5038,12 @@ function characterSectionEditorHtml(section = null) {
4939
5038
  const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
4940
5039
  return `<div class="character-section-editor-shell">
4941
5040
  <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>
5041
+ <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
5042
  <button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
4944
5043
  </header>
4945
5044
  <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
4946
5045
  <div class="character-markdown-editor-meta">
4947
5046
  <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
5047
  <label class="character-markdown-summary-field">章节摘要<textarea id="character-section-summary" maxlength="20000" placeholder="用于角色列表和 AI 快速定位,不会替代正文">${esc(section?.summary ?? "")}</textarea></label>
4950
5048
  </div>
4951
5049
  <div id="character-section-markdown" class="vditor-editor-host" data-vditor-editor aria-label="Markdown 编辑器"></div>
@@ -5120,7 +5218,6 @@ function renderCharacterEditorFields(item) {
5120
5218
  : organizationOptions.length
5121
5219
  ? field("organizationIds", "所属组织(可多选)", "chips", item?.organizationIds ?? [], organizationOptions)
5122
5220
  : '<div class="character-editor-empty-field"><b>所属组织</b><span>尚未创建组织,可稍后在“组织”模块中补充。</span></div>') +
5123
- field("visibility", "可见范围", "select", item?.visibility ?? "author", [["author", "仅作者"], ["collaborators", "协作者"], ["public", "公开"]]) +
5124
5221
  (canReadModule("editor")
5125
5222
  ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
5126
5223
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
@@ -5176,7 +5273,6 @@ function collectCharacterBody(form) {
5176
5273
  },
5177
5274
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
5178
5275
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
5179
- visibility: String(form.get("visibility") ?? "author"),
5180
5276
  changeNote: String(form.get("changeNote") ?? "").trim()
5181
5277
  };
5182
5278
  if (canReadModule("races")) body.raceId = form.get("raceId") || null;
@@ -6176,7 +6272,9 @@ $("#profile-form").addEventListener("submit", async (event) => {
6176
6272
  state.user = updated;
6177
6273
  applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
6178
6274
  toast("显示名称已更新");
6179
- } catch (error) { toast(error.message, "error"); }
6275
+ } catch (error) {
6276
+ toast(error.code === "INVALID_CURRENT_PASSWORD" ? "当前密码错误,请重新输入" : error.message, "error");
6277
+ }
6180
6278
  });
6181
6279
  $("#password-form").addEventListener("submit", async (event) => {
6182
6280
  event.preventDefault();
@@ -0,0 +1,24 @@
1
+ type CharacterFilterItem = {
2
+ id?: string;
3
+ race?: { id?: string | null } | null;
4
+ raceId?: string | null;
5
+ organizations?: Array<{ id?: string | null }> | null;
6
+ };
7
+
8
+ export declare function filterCharacters<T extends CharacterFilterItem>(
9
+ characters: T[],
10
+ filters?: { raceIds?: string[]; organizationIds?: string[] }
11
+ ): T[];
12
+
13
+ export declare function paginateCharacters<T>(
14
+ characters: T[],
15
+ page: number,
16
+ limit: number
17
+ ): {
18
+ items: T[];
19
+ page: number;
20
+ limit: number;
21
+ hasMore: boolean;
22
+ nextPage: number | null;
23
+ total: number;
24
+ };
@@ -0,0 +1,35 @@
1
+ function characterRaceId(character) {
2
+ return String(character?.race?.id ?? character?.raceId ?? "");
3
+ }
4
+
5
+ function characterOrganizationIds(character) {
6
+ return (Array.isArray(character?.organizations) ? character.organizations : [])
7
+ .map((organization) => String(organization?.organizationId ?? organization?.id ?? ""))
8
+ .filter(Boolean);
9
+ }
10
+
11
+ export function filterCharacters(characters, { raceIds = [], organizationIds = [] } = {}) {
12
+ const selectedRaceIds = new Set(raceIds.map(String).filter(Boolean));
13
+ const selectedOrganizationIds = new Set(organizationIds.map(String).filter(Boolean));
14
+ return characters.filter((character) => {
15
+ const matchesRace = selectedRaceIds.size === 0 || selectedRaceIds.has(characterRaceId(character));
16
+ const matchesOrganization = selectedOrganizationIds.size === 0
17
+ || characterOrganizationIds(character).some((organizationId) => selectedOrganizationIds.has(organizationId));
18
+ return matchesRace && matchesOrganization;
19
+ });
20
+ }
21
+
22
+ export function paginateCharacters(characters, page, limit) {
23
+ const safeLimit = Math.max(1, Number(limit) || 1);
24
+ const safePage = Math.max(1, Number(page) || 1);
25
+ const start = (safePage - 1) * safeLimit;
26
+ const items = characters.slice(start, start + safeLimit);
27
+ return {
28
+ items,
29
+ page: safePage,
30
+ limit: safeLimit,
31
+ hasMore: start + safeLimit < characters.length,
32
+ nextPage: start + safeLimit < characters.length ? safePage + 1 : null,
33
+ total: characters.length
34
+ };
35
+ }
@@ -9,7 +9,6 @@ const fieldLabels = Object.freeze({
9
9
  profile: "人物档案与设定章节",
10
10
  currentState: "当前状态",
11
11
  lockedFields: "锁定字段",
12
- visibility: "可见范围",
13
12
  firstChapterId: "首次登场章节"
14
13
  });
15
14
 
@@ -1,5 +1,4 @@
1
1
  export function settingStatusLabel(value: unknown): string;
2
- export function characterVisibilityLabel(value: unknown): string;
3
2
  export function timelineStatusLabel(value: unknown): string;
4
3
  export function levelLabel(value: unknown): string;
5
4
  export function foreshadowStatusLabel(value: unknown): string;
@@ -9,10 +9,6 @@ export function settingStatusLabel(value) {
9
9
  return enumLabel({ draft: "草稿", pending: "待确认", confirmed: "已确认", deprecated: "已弃用" }, value, "未知状态");
10
10
  }
11
11
 
12
- export function characterVisibilityLabel(value) {
13
- return enumLabel({ public: "公开", author: "仅作者", collaborators: "协作者可见" }, value, "未知范围");
14
- }
15
-
16
12
  export function timelineStatusLabel(value) {
17
13
  return enumLabel({ candidate: "候选", pending: "待确认", confirmed: "已确认", deprecated: "已弃用" }, value, "未知状态");
18
14
  }