@musnows/scriverse 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,16 +33,16 @@ import {
33
33
  taskScopeLabel,
34
34
  timelineStatusLabel,
35
35
  characterStateFieldLabel
36
- } from "/display-labels.js?v=20260726-anthropic-messages-v2";
36
+ } from "/display-labels.js?v=20260728-hybrid-search-v1";
37
37
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
38
38
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
39
39
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
40
- import { eligibleRaceParents, orderRaceFilterOptions, paginateRaceForest, racePathLabel } from "/race-hierarchy.js?v=20260727-race-tree-pagination-v1";
40
+ import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
41
41
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
42
42
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
43
43
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
44
44
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
45
- import { resolveGlobalSearchTarget } from "/global-search.js?v=20260726-search-result-details";
45
+ import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
46
46
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
47
47
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
48
48
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
@@ -210,8 +210,12 @@ function renderAnalysisTaskStatus(item) {
210
210
  const status = normalizedAnalysisTaskStatus(item.status);
211
211
  const statusChanged = taskStatusSnapshots.get(taskId) !== status;
212
212
  taskStatusSnapshots.set(taskId, status);
213
- const label = analysisTaskStatusLabel(item.status);
214
- return `<span class="task-status-badge is-${status}${statusChanged ? " is-state-change" : ""}" aria-label="任务状态:${esc(label)}">
213
+ const waitingToRetry = status === "pending" && Boolean(item.nextAttemptAt);
214
+ const label = waitingToRetry ? "等待重试" : analysisTaskStatusLabel(item.status);
215
+ const retryTitle = waitingToRetry
216
+ ? ` title="第 ${esc(String(item.attemptCount ?? 0))} 次尝试失败,将于 ${esc(formatDateTime(item.nextAttemptAt))} 重试"`
217
+ : "";
218
+ return `<span class="task-status-badge is-${status}${statusChanged ? " is-state-change" : ""}" aria-label="任务状态:${esc(label)}"${retryTitle}>
215
219
  <span class="task-status-indicator" aria-hidden="true"></span>
216
220
  <span>${esc(label)}</span>
217
221
  </span>`;
@@ -362,6 +366,10 @@ let aiReferencesLoadWorkId = null;
362
366
  let aiConversationsLoadPromise = null;
363
367
  let aiConversationsLoadWorkId = null;
364
368
  let workScopedUiGeneration = 0;
369
+ let raceHierarchyLoadPromise = null;
370
+ let raceHierarchyLoadWorkId = null;
371
+ let loadedRaceHierarchyWorkId = null;
372
+ let raceListRequestId = 0;
365
373
  let importHistoryRecords = [];
366
374
  let importHistoryNextPage = null;
367
375
  let importHistoryRequestId = 0;
@@ -862,7 +870,6 @@ let characterListPage = 1;
862
870
  let taskListPage = 1;
863
871
  const moduleListPages = {
864
872
  settings: 1,
865
- races: 1,
866
873
  organizations: 1,
867
874
  timeline: 1,
868
875
  outlinePlans: 1,
@@ -1364,7 +1371,7 @@ const AI_TOOL_DESCRIPTIONS = {
1364
1371
  story_index: "分页读取当前作品的卷章目录和章节概要。",
1365
1372
  read_chapters: "读取指定章节的概要、正文或两者。",
1366
1373
  grep: "查询正文关键字所在的完整段落及章节信息。",
1367
- search_story_entities: "按实体名或关键词子串匹配设定、人物、组织等结构化记录;非语义检索。",
1374
+ search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1368
1375
  read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
1369
1376
  };
1370
1377
 
@@ -2290,6 +2297,22 @@ function toast(message, type = "info") {
2290
2297
  }, 3600);
2291
2298
  }
2292
2299
 
2300
+ function persistentToast(message, type = "info") {
2301
+ const region = $("#toast-region");
2302
+ const element = document.createElement("div");
2303
+ element.className = `toast ${type}`;
2304
+ element.setAttribute("role", "status");
2305
+ element.textContent = message;
2306
+ region.append(element);
2307
+ raiseToastRegion();
2308
+ return () => {
2309
+ element.remove();
2310
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
2311
+ region.hidePopover();
2312
+ }
2313
+ };
2314
+ }
2315
+
2293
2316
  function confirmToast(message, { title = "请再次确认", confirmLabel = "确认", cancelLabel = "取消" } = {}) {
2294
2317
  const region = $("#toast-region");
2295
2318
  const element = document.createElement("section");
@@ -2768,6 +2791,7 @@ const workAuditActionLabels = {
2768
2791
  "chapter.saved": "保存章节",
2769
2792
  "chapter.moved": "移动章节",
2770
2793
  "chapter.deleted": "删除章节",
2794
+ "chapter.purged": "彻底删除章节",
2771
2795
  "chapter.restored": "恢复章节",
2772
2796
  "work.imported": "导入正文"
2773
2797
  };
@@ -2886,7 +2910,6 @@ async function openPlatformUiSettingsDialog() {
2886
2910
  const pageSizes = normalizePageSizes(settings.pageSizes);
2887
2911
  $("#page-size-settings").value = String(pageSizes.settings);
2888
2912
  $("#page-size-characters").value = String(pageSizes.characters);
2889
- $("#page-size-races").value = String(pageSizes.races);
2890
2913
  $("#page-size-organizations").value = String(pageSizes.organizations);
2891
2914
  $("#page-size-timeline").value = String(pageSizes.timeline);
2892
2915
  $("#page-size-outlines").value = String(pageSizes.outlines);
@@ -3009,24 +3032,40 @@ async function openSearchDialog() {
3009
3032
  }
3010
3033
  $("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
3011
3034
  $("#search-query").value = "";
3035
+ $("#search-type").value = "";
3012
3036
  $("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
3013
3037
  $("#search-dialog").showModal();
3014
3038
  queueMicrotask(() => $("#search-query").focus());
3015
3039
  }
3016
3040
 
3017
- function renderSearchResults(results) {
3041
+ function highlightedSearchText(value, query) {
3042
+ return splitGlobalSearchHighlight(value, query)
3043
+ .map((segment) => segment.match ? `<mark>${esc(segment.text)}</mark>` : esc(segment.text))
3044
+ .join("");
3045
+ }
3046
+
3047
+ function renderSearchResults(results, query) {
3018
3048
  if (!results.length) {
3019
3049
  $("#search-results").innerHTML = '<p class="search-results-status">未找到相关内容。</p>';
3020
3050
  return;
3021
3051
  }
3022
- $("#search-results").innerHTML = results.map((item) => `
3023
- <button type="button" class="search-result" data-search-type="${esc(item.type)}" data-search-id="${esc(item.id)}">
3024
- <div class="search-result-meta"><span>${esc(searchResultTypeLabel(item.type))}</span><strong>${esc(item.title)}</strong></div>
3025
- <p>${esc(item.snippet || "无摘要")}</p>
3026
- </button>`).join("");
3027
- $("#search-results").querySelectorAll(".search-result").forEach((button) => {
3052
+ const matchKindLabel = { metadata: "资料命中", exact: "精确命中", phonetic: "拼音命中" };
3053
+ $("#search-results").innerHTML = `<p class="search-results-summary">找到 ${results.length} 条结果,按综合相关度排序。</p>${results.map((item) => {
3054
+ const matchKinds = Array.isArray(item.matchKinds) ? item.matchKinds : [];
3055
+ const lineRange = Number.isInteger(item.startLine)
3056
+ ? `<span class="search-result-chip">${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
3057
+ : "";
3058
+ const subtitle = item.subtitle ? `<small>${esc(item.subtitle)}</small>` : "";
3059
+ return `
3060
+ <button type="button" class="search-result">
3061
+ <div class="search-result-meta"><span>${esc(searchResultTypeLabel(item.type))}</span><strong>${highlightedSearchText(item.title, query)}</strong></div>
3062
+ <p>${highlightedSearchText(item.snippet || "无摘要", query)}</p>
3063
+ <div class="search-result-details">${subtitle}${lineRange}${matchKinds.map((kind) => `<span class="search-result-chip search-result-chip-${esc(kind)}">${esc(matchKindLabel[kind] ?? kind)}</span>`).join("")}</div>
3064
+ </button>`;
3065
+ }).join("")}`;
3066
+ $("#search-results").querySelectorAll(".search-result").forEach((button, index) => {
3028
3067
  button.addEventListener("click", () => {
3029
- openSearchResult({ type: button.dataset.searchType, id: button.dataset.searchId })
3068
+ openSearchResult(results[index])
3030
3069
  .catch((error) => toast(error.message, "error"));
3031
3070
  });
3032
3071
  });
@@ -3040,8 +3079,50 @@ async function runWorkSearch() {
3040
3079
  return;
3041
3080
  }
3042
3081
  $("#search-results").innerHTML = '<p class="search-results-status">正在检索……</p>';
3043
- const results = await api(`/api/works/${encodeURIComponent(state.work.id)}/search?q=${encodeURIComponent(query)}`);
3044
- renderSearchResults(results);
3082
+ const parameters = new URLSearchParams({ q: query });
3083
+ const type = $("#search-type").value;
3084
+ if (type) parameters.set("type", type);
3085
+ const results = await api(`/api/works/${encodeURIComponent(state.work.id)}/search?${parameters}`);
3086
+ renderSearchResults(results, query);
3087
+ }
3088
+
3089
+ function revealChapterSearchLines(startLine, endLine) {
3090
+ const start = Math.max(0, Number(startLine) - 1);
3091
+ const end = Math.max(start, Number(endLine ?? startLine) - 1);
3092
+ const input = $("#chapter-content");
3093
+ const selection = selectedChapterLinePayload(start, end);
3094
+ chapterLineSelection = { start: selection.safeStart, end: selection.safeEnd };
3095
+ input.focus({ preventScroll: true });
3096
+ input.setSelectionRange(selection.startOffset, selection.startOffset + selection.text.length);
3097
+ scheduleChapterLineNumbers();
3098
+ requestAnimationFrame(() => {
3099
+ paintChapterLineSelection(selection.safeStart, selection.safeEnd);
3100
+ const row = $("#chapter-line-numbers-inner").querySelector(`[data-line-index="${selection.safeStart}"]`);
3101
+ if (row) input.scrollTop = Math.max(0, row.offsetTop - input.clientHeight / 3);
3102
+ syncChapterLineNumberScroll();
3103
+ });
3104
+ }
3105
+
3106
+ function openSearchEntityPreview(result, item) {
3107
+ const rowsByType = {
3108
+ "timeline-track": [["时间轴名称", item.name], ["简介", item.description], ["排序", item.sortOrder]],
3109
+ "timeline-event": [["事件名称", item.name], ["时间", item.timeLabel], ["类型", item.eventType], ["地点", item.location], ["说明", item.description], ["状态", item.status]],
3110
+ relationship: [["关系", result.title], ["大类", relationshipCategoryLabel(item.category)], ["子类", item.subtype], ["关键词", Array.isArray(item.keywords) ? item.keywords.join("、") : ""], ["当前状态", item.currentStatus], ["置信度", typeof item.confidence === "number" ? `${Math.round(item.confidence * 100)}%` : ""]],
3111
+ "chapter-outline": [["章节", item.chapterTitle], ["本章目标", item.goal], ["核心冲突", item.conflict], ["关键转折", item.turningPoint], ["补充说明", item.notes], ["规划状态", outlineStatusLabel(item.status)]],
3112
+ foreshadow: [["伏笔名称", item.title], ["内容与作用", item.description], ["重要程度", levelLabel(item.importance)], ["状态", foreshadowStatusLabel(item.status)], ["回收结论", item.resolutionNote]]
3113
+ };
3114
+ const rows = rowsByType[result.type] ?? [];
3115
+ const content = rows
3116
+ .filter(([, value]) => value !== undefined && value !== null && String(value).trim())
3117
+ .map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`)
3118
+ .join("");
3119
+ openDialog(
3120
+ result.title || searchResultTypeLabel(result.type),
3121
+ `<dl class="search-result-preview">${content || "<div><dt>详情</dt><dd>暂无补充信息</dd></div>"}</dl>`,
3122
+ async () => undefined,
3123
+ searchResultTypeLabel(result.type),
3124
+ { submitLabel: "关闭", hideCancel: true, wide: true }
3125
+ );
3045
3126
  }
3046
3127
 
3047
3128
  async function openSearchResult(result) {
@@ -3054,6 +3135,10 @@ async function openSearchResult(result) {
3054
3135
  if (inSettings) await returnFromSettings();
3055
3136
  if (target.kind === "chapter") {
3056
3137
  await selectChapter(target.id);
3138
+ if (state.chapter?.id === target.id && target.startLine) {
3139
+ revealChapterSearchLines(target.startLine, target.endLine);
3140
+ toast(target.startLine === target.endLine ? `已定位到第 ${target.startLine} 行` : `已定位到第 ${target.startLine}-${target.endLine} 行`);
3141
+ }
3057
3142
  return;
3058
3143
  }
3059
3144
  await showModule(target.module);
@@ -3063,6 +3148,10 @@ async function openSearchResult(result) {
3063
3148
  if (target.entity === "character") await openCharacterEditor(item, { readOnly: true });
3064
3149
  if (target.entity === "race") await openRaceDialog(item, { readOnly: true });
3065
3150
  if (target.entity === "organization") await openOrganizationDialog(item, { readOnly: true });
3151
+ if (target.entity === "review") openReviewDetailDialog(item);
3152
+ if (["timeline-track", "timeline-event", "relationship", "chapter-outline", "foreshadow"].includes(target.entity)) {
3153
+ openSearchEntityPreview(result, item);
3154
+ }
3066
3155
  }
3067
3156
 
3068
3157
  async function showSettingsHub() {
@@ -3191,9 +3280,14 @@ function resetWorkScopedUiCaches() {
3191
3280
  aiReferencesLoadWorkId = null;
3192
3281
  aiConversationsLoadPromise = null;
3193
3282
  aiConversationsLoadWorkId = null;
3283
+ raceHierarchyLoadPromise = null;
3284
+ raceHierarchyLoadWorkId = null;
3285
+ loadedRaceHierarchyWorkId = null;
3286
+ raceListRequestId += 1;
3194
3287
  state.models = [];
3195
3288
  state.characters = [];
3196
3289
  state.settings = [];
3290
+ state.races = [];
3197
3291
  characterListPage = 1;
3198
3292
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3199
3293
  relationshipFilters.fromCharacterIds = [];
@@ -4124,7 +4218,7 @@ async function renderCharacters(page = characterListPage) {
4124
4218
  const pageSize = pageSizeFor("characters");
4125
4219
  const [characterSource, races, organizations] = await Promise.all([
4126
4220
  hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page, pageSize),
4127
- canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4221
+ canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4128
4222
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4129
4223
  ]);
4130
4224
  const characterPage = hasCharacterFilters
@@ -4227,15 +4321,16 @@ async function renderCharacters(page = characterListPage) {
4227
4321
  $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(pageCharacters.find((item) => item.id === button.dataset.editCharacter))));
4228
4322
  }
4229
4323
 
4230
- async function renderRaces(page = moduleListPages.races) {
4231
- state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
4232
- mountModuleCount(state.races.length);
4324
+ function renderRaceCollection(total, descendantsLoading = false) {
4325
+ mountModuleCount(total);
4233
4326
  const layout = readModuleLayout();
4234
- const pageResult = layout === "rows"
4235
- ? paginateModuleItems(state.races, page, "races")
4236
- : paginateRaceForest(state.races, page, pageSizeFor("races"));
4237
- moduleListPages.races = pageResult.page;
4327
+ const raceItems = layout === "rows"
4328
+ ? [...state.races].sort((left, right) => String(left.name).localeCompare(String(right.name), "zh-CN"))
4329
+ : buildRaceForest(state.races);
4238
4330
  const canEditRaces = canEditModule("races");
4331
+ const directChildCount = (item) => Number.isInteger(Number(item.childCount))
4332
+ ? Number(item.childCount)
4333
+ : Array.isArray(item.children) ? item.children.length : 0;
4239
4334
  const raceActions = (item) => canEditRaces
4240
4335
  ? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
4241
4336
  : recordHistoryButton("race", item.id, item.name);
@@ -4243,7 +4338,7 @@ async function renderRaces(page = moduleListPages.races) {
4243
4338
  ? raceActions(item)
4244
4339
  : `<div class="card-actions">${raceActions(item)}</div>`;
4245
4340
  const renderRaceNode = (item) => `<details class="race-tree-node"${state.collapsedRaceIds.has(item.id) ? "" : " open"} data-race-node="${esc(item.id)}">
4246
- <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
4341
+ <summary><span>${esc(item.name)}</span><small>${directChildCount(item)} 个直接子种族</small></summary>
4247
4342
  <div class="race-tree-branch">
4248
4343
  <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>
4249
4344
  <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
@@ -4255,9 +4350,9 @@ async function renderRaces(page = moduleListPages.races) {
4255
4350
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
4256
4351
  </div>
4257
4352
  </details>`;
4258
- const raceRows = () => `<div class="module-row-list">${pageResult.items.map((item) => {
4353
+ const raceRows = () => `<div class="module-row-list">${raceItems.map((item) => {
4259
4354
  const preview = moduleRowPreview(item.description || "尚未填写种族简介");
4260
- const meta = `${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
4355
+ const meta = `${directChildCount(item)} 个直接子种族 · ${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
4261
4356
  return `
4262
4357
  <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)}">
4263
4358
  <small>${esc(meta)}</small>
@@ -4269,15 +4364,65 @@ async function renderRaces(page = moduleListPages.races) {
4269
4364
  if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
4270
4365
  if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
4271
4366
  $("#module-content").innerHTML = state.races.length
4272
- ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${pageResult.items.map(renderRaceNode).join("")}</section>`}${renderModulePagination(pageResult, "races", "种族列表")}`
4367
+ ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级" aria-busy="${descendantsLoading}">${raceItems.map(renderRaceNode).join("")}</section>`}`
4273
4368
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
4274
- bindModuleLayoutToggle(() => renderRaces(pageResult.page));
4275
- bindModulePagination("races", renderRaces);
4369
+ bindModuleLayoutToggle(() => renderRaceCollection(total, descendantsLoading));
4276
4370
  bindRaceTreeExpandToggle();
4277
4371
  bindRaceTreeNodeToggles();
4278
4372
  const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
4279
4373
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
4280
- bindEntityHistoryButtons(async () => { await renderRaces(pageResult.page); await loadAiReferences(); });
4374
+ bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
4375
+ }
4376
+
4377
+ async function ensureCompleteRaceList() {
4378
+ const workId = state.work?.id;
4379
+ if (!workId) return false;
4380
+ const generation = workScopedUiGeneration;
4381
+ if (loadedRaceHierarchyWorkId === workId) return true;
4382
+ if (raceHierarchyLoadPromise && raceHierarchyLoadWorkId === workId) {
4383
+ await raceHierarchyLoadPromise.catch(() => {});
4384
+ }
4385
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return false;
4386
+ if (loadedRaceHierarchyWorkId === workId) return true;
4387
+ const races = await api(`/api/works/${workId}/races`);
4388
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return false;
4389
+ state.races = races;
4390
+ loadedRaceHierarchyWorkId = workId;
4391
+ return true;
4392
+ }
4393
+
4394
+ async function renderRaces() {
4395
+ const workId = state.work.id;
4396
+ const generation = workScopedUiGeneration;
4397
+ const requestId = ++raceListRequestId;
4398
+ const roots = await api(`/api/works/${workId}/races?scope=roots`);
4399
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4400
+ state.races = roots.items;
4401
+ loadedRaceHierarchyWorkId = roots.items.length === roots.total ? workId : null;
4402
+ renderRaceCollection(roots.total, loadedRaceHierarchyWorkId !== workId);
4403
+ if (loadedRaceHierarchyWorkId === workId) return;
4404
+
4405
+ const dismissLoadingToast = persistentToast("正在加载子种族……");
4406
+ const loadPromise = api(`/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4407
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4408
+ state.races = [...roots.items, ...descendants];
4409
+ loadedRaceHierarchyWorkId = workId;
4410
+ if (state.module === "races") renderRaceCollection(roots.total);
4411
+ });
4412
+ raceHierarchyLoadPromise = loadPromise;
4413
+ raceHierarchyLoadWorkId = workId;
4414
+ void loadPromise.catch((error) => {
4415
+ if (state.work?.id === workId && generation === workScopedUiGeneration && requestId === raceListRequestId) {
4416
+ renderRaceCollection(roots.total);
4417
+ toast(`父种族已显示,但子种族载入失败:${error.message}`, "error");
4418
+ }
4419
+ }).finally(() => {
4420
+ if (raceHierarchyLoadPromise === loadPromise) {
4421
+ raceHierarchyLoadPromise = null;
4422
+ raceHierarchyLoadWorkId = null;
4423
+ }
4424
+ dismissLoadingToast();
4425
+ });
4281
4426
  }
4282
4427
 
4283
4428
  async function renderOrganizations(page = moduleListPages.organizations) {
@@ -4618,7 +4763,7 @@ async function renderTasks(page = taskListPage) {
4618
4763
  apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
4619
4764
  canReadModule("ai-settings")
4620
4765
  ? api(`/api/works/${state.work.id}/ai-settings`)
4621
- : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
4766
+ : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
4622
4767
  ]);
4623
4768
  if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
4624
4769
  taskListPage = taskPage.page;
@@ -4630,6 +4775,12 @@ async function renderTasks(page = taskListPage) {
4630
4775
  const runningCount = Number(taskPage.stats?.runningCount ?? 0);
4631
4776
  const activeTaskCount = pendingCount + runningCount;
4632
4777
  const runningProgress = runningCount ? analysisTaskProgressValue(taskPage.stats?.runningProgress) : 0;
4778
+ const autoRunPaused = Boolean(settings.autoRunEnabled && settings.autoRunPaused);
4779
+ const autoRunActive = Boolean(settings.autoRunEnabled && !autoRunPaused);
4780
+ const queueProgressLabel = runningCount
4781
+ ? "运行中任务平均进度"
4782
+ : autoRunPaused ? "自动执行已暂停" : autoRunActive ? "等待任务开始" : "自动执行已关闭";
4783
+ const queueProgressClass = runningCount ? "is-running" : autoRunPaused ? "is-paused" : "is-waiting";
4633
4784
  const visibleTaskIds = new Set(tasks.map((item) => String(item.id)));
4634
4785
  for (const taskId of taskStatusSnapshots.keys()) {
4635
4786
  if (!visibleTaskIds.has(taskId)) taskStatusSnapshots.delete(taskId);
@@ -4646,27 +4797,30 @@ async function renderTasks(page = taskListPage) {
4646
4797
  <div class="task-auto-run-copy">
4647
4798
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
4648
4799
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
4649
- <small>每轮最多启动「每轮任务上限」个,同时运行数量不超过「同时运行上限」;剩余任务需点击“开始下一轮”。</small>
4800
+ <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
4650
4801
  </div>
4651
4802
  <div class="task-auto-run-controls">
4652
- <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""}><span>自动执行待分析任务</span></label>
4803
+ <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""}><span>持续自动执行</span></label>
4653
4804
  <label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}"></label>
4654
- <label>每轮任务上限<input id="task-auto-run-batch-limit" type="number" min="1" max="200" value="${esc(String(settings.autoRunBatchLimit ?? 20))}"></label>
4805
+ <label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" aria-describedby="task-auto-run-daily-help"></label>
4806
+ <label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}"></label>
4655
4807
  <button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button>
4656
- <button id="task-auto-run-continue" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>开始下一轮</button>
4808
+ <button id="task-auto-run-toggle" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>${autoRunPaused ? "恢复自动执行" : "暂停自动执行"}</button>
4657
4809
  </div>
4658
- <p class="task-auto-run-meta">待执行队列 ${pendingCount} · 正在运行 ${runningCount} 个</p>
4810
+ <p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
4811
+ <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
4812
+ ${autoRunPaused ? `<p class="task-auto-run-alert" role="status"><strong>自动执行已暂停</strong><span>${esc(settings.autoRunPauseReason || "需要人工确认后恢复")}</span>${settings.autoRunResumeAt ? `<small>预计 ${esc(formatDateTime(settings.autoRunResumeAt))} 自动恢复</small>` : ""}</p>` : ""}
4659
4813
  <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
4660
- <div class="task-auto-run-progress-ring ${runningCount ? "is-running" : "is-waiting"}" role="progressbar" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${runningProgress}">
4814
+ <div class="task-auto-run-progress-ring ${queueProgressClass}" role="progressbar" aria-label="${queueProgressLabel}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${runningProgress}">
4661
4815
  <svg viewBox="0 0 120 120" aria-hidden="true" focusable="false">
4662
4816
  <circle class="task-auto-run-progress-ring-track" cx="60" cy="60" r="52"></circle>
4663
4817
  <circle class="task-auto-run-progress-ring-value" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="${runningProgress} 100"></circle>
4664
4818
  </svg>
4665
- <div class="task-auto-run-progress-ring-label"><strong>${runningProgress}%</strong><span>${runningCount ? "运行中平均进度" : "等待任务开始"}</span></div>
4819
+ <div class="task-auto-run-progress-ring-label"><strong>${runningProgress}%</strong><span>${queueProgressLabel}</span></div>
4666
4820
  </div>
4667
4821
  <div class="task-auto-run-progress-bar-layout">
4668
- <div class="task-auto-run-progress-label"><span>${runningCount ? "运行中任务平均进度" : "等待任务开始"}</span><strong>${runningProgress}%</strong></div>
4669
- <progress class="task-auto-run-progress-bar ${runningCount ? "is-running" : "is-waiting"}" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
4822
+ <div class="task-auto-run-progress-label"><span>${queueProgressLabel}</span><strong>${runningProgress}%</strong></div>
4823
+ <progress class="task-auto-run-progress-bar ${queueProgressClass}" max="100" value="${runningProgress}" aria-label="${queueProgressLabel}">${runningProgress}%</progress>
4670
4824
  </div>
4671
4825
  </div>
4672
4826
  </section>
@@ -4679,7 +4833,7 @@ async function renderTasks(page = taskListPage) {
4679
4833
  <td class="task-progress-cell">${renderAnalysisTaskProgress(item)}</td>
4680
4834
  <td class="task-row-actions">
4681
4835
  <button class="ghost-button" type="button" data-task-detail="${esc(item.id)}">详情</button>
4682
- ${item.status === "pending" ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
4836
+ ${item.status === "pending" && !item.nextAttemptAt ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
4683
4837
  ${item.status === "pending" || item.status === "running" ? `<button class="ghost-button" type="button" data-cancel-task="${esc(item.id)}">取消</button>` : ""}
4684
4838
  </td>
4685
4839
  </tr>`).join("")}</tbody></table>${pagination}` : emptyModule("还没有 AI 分析记录", "点击“开始 AI 分析”,可分析指定章节或整部作品。")}`;
@@ -4699,26 +4853,34 @@ async function renderTasks(page = taskListPage) {
4699
4853
  body: {
4700
4854
  autoRunEnabled: $("#task-auto-run-enabled").checked,
4701
4855
  autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
4702
- autoRunBatchLimit: Number($("#task-auto-run-batch-limit").value)
4856
+ autoRunDailyTaskLimit: Number($("#task-auto-run-daily-limit").value),
4857
+ autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value)
4703
4858
  }
4704
4859
  });
4705
4860
  toast(updated.autoRunEnabled
4706
- ? `自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个,每轮最多 ${updated.autoRunBatchLimit} 个`
4861
+ ? `持续自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个`
4707
4862
  : "自动执行已关闭");
4708
4863
  await renderTasks();
4864
+ window.setTimeout(() => $("#task-auto-run-save")?.focus(), 0);
4709
4865
  } catch (error) {
4710
4866
  toast(error.message, "error");
4711
4867
  button.disabled = false;
4712
4868
  }
4713
4869
  });
4714
- $("#task-auto-run-continue")?.addEventListener("click", async () => {
4715
- const button = $("#task-auto-run-continue");
4870
+ $("#task-auto-run-toggle")?.addEventListener("click", async () => {
4871
+ const button = $("#task-auto-run-toggle");
4716
4872
  button.disabled = true;
4717
4873
  try {
4718
- const result = await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
4719
- toast(`已开始下一轮,队列中还有 ${result.pendingCount} 个待执行任务`);
4874
+ if (autoRunPaused) {
4875
+ await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
4876
+ toast("自动执行已恢复");
4877
+ } else {
4878
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { autoRunEnabled: false } });
4879
+ toast("已暂停启动新的分析任务,正在运行的任务会继续完成");
4880
+ }
4720
4881
  await refreshBackgroundTaskCenter({ announce: false });
4721
4882
  await renderTasks();
4883
+ window.setTimeout(() => $(autoRunPaused ? "#task-auto-run-toggle" : "#task-auto-run-enabled")?.focus(), 0);
4722
4884
  } catch (error) {
4723
4885
  toast(error.message, "error");
4724
4886
  button.disabled = false;
@@ -4775,7 +4937,7 @@ async function renderTasks(page = taskListPage) {
4775
4937
  button.disabled = false;
4776
4938
  }
4777
4939
  }));
4778
- scheduleTaskProgressRefresh(state.work.id, runningCount);
4940
+ scheduleTaskProgressRefresh(state.work.id, runningCount > 0 || (pendingCount > 0 && autoRunActive) ? 1 : 0);
4779
4941
  }
4780
4942
 
4781
4943
  async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
@@ -5702,7 +5864,7 @@ async function renderBookAiSettings() {
5702
5864
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5703
5865
  title: "本书 Token 用量",
5704
5866
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
5705
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名或关键词子串匹配设定、人物、组织、时间线、关系、大纲和伏笔;非语义检索。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
5867
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
5706
5868
  scrollUsageCalendarsToLatest(host);
5707
5869
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
5708
5870
  "beforebegin",
@@ -6354,7 +6516,7 @@ function openWorkSettingsDialog(work) {
6354
6516
  <button id="work-export-button" class="ghost-button" type="button">下载 ZIP</button>
6355
6517
  </section>`;
6356
6518
  const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
6357
- <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>查看并恢复已软删除的章节,正文、版本和关联资料不会在删除时清理。</small></div>
6519
+ <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
6358
6520
  <button id="chapter-recycle-bin-button" class="ghost-button" type="button" aria-controls="chapter-recycle-bin-dialog" aria-haspopup="dialog" ${canEditProse() ? "" : "disabled"}>打开回收站</button>
6359
6521
  </section>` : "";
6360
6522
  const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
@@ -6417,7 +6579,7 @@ function openVolumeDialog(item) {
6417
6579
  if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
6418
6580
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
6419
6581
  const management = item ? `<section class="entity-dialog-management" aria-label="分卷操作">
6420
- <div><strong>分卷操作</strong><small>仅空分卷可以删除;卷内章节及回收站章节需先移动到其他分卷。</small></div>
6582
+ <div><strong>分卷操作</strong><small>仅空分卷可以删除;回收站章节需先彻底删除,或恢复后移动到其他分卷。</small></div>
6421
6583
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-volume-delete>删除分卷</button></div>
6422
6584
  </section>` : "";
6423
6585
  openDialog(item ? "编辑分卷" : "新建分卷",
@@ -7306,7 +7468,7 @@ async function showCharacterHistory() {
7306
7468
  async function openCharacterEditor(item = null, { readOnly = false } = {}) {
7307
7469
  entityEditorReadOnly = readOnly;
7308
7470
  [state.races, state.organizations, state.characters] = await Promise.all([
7309
- canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
7471
+ canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
7310
7472
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([]),
7311
7473
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
7312
7474
  ]);
@@ -7439,6 +7601,7 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
7439
7601
  async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
7440
7602
  entityEditorReadOnly = readOnly;
7441
7603
  await discardPendingMarkdownAttachments();
7604
+ if (kind === "race" && !(await ensureCompleteRaceList())) return;
7442
7605
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
7443
7606
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
7444
7607
  const isRace = kind === "race";
@@ -8458,7 +8621,10 @@ function renderChapterRecycleBin(chapters) {
8458
8621
  <time>${esc(formatDateTime(chapter.deletedAt))} · ${esc(chapter.actor)}</time>
8459
8622
  <p>${esc(chapter.contentPreview || "空白章节")}</p>
8460
8623
  <small>${Number(chapter.wordCount).toLocaleString("zh-CN")} 字 · 删除版本 v${Number(chapter.versionNo)}</small>
8461
- <button type="button" data-restore-deleted-chapter="${esc(chapter.id)}">恢复章节</button>
8624
+ <footer class="entity-version-actions">
8625
+ <button type="button" data-restore-deleted-chapter="${esc(chapter.id)}">恢复章节</button>
8626
+ <button class="danger-button" type="button" data-purge-deleted-chapter="${esc(chapter.id)}">彻底删除</button>
8627
+ </footer>
8462
8628
  </article>`).join("");
8463
8629
  host.querySelectorAll("[data-restore-deleted-chapter]").forEach((button) => button.addEventListener("click", async () => {
8464
8630
  const chapter = chapters.find((item) => item.id === button.dataset.restoreDeletedChapter);
@@ -8490,6 +8656,36 @@ function renderChapterRecycleBin(chapters) {
8490
8656
  toast(error.message, "error");
8491
8657
  }
8492
8658
  }));
8659
+ host.querySelectorAll("[data-purge-deleted-chapter]").forEach((button) => button.addEventListener("click", async () => {
8660
+ const chapter = chapters.find((item) => item.id === button.dataset.purgeDeletedChapter);
8661
+ if (!chapter || !state.work) return;
8662
+ const dialog = $("#chapter-recycle-bin-dialog");
8663
+ dialog.close();
8664
+ const confirmed = await confirmToast(`彻底删除章节“${chapter.title}”吗?正文、版本和关联资料将无法恢复。`, {
8665
+ title: "彻底删除章节",
8666
+ confirmLabel: "确认彻底删除"
8667
+ });
8668
+ if (!confirmed) {
8669
+ dialog.showModal();
8670
+ return;
8671
+ }
8672
+ button.disabled = true;
8673
+ try {
8674
+ await api(`/api/chapters/${encodeURIComponent(chapter.id)}/permanent`, {
8675
+ method: "DELETE",
8676
+ body: { expectedVersionNo: chapter.versionNo }
8677
+ });
8678
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
8679
+ renderTree();
8680
+ await loadChapterRecycleBin();
8681
+ dialog.showModal();
8682
+ toast(`已彻底删除章节“${chapter.title}”`);
8683
+ } catch (error) {
8684
+ button.disabled = false;
8685
+ dialog.showModal();
8686
+ toast(error.message, "error");
8687
+ }
8688
+ }));
8493
8689
  }
8494
8690
 
8495
8691
  async function loadChapterRecycleBin() {
@@ -9175,7 +9371,6 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
9175
9371
  pageSizes: {
9176
9372
  settings: Number($("#page-size-settings").value),
9177
9373
  characters: Number($("#page-size-characters").value),
9178
- races: Number($("#page-size-races").value),
9179
9374
  organizations: Number($("#page-size-organizations").value),
9180
9375
  timeline: Number($("#page-size-timeline").value),
9181
9376
  outlines: Number($("#page-size-outlines").value),