@musnows/scriverse 0.4.12 → 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";
@@ -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,
@@ -106,6 +127,7 @@ let collaborationAutoSaveDisabled = false;
106
127
  let timelineMultiSelectEnabled = false;
107
128
  let taskProgressRefreshTimer = null;
108
129
  const taskProgressRefreshInterval = 2_500;
130
+ const taskStatusSnapshots = new Map();
109
131
 
110
132
  const chapterTypes = ["正文", "设定", "作者的话", "其他"];
111
133
 
@@ -137,6 +159,41 @@ function analysisTaskStatusLabel(status) {
137
159
  })[String(status)] ?? "未知状态";
138
160
  }
139
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
+
140
197
  function reviewSeverityLabel(severity) {
141
198
  return levelLabel(severity);
142
199
  }
@@ -776,8 +833,11 @@ let entityEditorDirty = false;
776
833
  let entityEditorReadOnly = false;
777
834
  let chapterEditorReadOnly = true;
778
835
  let characterListPage = 1;
836
+ let taskListPage = 1;
779
837
  const characterFilters = { raceIds: [], organizationIds: [] };
780
838
  let characterFiltersPanelOpen = false;
839
+ const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
840
+ let relationshipFiltersPanelOpen = false;
781
841
  let settingEditorItem = null;
782
842
  let characterEditorItem = null;
783
843
  let knowledgeEditorItem = null;
@@ -1891,7 +1951,7 @@ async function api(path, options = {}) {
1891
1951
  return payload.data;
1892
1952
  }
1893
1953
 
1894
- async function apiPage(path, page = 1, limit = 50) {
1954
+ async function apiPage(path, page = 1, limit = 30) {
1895
1955
  const separator = path.includes("?") ? "&" : "?";
1896
1956
  const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
1897
1957
  if (Array.isArray(result)) return { items: result, page, limit, hasMore: false, nextPage: null };
@@ -2000,9 +2060,14 @@ function applyAuthenticatedUser(session) {
2000
2060
 
2001
2061
  function applyPlatformUiSettings(settings) {
2002
2062
  const position = settings?.toastPosition === "top-right" ? "top-right" : "bottom-right";
2063
+ state.uiSettings = { toastPosition: position, pageSizes: normalizePageSizes(settings?.pageSizes) };
2003
2064
  $("#toast-region").dataset.position = position;
2004
2065
  }
2005
2066
 
2067
+ function pageSizeFor(module) {
2068
+ return normalizePageSize(state.uiSettings.pageSizes[module], defaultPageSizes[module] ?? 30);
2069
+ }
2070
+
2006
2071
  async function loadPlatformUiSettings() {
2007
2072
  try {
2008
2073
  applyPlatformUiSettings(await api("/api/ui-settings"));
@@ -2495,6 +2560,10 @@ async function openPlatformUiSettingsDialog() {
2495
2560
  try {
2496
2561
  const settings = await api("/api/platform/ui-settings");
2497
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);
2498
2567
  $("#platform-ui-settings-dialog").showModal();
2499
2568
  } catch (error) {
2500
2569
  toast(error.message, "error");
@@ -2645,36 +2714,22 @@ async function runWorkSearch() {
2645
2714
  }
2646
2715
 
2647
2716
  async function openSearchResult(result) {
2717
+ const target = resolveGlobalSearchTarget(result);
2718
+ if (!target) throw new Error("无法打开该搜索结果");
2648
2719
  $("#search-dialog").close();
2649
2720
  const inSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
2650
2721
  if (inSettings) await returnFromSettings();
2651
- if (result.type === "chapter") {
2652
- await selectChapter(result.id);
2653
- return;
2654
- }
2655
- if (result.type === "character") {
2656
- await showModule("characters");
2657
- const character = state.characters.find((item) => item.id === result.id);
2658
- if (character) openCharacterEditor(character);
2659
- return;
2660
- }
2661
- if (result.type === "setting") {
2662
- await showModule("settings");
2663
- const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
2664
- openSettingEditor(setting);
2665
- return;
2666
- }
2667
- if (result.type === "race") {
2668
- await showModule("races");
2669
- const race = state.races.find((item) => item.id === result.id);
2670
- if (race) openRaceDialog(race);
2722
+ if (target.kind === "chapter") {
2723
+ await selectChapter(target.id);
2671
2724
  return;
2672
2725
  }
2673
- if (result.type === "organization") {
2674
- await showModule("organizations");
2675
- const organization = state.organizations.find((item) => item.id === result.id);
2676
- if (organization) openOrganizationDialog(organization);
2677
- }
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 });
2678
2733
  }
2679
2734
 
2680
2735
  async function showSettingsHub() {
@@ -2772,6 +2827,9 @@ function resetWorkScopedUiCaches() {
2772
2827
  state.characters = [];
2773
2828
  state.settings = [];
2774
2829
  characterListPage = 1;
2830
+ relationshipFilters.fromCharacterIds = [];
2831
+ relationshipFilters.toCharacterIds = [];
2832
+ taskListPage = 1;
2775
2833
  state.collapsedVolumeIds.clear();
2776
2834
  state.collapsedRaceIds.clear();
2777
2835
  lastSavedChapterSnapshot = null;
@@ -3024,7 +3082,7 @@ async function showModule(module) {
3024
3082
  if (module === "outlines") await renderOutlines();
3025
3083
  if (module === "relationships") await renderRelationships();
3026
3084
  if (module === "reviews") await renderReviews();
3027
- if (module === "tasks") await renderTasks();
3085
+ if (module === "tasks") await renderTasks(taskListPage);
3028
3086
  if (module === "ai-settings") await renderBookAiSettings();
3029
3087
  } catch (error) {
3030
3088
  $("#module-content").innerHTML = `<div class="empty-state"><b>载入失败</b>${esc(error.message)}</div>`;
@@ -3319,6 +3377,15 @@ function mountCharacterFilterToggle() {
3319
3377
  });
3320
3378
  }
3321
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
+
3322
3389
  function bindRecordPreview(selector, open) {
3323
3390
  $("#module-content").querySelectorAll(selector).forEach((card) => {
3324
3391
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -3439,13 +3506,14 @@ async function renderSettings() {
3439
3506
 
3440
3507
  async function renderCharacters(page = characterListPage) {
3441
3508
  const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
3509
+ const pageSize = pageSizeFor("characters");
3442
3510
  const [characterSource, races, organizations] = await Promise.all([
3443
- 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),
3444
3512
  canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
3445
3513
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
3446
3514
  ]);
3447
3515
  const characterPage = hasCharacterFilters
3448
- ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, 50)
3516
+ ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
3449
3517
  : characterSource;
3450
3518
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3451
3519
  characterListPage = characterPage.page;
@@ -3748,17 +3816,34 @@ async function renderOutlines() {
3748
3816
  async function renderRelationships() {
3749
3817
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3750
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;
3751
3821
  const canEditRelationships = canEditModule("relationships");
3752
- mountModuleCount(relationships.length);
3822
+ mountModuleCount(filteredRelationships.length);
3753
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();
3754
3838
  state.galaxy?.destroy();
3755
3839
  state.relationshipExpandedMap?.destroy?.();
3756
3840
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
3757
3841
  const graph = buildRelationshipGraph(state.characters, relationships);
3758
3842
  state.relationshipGraph = graph;
3759
- $("#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) => `
3760
3844
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
3761
- <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}`;
3762
3847
  const openGalaxy = () => {
3763
3848
  state.galaxy?.destroy();
3764
3849
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -3774,7 +3859,24 @@ async function renderRelationships() {
3774
3859
  };
3775
3860
  state.relationshipMindMap?.destroy?.();
3776
3861
  state.relationshipMindMap = renderRelationshipMindMap($("#relationship-map-host"), graph, { onOpenGalaxy: openGalaxy, onOpenExpanded: openExpanded });
3777
- $("#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))));
3778
3880
  bindEntityHistoryButtons(async () => { await renderRelationships(); await loadAiReferences(); });
3779
3881
  }
3780
3882
 
@@ -3870,23 +3972,36 @@ async function renderReviews() {
3870
3972
  }));
3871
3973
  }
3872
3974
 
3873
- async function renderTasks() {
3975
+ async function renderTasks(page = taskListPage) {
3874
3976
  stopTaskProgressRefresh();
3875
- const [tasks, settings] = await Promise.all([
3876
- apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
3977
+ const pageSize = pageSizeFor("analysisTasks");
3978
+ const [taskPage, settings] = await Promise.all([
3979
+ apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
3877
3980
  canReadModule("ai-settings")
3878
3981
  ? api(`/api/works/${state.work.id}/ai-settings`)
3879
3982
  : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
3880
3983
  ]);
3881
- 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);
3882
3989
  const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
3883
- const pendingCount = tasks.filter((item) => item.status === "pending").length;
3884
- const runningTasks = tasks.filter((item) => item.status === "running");
3885
- const runningCount = runningTasks.length;
3990
+ const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
3991
+ const runningCount = Number(taskPage.stats?.runningCount ?? 0);
3886
3992
  const activeTaskCount = pendingCount + runningCount;
3887
- const runningProgress = runningCount
3888
- ? Math.round(runningTasks.reduce((total, item) => total + Math.min(100, Math.max(0, Number(item.progress) || 0)), 0) / runningCount)
3889
- : 0;
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
+ : "";
3890
4005
  $("#module-content").innerHTML = `
3891
4006
  <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
3892
4007
  <div class="task-auto-run-copy">
@@ -3904,21 +4019,27 @@ async function renderTasks() {
3904
4019
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个</p>
3905
4020
  <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
3906
4021
  <div class="task-auto-run-progress-label"><span>${runningCount ? "运行中任务平均进度" : "等待任务开始"}</span><strong>${runningProgress}%</strong></div>
3907
- <progress class="task-auto-run-progress-bar" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
4022
+ <progress class="task-auto-run-progress-bar ${runningCount ? "is-running" : "is-waiting"}" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
3908
4023
  </div>
3909
4024
  </section>
3910
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) => `
3911
4026
  <tr>
3912
4027
  <td>${esc(analysisTaskTypeLabel(item.taskType))}</td>
3913
4028
  <td>${esc(item.scopeSummary || taskScopeLabel(item.scope?.type || "book"))}</td>
3914
- <td>${esc(analysisTaskStatusLabel(item.status))}</td>
3915
- <td>${Number(item.progress ?? 0)}%</td>
4029
+ <td class="task-status-cell">${renderAnalysisTaskStatus(item)}</td>
4030
+ <td class="task-progress-cell">${renderAnalysisTaskProgress(item)}</td>
3916
4031
  <td class="task-row-actions">
3917
4032
  <button class="ghost-button" type="button" data-task-detail="${esc(item.id)}">详情</button>
3918
4033
  ${item.status === "pending" ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
3919
4034
  ${item.status === "pending" || item.status === "running" ? `<button class="ghost-button" type="button" data-cancel-task="${esc(item.id)}">取消</button>` : ""}
3920
4035
  </td>
3921
- </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
+ }));
3922
4043
 
3923
4044
  $("#task-auto-run-save")?.addEventListener("click", async () => {
3924
4045
  const button = $("#task-auto-run-save");
@@ -3957,8 +4078,15 @@ async function renderTasks() {
3957
4078
  $("#module-content").querySelectorAll("[data-task-detail]").forEach((button) => button.addEventListener("click", () => {
3958
4079
  if (button.disabled) return;
3959
4080
  button.disabled = true;
3960
- api(`/api/tasks/${encodeURIComponent(button.dataset.taskDetail)}`)
3961
- .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))
3962
4090
  .catch((error) => toast(error.message, "error"))
3963
4091
  .finally(() => { button.disabled = false; });
3964
4092
  }));
@@ -3967,6 +4095,12 @@ async function renderTasks() {
3967
4095
  try {
3968
4096
  button.disabled = true;
3969
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);
3970
4104
  const cancel = button.parentElement.querySelector("[data-cancel-task]");
3971
4105
  if (cancel) cancel.textContent = "取消运行";
3972
4106
  scheduleTaskProgressRefresh(workId, 1);
@@ -4017,7 +4151,143 @@ function scheduleTaskProgressRefresh(workId, runningCount) {
4017
4151
  }, taskProgressRefreshInterval);
4018
4152
  }
4019
4153
 
4020
- function openTaskDetailDialog(task) {
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>`;
4174
+ }
4175
+
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) {
4021
4291
  if (!task) return;
4022
4292
  const details = Array.isArray(task.scopeDetails) ? task.scopeDetails : [];
4023
4293
  const detailHtml = details.map((item) => {
@@ -4044,18 +4314,21 @@ function openTaskDetailDialog(task) {
4044
4314
  : "<p>尚无结果</p>";
4045
4315
  openDialog("任务详情",
4046
4316
  `<div class="task-detail">
4047
- <p><strong>任务 ID</strong><br><code>${esc(task.id)}</code></p>
4048
- <p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}</p>
4049
- <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
4050
- <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
4051
- <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
4052
- <div><strong>失败信息</strong>${failureHtml}</div>
4053
- <div><strong>结果摘要</strong>${resultPreview}</div>
4054
- <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)}
4055
4328
  </div>`,
4056
4329
  async () => undefined,
4057
4330
  "AI 分析详情",
4058
- { submitLabel: "关闭", wide: true });
4331
+ { submitLabel: "关闭", wide: true, trace: true });
4059
4332
  }
4060
4333
 
4061
4334
  function renderProviderCards(providers, models) {
@@ -4558,41 +4831,78 @@ function commitRelationshipKeywordInputs(container) {
4558
4831
 
4559
4832
  function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
4560
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 = [];
4561
4842
  $("#dialog-title").textContent = title;
4562
4843
  $("#dialog-eyebrow").textContent = eyebrow;
4563
4844
  $("#dialog-meta").textContent = options.meta ?? "";
4564
4845
  $("#dialog-meta").classList.toggle("hidden", !options.meta);
4565
4846
  $("#dialog-fields").innerHTML = fields;
4566
- $("#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");
4567
4852
  $("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
4568
- $("#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));
4569
4855
  bindDynamicListControls($("#dialog-fields"));
4570
4856
  bindRelationshipKeywordControls($("#dialog-fields"));
4571
4857
  bindVditorEditors($("#dialog-fields"));
4572
- const form = $("#dynamic-form");
4573
4858
  form.onclick = null;
4574
4859
  form.onkeydown = null;
4860
+ dialog.oncancel = (event) => {
4861
+ if (submitting) event.preventDefault();
4862
+ };
4575
4863
  form.onsubmit = async (event) => {
4864
+ if (submitting) {
4865
+ event.preventDefault();
4866
+ return;
4867
+ }
4576
4868
  if (event.submitter?.value === "cancel") {
4577
4869
  void discardPendingMarkdownAttachments();
4578
4870
  return;
4579
4871
  }
4580
4872
  event.preventDefault();
4581
- const submit = $("#dialog-submit");
4582
- 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 ?? "处理中…";
4583
4878
  try {
4584
4879
  commitRelationshipKeywordInputs(form);
4585
- 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);
4586
4886
  const markdown = [...form.querySelectorAll("[data-vditor-value]")].map((textarea) => textarea.value).join("\n\n");
4587
4887
  await cleanupPendingMarkdownAttachments(markdown);
4588
- $("#form-dialog").close();
4888
+ dialog.close();
4589
4889
  } catch (error) {
4590
- toast(error.message, "error");
4890
+ const message = error instanceof Error ? error.message : "未知错误";
4891
+ toast(`${options.errorPrefix ?? ""}${message}`, "error");
4591
4892
  } finally {
4592
- 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;
4593
4902
  }
4594
4903
  };
4595
- $("#form-dialog").showModal();
4904
+ dialog.showModal();
4905
+ $("#dialog-fields").scrollTop = 0;
4596
4906
  }
4597
4907
 
4598
4908
  function openWorkDialog() {
@@ -6016,7 +6326,14 @@ async function openTaskDialog() {
6016
6326
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6017
6327
  : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
6018
6328
  await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope } });
6019
- await renderTasks();
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: "任务创建失败:"
6020
6337
  });
6021
6338
  const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
6022
6339
  const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
@@ -6514,7 +6831,7 @@ async function loadImportHistoryPage(page) {
6514
6831
  const workId = state.work?.id;
6515
6832
  if (!workId || !page) return;
6516
6833
  const requestId = ++importHistoryRequestId;
6517
- 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"));
6518
6835
  if (requestId !== importHistoryRequestId || state.work?.id !== workId || !$("#import-history-dialog").open) return;
6519
6836
  importHistoryRecords = page === 1 ? result.items : [...importHistoryRecords, ...result.items];
6520
6837
  importHistoryNextPage = result.nextPage;
@@ -7115,11 +7432,20 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
7115
7432
  try {
7116
7433
  const settings = await api("/api/platform/ui-settings", {
7117
7434
  method: "PATCH",
7118
- 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
+ }
7119
7443
  });
7120
7444
  applyPlatformUiSettings(settings);
7445
+ characterListPage = 1;
7446
+ taskListPage = 1;
7121
7447
  $("#platform-ui-settings-dialog").close();
7122
- toast("界面通知设置已保存");
7448
+ toast("界面与分页设置已保存");
7123
7449
  } catch (error) {
7124
7450
  toast(error.message, "error");
7125
7451
  } finally {