@musnows/scriverse 0.4.11 → 0.5.0

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=20260726-network-theme-palette";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260726-network-label-scale";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
@@ -6,7 +6,7 @@ import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-a
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
7
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
8
8
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
9
- import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260713-persisted-output-tokens";
9
+ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
10
10
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
11
11
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
12
12
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
@@ -40,7 +40,9 @@ import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20
40
40
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
41
41
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
42
42
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
43
+ import { resolveGlobalSearchTarget } from "/global-search.js?v=20260726-search-result-details";
43
44
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
45
+ import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
44
46
  import {
45
47
  clampCropRect,
46
48
  containImageRect,
@@ -52,6 +54,24 @@ import {
52
54
  resizeCropRect
53
55
  } from "/avatar-crop.js?v=20260725-avatar-crop";
54
56
 
57
+ const defaultPageSizes = Object.freeze({
58
+ characters: 30,
59
+ analysisTasks: 30,
60
+ fileVersions: 30
61
+ });
62
+
63
+ function normalizePageSize(value, fallback = 30) {
64
+ const candidate = Number(value);
65
+ return Number.isInteger(candidate) && candidate >= 10 && candidate <= 100 ? candidate : fallback;
66
+ }
67
+
68
+ function normalizePageSizes(value) {
69
+ return Object.fromEntries(Object.entries(defaultPageSizes).map(([module, fallback]) => [
70
+ module,
71
+ normalizePageSize(value?.[module], fallback)
72
+ ]));
73
+ }
74
+
55
75
  const state = {
56
76
  user: null,
57
77
  csrfToken: null,
@@ -74,6 +94,7 @@ const state = {
74
94
  dirty: false,
75
95
  pendingImportMeta: null,
76
96
  pendingCoverWorkId: null,
97
+ uiSettings: { toastPosition: "bottom-right", pageSizes: { ...defaultPageSizes } },
77
98
  relationshipGraph: null,
78
99
  galaxy: null,
79
100
  relationshipMindMap: null,
@@ -104,6 +125,9 @@ let peerPageStale = false;
104
125
  let collaborationAutoSaveDisabled = false;
105
126
 
106
127
  let timelineMultiSelectEnabled = false;
128
+ let taskProgressRefreshTimer = null;
129
+ const taskProgressRefreshInterval = 2_500;
130
+ const taskStatusSnapshots = new Map();
107
131
 
108
132
  const chapterTypes = ["正文", "设定", "作者的话", "其他"];
109
133
 
@@ -135,6 +159,41 @@ function analysisTaskStatusLabel(status) {
135
159
  })[String(status)] ?? "未知状态";
136
160
  }
137
161
 
162
+ function normalizedAnalysisTaskStatus(status) {
163
+ const value = String(status);
164
+ return ["pending", "running", "review", "completed", "partial", "expired", "cancelled"].includes(value)
165
+ ? value
166
+ : "unknown";
167
+ }
168
+
169
+ function analysisTaskProgressValue(progress) {
170
+ const value = Number(progress);
171
+ return Math.round(Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0)));
172
+ }
173
+
174
+ function renderAnalysisTaskStatus(item) {
175
+ const taskId = String(item.id);
176
+ const status = normalizedAnalysisTaskStatus(item.status);
177
+ const statusChanged = taskStatusSnapshots.get(taskId) !== status;
178
+ taskStatusSnapshots.set(taskId, status);
179
+ const label = analysisTaskStatusLabel(item.status);
180
+ return `<span class="task-status-badge is-${status}${statusChanged ? " is-state-change" : ""}" aria-label="任务状态:${esc(label)}">
181
+ <span class="task-status-indicator" aria-hidden="true"></span>
182
+ <span>${esc(label)}</span>
183
+ </span>`;
184
+ }
185
+
186
+ function renderAnalysisTaskProgress(item) {
187
+ const status = normalizedAnalysisTaskStatus(item.status);
188
+ const progress = analysisTaskProgressValue(item.progress);
189
+ return `<div class="task-progress is-${status}" aria-label="任务进度 ${progress}%">
190
+ <span class="task-progress-value">${progress}%</span>
191
+ <span class="task-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${progress}">
192
+ <span class="task-progress-fill" style="--task-progress: ${progress}%"></span>
193
+ </span>
194
+ </div>`;
195
+ }
196
+
138
197
  function reviewSeverityLabel(severity) {
139
198
  return levelLabel(severity);
140
199
  }
@@ -774,8 +833,11 @@ let entityEditorDirty = false;
774
833
  let entityEditorReadOnly = false;
775
834
  let chapterEditorReadOnly = true;
776
835
  let characterListPage = 1;
836
+ let taskListPage = 1;
777
837
  const characterFilters = { raceIds: [], organizationIds: [] };
778
838
  let characterFiltersPanelOpen = false;
839
+ const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
840
+ let relationshipFiltersPanelOpen = false;
779
841
  let settingEditorItem = null;
780
842
  let characterEditorItem = null;
781
843
  let knowledgeEditorItem = null;
@@ -1889,7 +1951,7 @@ async function api(path, options = {}) {
1889
1951
  return payload.data;
1890
1952
  }
1891
1953
 
1892
- async function apiPage(path, page = 1, limit = 50) {
1954
+ async function apiPage(path, page = 1, limit = 30) {
1893
1955
  const separator = path.includes("?") ? "&" : "?";
1894
1956
  const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
1895
1957
  if (Array.isArray(result)) return { items: result, page, limit, hasMore: false, nextPage: null };
@@ -1998,9 +2060,14 @@ function applyAuthenticatedUser(session) {
1998
2060
 
1999
2061
  function applyPlatformUiSettings(settings) {
2000
2062
  const position = settings?.toastPosition === "top-right" ? "top-right" : "bottom-right";
2063
+ state.uiSettings = { toastPosition: position, pageSizes: normalizePageSizes(settings?.pageSizes) };
2001
2064
  $("#toast-region").dataset.position = position;
2002
2065
  }
2003
2066
 
2067
+ function pageSizeFor(module) {
2068
+ return normalizePageSize(state.uiSettings.pageSizes[module], defaultPageSizes[module] ?? 30);
2069
+ }
2070
+
2004
2071
  async function loadPlatformUiSettings() {
2005
2072
  try {
2006
2073
  applyPlatformUiSettings(await api("/api/ui-settings"));
@@ -2493,6 +2560,10 @@ async function openPlatformUiSettingsDialog() {
2493
2560
  try {
2494
2561
  const settings = await api("/api/platform/ui-settings");
2495
2562
  $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
2563
+ const pageSizes = normalizePageSizes(settings.pageSizes);
2564
+ $("#page-size-characters").value = String(pageSizes.characters);
2565
+ $("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
2566
+ $("#page-size-file-versions").value = String(pageSizes.fileVersions);
2496
2567
  $("#platform-ui-settings-dialog").showModal();
2497
2568
  } catch (error) {
2498
2569
  toast(error.message, "error");
@@ -2643,36 +2714,22 @@ async function runWorkSearch() {
2643
2714
  }
2644
2715
 
2645
2716
  async function openSearchResult(result) {
2717
+ const target = resolveGlobalSearchTarget(result);
2718
+ if (!target) throw new Error("无法打开该搜索结果");
2646
2719
  $("#search-dialog").close();
2647
2720
  const inSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
2648
2721
  if (inSettings) await returnFromSettings();
2649
- if (result.type === "chapter") {
2650
- await selectChapter(result.id);
2722
+ if (target.kind === "chapter") {
2723
+ await selectChapter(target.id);
2651
2724
  return;
2652
2725
  }
2653
- if (result.type === "character") {
2654
- await showModule("characters");
2655
- const character = state.characters.find((item) => item.id === result.id);
2656
- if (character) openCharacterEditor(character);
2657
- return;
2658
- }
2659
- if (result.type === "setting") {
2660
- await showModule("settings");
2661
- const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
2662
- openSettingEditor(setting);
2663
- return;
2664
- }
2665
- if (result.type === "race") {
2666
- await showModule("races");
2667
- const race = state.races.find((item) => item.id === result.id);
2668
- if (race) openRaceDialog(race);
2669
- return;
2670
- }
2671
- if (result.type === "organization") {
2672
- await showModule("organizations");
2673
- const organization = state.organizations.find((item) => item.id === result.id);
2674
- if (organization) openOrganizationDialog(organization);
2675
- }
2726
+ await showModule(target.module);
2727
+ if (state.module !== target.module) return;
2728
+ const item = await api(target.apiPath);
2729
+ if (target.entity === "setting") openSettingEditor(item, { readOnly: true });
2730
+ if (target.entity === "character") await openCharacterEditor(item, { readOnly: true });
2731
+ if (target.entity === "race") await openRaceDialog(item, { readOnly: true });
2732
+ if (target.entity === "organization") await openOrganizationDialog(item, { readOnly: true });
2676
2733
  }
2677
2734
 
2678
2735
  async function showSettingsHub() {
@@ -2770,6 +2827,9 @@ function resetWorkScopedUiCaches() {
2770
2827
  state.characters = [];
2771
2828
  state.settings = [];
2772
2829
  characterListPage = 1;
2830
+ relationshipFilters.fromCharacterIds = [];
2831
+ relationshipFilters.toCharacterIds = [];
2832
+ taskListPage = 1;
2773
2833
  state.collapsedVolumeIds.clear();
2774
2834
  state.collapsedRaceIds.clear();
2775
2835
  lastSavedChapterSnapshot = null;
@@ -2989,6 +3049,7 @@ async function showModule(module) {
2989
3049
  if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
2990
3050
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2991
3051
  state.module = module;
3052
+ if (module !== "tasks") stopTaskProgressRefresh();
2992
3053
  applyWorkAccessMode();
2993
3054
  markActiveModule(module);
2994
3055
  if (module === "editor") {
@@ -3021,7 +3082,7 @@ async function showModule(module) {
3021
3082
  if (module === "outlines") await renderOutlines();
3022
3083
  if (module === "relationships") await renderRelationships();
3023
3084
  if (module === "reviews") await renderReviews();
3024
- if (module === "tasks") await renderTasks();
3085
+ if (module === "tasks") await renderTasks(taskListPage);
3025
3086
  if (module === "ai-settings") await renderBookAiSettings();
3026
3087
  } catch (error) {
3027
3088
  $("#module-content").innerHTML = `<div class="empty-state"><b>载入失败</b>${esc(error.message)}</div>`;
@@ -3316,6 +3377,15 @@ function mountCharacterFilterToggle() {
3316
3377
  });
3317
3378
  }
3318
3379
 
3380
+ function mountRelationshipFilterToggle() {
3381
+ $("#module-header-actions").querySelector('[data-module-header-action="relationship-filter-toggle"]')?.remove();
3382
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="relationship-filter-toggle" aria-label="筛选关系" aria-controls="relationship-filter-panel" aria-expanded="${relationshipFiltersPanelOpen}" 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>`);
3383
+ $("#module-header-actions").querySelector('[data-module-header-action="relationship-filter-toggle"]')?.addEventListener("click", async () => {
3384
+ relationshipFiltersPanelOpen = !relationshipFiltersPanelOpen;
3385
+ await renderRelationships();
3386
+ });
3387
+ }
3388
+
3319
3389
  function bindRecordPreview(selector, open) {
3320
3390
  $("#module-content").querySelectorAll(selector).forEach((card) => {
3321
3391
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -3436,13 +3506,14 @@ async function renderSettings() {
3436
3506
 
3437
3507
  async function renderCharacters(page = characterListPage) {
3438
3508
  const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
3509
+ const pageSize = pageSizeFor("characters");
3439
3510
  const [characterSource, races, organizations] = await Promise.all([
3440
- hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page),
3511
+ hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page, pageSize),
3441
3512
  canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
3442
3513
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
3443
3514
  ]);
3444
3515
  const characterPage = hasCharacterFilters
3445
- ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, 50)
3516
+ ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
3446
3517
  : characterSource;
3447
3518
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3448
3519
  characterListPage = characterPage.page;
@@ -3745,17 +3816,34 @@ async function renderOutlines() {
3745
3816
  async function renderRelationships() {
3746
3817
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3747
3818
  const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
3819
+ const filteredRelationships = filterRelationships(relationships, relationshipFilters);
3820
+ const hasRelationshipFilters = relationshipFilters.fromCharacterIds.length > 0 || relationshipFilters.toCharacterIds.length > 0;
3748
3821
  const canEditRelationships = canEditModule("relationships");
3749
- mountModuleCount(relationships.length);
3822
+ mountModuleCount(filteredRelationships.length);
3750
3823
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
3824
+ const selectedFromCharacterIds = new Set(relationshipFilters.fromCharacterIds);
3825
+ const selectedToCharacterIds = new Set(relationshipFilters.toCharacterIds);
3826
+ const selectedFromCharacterNames = state.characters.filter((character) => selectedFromCharacterIds.has(String(character.id))).map((character) => character.name);
3827
+ const selectedToCharacterNames = state.characters.filter((character) => selectedToCharacterIds.has(String(character.id))).map((character) => character.name);
3828
+ const filterOptionList = (selectedIds) => state.characters.map((character) => {
3829
+ const value = String(character.id);
3830
+ return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(character.name)}</span></label>`;
3831
+ }).join("");
3832
+ const filterToolbar = `<section id="relationship-filter-panel" class="character-filter-toolbar${relationshipFiltersPanelOpen ? "" : " hidden"}" aria-label="关系筛选">
3833
+ <details class="character-filter-dropdown"><summary><span>按起点角色筛选</span><strong>${selectedFromCharacterNames.length ? `已选 ${selectedFromCharacterNames.length} 项` : "全部起点"}</strong></summary><div id="relationship-from-character-filter" class="character-filter-options">${filterOptionList(selectedFromCharacterIds)}</div></details>
3834
+ <details class="character-filter-dropdown"><summary><span>按终点角色筛选</span><strong>${selectedToCharacterNames.length ? `已选 ${selectedToCharacterNames.length} 项` : "全部终点"}</strong></summary><div id="relationship-to-character-filter" class="character-filter-options">${filterOptionList(selectedToCharacterIds)}</div></details>
3835
+ <div class="character-filter-toolbar-actions">${hasRelationshipFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${filteredRelationships.length} 条关系</span>` : ""}<button id="clear-relationship-filters" class="ghost-button" type="button" ${hasRelationshipFilters ? "" : "disabled"}>重置筛选</button></div>
3836
+ </section>`;
3837
+ mountRelationshipFilterToggle();
3751
3838
  state.galaxy?.destroy();
3752
3839
  state.relationshipExpandedMap?.destroy?.();
3753
3840
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
3754
3841
  const graph = buildRelationshipGraph(state.characters, relationships);
3755
3842
  state.relationshipGraph = graph;
3756
- $("#module-content").innerHTML = `<div id="relationship-map-host"></div>${relationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${relationships.map((item) => `
3843
+ const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${filteredRelationships.map((item) => `
3757
3844
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
3758
- <td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
3845
+ <td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
3846
+ $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}`;
3759
3847
  const openGalaxy = () => {
3760
3848
  state.galaxy?.destroy();
3761
3849
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -3771,7 +3859,24 @@ async function renderRelationships() {
3771
3859
  };
3772
3860
  state.relationshipMindMap?.destroy?.();
3773
3861
  state.relationshipMindMap = renderRelationshipMindMap($("#relationship-map-host"), graph, { onOpenGalaxy: openGalaxy, onOpenExpanded: openExpanded });
3774
- $("#module-content").querySelectorAll("[data-edit-relationship]").forEach((button) => button.addEventListener("click", () => openRelationshipDialog(relationships.find((item) => item.id === button.dataset.editRelationship))));
3862
+ const readSelectedValues = (selector) => [...$(selector).querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
3863
+ $("#relationship-from-character-filter").addEventListener("change", async () => {
3864
+ relationshipFiltersPanelOpen = true;
3865
+ relationshipFilters.fromCharacterIds = readSelectedValues("#relationship-from-character-filter");
3866
+ await renderRelationships();
3867
+ });
3868
+ $("#relationship-to-character-filter").addEventListener("change", async () => {
3869
+ relationshipFiltersPanelOpen = true;
3870
+ relationshipFilters.toCharacterIds = readSelectedValues("#relationship-to-character-filter");
3871
+ await renderRelationships();
3872
+ });
3873
+ $("#clear-relationship-filters")?.addEventListener("click", async () => {
3874
+ relationshipFiltersPanelOpen = true;
3875
+ relationshipFilters.fromCharacterIds = [];
3876
+ relationshipFilters.toCharacterIds = [];
3877
+ await renderRelationships();
3878
+ });
3879
+ $("#module-content").querySelectorAll("[data-edit-relationship]").forEach((button) => button.addEventListener("click", () => openRelationshipDialog(filteredRelationships.find((item) => item.id === button.dataset.editRelationship))));
3775
3880
  bindEntityHistoryButtons(async () => { await renderRelationships(); await loadAiReferences(); });
3776
3881
  }
3777
3882
 
@@ -3867,17 +3972,36 @@ async function renderReviews() {
3867
3972
  }));
3868
3973
  }
3869
3974
 
3870
- async function renderTasks() {
3871
- const [tasks, settings] = await Promise.all([
3872
- apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
3975
+ async function renderTasks(page = taskListPage) {
3976
+ stopTaskProgressRefresh();
3977
+ const pageSize = pageSizeFor("analysisTasks");
3978
+ const [taskPage, settings] = await Promise.all([
3979
+ apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
3873
3980
  canReadModule("ai-settings")
3874
3981
  ? api(`/api/works/${state.work.id}/ai-settings`)
3875
3982
  : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
3876
3983
  ]);
3877
- mountModuleCount(tasks.length);
3984
+ if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
3985
+ taskListPage = taskPage.page;
3986
+ const tasks = taskPage.items;
3987
+ const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
3988
+ mountModuleCount(taskTotal);
3878
3989
  const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
3879
- const pendingCount = tasks.filter((item) => item.status === "pending").length;
3880
- const runningCount = tasks.filter((item) => item.status === "running").length;
3990
+ const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
3991
+ const runningCount = Number(taskPage.stats?.runningCount ?? 0);
3992
+ const activeTaskCount = pendingCount + runningCount;
3993
+ const runningProgress = runningCount ? analysisTaskProgressValue(taskPage.stats?.runningProgress) : 0;
3994
+ const visibleTaskIds = new Set(tasks.map((item) => String(item.id)));
3995
+ for (const taskId of taskStatusSnapshots.keys()) {
3996
+ if (!visibleTaskIds.has(taskId)) taskStatusSnapshots.delete(taskId);
3997
+ }
3998
+ const pagination = tasks.length && (taskPage.page > 1 || taskPage.hasMore)
3999
+ ? `<nav class="module-pagination" aria-label="AI 分析任务分页">
4000
+ <button type="button" data-task-page="${taskPage.page - 1}" ${taskPage.page <= 1 ? "disabled" : ""}>上一页</button>
4001
+ <span>第 ${taskPage.page}/${Math.max(1, Math.ceil(taskTotal / taskPage.limit))} 页 · 本页 ${tasks.length} 个任务 · 共 ${taskTotal} 个任务</span>
4002
+ <button type="button" data-task-page="${taskPage.nextPage ?? taskPage.page + 1}" ${taskPage.hasMore ? "" : "disabled"}>下一页</button>
4003
+ </nav>`
4004
+ : "";
3881
4005
  $("#module-content").innerHTML = `
3882
4006
  <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
3883
4007
  <div class="task-auto-run-copy">
@@ -3893,19 +4017,29 @@ async function renderTasks() {
3893
4017
  <button id="task-auto-run-continue" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>开始下一轮</button>
3894
4018
  </div>
3895
4019
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个</p>
4020
+ <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
4021
+ <div class="task-auto-run-progress-label"><span>${runningCount ? "运行中任务平均进度" : "等待任务开始"}</span><strong>${runningProgress}%</strong></div>
4022
+ <progress class="task-auto-run-progress-bar ${runningCount ? "is-running" : "is-waiting"}" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
4023
+ </div>
3896
4024
  </section>
3897
4025
  ${tasks.length ? `<table class="table-list task-table"><thead><tr><th>分析类型</th><th>范围</th><th>状态</th><th>进度</th><th>操作</th></tr></thead><tbody>${tasks.map((item) => `
3898
4026
  <tr>
3899
4027
  <td>${esc(analysisTaskTypeLabel(item.taskType))}</td>
3900
4028
  <td>${esc(item.scopeSummary || taskScopeLabel(item.scope?.type || "book"))}</td>
3901
- <td>${esc(analysisTaskStatusLabel(item.status))}</td>
3902
- <td>${Number(item.progress ?? 0)}%</td>
4029
+ <td class="task-status-cell">${renderAnalysisTaskStatus(item)}</td>
4030
+ <td class="task-progress-cell">${renderAnalysisTaskProgress(item)}</td>
3903
4031
  <td class="task-row-actions">
3904
4032
  <button class="ghost-button" type="button" data-task-detail="${esc(item.id)}">详情</button>
3905
4033
  ${item.status === "pending" ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
3906
4034
  ${item.status === "pending" || item.status === "running" ? `<button class="ghost-button" type="button" data-cancel-task="${esc(item.id)}">取消</button>` : ""}
3907
4035
  </td>
3908
- </tr>`).join("")}</tbody></table>` : emptyModule("还没有 AI 分析记录", "点击“开始 AI 分析”,可分析指定章节或整部作品。")}`;
4036
+ </tr>`).join("")}</tbody></table>${pagination}` : emptyModule("还没有 AI 分析记录", "点击“开始 AI 分析”,可分析指定章节或整部作品。")}`;
4037
+
4038
+ $("#module-content").querySelectorAll("[data-task-page]").forEach((button) => button.addEventListener("click", async () => {
4039
+ if (button.disabled) return;
4040
+ $("#module-content").querySelectorAll("[data-task-page]").forEach((control) => { control.disabled = true; });
4041
+ await renderTasks(Number(button.dataset.taskPage));
4042
+ }));
3909
4043
 
3910
4044
  $("#task-auto-run-save")?.addEventListener("click", async () => {
3911
4045
  const button = $("#task-auto-run-save");
@@ -3944,8 +4078,15 @@ async function renderTasks() {
3944
4078
  $("#module-content").querySelectorAll("[data-task-detail]").forEach((button) => button.addEventListener("click", () => {
3945
4079
  if (button.disabled) return;
3946
4080
  button.disabled = true;
3947
- api(`/api/tasks/${encodeURIComponent(button.dataset.taskDetail)}`)
3948
- .then((task) => openTaskDetailDialog(task))
4081
+ const taskId = encodeURIComponent(button.dataset.taskDetail);
4082
+ Promise.all([
4083
+ api(`/api/tasks/${taskId}`),
4084
+ api(`/api/tasks/${taskId}/trace`).catch((error) => {
4085
+ if (error.code === "WORK_MODULE_READ_DENIED") return { restricted: true, captured: false, calls: [] };
4086
+ throw error;
4087
+ })
4088
+ ])
4089
+ .then(([task, trace]) => openTaskDetailDialog(task, trace))
3949
4090
  .catch((error) => toast(error.message, "error"))
3950
4091
  .finally(() => { button.disabled = false; });
3951
4092
  }));
@@ -3954,8 +4095,15 @@ async function renderTasks() {
3954
4095
  try {
3955
4096
  button.disabled = true;
3956
4097
  button.textContent = "运行中";
4098
+ const row = button.closest("tr");
4099
+ const optimisticTask = { id: button.dataset.runTask, status: "running", progress: 5 };
4100
+ const statusCell = row?.querySelector(".task-status-cell");
4101
+ const progressCell = row?.querySelector(".task-progress-cell");
4102
+ if (statusCell) statusCell.innerHTML = renderAnalysisTaskStatus(optimisticTask);
4103
+ if (progressCell) progressCell.innerHTML = renderAnalysisTaskProgress(optimisticTask);
3957
4104
  const cancel = button.parentElement.querySelector("[data-cancel-task]");
3958
4105
  if (cancel) cancel.textContent = "取消运行";
4106
+ scheduleTaskProgressRefresh(workId, 1);
3959
4107
  const completed = await api(`/api/tasks/${button.dataset.runTask}/run`, { method: "POST", body: { modelId: $("#ai-model").value || undefined } });
3960
4108
  toast(completed.status === "cancelled" ? "分析任务已取消" : completed.status === "expired" ? "正文已变化,本次分析已过期" : "分析已完成");
3961
4109
  if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
@@ -3975,9 +4123,171 @@ async function renderTasks() {
3975
4123
  button.disabled = false;
3976
4124
  }
3977
4125
  }));
4126
+ scheduleTaskProgressRefresh(state.work.id, runningCount);
4127
+ }
4128
+
4129
+ function stopTaskProgressRefresh() {
4130
+ if (taskProgressRefreshTimer === null) return;
4131
+ window.clearTimeout(taskProgressRefreshTimer);
4132
+ taskProgressRefreshTimer = null;
4133
+ }
4134
+
4135
+ function scheduleTaskProgressRefresh(workId, runningCount) {
4136
+ stopTaskProgressRefresh();
4137
+ if (runningCount === 0) return;
4138
+ taskProgressRefreshTimer = window.setTimeout(async () => {
4139
+ taskProgressRefreshTimer = null;
4140
+ if (state.module !== "tasks" || state.work?.id !== workId) return;
4141
+ if ($(".task-auto-run-controls")?.contains(document.activeElement)) {
4142
+ scheduleTaskProgressRefresh(workId, runningCount);
4143
+ return;
4144
+ }
4145
+ try {
4146
+ await renderTasks();
4147
+ } catch (error) {
4148
+ console.error("Failed to refresh task progress", error);
4149
+ scheduleTaskProgressRefresh(workId, runningCount);
4150
+ }
4151
+ }, taskProgressRefreshInterval);
4152
+ }
4153
+
4154
+ function taskTraceRoleLabel(role) {
4155
+ if (role === "system") return "系统提示词";
4156
+ if (role === "assistant") return "Agent";
4157
+ if (role === "tool") return "工具结果";
4158
+ return "用户提示词";
4159
+ }
4160
+
4161
+ function renderTaskTraceMessages(messages) {
4162
+ if (!Array.isArray(messages) || messages.length === 0) return '<p class="task-trace-empty">本轮没有消息。</p>';
4163
+ return `<div class="task-trace-messages">${messages.map((message, index) => {
4164
+ const role = String(message?.role || "user");
4165
+ const content = message?.content === null ? "" : String(message?.content ?? "");
4166
+ const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
4167
+ return `<article class="task-trace-message is-${esc(role)}">
4168
+ <header><span>${esc(taskTraceRoleLabel(role))}</span><small>#${index + 1} · ${content.length.toLocaleString("zh-CN")} 字符</small></header>
4169
+ ${content ? `<pre>${esc(content)}</pre>` : '<p class="task-trace-empty">无文本正文</p>'}
4170
+ ${toolCalls.length ? `<details><summary>Agent 请求的工具调用(${toolCalls.length})</summary><pre>${esc(JSON.stringify(toolCalls, null, 2))}</pre></details>` : ""}
4171
+ ${message?.tool_call_id ? `<small>工具调用 ID:<code>${esc(message.tool_call_id)}</code></small>` : ""}
4172
+ </article>`;
4173
+ }).join("")}</div>`;
3978
4174
  }
3979
4175
 
3980
- function openTaskDetailDialog(task) {
4176
+ function renderTaskTraceAttempt(attempt) {
4177
+ const response = attempt?.response && typeof attempt.response === "object" ? attempt.response : {};
4178
+ const choice = Array.isArray(response.choices) ? response.choices[0] : null;
4179
+ const message = choice?.message && typeof choice.message === "object" ? choice.message : {};
4180
+ const reasoning = String(message.reasoning_content ?? "");
4181
+ const content = String(message.content ?? "");
4182
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
4183
+ return `<article class="task-trace-attempt is-${esc(attempt?.status || "failed")}">
4184
+ <header>
4185
+ <strong>尝试 ${esc(String(attempt?.attempt ?? 1))}</strong>
4186
+ <span>${attempt?.status === "completed" ? "响应成功" : attempt?.status === "running" ? "等待响应" : "请求失败"}${attempt?.httpStatus ? ` · HTTP ${esc(String(attempt.httpStatus))}` : ""}</span>
4187
+ <small>${esc(formatDateTime(attempt?.startedAt))}${attempt?.completedAt ? ` → ${esc(formatDateTime(attempt.completedAt))}` : ""}</small>
4188
+ </header>
4189
+ ${reasoning ? `<details class="task-trace-response"><summary>模型思考过程 · ${reasoning.length.toLocaleString("zh-CN")} 字符</summary><pre>${esc(reasoning)}</pre></details>` : ""}
4190
+ ${content ? `<details class="task-trace-response" open><summary>Agent 响应 · ${content.length.toLocaleString("zh-CN")} 字符</summary><pre>${esc(content)}</pre></details>` : ""}
4191
+ ${toolCalls.length ? `<details class="task-trace-response" open><summary>Agent 工具请求 · ${toolCalls.length} 项</summary><pre>${esc(JSON.stringify(toolCalls, null, 2))}</pre></details>` : ""}
4192
+ ${attempt?.failure ? `<pre class="task-trace-failure">${esc(attempt.failure)}</pre>` : ""}
4193
+ ${response.usage ? `<details class="task-trace-response"><summary>Token 用量</summary><pre>${esc(JSON.stringify(response.usage, null, 2))}</pre></details>` : ""}
4194
+ </article>`;
4195
+ }
4196
+
4197
+ function renderTaskTraceRound(round) {
4198
+ const messages = Array.isArray(round?.request?.messages) ? round.request.messages : [];
4199
+ const attempts = Array.isArray(round?.attempts) ? round.attempts : [];
4200
+ const executions = Array.isArray(round?.toolExecutions) ? round.toolExecutions : [];
4201
+ return `<section class="task-trace-round">
4202
+ <header class="task-trace-round-header">
4203
+ <span class="task-trace-round-index">${esc(String(round?.round ?? 1))}</span>
4204
+ <div><strong>Agent 轮次 ${esc(String(round?.round ?? 1))}</strong><small>${messages.length} 条消息 · ${attempts.length} 次请求尝试 · ${executions.length} 次工具执行</small></div>
4205
+ <time>${esc(formatDateTime(round?.requestedAt))}</time>
4206
+ </header>
4207
+ <div class="task-trace-flow" aria-label="本轮调用流程">
4208
+ <span>完整 Prompt</span><i aria-hidden="true">→</i><span>模型响应</span>${executions.length ? '<i aria-hidden="true">→</i><span>工具执行</span>' : ""}
4209
+ </div>
4210
+ <details class="task-trace-prompt">
4211
+ <summary>查看本轮发出的完整 Prompt(${messages.length} 条消息)</summary>
4212
+ ${renderTaskTraceMessages(messages)}
4213
+ <details class="task-trace-request-meta"><summary>模型参数与工具定义</summary><pre>${esc(JSON.stringify({
4214
+ model: round?.request?.model,
4215
+ parameters: round?.request?.parameters ?? {},
4216
+ toolChoice: round?.request?.toolChoice,
4217
+ tools: round?.request?.tools ?? []
4218
+ }, null, 2))}</pre></details>
4219
+ </details>
4220
+ <div class="task-trace-attempts">${attempts.map(renderTaskTraceAttempt).join("") || '<p class="task-trace-empty">尚未记录模型响应。</p>'}</div>
4221
+ ${executions.length ? `<div class="task-trace-tools"><strong>工具执行结果</strong>${executions.map((execution) => `<details>
4222
+ <summary>${esc(execution.name || "未知工具")} · ${execution.status === "completed" ? "成功" : "失败"}</summary>
4223
+ <div class="task-trace-tool-grid">
4224
+ <div><small>调用参数</small><pre>${esc(JSON.stringify(execution.arguments, null, 2))}</pre></div>
4225
+ <div><small>返回结果</small><pre>${esc(JSON.stringify(execution.result, null, 2))}</pre></div>
4226
+ </div>
4227
+ </details>`).join("")}</div>` : ""}
4228
+ </section>`;
4229
+ }
4230
+
4231
+ function renderTaskTraceVisualization(trace) {
4232
+ if (trace?.restricted) {
4233
+ return `<section class="task-trace-section" aria-labelledby="task-trace-title">
4234
+ <header class="task-trace-heading"><div><span class="eyebrow">执行追踪</span><h3 id="task-trace-title">完整全流程上下文</h3></div></header>
4235
+ <div class="task-trace-unavailable"><strong>完整上下文受权限保护</strong><p>当前账号缺少正文或作品资料的读取权限,无法查看原始 Prompt、模型响应与工具结果。</p></div>
4236
+ </section>`;
4237
+ }
4238
+ const calls = Array.isArray(trace?.calls) ? trace.calls : [];
4239
+ const capturedCalls = calls.filter((call) => call.trace);
4240
+ const roundCount = capturedCalls.reduce((total, call) => total + (Array.isArray(call.trace?.rounds) ? call.trace.rounds.length : 0), 0);
4241
+ const toolCount = capturedCalls.reduce((total, call) => total + (Array.isArray(call.trace?.rounds)
4242
+ ? call.trace.rounds.reduce((roundTotal, round) => roundTotal + (Array.isArray(round.toolExecutions) ? round.toolExecutions.length : 0), 0)
4243
+ : 0), 0);
4244
+ const promptChars = capturedCalls.reduce((total, call) => total + (Array.isArray(call.trace?.rounds)
4245
+ ? call.trace.rounds.reduce((roundTotal, round) => roundTotal + JSON.stringify(round.request?.messages ?? []).length, 0)
4246
+ : 0), 0);
4247
+ const outputChars = capturedCalls.reduce((total, call) => total + Number(call.outputChars || 0), 0);
4248
+ if (!trace?.captured || capturedCalls.length === 0) {
4249
+ return `<section class="task-trace-section" aria-labelledby="task-trace-title">
4250
+ <header class="task-trace-heading"><div><span class="eyebrow">执行追踪</span><h3 id="task-trace-title">完整全流程上下文</h3></div></header>
4251
+ <div class="task-trace-unavailable"><strong>没有可用的全流程记录</strong><p>${calls.length ? "该任务仅保留了调用摘要,没有保存完整 Prompt 与 Agent 轮次。" : "这是追踪功能启用前创建的历史任务,或任务尚未发起任何模型调用。"}</p></div>
4252
+ </section>`;
4253
+ }
4254
+ return `<section class="task-trace-section" aria-labelledby="task-trace-title">
4255
+ <header class="task-trace-heading">
4256
+ <div><span class="eyebrow">执行追踪</span><h3 id="task-trace-title">完整全流程上下文</h3></div>
4257
+ <p>按模型调用与 Agent 轮次还原实际发送内容、模型响应和工具结果。</p>
4258
+ </header>
4259
+ <div class="task-trace-metrics" aria-label="执行追踪统计">
4260
+ <div><strong>${capturedCalls.length}</strong><span>模型调用</span></div>
4261
+ <div><strong>${roundCount}</strong><span>Agent 轮次</span></div>
4262
+ <div><strong>${toolCount}</strong><span>工具执行</span></div>
4263
+ <div><strong>${promptChars.toLocaleString("zh-CN")}</strong><span>Prompt 字符</span></div>
4264
+ <div><strong>${outputChars.toLocaleString("zh-CN")}</strong><span>输出字符</span></div>
4265
+ </div>
4266
+ <div class="task-trace-calls">${capturedCalls.map((call, index) => {
4267
+ const rounds = Array.isArray(call.trace?.rounds) ? call.trace.rounds : [];
4268
+ const modelName = call.model?.displayName || call.model?.modelId || "未知模型";
4269
+ const providerName = call.provider?.name || "未知供应商";
4270
+ return `<details class="task-trace-call is-${esc(call.status || "failed")}" ${index === 0 ? "open" : ""}>
4271
+ <summary>
4272
+ <span class="task-trace-call-index">${index + 1}</span>
4273
+ <span><strong>${esc(modelName)}</strong><small>${esc(providerName)} · ${rounds.length} 轮 · ${Number(call.inputChars || 0).toLocaleString("zh-CN")} → ${Number(call.outputChars || 0).toLocaleString("zh-CN")} 字符</small></span>
4274
+ <span class="task-trace-status">${call.status === "completed" ? "已完成" : call.status === "running" ? "运行中" : "失败"}</span>
4275
+ </summary>
4276
+ <div class="task-trace-call-body">
4277
+ <div class="task-trace-call-meta"><code>${esc(call.id)}</code><span>${esc(formatDateTime(call.createdAt))}</span></div>
4278
+ <details class="task-trace-initial">
4279
+ <summary>初始完整上下文(${Array.isArray(call.trace?.initialMessages) ? call.trace.initialMessages.length : 0} 条消息)</summary>
4280
+ ${renderTaskTraceMessages(call.trace?.initialMessages)}
4281
+ </details>
4282
+ ${call.failure ? `<pre class="task-trace-failure">${esc(call.failure)}</pre>` : ""}
4283
+ <div class="task-trace-rounds">${rounds.map(renderTaskTraceRound).join("")}</div>
4284
+ </div>
4285
+ </details>`;
4286
+ }).join("")}</div>
4287
+ </section>`;
4288
+ }
4289
+
4290
+ function openTaskDetailDialog(task, trace) {
3981
4291
  if (!task) return;
3982
4292
  const details = Array.isArray(task.scopeDetails) ? task.scopeDetails : [];
3983
4293
  const detailHtml = details.map((item) => {
@@ -4004,18 +4314,21 @@ function openTaskDetailDialog(task) {
4004
4314
  : "<p>尚无结果</p>";
4005
4315
  openDialog("任务详情",
4006
4316
  `<div class="task-detail">
4007
- <p><strong>任务 ID</strong><br><code>${esc(task.id)}</code></p>
4008
- <p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}</p>
4009
- <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
4010
- <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
4011
- <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
4012
- <div><strong>失败信息</strong>${failureHtml}</div>
4013
- <div><strong>结果摘要</strong>${resultPreview}</div>
4014
- <p><small>创建于 ${esc(formatDateTime(task.createdAt))} · 更新于 ${esc(formatDateTime(task.updatedAt))}</small></p>
4317
+ <section class="task-detail-overview">
4318
+ <p><strong>任务 ID</strong><br><code>${esc(task.id)}</code></p>
4319
+ <p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}</p>
4320
+ <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
4321
+ <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
4322
+ <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
4323
+ <div><strong>失败信息</strong>${failureHtml}</div>
4324
+ <div><strong>结果摘要</strong>${resultPreview}</div>
4325
+ <p><small>创建于 ${esc(formatDateTime(task.createdAt))} · 更新于 ${esc(formatDateTime(task.updatedAt))}</small></p>
4326
+ </section>
4327
+ ${renderTaskTraceVisualization(trace)}
4015
4328
  </div>`,
4016
4329
  async () => undefined,
4017
4330
  "AI 分析详情",
4018
- { submitLabel: "关闭", wide: true });
4331
+ { submitLabel: "关闭", wide: true, trace: true });
4019
4332
  }
4020
4333
 
4021
4334
  function renderProviderCards(providers, models) {
@@ -4518,39 +4831,78 @@ function commitRelationshipKeywordInputs(container) {
4518
4831
 
4519
4832
  function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
4520
4833
  void discardPendingMarkdownAttachments();
4834
+ const dialog = $("#form-dialog");
4835
+ const form = $("#dynamic-form");
4836
+ const submit = $("#dialog-submit");
4837
+ const submitStatus = $("#dialog-submit-status");
4838
+ const submitStatusMessage = $("#dialog-submit-status-message");
4839
+ const submitLabel = options.submitLabel ?? "保存";
4840
+ let submitting = false;
4841
+ let disabledStates = [];
4521
4842
  $("#dialog-title").textContent = title;
4522
4843
  $("#dialog-eyebrow").textContent = eyebrow;
4523
4844
  $("#dialog-meta").textContent = options.meta ?? "";
4524
4845
  $("#dialog-meta").classList.toggle("hidden", !options.meta);
4525
4846
  $("#dialog-fields").innerHTML = fields;
4526
- $("#dialog-submit").textContent = options.submitLabel ?? "保存";
4847
+ submit.textContent = submitLabel;
4848
+ submitStatusMessage.textContent = options.pendingMessage ?? "正在提交,请稍候";
4849
+ submitStatus.classList.add("hidden");
4850
+ form.classList.remove("is-submitting");
4851
+ form.removeAttribute("aria-busy");
4527
4852
  $("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
4528
- $("#form-dialog").classList.toggle("wide-dialog", Boolean(options.wide));
4853
+ dialog.classList.toggle("wide-dialog", Boolean(options.wide));
4854
+ dialog.classList.toggle("trace-dialog", Boolean(options.trace));
4529
4855
  bindDynamicListControls($("#dialog-fields"));
4530
4856
  bindRelationshipKeywordControls($("#dialog-fields"));
4531
4857
  bindVditorEditors($("#dialog-fields"));
4532
- const form = $("#dynamic-form");
4858
+ form.onclick = null;
4859
+ form.onkeydown = null;
4860
+ dialog.oncancel = (event) => {
4861
+ if (submitting) event.preventDefault();
4862
+ };
4533
4863
  form.onsubmit = async (event) => {
4864
+ if (submitting) {
4865
+ event.preventDefault();
4866
+ return;
4867
+ }
4534
4868
  if (event.submitter?.value === "cancel") {
4535
4869
  void discardPendingMarkdownAttachments();
4536
4870
  return;
4537
4871
  }
4538
4872
  event.preventDefault();
4539
- const submit = $("#dialog-submit");
4540
- submit.disabled = true;
4873
+ submitting = true;
4874
+ form.setAttribute("aria-busy", "true");
4875
+ form.classList.add("is-submitting");
4876
+ submitStatus.classList.remove("hidden");
4877
+ submit.textContent = options.pendingLabel ?? "处理中…";
4541
4878
  try {
4542
4879
  commitRelationshipKeywordInputs(form);
4543
- await onSubmit(new FormData(form));
4880
+ const formData = new FormData(form);
4881
+ disabledStates = [...form.elements].map((control) => [control, control.disabled]);
4882
+ disabledStates.forEach(([control]) => {
4883
+ control.disabled = true;
4884
+ });
4885
+ await onSubmit(formData);
4544
4886
  const markdown = [...form.querySelectorAll("[data-vditor-value]")].map((textarea) => textarea.value).join("\n\n");
4545
4887
  await cleanupPendingMarkdownAttachments(markdown);
4546
- $("#form-dialog").close();
4888
+ dialog.close();
4547
4889
  } catch (error) {
4548
- toast(error.message, "error");
4890
+ const message = error instanceof Error ? error.message : "未知错误";
4891
+ toast(`${options.errorPrefix ?? ""}${message}`, "error");
4549
4892
  } finally {
4550
- submit.disabled = false;
4893
+ disabledStates.forEach(([control, wasDisabled]) => {
4894
+ control.disabled = wasDisabled;
4895
+ });
4896
+ disabledStates = [];
4897
+ submitting = false;
4898
+ form.removeAttribute("aria-busy");
4899
+ form.classList.remove("is-submitting");
4900
+ submitStatus.classList.add("hidden");
4901
+ submit.textContent = submitLabel;
4551
4902
  }
4552
4903
  };
4553
- $("#form-dialog").showModal();
4904
+ dialog.showModal();
4905
+ $("#dialog-fields").scrollTop = 0;
4554
4906
  }
4555
4907
 
4556
4908
  function openWorkDialog() {
@@ -5922,32 +6274,165 @@ function openReviewDialog() {
5922
6274
  });
5923
6275
  }
5924
6276
 
5925
- function openTaskDialog() {
6277
+ async function openTaskDialog() {
5926
6278
  const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
6279
+ let relationshipCharacters = [];
6280
+ try {
6281
+ relationshipCharacters = canReadModule("characters")
6282
+ ? await apiAllPages(`/api/works/${state.work.id}/characters`)
6283
+ : [];
6284
+ } catch (error) {
6285
+ toast(`角色列表加载失败:${error.message}`, "error");
6286
+ return;
6287
+ }
6288
+ const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
6289
+ const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
6290
+ <span id="relationship-character-label">被分析角色(可多选)</span>
6291
+ <div class="relationship-character-picker">
6292
+ <button class="relationship-character-trigger" type="button" aria-expanded="false" aria-controls="relationship-character-bubble" aria-labelledby="relationship-character-label relationship-character-summary">
6293
+ <span id="relationship-character-summary">选择需要定向分析的角色</span>
6294
+ <span class="relationship-character-trigger-meta"><span data-relationship-character-count>未选择</span><span class="relationship-character-chevron" aria-hidden="true">⌄</span></span>
6295
+ </button>
6296
+ <div id="relationship-character-bubble" class="relationship-character-bubble hidden">
6297
+ <label class="relationship-character-search">筛选角色<input type="search" data-relationship-character-search placeholder="输入角色名" autocomplete="off"></label>
6298
+ <div class="relationship-character-bubble-meta"><span>共 ${characterOptions.length} 个角色</span><button type="button" data-relationship-character-clear disabled>清空选择</button></div>
6299
+ <div class="relationship-character-options" role="group" aria-labelledby="relationship-character-label">
6300
+ ${characterOptions.map(([characterId, characterName]) => `<label class="relationship-character-chip" data-character-search-name="${esc(String(characterName).toLocaleLowerCase())}"><input type="checkbox" name="characterIds" value="${esc(characterId)}" data-character-name="${esc(characterName)}"><span>${esc(characterName)}</span></label>`).join("")}
6301
+ </div>
6302
+ <p class="relationship-character-empty hidden" data-relationship-character-empty>没有匹配的角色</p>
6303
+ </div>
6304
+ </div>
6305
+ </div>`;
5927
6306
  const defaultTaskType = ANALYSIS_TYPES[0].value;
5928
6307
  const taskTypeField = `<div class="form-field analysis-type-field"><label>分析类型<select name="taskType" aria-describedby="analysis-type-description">${ANALYSIS_TYPES.map(({ value, label }) => `<option value="${esc(value)}" ${value === defaultTaskType ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label><p id="analysis-type-description" class="analysis-type-description" aria-live="polite">${esc(analysisTypeDescription(defaultTaskType))}</p></div>`;
5929
6308
  const chapterField = `<label class="task-chapter-field">章节<select name="chapterId">${chapterOptions.map(([key, text], index) => `<option value="${esc(key)}" ${index === 0 ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
5930
- openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField, async (form) => {
5931
- const scope = form.get("taskType") === "character-identity-audit" || form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
5932
- await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
5933
- await renderTasks();
6309
+ const relationshipFields = `<div class="relationship-analysis-options hidden">
6310
+ ${relationshipCharacterPicker}
6311
+ <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。</span></p>
6312
+ <div class="relationship-overwrite-card hidden">
6313
+ <label class="checkbox-field"><input name="replaceExistingRelationships" type="checkbox" disabled><span>用本次结果覆盖所选角色的已有关系</span></label>
6314
+ <p>任务成功后,会先删除所有涉及所选角色的旧关系,再写入本次分析结果。</p>
6315
+ </div>
6316
+ <label>额外分析提示<textarea name="additionalPrompt" maxlength="10000" placeholder="例如:重点识别权力继承、师承变化或隐秘亲缘关系"></textarea><small>将同时追加到证据收集和全局关系归纳提示词,仅影响本次任务。</small></label>
6317
+ </div>`;
6318
+ openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
6319
+ const taskType = String(form.get("taskType"));
6320
+ const scopeType = String(form.get("scopeType"));
6321
+ const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
6322
+ const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
6323
+ const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
6324
+ const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
6325
+ const scope = taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
6326
+ ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6327
+ : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
6328
+ await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope } });
6329
+ taskListPage = 1;
6330
+ toast("分析任务已创建,已进入任务队列");
6331
+ void renderTasks(1).catch((error) => toast(`任务已创建,但列表刷新失败:${error.message}`, "error"));
6332
+ }, "AI 分析", {
6333
+ submitLabel: "创建任务",
6334
+ pendingLabel: "创建中…",
6335
+ pendingMessage: "正在创建分析任务,请稍候",
6336
+ errorPrefix: "任务创建失败:"
5934
6337
  });
5935
6338
  const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
5936
6339
  const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
5937
6340
  const chapterSelect = $("#dialog-fields").querySelector('select[name="chapterId"]');
5938
6341
  const chapterFieldElement = chapterSelect.closest(".task-chapter-field");
5939
6342
  const description = $("#analysis-type-description");
6343
+ const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
6344
+ const relationshipPrompt = relationshipOptions.querySelector('textarea[name="additionalPrompt"]');
6345
+ const relationshipCharacterPickerElement = relationshipOptions.querySelector(".relationship-character-picker");
6346
+ const relationshipCharacterTrigger = relationshipOptions.querySelector(".relationship-character-trigger");
6347
+ const relationshipCharacterBubble = relationshipOptions.querySelector(".relationship-character-bubble");
6348
+ const relationshipCharacterSearch = relationshipOptions.querySelector("[data-relationship-character-search]");
6349
+ const relationshipCharacterInputs = [...relationshipOptions.querySelectorAll('input[name="characterIds"]')];
6350
+ const relationshipCharacterSummary = relationshipOptions.querySelector("#relationship-character-summary");
6351
+ const relationshipCharacterCount = relationshipOptions.querySelector("[data-relationship-character-count]");
6352
+ const relationshipCharacterClear = relationshipOptions.querySelector("[data-relationship-character-clear]");
6353
+ const relationshipCharacterEmpty = relationshipOptions.querySelector("[data-relationship-character-empty]");
6354
+ const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
6355
+ const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-overwrite-card");
6356
+ const allSettingsOption = document.createElement("option");
6357
+ allSettingsOption.value = "book-with-settings";
6358
+ allSettingsOption.textContent = "全书 + 所有设定";
5940
6359
  const syncChapterField = () => {
5941
- const disabled = scopeTypeSelect.value === "book";
6360
+ const disabled = scopeTypeSelect.value !== "chapter";
5942
6361
  chapterSelect.disabled = disabled;
5943
6362
  chapterFieldElement.classList.toggle("is-disabled", disabled);
5944
6363
  chapterFieldElement.setAttribute("aria-disabled", String(disabled));
5945
6364
  };
6365
+ const setRelationshipCharacterBubbleOpen = (open) => {
6366
+ relationshipCharacterBubble.classList.toggle("hidden", !open);
6367
+ relationshipCharacterTrigger.setAttribute("aria-expanded", String(open));
6368
+ if (open) relationshipCharacterSearch.focus();
6369
+ };
6370
+ const syncRelationshipCharacterPicker = () => {
6371
+ const selected = relationshipCharacterInputs.filter((input) => input.checked);
6372
+ const selectedNames = selected.map((input) => input.dataset.characterName);
6373
+ relationshipCharacterSummary.textContent = selectedNames.length
6374
+ ? `${selectedNames.slice(0, 2).join("、")}${selectedNames.length > 2 ? ` 等 ${selectedNames.length} 人` : ""}`
6375
+ : "选择需要定向分析的角色";
6376
+ relationshipCharacterCount.textContent = selected.length ? `已选 ${selected.length}` : "未选择";
6377
+ relationshipCharacterClear.disabled = selected.length === 0;
6378
+ relationshipCharacterTrigger.setAttribute("aria-label", `筛选被分析角色,已选择 ${selected.length} 个`);
6379
+ };
6380
+ const filterRelationshipCharacters = () => {
6381
+ const query = relationshipCharacterSearch.value.trim().toLocaleLowerCase();
6382
+ let visibleCount = 0;
6383
+ for (const input of relationshipCharacterInputs) {
6384
+ const chip = input.closest(".relationship-character-chip");
6385
+ const visible = !query || chip.dataset.characterSearchName.includes(query);
6386
+ chip.classList.toggle("hidden", !visible);
6387
+ if (visible) visibleCount += 1;
6388
+ }
6389
+ relationshipCharacterEmpty.classList.toggle("hidden", visibleCount > 0);
6390
+ };
6391
+ const syncRelationshipOptions = () => {
6392
+ const enabled = taskTypeSelect.value === "relationship-analysis";
6393
+ if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
6394
+ if (!enabled && allSettingsOption.isConnected) {
6395
+ if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
6396
+ allSettingsOption.remove();
6397
+ }
6398
+ relationshipOptions.classList.toggle("hidden", !enabled);
6399
+ relationshipPrompt.disabled = !enabled;
6400
+ relationshipCharacterTrigger.disabled = !enabled;
6401
+ relationshipCharacterSearch.disabled = !enabled;
6402
+ for (const input of relationshipCharacterInputs) input.disabled = !enabled;
6403
+ if (!enabled) setRelationshipCharacterBubbleOpen(false);
6404
+ const hasSelectedCharacters = enabled && relationshipCharacterInputs.some((input) => input.checked);
6405
+ replaceRelationships.disabled = !hasSelectedCharacters;
6406
+ relationshipOverwriteCard.classList.toggle("hidden", !hasSelectedCharacters);
6407
+ if (!hasSelectedCharacters) replaceRelationships.checked = false;
6408
+ syncRelationshipCharacterPicker();
6409
+ filterRelationshipCharacters();
6410
+ syncChapterField();
6411
+ };
5946
6412
  taskTypeSelect.addEventListener("change", () => {
5947
6413
  description.textContent = analysisTypeDescription(taskTypeSelect.value);
6414
+ syncRelationshipOptions();
5948
6415
  });
6416
+ relationshipCharacterTrigger.addEventListener("click", () => {
6417
+ setRelationshipCharacterBubbleOpen(relationshipCharacterTrigger.getAttribute("aria-expanded") !== "true");
6418
+ });
6419
+ relationshipCharacterSearch.addEventListener("input", filterRelationshipCharacters);
6420
+ for (const input of relationshipCharacterInputs) input.addEventListener("change", syncRelationshipOptions);
6421
+ relationshipCharacterClear.addEventListener("click", () => {
6422
+ for (const input of relationshipCharacterInputs) input.checked = false;
6423
+ syncRelationshipOptions();
6424
+ });
6425
+ $("#dynamic-form").onclick = (event) => {
6426
+ if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
6427
+ };
6428
+ $("#dynamic-form").onkeydown = (event) => {
6429
+ if (event.key !== "Escape" || relationshipCharacterBubble.classList.contains("hidden")) return;
6430
+ event.preventDefault();
6431
+ setRelationshipCharacterBubbleOpen(false);
6432
+ relationshipCharacterTrigger.focus();
6433
+ };
5949
6434
  scopeTypeSelect.addEventListener("change", syncChapterField);
5950
- syncChapterField();
6435
+ syncRelationshipOptions();
5951
6436
  }
5952
6437
 
5953
6438
  function openProviderDialog(item) {
@@ -6038,7 +6523,7 @@ async function sendAi() {
6038
6523
  } else {
6039
6524
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
6040
6525
  assistantContent = suggestion.content;
6041
- assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens };
6526
+ assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
6042
6527
  }
6043
6528
  try {
6044
6529
  const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
@@ -6134,9 +6619,9 @@ async function streamChat(body) {
6134
6619
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
6135
6620
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
6136
6621
  const processDurationMs = elapsedProcessTime();
6137
- generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, toolCalls, processSteps, processDurationMs };
6622
+ generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, cacheHitPercent: payload.cacheHitPercent, toolCalls, processSteps, processDurationMs };
6138
6623
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
6139
- meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens);
6624
+ meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent);
6140
6625
  attachAssistantCopyAction(message, streamedText);
6141
6626
  scrollAiFeedToBottom();
6142
6627
  } else if (eventName === "error") {
@@ -6191,7 +6676,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
6191
6676
  const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
6192
6677
  const meta = document.createElement("div");
6193
6678
  meta.className = "message-meta";
6194
- meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens);
6679
+ meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent);
6195
6680
  message.append(meta);
6196
6681
  attachAssistantCopyAction(message, text);
6197
6682
  }
@@ -6206,7 +6691,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
6206
6691
  const applicable = suggestion.action !== "note";
6207
6692
  const guard = suggestion.guard;
6208
6693
  const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
6209
- message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
6694
+ message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
6210
6695
  attachMessageHeading(message, "助手建议", createdAt ?? undefined);
6211
6696
  attachAssistantCopyAction(message, suggestion.content);
6212
6697
  attachMessageIdentity(message, messageId);
@@ -6346,7 +6831,7 @@ async function loadImportHistoryPage(page) {
6346
6831
  const workId = state.work?.id;
6347
6832
  if (!workId || !page) return;
6348
6833
  const requestId = ++importHistoryRequestId;
6349
- const result = await apiPage(`/api/works/${encodeURIComponent(workId)}/file-versions`, page, 25);
6834
+ const result = await apiPage(`/api/works/${encodeURIComponent(workId)}/file-versions`, page, pageSizeFor("fileVersions"));
6350
6835
  if (requestId !== importHistoryRequestId || state.work?.id !== workId || !$("#import-history-dialog").open) return;
6351
6836
  importHistoryRecords = page === 1 ? result.items : [...importHistoryRecords, ...result.items];
6352
6837
  importHistoryNextPage = result.nextPage;
@@ -6947,11 +7432,20 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
6947
7432
  try {
6948
7433
  const settings = await api("/api/platform/ui-settings", {
6949
7434
  method: "PATCH",
6950
- body: { toastPosition: $("#toast-position").value }
7435
+ body: {
7436
+ toastPosition: $("#toast-position").value,
7437
+ pageSizes: {
7438
+ characters: Number($("#page-size-characters").value),
7439
+ analysisTasks: Number($("#page-size-analysis-tasks").value),
7440
+ fileVersions: Number($("#page-size-file-versions").value)
7441
+ }
7442
+ }
6951
7443
  });
6952
7444
  applyPlatformUiSettings(settings);
7445
+ characterListPage = 1;
7446
+ taskListPage = 1;
6953
7447
  $("#platform-ui-settings-dialog").close();
6954
- toast("界面通知设置已保存");
7448
+ toast("界面与分页设置已保存");
6955
7449
  } catch (error) {
6956
7450
  toast(error.message, "error");
6957
7451
  } finally {