@musnows/scriverse 0.6.1 → 0.6.3
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.
- package/README.en.md +3 -2
- package/README.md +3 -2
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +267 -54
- package/dist/ai.js.map +1 -1
- package/dist/app.js +256 -57
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +9 -1
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +4 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +23 -7
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +7 -5
- package/dist/credential-vault.js.map +1 -1
- package/dist/database.js +243 -2
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +89 -0
- package/dist/docx-export.js.map +1 -0
- package/dist/domain.js +9 -0
- package/dist/domain.js.map +1 -1
- package/dist/image-captcha.js +8 -5
- package/dist/image-captcha.js.map +1 -1
- package/dist/public/ai-context-meter.js +4 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +765 -111
- package/dist/public/index.html +49 -17
- package/dist/public/markdown.js +1 -2
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +87 -29
- package/dist/public/system-status.d.ts +12 -0
- package/dist/public/system-status.js +16 -0
- package/dist/public/theme-init.js +2 -2
- package/dist/security.js +143 -16
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +56 -1
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +289 -39
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +56 -88
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +15 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/public/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
|
-
import { renderMarkdown } from "/markdown.js?v=
|
|
3
|
+
import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
|
|
4
4
|
import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
|
|
5
5
|
import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
|
|
6
6
|
import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
@@ -10,8 +10,9 @@ import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-s
|
|
|
10
10
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
|
|
11
11
|
import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
|
|
12
12
|
import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
|
|
13
|
-
import { formatAiMessageTime } from "/ai-message-time.js?v=
|
|
14
|
-
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=
|
|
13
|
+
import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
|
|
14
|
+
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260801-retain-usage-v1";
|
|
15
|
+
import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
|
|
15
16
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
16
17
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
17
18
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
@@ -49,6 +50,7 @@ import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20
|
|
|
49
50
|
import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
|
|
50
51
|
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
|
|
51
52
|
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
53
|
+
import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
|
|
52
54
|
import {
|
|
53
55
|
clampCropRect,
|
|
54
56
|
containImageRect,
|
|
@@ -152,6 +154,7 @@ function createPresenceClientId() {
|
|
|
152
154
|
|
|
153
155
|
const presenceClientId = createPresenceClientId();
|
|
154
156
|
const presenceHeartbeatInterval = 12_000;
|
|
157
|
+
const systemBootCheckInterval = 8_000;
|
|
155
158
|
let presenceParticipants = [];
|
|
156
159
|
let presenceHeartbeatTimer = null;
|
|
157
160
|
let presenceHeartbeatQueued = null;
|
|
@@ -160,6 +163,10 @@ const acknowledgedCollaborativeChangeIds = new Set();
|
|
|
160
163
|
let collaborativeChangePromptOpen = false;
|
|
161
164
|
let relationshipPresenceId = null;
|
|
162
165
|
let collaborationAutoSaveDisabled = false;
|
|
166
|
+
let systemBootId = null;
|
|
167
|
+
let systemBootCheckTimer = null;
|
|
168
|
+
let systemBootCheckPromise = null;
|
|
169
|
+
let systemRestartDetected = false;
|
|
163
170
|
let chapterAnnotations = [];
|
|
164
171
|
let workAuditRecords = [];
|
|
165
172
|
let workAuditNextPage = null;
|
|
@@ -177,6 +184,10 @@ let backgroundTaskCenterWorkId = null;
|
|
|
177
184
|
let backgroundTaskCenterTasksInitialized = false;
|
|
178
185
|
let backgroundTaskCenterTaskSnapshots = new Map();
|
|
179
186
|
let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
|
|
187
|
+
const systemHealthPollInterval = 30_000;
|
|
188
|
+
let systemHealthTimer = null;
|
|
189
|
+
let systemHealthSnapshot = { status: "checking", version: "" };
|
|
190
|
+
let topbarStatusSource = "view";
|
|
180
191
|
const taskProgressRefreshInterval = 2_500;
|
|
181
192
|
const taskStatusSnapshots = new Map();
|
|
182
193
|
|
|
@@ -338,6 +349,15 @@ const $ = (selector) => document.querySelector(selector);
|
|
|
338
349
|
const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
|
|
339
350
|
const maximumAvatarFileSize = 5 * 1024 * 1024;
|
|
340
351
|
|
|
352
|
+
function setAiAssistantStatus(status) {
|
|
353
|
+
const failed = status === "error";
|
|
354
|
+
const label = failed ? "创作助手状态:执行失败" : "创作助手状态:正常";
|
|
355
|
+
const dot = $("#ai-status-dot");
|
|
356
|
+
dot.classList.toggle("is-error", failed);
|
|
357
|
+
dot.setAttribute("aria-label", label);
|
|
358
|
+
dot.title = label;
|
|
359
|
+
}
|
|
360
|
+
|
|
341
361
|
function userAvatarInitial(user) {
|
|
342
362
|
return Array.from(String(user?.displayName || user?.username || "作"))[0] ?? "作";
|
|
343
363
|
}
|
|
@@ -395,6 +415,7 @@ let aiReferencesLoadPromise = null;
|
|
|
395
415
|
let aiReferencesLoadWorkId = null;
|
|
396
416
|
let aiConversationsLoadPromise = null;
|
|
397
417
|
let aiConversationsLoadWorkId = null;
|
|
418
|
+
let latestAiContextUsage = null;
|
|
398
419
|
const aiConversationHistoryPageLimit = 20;
|
|
399
420
|
let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
400
421
|
let workScopedUiGeneration = 0;
|
|
@@ -614,7 +635,7 @@ function presencePageForRoute(route = currentPageRoute()) {
|
|
|
614
635
|
if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
|
|
615
636
|
if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
|
|
616
637
|
if (route.view === "module") return { kind: "module", module: route.module };
|
|
617
|
-
if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage") return { kind: "settings" };
|
|
638
|
+
if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage" || route.view === "work-audit") return { kind: "settings" };
|
|
618
639
|
return { kind: "welcome" };
|
|
619
640
|
}
|
|
620
641
|
|
|
@@ -780,6 +801,7 @@ function currentPageRoute() {
|
|
|
780
801
|
if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
|
|
781
802
|
if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
|
|
782
803
|
if (!$("#platform-usage-view").classList.contains("hidden")) return { view: "platform-usage", workId, ...settingsRouteContext() };
|
|
804
|
+
if (!$("#work-audit-view").classList.contains("hidden")) return { view: "work-audit", workId, ...settingsRouteContext() };
|
|
783
805
|
if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
|
|
784
806
|
if (!workId) return { view: "shelf" };
|
|
785
807
|
if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
|
|
@@ -904,6 +926,7 @@ let chapterEditorReadOnly = true;
|
|
|
904
926
|
let characterListPage = 1;
|
|
905
927
|
let taskListPage = 1;
|
|
906
928
|
let draftTypeFilter = "all";
|
|
929
|
+
let draftBindingFilters = [];
|
|
907
930
|
let draftFiltersPanelOpen = false;
|
|
908
931
|
const moduleListPages = {
|
|
909
932
|
drafts: 1,
|
|
@@ -1313,7 +1336,7 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
|
|
|
1313
1336
|
role.textContent = label;
|
|
1314
1337
|
const time = document.createElement("time");
|
|
1315
1338
|
time.dateTime = timestamp;
|
|
1316
|
-
time.textContent = formatAiMessageTime(timestamp
|
|
1339
|
+
time.textContent = formatAiMessageTime(timestamp);
|
|
1317
1340
|
heading.append(role, time);
|
|
1318
1341
|
message.prepend(heading);
|
|
1319
1342
|
message.dataset.createdAt = timestamp;
|
|
@@ -1327,7 +1350,7 @@ function updateMessageCreatedAt(message, createdAt) {
|
|
|
1327
1350
|
const time = message.querySelector(".message-heading time");
|
|
1328
1351
|
if (!time) return;
|
|
1329
1352
|
time.dateTime = createdAt;
|
|
1330
|
-
time.textContent = formatAiMessageTime(createdAt
|
|
1353
|
+
time.textContent = formatAiMessageTime(createdAt);
|
|
1331
1354
|
message.dataset.createdAt = createdAt;
|
|
1332
1355
|
if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
|
|
1333
1356
|
}
|
|
@@ -1337,14 +1360,20 @@ function resetAiFeed() {
|
|
|
1337
1360
|
$("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
|
|
1338
1361
|
}
|
|
1339
1362
|
|
|
1340
|
-
function
|
|
1363
|
+
function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
|
|
1341
1364
|
const divider = document.createElement("div");
|
|
1342
1365
|
divider.className = "ai-context-compaction-divider";
|
|
1343
1366
|
divider.dataset.contextCompaction = kind;
|
|
1344
1367
|
divider.dataset.testid = "ai-context-compaction-divider";
|
|
1345
1368
|
divider.setAttribute("role", "separator");
|
|
1346
|
-
divider.setAttribute("aria-label",
|
|
1369
|
+
divider.setAttribute("aria-label", ariaLabel);
|
|
1370
|
+
if (title) divider.title = title;
|
|
1347
1371
|
divider.innerHTML = "<span>已压缩上下文</span>";
|
|
1372
|
+
return divider;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function appendAiContextCompactionDivider(kind, before = null) {
|
|
1376
|
+
const divider = createAiContextCompactionDivider({ kind });
|
|
1348
1377
|
const feed = $("#ai-feed");
|
|
1349
1378
|
if (before?.parentElement === feed) feed.insertBefore(divider, before);
|
|
1350
1379
|
else feed.append(divider);
|
|
@@ -1523,8 +1552,10 @@ function openAiToolCallDetail(toolCall) {
|
|
|
1523
1552
|
time.textContent = formatAiToolCallTime(calledAt);
|
|
1524
1553
|
if (calledAt && !Number.isNaN(new Date(calledAt).getTime())) time.dateTime = new Date(calledAt).toISOString();
|
|
1525
1554
|
else time.removeAttribute("datetime");
|
|
1555
|
+
const resultDetails = formatAiToolCallResult(toolCall?.result);
|
|
1526
1556
|
$("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
|
|
1527
|
-
$("#ai-tool-call-result").textContent =
|
|
1557
|
+
$("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
|
|
1558
|
+
$("#ai-tool-call-result").textContent = resultDetails.text;
|
|
1528
1559
|
$("#ai-tool-call-dialog").showModal();
|
|
1529
1560
|
}
|
|
1530
1561
|
|
|
@@ -1594,14 +1625,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
|
|
|
1594
1625
|
list.className = "ai-process-list";
|
|
1595
1626
|
for (const step of steps) {
|
|
1596
1627
|
if (step?.type === "context_compaction") {
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
|
|
1603
|
-
compaction.innerHTML = "<span>已压缩上下文</span>";
|
|
1604
|
-
list.append(compaction);
|
|
1628
|
+
list.append(createAiContextCompactionDivider({
|
|
1629
|
+
kind: "tool",
|
|
1630
|
+
ariaLabel: `第 ${Number(step.round) || 1} 轮已压缩上下文`,
|
|
1631
|
+
title: `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`
|
|
1632
|
+
}));
|
|
1605
1633
|
continue;
|
|
1606
1634
|
}
|
|
1607
1635
|
if (step?.type === "tool" && step.toolCall) {
|
|
@@ -1758,6 +1786,7 @@ async function openAiConversation(conversationId, hideHistory = true) {
|
|
|
1758
1786
|
upsertAiConversationSummary(conversation);
|
|
1759
1787
|
state.aiConversationId = conversation.id;
|
|
1760
1788
|
state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
|
|
1789
|
+
resetAiContextMeter();
|
|
1761
1790
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
1762
1791
|
resetAiFeed();
|
|
1763
1792
|
for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
|
|
@@ -1785,7 +1814,7 @@ async function createNewAiConversation() {
|
|
|
1785
1814
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
1786
1815
|
resetAiFeed();
|
|
1787
1816
|
hideAiContextWarning();
|
|
1788
|
-
|
|
1817
|
+
resetAiContextMeter();
|
|
1789
1818
|
renderAiQuickActions();
|
|
1790
1819
|
setAiHistoryVisible(false);
|
|
1791
1820
|
}
|
|
@@ -2295,19 +2324,29 @@ async function api(path, options = {}) {
|
|
|
2295
2324
|
const headers = { ...(options.headers ?? {}) };
|
|
2296
2325
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
2297
2326
|
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
headers
|
|
2301
|
-
|
|
2302
|
-
|
|
2327
|
+
let response;
|
|
2328
|
+
try {
|
|
2329
|
+
response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
|
|
2330
|
+
...options,
|
|
2331
|
+
headers,
|
|
2332
|
+
body: body && typeof body !== "string" ? JSON.stringify(body) : body
|
|
2333
|
+
});
|
|
2334
|
+
} catch (error) {
|
|
2335
|
+
updateSystemHealth({ status: "offline" });
|
|
2336
|
+
throw error;
|
|
2337
|
+
}
|
|
2338
|
+
updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
|
|
2303
2339
|
if (!response.ok) {
|
|
2304
2340
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
2305
2341
|
// Presence is best-effort; a heartbeat 401 must not force the login wall.
|
|
2306
2342
|
if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2343
|
+
const restarted = await checkSystemBoot(true);
|
|
2344
|
+
if (!restarted) {
|
|
2345
|
+
state.user = null;
|
|
2346
|
+
state.csrfToken = null;
|
|
2347
|
+
moduleRequestCache.clear();
|
|
2348
|
+
showAuth(false);
|
|
2349
|
+
}
|
|
2311
2350
|
}
|
|
2312
2351
|
throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
|
|
2313
2352
|
}
|
|
@@ -2316,6 +2355,12 @@ async function api(path, options = {}) {
|
|
|
2316
2355
|
return null;
|
|
2317
2356
|
}
|
|
2318
2357
|
const payload = await response.json();
|
|
2358
|
+
if (path === "/api/health") {
|
|
2359
|
+
updateSystemHealth({
|
|
2360
|
+
status: payload.data?.status === "ok" ? "ready" : "degraded",
|
|
2361
|
+
version: payload.data?.version
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2319
2364
|
invalidateModuleRequestsAfterMutation(path, method);
|
|
2320
2365
|
return payload.data;
|
|
2321
2366
|
}
|
|
@@ -2350,9 +2395,9 @@ function formatAiFailureMessage(error) {
|
|
|
2350
2395
|
if (status) lines.push(`服务端状态:HTTP ${status}`);
|
|
2351
2396
|
if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
|
|
2352
2397
|
if (modelId) lines.push(`模型 ID:${modelId}`);
|
|
2353
|
-
if (failure && failure !== message) lines.push(`详细原因:${failure}`);
|
|
2354
2398
|
if (callId) lines.push(`调用 ID:${callId}`);
|
|
2355
|
-
|
|
2399
|
+
if (failure && failure !== message) lines.push(`详细原因:${failure}`);
|
|
2400
|
+
return lines.join("\n");
|
|
2356
2401
|
}
|
|
2357
2402
|
|
|
2358
2403
|
async function apiPage(path, page = 1, limit = 30) {
|
|
@@ -2430,23 +2475,97 @@ function invalidateModuleRequestsAfterMutation(path, method) {
|
|
|
2430
2475
|
affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
|
|
2431
2476
|
}
|
|
2432
2477
|
|
|
2478
|
+
function applyProductHealthMetadata(health) {
|
|
2479
|
+
const version = String(health?.version ?? "").trim();
|
|
2480
|
+
document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
|
|
2481
|
+
element.textContent = version ? `v${version}` : "v—";
|
|
2482
|
+
});
|
|
2483
|
+
document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
|
|
2484
|
+
element.classList.toggle("hidden", health?.development !== true);
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function scheduleSystemHealthCheck() {
|
|
2489
|
+
if (systemHealthTimer !== null) window.clearTimeout(systemHealthTimer);
|
|
2490
|
+
systemHealthTimer = window.setTimeout(() => {
|
|
2491
|
+
systemHealthTimer = null;
|
|
2492
|
+
void refreshSystemHealth();
|
|
2493
|
+
}, systemHealthPollInterval);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
async function refreshSystemHealth() {
|
|
2497
|
+
try {
|
|
2498
|
+
const health = await api("/api/health");
|
|
2499
|
+
applyProductHealthMetadata(health);
|
|
2500
|
+
} catch {
|
|
2501
|
+
// 系统状态已由 api 记录;健康检查失败不弹出重复提示
|
|
2502
|
+
} finally {
|
|
2503
|
+
scheduleSystemHealthCheck();
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2433
2507
|
async function initializeProductFooters() {
|
|
2434
2508
|
const year = String(new Date().getFullYear());
|
|
2435
2509
|
document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
|
|
2510
|
+
applyProductHealthMetadata(null);
|
|
2511
|
+
await refreshSystemHealth();
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function observeSystemBootId(value) {
|
|
2515
|
+
const nextBootId = typeof value === "string" ? value.trim() : "";
|
|
2516
|
+
if (!nextBootId) return false;
|
|
2517
|
+
if (!systemBootId) {
|
|
2518
|
+
systemBootId = nextBootId;
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
if (nextBootId === systemBootId) return false;
|
|
2522
|
+
systemRestartDetected = true;
|
|
2523
|
+
if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
|
|
2524
|
+
systemBootCheckTimer = null;
|
|
2525
|
+
const dialog = $("#system-restart-dialog");
|
|
2526
|
+
if (!dialog.open) dialog.showModal();
|
|
2527
|
+
window.requestAnimationFrame(() => $("#system-restart-dialog-title").focus());
|
|
2528
|
+
return true;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
async function checkSystemBoot(forceFresh = false) {
|
|
2532
|
+
if (!state.user || systemRestartDetected) return systemRestartDetected;
|
|
2533
|
+
if (systemBootCheckPromise) {
|
|
2534
|
+
if (!forceFresh) return systemBootCheckPromise;
|
|
2535
|
+
await systemBootCheckPromise;
|
|
2536
|
+
if (systemRestartDetected) return true;
|
|
2537
|
+
}
|
|
2538
|
+
systemBootCheckPromise = (async () => {
|
|
2539
|
+
try {
|
|
2540
|
+
const response = await fetch("/api/health", {
|
|
2541
|
+
cache: "no-store",
|
|
2542
|
+
headers: { Accept: "application/json" }
|
|
2543
|
+
});
|
|
2544
|
+
if (!response.ok) return false;
|
|
2545
|
+
const payload = await response.json();
|
|
2546
|
+
return observeSystemBootId(payload?.data?.bootId);
|
|
2547
|
+
} catch {
|
|
2548
|
+
return false;
|
|
2549
|
+
}
|
|
2550
|
+
})();
|
|
2436
2551
|
try {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
element.textContent = version ? `v${version}` : "v—";
|
|
2441
|
-
});
|
|
2442
|
-
document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
|
|
2443
|
-
element.classList.toggle("hidden", health.development !== true);
|
|
2444
|
-
});
|
|
2445
|
-
} catch {
|
|
2446
|
-
document.querySelectorAll("[data-product-footer-version]").forEach((element) => { element.textContent = "v—"; });
|
|
2552
|
+
return await systemBootCheckPromise;
|
|
2553
|
+
} finally {
|
|
2554
|
+
systemBootCheckPromise = null;
|
|
2447
2555
|
}
|
|
2448
2556
|
}
|
|
2449
2557
|
|
|
2558
|
+
function scheduleSystemBootCheck(delay = systemBootCheckInterval) {
|
|
2559
|
+
if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
|
|
2560
|
+
systemBootCheckTimer = null;
|
|
2561
|
+
if (!state.user || systemRestartDetected) return;
|
|
2562
|
+
systemBootCheckTimer = setTimeout(async () => {
|
|
2563
|
+
systemBootCheckTimer = null;
|
|
2564
|
+
await checkSystemBoot();
|
|
2565
|
+
scheduleSystemBootCheck();
|
|
2566
|
+
}, delay);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2450
2569
|
function selectAuthMode(mode) {
|
|
2451
2570
|
const registerTab = $("#auth-register-tab");
|
|
2452
2571
|
const login = mode === "login" || registerTab.disabled;
|
|
@@ -2484,7 +2603,7 @@ async function refreshAuthCaptcha(target = "login") {
|
|
|
2484
2603
|
if (answerInput) answerInput.value = "";
|
|
2485
2604
|
}
|
|
2486
2605
|
|
|
2487
|
-
function showAuth(setupRequired, registrationOpen = false) {
|
|
2606
|
+
function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
|
|
2488
2607
|
if (state.user) return;
|
|
2489
2608
|
document.body.classList.add("auth-pending");
|
|
2490
2609
|
$("#auth-view").classList.remove("hidden");
|
|
@@ -2501,6 +2620,10 @@ function showAuth(setupRequired, registrationOpen = false) {
|
|
|
2501
2620
|
registerTab.disabled = !canRegister;
|
|
2502
2621
|
registerTab.setAttribute("aria-disabled", String(!canRegister));
|
|
2503
2622
|
registerTab.textContent = canRegister ? "注册" : "注册已禁用";
|
|
2623
|
+
const setupTokenField = $("#register-setup-token-field");
|
|
2624
|
+
const setupTokenInput = setupTokenField.querySelector('input[name="setupToken"]');
|
|
2625
|
+
setupTokenField.classList.toggle("hidden", !setupTokenRequired);
|
|
2626
|
+
setupTokenInput.required = setupTokenRequired;
|
|
2504
2627
|
selectAuthMode(setupRequired && canRegister ? "register" : "login");
|
|
2505
2628
|
}
|
|
2506
2629
|
|
|
@@ -2579,12 +2702,14 @@ async function initializeAuthentication() {
|
|
|
2579
2702
|
if (!session.authenticated) {
|
|
2580
2703
|
// 未登录时一律转到登录页路由;登录页本身则保持原样
|
|
2581
2704
|
if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
|
|
2582
|
-
showAuth(session.setupRequired, session.registrationOpen === true);
|
|
2705
|
+
showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true);
|
|
2583
2706
|
return false;
|
|
2584
2707
|
}
|
|
2585
2708
|
// 已登录却停在登录页路由时,回到书架首页
|
|
2586
2709
|
if (route.view === "login") window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
|
|
2710
|
+
observeSystemBootId(session.bootId);
|
|
2587
2711
|
applyAuthenticatedUser(session);
|
|
2712
|
+
scheduleSystemBootCheck();
|
|
2588
2713
|
await loadPlatformUiSettings();
|
|
2589
2714
|
return true;
|
|
2590
2715
|
}
|
|
@@ -2740,10 +2865,53 @@ document.addEventListener("toggle", (event) => {
|
|
|
2740
2865
|
}
|
|
2741
2866
|
}, true);
|
|
2742
2867
|
|
|
2868
|
+
function paintTopbarStatus(text, { source, tone, title }) {
|
|
2869
|
+
const element = $("#save-state");
|
|
2870
|
+
const colors = { ok: "var(--green)", pending: "var(--muted)", error: "var(--accent)" };
|
|
2871
|
+
topbarStatusSource = source;
|
|
2872
|
+
element.textContent = text;
|
|
2873
|
+
element.style.color = colors[tone] ?? colors.ok;
|
|
2874
|
+
element.dataset.statusSource = source;
|
|
2875
|
+
element.setAttribute("aria-label", `${source === "system" ? "系统" : "工作台"}状态:${text}`);
|
|
2876
|
+
element.title = title || text;
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
function setTopbarViewState(text) {
|
|
2880
|
+
state.dirty = false;
|
|
2881
|
+
paintTopbarStatus(text, { source: "view", tone: "ok", title: text });
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
function updateSystemHealth(next) {
|
|
2885
|
+
systemHealthSnapshot = {
|
|
2886
|
+
...systemHealthSnapshot,
|
|
2887
|
+
...next,
|
|
2888
|
+
version: next.version === undefined ? systemHealthSnapshot.version : String(next.version ?? "")
|
|
2889
|
+
};
|
|
2890
|
+
if (topbarStatusSource === "system") renderSystemHealth();
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
function renderSystemHealth() {
|
|
2894
|
+
const presentation = systemStatusPresentation(systemHealthSnapshot);
|
|
2895
|
+
paintTopbarStatus(presentation.label, {
|
|
2896
|
+
source: "system",
|
|
2897
|
+
tone: presentation.tone,
|
|
2898
|
+
title: presentation.title
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function showSystemStatus() {
|
|
2903
|
+
state.dirty = false;
|
|
2904
|
+
topbarStatusSource = "system";
|
|
2905
|
+
renderSystemHealth();
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2743
2908
|
function setSaveState(text, dirty = false) {
|
|
2744
2909
|
state.dirty = dirty;
|
|
2745
|
-
|
|
2746
|
-
|
|
2910
|
+
paintTopbarStatus(text, {
|
|
2911
|
+
source: "save",
|
|
2912
|
+
tone: dirty ? "error" : "ok",
|
|
2913
|
+
title: text
|
|
2914
|
+
});
|
|
2747
2915
|
}
|
|
2748
2916
|
|
|
2749
2917
|
function chapterDraftSnapshot() {
|
|
@@ -2985,6 +3153,11 @@ async function initializePage() {
|
|
|
2985
3153
|
if (route.view === "platform-usage") {
|
|
2986
3154
|
await showPlatformUsage();
|
|
2987
3155
|
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
if (route.view === "work-audit") {
|
|
3159
|
+
await showWorkAudit();
|
|
3160
|
+
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
2988
3161
|
}
|
|
2989
3162
|
} finally {
|
|
2990
3163
|
document.body.classList.remove("auth-pending");
|
|
@@ -3004,6 +3177,7 @@ function showShelf() {
|
|
|
3004
3177
|
$("#shelf-view").classList.remove("hidden");
|
|
3005
3178
|
$("#platform-ai-view").classList.add("hidden");
|
|
3006
3179
|
$("#platform-usage-view").classList.add("hidden");
|
|
3180
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3007
3181
|
$("#settings-hub-view").classList.add("hidden");
|
|
3008
3182
|
$("#welcome-view").classList.add("hidden");
|
|
3009
3183
|
$("#editor-view").classList.add("hidden");
|
|
@@ -3011,7 +3185,7 @@ function showShelf() {
|
|
|
3011
3185
|
$("#work-meta").textContent = `${state.works.length} 部作品`;
|
|
3012
3186
|
$("#settings-button").removeAttribute("aria-current");
|
|
3013
3187
|
$("#top-search-button").disabled = true;
|
|
3014
|
-
|
|
3188
|
+
setTopbarViewState("书架");
|
|
3015
3189
|
renderShelf();
|
|
3016
3190
|
replacePageRoute({ view: "shelf" });
|
|
3017
3191
|
}
|
|
@@ -3028,7 +3202,7 @@ function renderSettingsHub() {
|
|
|
3028
3202
|
const hasWork = Boolean(state.work);
|
|
3029
3203
|
const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
|
|
3030
3204
|
const canReadAggregate = hasWork && canReadAggregateContent();
|
|
3031
|
-
const
|
|
3205
|
+
const canExportManuscript = hasWork && canReadModule("editor");
|
|
3032
3206
|
const isAdmin = state.user?.role === "admin";
|
|
3033
3207
|
$("#platform-ai-button").classList.toggle("hidden", !isAdmin);
|
|
3034
3208
|
$("#platform-usage-button").classList.toggle("hidden", !isAdmin);
|
|
@@ -3038,10 +3212,11 @@ function renderSettingsHub() {
|
|
|
3038
3212
|
$("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
|
|
3039
3213
|
$("#work-audit-button").disabled = !canManageWork;
|
|
3040
3214
|
$("#top-search-button").disabled = !canReadAggregate;
|
|
3041
|
-
$("#export-button").disabled = !
|
|
3215
|
+
$("#export-button").disabled = !canExportManuscript;
|
|
3216
|
+
$("#export-button").setAttribute("aria-expanded", "false");
|
|
3042
3217
|
$("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
|
|
3043
3218
|
$("#settings-work-note").textContent = hasWork
|
|
3044
|
-
? `当前作品:《${state.work.title}
|
|
3219
|
+
? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP 或 DOCX;DOCX 在有封面时会嵌入为首页。`
|
|
3045
3220
|
: "当前未选择作品;打开作品后可使用导出。";
|
|
3046
3221
|
}
|
|
3047
3222
|
|
|
@@ -3110,36 +3285,173 @@ async function openWritingProgressDialog() {
|
|
|
3110
3285
|
const workAuditActionLabels = {
|
|
3111
3286
|
"work.created": "创建作品",
|
|
3112
3287
|
"work.updated": "更新作品",
|
|
3288
|
+
"work.cover.updated": "更新作品封面",
|
|
3289
|
+
"work.cover.deleted": "删除作品封面",
|
|
3290
|
+
"work.member-added": "添加作品成员",
|
|
3291
|
+
"work.member-role-updated": "更新成员权限",
|
|
3292
|
+
"work.member-removed": "移除作品成员",
|
|
3293
|
+
"work.writing_goal.updated": "更新写作目标",
|
|
3113
3294
|
"volume.created": "创建分卷",
|
|
3114
3295
|
"volume.updated": "更新分卷",
|
|
3115
3296
|
"volume.deleted": "删除分卷",
|
|
3297
|
+
"volume.restored": "恢复分卷",
|
|
3116
3298
|
"chapter.created": "创建章节",
|
|
3117
3299
|
"chapter.saved": "保存章节",
|
|
3118
3300
|
"chapter.moved": "移动章节",
|
|
3119
3301
|
"chapter.deleted": "删除章节",
|
|
3120
3302
|
"chapter.purged": "彻底删除章节",
|
|
3121
3303
|
"chapter.restored": "恢复章节",
|
|
3304
|
+
"chapter.annotation.created": "添加正文评论",
|
|
3305
|
+
"chapter.annotation.updated": "更新正文评论",
|
|
3306
|
+
"chapter.annotation.deleted": "删除正文评论",
|
|
3122
3307
|
"draft.created": "创建想法",
|
|
3123
3308
|
"draft.updated": "更新想法",
|
|
3124
3309
|
"draft.deleted": "删除想法",
|
|
3125
3310
|
"draft.restored": "恢复想法",
|
|
3311
|
+
"setting.created": "创建设定",
|
|
3312
|
+
"setting.updated": "更新设定",
|
|
3313
|
+
"setting.deleted": "删除设定",
|
|
3314
|
+
"setting.restored": "恢复设定",
|
|
3315
|
+
"character.created": "创建角色",
|
|
3316
|
+
"character.updated": "更新角色",
|
|
3317
|
+
"character.deleted": "删除角色",
|
|
3318
|
+
"character.restored": "恢复角色",
|
|
3319
|
+
"race.created": "创建种族",
|
|
3320
|
+
"race.updated": "更新种族",
|
|
3321
|
+
"race.deleted": "删除种族",
|
|
3322
|
+
"race.restored": "恢复种族",
|
|
3323
|
+
"organization.created": "创建组织",
|
|
3324
|
+
"organization.updated": "更新组织",
|
|
3325
|
+
"organization.deleted": "删除组织",
|
|
3326
|
+
"organization.restored": "恢复组织",
|
|
3327
|
+
"timeline-track.created": "创建时间轴",
|
|
3328
|
+
"timeline-track.updated": "更新时间轴",
|
|
3329
|
+
"timeline-track.deleted": "删除时间轴",
|
|
3330
|
+
"timeline-track.restored": "恢复时间轴",
|
|
3331
|
+
"timeline.created": "创建时间事件",
|
|
3332
|
+
"timeline.updated": "更新时间事件",
|
|
3333
|
+
"timeline.deleted": "删除时间事件",
|
|
3334
|
+
"timeline.restored": "恢复时间事件",
|
|
3335
|
+
"relationship.created": "创建人物关系",
|
|
3336
|
+
"relationship.updated": "更新人物关系",
|
|
3337
|
+
"relationship.deleted": "删除人物关系",
|
|
3338
|
+
"relationship.restored": "恢复人物关系",
|
|
3339
|
+
"outline.created": "创建章节大纲",
|
|
3340
|
+
"outline.updated": "更新章节大纲",
|
|
3341
|
+
"outline.deleted": "删除章节大纲",
|
|
3342
|
+
"foreshadow.created": "创建伏笔",
|
|
3343
|
+
"foreshadow.updated": "更新伏笔",
|
|
3344
|
+
"foreshadow.deleted": "删除伏笔",
|
|
3345
|
+
"foreshadow.restored": "恢复伏笔",
|
|
3346
|
+
"task.created": "创建 AI 分析任务",
|
|
3347
|
+
"task.cancelled": "取消 AI 分析任务",
|
|
3348
|
+
"attachment.created": "创建附件",
|
|
3349
|
+
"attachment.deleted": "删除附件",
|
|
3350
|
+
"attachment.garbage-collected": "清理未引用附件",
|
|
3351
|
+
"file.restored": "恢复导入快照",
|
|
3126
3352
|
"work.imported": "导入正文"
|
|
3127
3353
|
};
|
|
3128
3354
|
|
|
3129
3355
|
function workAuditEntityLabel(type) {
|
|
3130
|
-
return ({
|
|
3356
|
+
return ({
|
|
3357
|
+
work: "作品",
|
|
3358
|
+
volume: "分卷",
|
|
3359
|
+
chapter: "章节",
|
|
3360
|
+
draft: "想法",
|
|
3361
|
+
user: "用户",
|
|
3362
|
+
setting: "设定",
|
|
3363
|
+
character: "角色",
|
|
3364
|
+
race: "种族",
|
|
3365
|
+
organization: "组织",
|
|
3366
|
+
"timeline-track": "时间轴",
|
|
3367
|
+
"timeline-event": "时间事件",
|
|
3368
|
+
relationship: "人物关系",
|
|
3369
|
+
"chapter-outline": "章节大纲",
|
|
3370
|
+
foreshadow: "伏笔",
|
|
3371
|
+
"chapter-annotation": "正文评论",
|
|
3372
|
+
attachment: "附件",
|
|
3373
|
+
"file-version": "导入快照",
|
|
3374
|
+
"analysis-task": "AI 分析任务",
|
|
3375
|
+
review: "审核"
|
|
3376
|
+
})[type] ?? type;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
const workAuditDetailLabels = {
|
|
3380
|
+
fields: "变更字段",
|
|
3381
|
+
versionNo: "版本号",
|
|
3382
|
+
fromVersion: "来源版本",
|
|
3383
|
+
source: "操作来源",
|
|
3384
|
+
sourceRef: "来源引用",
|
|
3385
|
+
changeNote: "变更说明",
|
|
3386
|
+
name: "名称",
|
|
3387
|
+
description: "说明",
|
|
3388
|
+
chapterType: "章节类型",
|
|
3389
|
+
volumeId: "所属分卷",
|
|
3390
|
+
previousVolumeId: "原分卷",
|
|
3391
|
+
sortOrder: "排序位置",
|
|
3392
|
+
timeLabel: "时间标签",
|
|
3393
|
+
location: "地点",
|
|
3394
|
+
eventType: "事件类型",
|
|
3395
|
+
batch: "批量操作",
|
|
3396
|
+
recoverable: "可恢复",
|
|
3397
|
+
excludedFromAnalysis: "排除 AI 分析",
|
|
3398
|
+
startLine: "起始行",
|
|
3399
|
+
endLine: "结束行",
|
|
3400
|
+
characterId: "角色 ID",
|
|
3401
|
+
restorePointId: "恢复点 ID",
|
|
3402
|
+
storageKey: "存储位置",
|
|
3403
|
+
byteLength: "文件字节数",
|
|
3404
|
+
mimeType: "文件类型",
|
|
3405
|
+
role: "成员角色",
|
|
3406
|
+
status: "状态",
|
|
3407
|
+
previousStatus: "原状态",
|
|
3408
|
+
dailyGoal: "每日目标",
|
|
3409
|
+
targetTotal: "总字数目标",
|
|
3410
|
+
deadline: "计划完成日期",
|
|
3411
|
+
title: "标题",
|
|
3412
|
+
reason: "原因"
|
|
3413
|
+
};
|
|
3414
|
+
|
|
3415
|
+
function workAuditDetailValue(value) {
|
|
3416
|
+
if (typeof value === "boolean") return value ? "是" : "否";
|
|
3417
|
+
if (Array.isArray(value)) return value.map((item) => typeof item === "object" ? JSON.stringify(item) : String(item)).join("、");
|
|
3418
|
+
if (typeof value === "object" && value !== null) return JSON.stringify(value);
|
|
3419
|
+
return String(value);
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
function workAuditDetailEntries(detail) {
|
|
3423
|
+
if (!detail || typeof detail !== "object" || Array.isArray(detail)) return [];
|
|
3424
|
+
return Object.entries(detail)
|
|
3425
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
|
3426
|
+
.map(([key, value]) => ({ label: workAuditDetailLabels[key] ?? key, value: workAuditDetailValue(value) }));
|
|
3131
3427
|
}
|
|
3132
3428
|
|
|
3133
|
-
function
|
|
3134
|
-
const
|
|
3135
|
-
|
|
3429
|
+
function workAuditTimestamp(createdAt) {
|
|
3430
|
+
const formatted = formatDateTime(createdAt);
|
|
3431
|
+
const parts = formatted.split(/\s+/u);
|
|
3432
|
+
return { date: parts[0] || "—", time: parts.slice(1).join(" ") || "—" };
|
|
3136
3433
|
}
|
|
3137
3434
|
|
|
3138
3435
|
function renderWorkAuditRecords() {
|
|
3139
|
-
$("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) =>
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3436
|
+
$("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => {
|
|
3437
|
+
const timestamp = workAuditTimestamp(record.createdAt);
|
|
3438
|
+
const details = workAuditDetailEntries(record.detail);
|
|
3439
|
+
return `<article class="work-audit-row">
|
|
3440
|
+
<header class="work-audit-time"><time datetime="${esc(record.createdAt)}"><span>${esc(timestamp.date)}</span><strong>${esc(timestamp.time)}</strong></time></header>
|
|
3441
|
+
<div class="work-audit-event">
|
|
3442
|
+
<div class="work-audit-event-heading"><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><code>${esc(record.action)}</code></div>
|
|
3443
|
+
<dl class="work-audit-meta">
|
|
3444
|
+
<div><dt>操作者</dt><dd>${esc(record.actor || "system")}</dd></div>
|
|
3445
|
+
<div><dt>对象类型</dt><dd>${esc(workAuditEntityLabel(record.entityType))}</dd></div>
|
|
3446
|
+
<div><dt>对象 ID</dt><dd><code>${record.entityId ? esc(record.entityId) : "—"}</code></dd></div>
|
|
3447
|
+
</dl>
|
|
3448
|
+
${details.length ? `<dl class="work-audit-details">${details.map((detail) => `<div><dt>${esc(detail.label)}</dt><dd><code>${esc(detail.value)}</code></dd></div>`).join("")}</dl>` : ""}
|
|
3449
|
+
</div>
|
|
3450
|
+
</article>`;
|
|
3451
|
+
}).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
|
|
3452
|
+
$("#work-audit-summary").textContent = workAuditRecords.length
|
|
3453
|
+
? `已显示 ${workAuditRecords.length} 条记录${workAuditNextPage === null ? ",已加载全部记录" : ",还有更多记录可继续加载"}`
|
|
3454
|
+
: "当前作品还没有操作记录。";
|
|
3143
3455
|
$("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
|
|
3144
3456
|
}
|
|
3145
3457
|
|
|
@@ -3151,18 +3463,36 @@ async function loadWorkAuditPage(page = 1, append = false) {
|
|
|
3151
3463
|
renderWorkAuditRecords();
|
|
3152
3464
|
}
|
|
3153
3465
|
|
|
3154
|
-
async function
|
|
3155
|
-
if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
|
|
3466
|
+
async function showWorkAudit() {
|
|
3467
|
+
if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return false;
|
|
3156
3468
|
workAuditRecords = [];
|
|
3157
3469
|
workAuditNextPage = null;
|
|
3470
|
+
dismissChapterInsightToast();
|
|
3471
|
+
updateDocumentTitle(state.work);
|
|
3472
|
+
$("#app").classList.add("shelf-mode");
|
|
3473
|
+
$("#shelf-view").classList.add("hidden");
|
|
3474
|
+
$("#platform-ai-view").classList.add("hidden");
|
|
3475
|
+
$("#platform-usage-view").classList.add("hidden");
|
|
3476
|
+
$("#settings-hub-view").classList.add("hidden");
|
|
3477
|
+
$("#work-audit-view").classList.remove("hidden");
|
|
3478
|
+
$("#welcome-view").classList.add("hidden");
|
|
3479
|
+
$("#editor-view").classList.add("hidden");
|
|
3480
|
+
$("#module-view").classList.add("hidden");
|
|
3481
|
+
$("#work-audit-eyebrow").textContent = `作品安全 · 《${state.work.title}》`;
|
|
3482
|
+
$("#work-meta").textContent = "操作记录";
|
|
3483
|
+
$("#settings-button").setAttribute("aria-current", "page");
|
|
3484
|
+
setTopbarViewState("操作记录");
|
|
3485
|
+
$("#work-audit-summary").textContent = "正在读取操作记录……";
|
|
3158
3486
|
$("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
|
|
3159
|
-
|
|
3487
|
+
replacePageRoute({ view: "work-audit", workId: state.work.id, ...settingsRouteContext() });
|
|
3160
3488
|
try {
|
|
3161
3489
|
await loadWorkAuditPage();
|
|
3162
3490
|
} catch (error) {
|
|
3163
|
-
$("#work-audit-
|
|
3491
|
+
$("#work-audit-summary").textContent = "操作记录加载失败。";
|
|
3492
|
+
$("#work-audit-list").innerHTML = '<p class="entity-history-empty">暂时无法读取操作记录,请稍后刷新。</p>';
|
|
3164
3493
|
toast(error.message, "error");
|
|
3165
3494
|
}
|
|
3495
|
+
return true;
|
|
3166
3496
|
}
|
|
3167
3497
|
|
|
3168
3498
|
async function saveWritingGoal(event) {
|
|
@@ -3463,7 +3793,8 @@ async function openSearchResult(result) {
|
|
|
3463
3793
|
$("#search-dialog").close();
|
|
3464
3794
|
const inSettings = !$("#settings-hub-view").classList.contains("hidden")
|
|
3465
3795
|
|| !$("#platform-ai-view").classList.contains("hidden")
|
|
3466
|
-
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3796
|
+
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3797
|
+
|| !$("#work-audit-view").classList.contains("hidden");
|
|
3467
3798
|
if (inSettings) await returnFromSettings();
|
|
3468
3799
|
if (target.kind === "chapter") {
|
|
3469
3800
|
await selectChapter(target.id);
|
|
@@ -3489,7 +3820,8 @@ async function openSearchResult(result) {
|
|
|
3489
3820
|
async function showSettingsHub() {
|
|
3490
3821
|
const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
|
|
3491
3822
|
|| !$("#platform-ai-view").classList.contains("hidden")
|
|
3492
|
-
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3823
|
+
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3824
|
+
|| !$("#work-audit-view").classList.contains("hidden");
|
|
3493
3825
|
if (!alreadyInSettings) {
|
|
3494
3826
|
if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
|
|
3495
3827
|
settingsReturnContext = captureSettingsReturnContext();
|
|
@@ -3501,13 +3833,14 @@ async function showSettingsHub() {
|
|
|
3501
3833
|
$("#shelf-view").classList.add("hidden");
|
|
3502
3834
|
$("#platform-ai-view").classList.add("hidden");
|
|
3503
3835
|
$("#platform-usage-view").classList.add("hidden");
|
|
3836
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3504
3837
|
$("#settings-hub-view").classList.remove("hidden");
|
|
3505
3838
|
$("#welcome-view").classList.add("hidden");
|
|
3506
3839
|
$("#editor-view").classList.add("hidden");
|
|
3507
3840
|
$("#module-view").classList.add("hidden");
|
|
3508
3841
|
$("#work-meta").textContent = "设置中心";
|
|
3509
3842
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
3510
|
-
|
|
3843
|
+
setTopbarViewState("设置");
|
|
3511
3844
|
renderSettingsHub();
|
|
3512
3845
|
replacePageRoute({ view: "settings", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
3513
3846
|
return true;
|
|
@@ -3527,6 +3860,7 @@ async function returnFromSettings() {
|
|
|
3527
3860
|
$("#settings-hub-view").classList.add("hidden");
|
|
3528
3861
|
$("#platform-ai-view").classList.add("hidden");
|
|
3529
3862
|
$("#platform-usage-view").classList.add("hidden");
|
|
3863
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3530
3864
|
if (context.view === "shelf" || !state.work) return showShelf();
|
|
3531
3865
|
$("#app").classList.remove("shelf-mode");
|
|
3532
3866
|
$("#shelf-view").classList.add("hidden");
|
|
@@ -3546,13 +3880,14 @@ async function showPlatformAi() {
|
|
|
3546
3880
|
$("#shelf-view").classList.add("hidden");
|
|
3547
3881
|
$("#platform-ai-view").classList.remove("hidden");
|
|
3548
3882
|
$("#platform-usage-view").classList.add("hidden");
|
|
3883
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3549
3884
|
$("#settings-hub-view").classList.add("hidden");
|
|
3550
3885
|
$("#welcome-view").classList.add("hidden");
|
|
3551
3886
|
$("#editor-view").classList.add("hidden");
|
|
3552
3887
|
$("#module-view").classList.add("hidden");
|
|
3553
3888
|
$("#work-meta").textContent = "平台 AI 管理";
|
|
3554
3889
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
3555
|
-
|
|
3890
|
+
setTopbarViewState("平台 AI");
|
|
3556
3891
|
await renderPlatformAiConfig();
|
|
3557
3892
|
replacePageRoute({ view: "platform-ai", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
3558
3893
|
return true;
|
|
@@ -3566,13 +3901,14 @@ async function showPlatformUsage() {
|
|
|
3566
3901
|
$("#shelf-view").classList.add("hidden");
|
|
3567
3902
|
$("#platform-ai-view").classList.add("hidden");
|
|
3568
3903
|
$("#platform-usage-view").classList.remove("hidden");
|
|
3904
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3569
3905
|
$("#settings-hub-view").classList.add("hidden");
|
|
3570
3906
|
$("#welcome-view").classList.add("hidden");
|
|
3571
3907
|
$("#editor-view").classList.add("hidden");
|
|
3572
3908
|
$("#module-view").classList.add("hidden");
|
|
3573
3909
|
$("#work-meta").textContent = "Token 用量";
|
|
3574
3910
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
3575
|
-
|
|
3911
|
+
setTopbarViewState("Token 用量");
|
|
3576
3912
|
await renderPlatformTokenUsage();
|
|
3577
3913
|
replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
3578
3914
|
return true;
|
|
@@ -3623,6 +3959,7 @@ function resetWorkScopedUiCaches() {
|
|
|
3623
3959
|
state.races = [];
|
|
3624
3960
|
characterListPage = 1;
|
|
3625
3961
|
draftTypeFilter = "all";
|
|
3962
|
+
draftBindingFilters = [];
|
|
3626
3963
|
draftFiltersPanelOpen = false;
|
|
3627
3964
|
Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
|
|
3628
3965
|
relationshipFilters.fromCharacterIds = [];
|
|
@@ -3645,7 +3982,7 @@ function resetWorkScopedUiCaches() {
|
|
|
3645
3982
|
resetAiFeed();
|
|
3646
3983
|
$("#ai-conversation-title").textContent = "新对话";
|
|
3647
3984
|
$("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
|
|
3648
|
-
|
|
3985
|
+
resetAiContextMeter();
|
|
3649
3986
|
renderAiConversationHistory();
|
|
3650
3987
|
}
|
|
3651
3988
|
|
|
@@ -3660,11 +3997,12 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
3660
3997
|
}
|
|
3661
3998
|
const nextWork = await api(`/api/works/${workId}?directory=volumes`);
|
|
3662
3999
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
3663
|
-
|
|
4000
|
+
showSystemStatus();
|
|
3664
4001
|
$("#app").classList.remove("shelf-mode");
|
|
3665
4002
|
$("#shelf-view").classList.add("hidden");
|
|
3666
4003
|
$("#platform-ai-view").classList.add("hidden");
|
|
3667
4004
|
$("#platform-usage-view").classList.add("hidden");
|
|
4005
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3668
4006
|
$("#settings-hub-view").classList.add("hidden");
|
|
3669
4007
|
$("#settings-button").removeAttribute("aria-current");
|
|
3670
4008
|
settingsReturnContext = null;
|
|
@@ -4145,6 +4483,7 @@ function showWelcome(hasWork = false) {
|
|
|
4145
4483
|
$("#editor-view").classList.add("hidden");
|
|
4146
4484
|
$("#module-view").classList.add("hidden");
|
|
4147
4485
|
$("#welcome-view").classList.remove("hidden");
|
|
4486
|
+
if (hasWork) showSystemStatus();
|
|
4148
4487
|
$("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
|
|
4149
4488
|
$("#welcome-new-work").textContent = hasWork ? "新建章节" : "创建第一部作品";
|
|
4150
4489
|
replacePageRoute(hasWork && state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
|
|
@@ -4198,6 +4537,7 @@ async function showModule(module) {
|
|
|
4198
4537
|
}
|
|
4199
4538
|
return;
|
|
4200
4539
|
}
|
|
4540
|
+
showSystemStatus();
|
|
4201
4541
|
dismissChapterInsightToast();
|
|
4202
4542
|
$("#welcome-view").classList.add("hidden");
|
|
4203
4543
|
$("#editor-view").classList.add("hidden");
|
|
@@ -4627,12 +4967,44 @@ function draftTypeLabel(draftType) {
|
|
|
4627
4967
|
return draftType === "setting" ? "设定想法" : "正文想法";
|
|
4628
4968
|
}
|
|
4629
4969
|
|
|
4970
|
+
const draftSettingModules = Object.freeze([
|
|
4971
|
+
["settings", "设定库"],
|
|
4972
|
+
["characters", "角色"],
|
|
4973
|
+
["races", "种族"],
|
|
4974
|
+
["organizations", "组织"],
|
|
4975
|
+
["timeline", "时间轴"],
|
|
4976
|
+
["relationships", "关系"],
|
|
4977
|
+
["outlines", "大纲/伏笔"]
|
|
4978
|
+
]);
|
|
4979
|
+
|
|
4980
|
+
function draftSettingModuleLabel(module) {
|
|
4981
|
+
return draftSettingModules.find(([value]) => value === module)?.[1] ?? "未知模块";
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
function draftBindingKey(item) {
|
|
4985
|
+
if (item.draftType === "prose" && item.volumeId) return `volume:${item.volumeId}`;
|
|
4986
|
+
if (item.draftType === "setting" && item.settingModule) return `module:${item.settingModule}`;
|
|
4987
|
+
return "global";
|
|
4988
|
+
}
|
|
4989
|
+
|
|
4990
|
+
function draftBindingLabel(item) {
|
|
4991
|
+
if (item.draftType === "prose" && item.volumeId) return `分卷 · ${item.volumeTitle || "已删除分卷"}`;
|
|
4992
|
+
if (item.draftType === "setting" && item.settingModule) return `设定模块 · ${draftSettingModuleLabel(item.settingModule)}`;
|
|
4993
|
+
return "全局";
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4630
4996
|
async function deleteDraft(item) {
|
|
4631
4997
|
if (!item || !canEditModule("drafts")) return;
|
|
4632
|
-
|
|
4633
|
-
dialog.close();
|
|
4998
|
+
$("#form-dialog").close();
|
|
4634
4999
|
if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
|
|
4635
5000
|
title: "删除想法",
|
|
5001
|
+
confirmLabel: "继续删除"
|
|
5002
|
+
})) {
|
|
5003
|
+
openDraftDialog(item);
|
|
5004
|
+
return;
|
|
5005
|
+
}
|
|
5006
|
+
if (!await confirmToast(`删除想法“${item.title}”后将从想法列表移除,版本历史仍会保留。仍要删除吗?`, {
|
|
5007
|
+
title: "删除操作需要再次确认",
|
|
4636
5008
|
confirmLabel: "确认删除"
|
|
4637
5009
|
})) {
|
|
4638
5010
|
openDraftDialog(item);
|
|
@@ -4654,23 +5026,30 @@ async function deleteDraft(item) {
|
|
|
4654
5026
|
|
|
4655
5027
|
function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
4656
5028
|
const viewOnly = readOnly || !canEditModule("drafts");
|
|
5029
|
+
const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
|
|
5030
|
+
const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
|
|
4657
5031
|
const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="想法操作">
|
|
4658
5032
|
<div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
|
|
4659
5033
|
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
|
|
4660
5034
|
</section>` : "";
|
|
4661
|
-
const fields =
|
|
4662
|
-
+ field("
|
|
5035
|
+
const fields = field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
|
|
5036
|
+
+ `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
|
|
5037
|
+
+ `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
|
|
4663
5038
|
+ field("title", "标题", "text", item?.title ?? "")
|
|
4664
5039
|
+ field("content", "内容", "markdown", item?.content ?? "", {
|
|
4665
5040
|
placeholder: "记录尚未定稿的片段、方向或设定想法……",
|
|
5041
|
+
attachmentModule: "drafts",
|
|
4666
5042
|
readOnly: viewOnly
|
|
4667
5043
|
}) + management;
|
|
4668
5044
|
openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
|
|
4669
5045
|
if (viewOnly) return;
|
|
4670
5046
|
const title = String(form.get("title") ?? "").trim();
|
|
4671
5047
|
if (!title) throw new Error("请填写想法标题");
|
|
5048
|
+
const draftType = form.get("draftType") === "setting" ? "setting" : "prose";
|
|
4672
5049
|
const body = {
|
|
4673
|
-
draftType
|
|
5050
|
+
draftType,
|
|
5051
|
+
volumeId: draftType === "prose" ? String(form.get("volumeId") ?? "") || null : null,
|
|
5052
|
+
settingModule: draftType === "setting" ? String(form.get("settingModule") ?? "") || null : null,
|
|
4674
5053
|
title,
|
|
4675
5054
|
content: String(form.get("content") ?? ""),
|
|
4676
5055
|
...(item ? { expectedVersionNo: item.versionNo } : {})
|
|
@@ -4685,8 +5064,21 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4685
5064
|
submitLabel: viewOnly ? "关闭" : "保存想法",
|
|
4686
5065
|
hideCancel: viewOnly,
|
|
4687
5066
|
editor: true,
|
|
4688
|
-
errorPrefix: "想法保存失败:"
|
|
4689
|
-
|
|
5067
|
+
errorPrefix: "想法保存失败:",
|
|
5068
|
+
meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。"
|
|
5069
|
+
});
|
|
5070
|
+
const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
|
|
5071
|
+
const syncDraftBindingFields = () => {
|
|
5072
|
+
const draftType = draftTypeSelect?.value === "setting" ? "setting" : "prose";
|
|
5073
|
+
$("#dialog-fields").querySelectorAll("[data-draft-binding-field]").forEach((bindingField) => {
|
|
5074
|
+
const active = bindingField.dataset.draftBindingField === draftType;
|
|
5075
|
+
bindingField.classList.toggle("hidden", !active);
|
|
5076
|
+
const select = bindingField.querySelector("select");
|
|
5077
|
+
if (select) select.disabled = !active || viewOnly;
|
|
5078
|
+
});
|
|
5079
|
+
};
|
|
5080
|
+
draftTypeSelect?.addEventListener("change", syncDraftBindingFields);
|
|
5081
|
+
syncDraftBindingFields();
|
|
4690
5082
|
if (viewOnly) {
|
|
4691
5083
|
$("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
|
|
4692
5084
|
if (control instanceof HTMLSelectElement) control.disabled = true;
|
|
@@ -4700,9 +5092,14 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4700
5092
|
|
|
4701
5093
|
async function renderDrafts(page = moduleListPages.drafts) {
|
|
4702
5094
|
const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
|
|
4703
|
-
const
|
|
5095
|
+
const typeDrafts = draftTypeFilter === "all"
|
|
4704
5096
|
? allDrafts
|
|
4705
5097
|
: allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
|
|
5098
|
+
const selectedBindingKeys = new Set(draftBindingFilters);
|
|
5099
|
+
const drafts = selectedBindingKeys.size
|
|
5100
|
+
? typeDrafts.filter((draft) => selectedBindingKeys.has(draftBindingKey(draft)))
|
|
5101
|
+
: typeDrafts;
|
|
5102
|
+
const hasDraftFilters = draftTypeFilter !== "all" || selectedBindingKeys.size > 0;
|
|
4706
5103
|
mountModuleCount(drafts.length);
|
|
4707
5104
|
const pageResult = paginateModuleItems(drafts, page, "drafts");
|
|
4708
5105
|
moduleListPages.drafts = pageResult.page;
|
|
@@ -4710,21 +5107,26 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4710
5107
|
if (drafts.length) mountModuleLayoutToggle(layout, "想法列表样式");
|
|
4711
5108
|
else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
|
|
4712
5109
|
mountDraftFilterToggle();
|
|
4713
|
-
const
|
|
4714
|
-
|
|
4715
|
-
|
|
5110
|
+
const bindingOptions = [
|
|
5111
|
+
["global", "全局(未绑定)"],
|
|
5112
|
+
...(state.work?.volumes ?? []).map((volume) => [`volume:${volume.id}`, `分卷 · ${volume.title}`]),
|
|
5113
|
+
...draftSettingModules.map(([value, label]) => [`module:${value}`, `设定模块 · ${label}`])
|
|
5114
|
+
];
|
|
5115
|
+
const filterToolbar = `<section id="draft-filter-panel" class="character-filter-toolbar draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="想法筛选">
|
|
5116
|
+
<label class="draft-type-filter-field" for="draft-type-filter"><span>按想法类型筛选</span><select id="draft-type-filter" aria-label="按想法类型筛选">
|
|
4716
5117
|
<option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部想法</option>
|
|
4717
5118
|
<option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文想法</option>
|
|
4718
5119
|
<option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定想法</option>
|
|
4719
|
-
</select>
|
|
4720
|
-
${
|
|
5120
|
+
</select></label>
|
|
5121
|
+
<details class="character-filter-dropdown"><summary><span>按绑定位置筛选</span><strong>${selectedBindingKeys.size ? `已选 ${selectedBindingKeys.size} 项` : "全部位置"}</strong></summary><div id="draft-binding-filter" class="character-filter-options">${bindingOptions.map(([value, label]) => `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedBindingKeys.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`).join("")}</div></details>
|
|
5122
|
+
<div class="character-filter-toolbar-actions">${hasDraftFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>` : ""}<button id="clear-draft-filters" class="ghost-button" type="button" ${hasDraftFilters ? "" : "disabled"}>重置筛选</button></div>
|
|
4721
5123
|
</section>`;
|
|
4722
5124
|
const actions = (item) => canEditModule("drafts")
|
|
4723
5125
|
? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
|
|
4724
5126
|
: recordHistoryButton("draft", item.id, item.title);
|
|
4725
5127
|
const cards = `<div class="card-grid">${pageResult.items.map((item) => `
|
|
4726
5128
|
<article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
|
|
4727
|
-
<small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
5129
|
+
<small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
4728
5130
|
<h3>${esc(item.title)}</h3>
|
|
4729
5131
|
<div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
|
|
4730
5132
|
<div class="card-actions">${actions(item)}</div>
|
|
@@ -4732,13 +5134,13 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4732
5134
|
const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
|
|
4733
5135
|
const preview = moduleRowPreview(item.contentPreview || "暂无内容");
|
|
4734
5136
|
return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
|
|
4735
|
-
<small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
5137
|
+
<small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
4736
5138
|
<h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
4737
5139
|
<div class="card-actions">${actions(item)}</div>
|
|
4738
5140
|
</article>`;
|
|
4739
5141
|
}).join("")}</div>`;
|
|
4740
5142
|
const emptyDrafts = allDrafts.length
|
|
4741
|
-
? emptyModule("没有符合筛选条件的想法", "
|
|
5143
|
+
? emptyModule("没有符合筛选条件的想法", "可以切换想法类型、绑定位置或重置筛选。")
|
|
4742
5144
|
: emptyModule("还没有想法", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
|
|
4743
5145
|
$("#module-content").innerHTML = filterToolbar + (drafts.length
|
|
4744
5146
|
? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "想法列表")}`
|
|
@@ -4749,6 +5151,19 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4749
5151
|
moduleListPages.drafts = 1;
|
|
4750
5152
|
await renderDrafts(1);
|
|
4751
5153
|
});
|
|
5154
|
+
$("#draft-binding-filter").addEventListener("change", async () => {
|
|
5155
|
+
draftBindingFilters = [...$("#draft-binding-filter").querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
|
|
5156
|
+
draftFiltersPanelOpen = true;
|
|
5157
|
+
moduleListPages.drafts = 1;
|
|
5158
|
+
await renderDrafts(1);
|
|
5159
|
+
});
|
|
5160
|
+
$("#clear-draft-filters")?.addEventListener("click", async () => {
|
|
5161
|
+
draftTypeFilter = "all";
|
|
5162
|
+
draftBindingFilters = [];
|
|
5163
|
+
draftFiltersPanelOpen = true;
|
|
5164
|
+
moduleListPages.drafts = 1;
|
|
5165
|
+
await renderDrafts(1);
|
|
5166
|
+
});
|
|
4752
5167
|
bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
|
|
4753
5168
|
bindModulePagination("drafts", renderDrafts);
|
|
4754
5169
|
$("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
|
|
@@ -6479,21 +6894,72 @@ function tokenUsageCalendarMarkup(daily) {
|
|
|
6479
6894
|
const calendar = buildUsageCalendar(daily);
|
|
6480
6895
|
const cells = calendar.cells.map((cell) => {
|
|
6481
6896
|
const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
|
|
6482
|
-
return
|
|
6897
|
+
return cell.future
|
|
6898
|
+
? `<span class="usage-calendar-cell is-future" data-level="${cell.level}" role="gridcell" aria-disabled="true"></span>`
|
|
6899
|
+
: `<button class="usage-calendar-cell" type="button" data-level="${cell.level}" data-usage-calendar-label="${esc(label)}" role="gridcell" aria-label="${esc(label)}"></button>`;
|
|
6483
6900
|
}).join("");
|
|
6484
6901
|
const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
|
|
6485
|
-
return `<div class="usage-calendar-
|
|
6486
|
-
<div class="usage-calendar-
|
|
6487
|
-
<div class="usage-calendar-
|
|
6488
|
-
|
|
6489
|
-
<div class="usage-calendar-
|
|
6490
|
-
|
|
6902
|
+
return `<div class="usage-calendar-widget">
|
|
6903
|
+
<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
|
|
6904
|
+
<div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
|
|
6905
|
+
<div class="usage-calendar-months" aria-hidden="true">${months}</div>
|
|
6906
|
+
<div class="usage-calendar-body">
|
|
6907
|
+
<div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
|
|
6908
|
+
<div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
|
|
6909
|
+
</div>
|
|
6491
6910
|
</div>
|
|
6492
6911
|
</div>
|
|
6912
|
+
<output class="usage-calendar-tooltip" role="tooltip" hidden></output>
|
|
6493
6913
|
</div>
|
|
6494
6914
|
<div class="usage-calendar-legend"><span>少</span>${[0, 1, 2, 3, 4].map((level) => `<i data-level="${level}" aria-hidden="true"></i>`).join("")}<span>多</span></div>`;
|
|
6495
6915
|
}
|
|
6496
6916
|
|
|
6917
|
+
function bindUsageCalendarInteractions(root) {
|
|
6918
|
+
root.querySelectorAll(".usage-calendar-widget").forEach((widget) => {
|
|
6919
|
+
const tooltip = widget.querySelector(".usage-calendar-tooltip");
|
|
6920
|
+
const calendarScroll = widget.querySelector(".usage-calendar-scroll");
|
|
6921
|
+
let activeCell = null;
|
|
6922
|
+
const hideTooltip = () => {
|
|
6923
|
+
tooltip.hidden = true;
|
|
6924
|
+
activeCell = null;
|
|
6925
|
+
};
|
|
6926
|
+
const showTooltip = (cell) => {
|
|
6927
|
+
activeCell = cell;
|
|
6928
|
+
tooltip.textContent = cell.dataset.usageCalendarLabel;
|
|
6929
|
+
tooltip.hidden = false;
|
|
6930
|
+
const widgetRect = widget.getBoundingClientRect();
|
|
6931
|
+
const cellRect = cell.getBoundingClientRect();
|
|
6932
|
+
const edgeInset = tooltip.offsetWidth / 2 + 8;
|
|
6933
|
+
const centeredLeft = cellRect.left + cellRect.width / 2 - widgetRect.left;
|
|
6934
|
+
const fitsAbove = cellRect.top - widgetRect.top >= tooltip.offsetHeight + 8;
|
|
6935
|
+
tooltip.dataset.placement = fitsAbove ? "top" : "bottom";
|
|
6936
|
+
tooltip.style.left = `${Math.min(widget.clientWidth - edgeInset, Math.max(edgeInset, centeredLeft))}px`;
|
|
6937
|
+
tooltip.style.top = fitsAbove
|
|
6938
|
+
? `${cellRect.top - widgetRect.top - 8}px`
|
|
6939
|
+
: `${cellRect.bottom - widgetRect.top + 8}px`;
|
|
6940
|
+
};
|
|
6941
|
+
widget.querySelectorAll("button.usage-calendar-cell").forEach((cell) => {
|
|
6942
|
+
cell.addEventListener("mouseenter", () => showTooltip(cell));
|
|
6943
|
+
cell.addEventListener("mouseleave", () => {
|
|
6944
|
+
if (document.activeElement !== cell) hideTooltip();
|
|
6945
|
+
});
|
|
6946
|
+
cell.addEventListener("focus", () => showTooltip(cell));
|
|
6947
|
+
cell.addEventListener("blur", () => {
|
|
6948
|
+
if (!cell.matches(":hover")) hideTooltip();
|
|
6949
|
+
});
|
|
6950
|
+
cell.addEventListener("click", () => showTooltip(cell));
|
|
6951
|
+
cell.addEventListener("keydown", (event) => {
|
|
6952
|
+
if (event.key !== "Escape") return;
|
|
6953
|
+
hideTooltip();
|
|
6954
|
+
cell.blur();
|
|
6955
|
+
});
|
|
6956
|
+
});
|
|
6957
|
+
calendarScroll.addEventListener("scroll", () => {
|
|
6958
|
+
if (activeCell && !tooltip.hidden) showTooltip(activeCell);
|
|
6959
|
+
});
|
|
6960
|
+
});
|
|
6961
|
+
}
|
|
6962
|
+
|
|
6497
6963
|
function scrollUsageCalendarsToLatest(root) {
|
|
6498
6964
|
window.requestAnimationFrame(() => {
|
|
6499
6965
|
root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
|
|
@@ -6552,6 +7018,7 @@ async function renderPlatformTokenUsage() {
|
|
|
6552
7018
|
description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
|
|
6553
7019
|
showWorks: true
|
|
6554
7020
|
});
|
|
7021
|
+
bindUsageCalendarInteractions(host);
|
|
6555
7022
|
scrollUsageCalendarsToLatest(host);
|
|
6556
7023
|
}
|
|
6557
7024
|
|
|
@@ -6571,10 +7038,20 @@ async function renderBookAiSettings() {
|
|
|
6571
7038
|
const host = $("#module-content");
|
|
6572
7039
|
const workId = String(state.work.id);
|
|
6573
7040
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
|
|
7041
|
+
const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
|
|
7042
|
+
const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
|
|
7043
|
+
const quotaRemainingTokens = usage?.quota?.remainingTokens === null
|
|
7044
|
+
? null
|
|
7045
|
+
: Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
|
|
7046
|
+
const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
|
|
7047
|
+
const quotaStatusText = dailyTokenQuota === null
|
|
7048
|
+
? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
|
|
7049
|
+
: `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
|
|
6574
7050
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
6575
7051
|
title: "本书 Token 用量",
|
|
6576
7052
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
6577
|
-
})}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p
|
|
7053
|
+
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
7054
|
+
bindUsageCalendarInteractions(host);
|
|
6578
7055
|
scrollUsageCalendarsToLatest(host);
|
|
6579
7056
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
6580
7057
|
"beforebegin",
|
|
@@ -6590,6 +7067,7 @@ async function renderBookAiSettings() {
|
|
|
6590
7067
|
);
|
|
6591
7068
|
if (!canEditModule("ai-settings")) {
|
|
6592
7069
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
7070
|
+
host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button").forEach((button) => { button.disabled = true; });
|
|
6593
7071
|
host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
|
|
6594
7072
|
}
|
|
6595
7073
|
const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
|
|
@@ -6619,6 +7097,31 @@ async function renderBookAiSettings() {
|
|
|
6619
7097
|
return status;
|
|
6620
7098
|
};
|
|
6621
7099
|
updateRelationshipIndexStatus(relationshipIndex);
|
|
7100
|
+
$("#daily-token-quota-enabled").addEventListener("change", (event) => {
|
|
7101
|
+
$("#daily-token-quota").disabled = !event.currentTarget.checked;
|
|
7102
|
+
});
|
|
7103
|
+
$("#save-daily-token-quota").addEventListener("click", async () => {
|
|
7104
|
+
const button = $("#save-daily-token-quota");
|
|
7105
|
+
const enabled = $("#daily-token-quota-enabled").checked;
|
|
7106
|
+
const quota = Number($("#daily-token-quota").value);
|
|
7107
|
+
if (enabled && (!Number.isInteger(quota) || quota < 10_000 || quota > 2_000_000_000)) {
|
|
7108
|
+
toast("每日 Token 额度必须是 10,000 到 2,000,000,000 之间的整数", "error");
|
|
7109
|
+
$("#daily-token-quota").focus();
|
|
7110
|
+
return;
|
|
7111
|
+
}
|
|
7112
|
+
button.disabled = true;
|
|
7113
|
+
try {
|
|
7114
|
+
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
7115
|
+
method: "PATCH",
|
|
7116
|
+
body: { dailyTokenQuota: enabled ? quota : null }
|
|
7117
|
+
});
|
|
7118
|
+
toast(enabled ? "本书每日 Token 额度已保存" : "本书每日 Token 额度限制已关闭");
|
|
7119
|
+
await renderBookAiSettings();
|
|
7120
|
+
} catch (error) {
|
|
7121
|
+
toast(error.message, "error");
|
|
7122
|
+
button.disabled = false;
|
|
7123
|
+
}
|
|
7124
|
+
});
|
|
6622
7125
|
$("#save-work-system-prompt").addEventListener("click", async () => {
|
|
6623
7126
|
const button = $("#save-work-system-prompt");
|
|
6624
7127
|
button.disabled = true;
|
|
@@ -6698,6 +7201,34 @@ async function renderBookAiSettings() {
|
|
|
6698
7201
|
button.disabled = false;
|
|
6699
7202
|
}
|
|
6700
7203
|
});
|
|
7204
|
+
$("#save-agent-tool-call-limit").addEventListener("click", async () => {
|
|
7205
|
+
const button = $("#save-agent-tool-call-limit");
|
|
7206
|
+
button.disabled = true;
|
|
7207
|
+
try {
|
|
7208
|
+
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
7209
|
+
method: "PATCH",
|
|
7210
|
+
body: {
|
|
7211
|
+
agentToolCallLimit: Number($("#agent-tool-call-limit").value),
|
|
7212
|
+
agentToolCallGlobalMultiplier: Number($("#agent-tool-call-global-multiplier").value)
|
|
7213
|
+
}
|
|
7214
|
+
});
|
|
7215
|
+
toast("Agent 工具调用上限已保存");
|
|
7216
|
+
} catch (error) {
|
|
7217
|
+
toast(error.message, "error");
|
|
7218
|
+
} finally {
|
|
7219
|
+
button.disabled = false;
|
|
7220
|
+
}
|
|
7221
|
+
});
|
|
7222
|
+
host.querySelector(".agent-tool-call-global-multiplier-toggle")?.addEventListener("click", (event) => {
|
|
7223
|
+
const option = event.target.closest("button[data-global-multiplier]");
|
|
7224
|
+
if (!option || option.disabled) return;
|
|
7225
|
+
const value = option.getAttribute("data-global-multiplier");
|
|
7226
|
+
const hidden = $("#agent-tool-call-global-multiplier");
|
|
7227
|
+
if (hidden) hidden.value = value;
|
|
7228
|
+
host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button[data-global-multiplier]").forEach((item) => {
|
|
7229
|
+
item.setAttribute("aria-pressed", String(item === option));
|
|
7230
|
+
});
|
|
7231
|
+
});
|
|
6701
7232
|
$("#save-agent-tools").addEventListener("click", async () => {
|
|
6702
7233
|
const button = $("#save-agent-tools");
|
|
6703
7234
|
button.disabled = true;
|
|
@@ -6836,10 +7367,12 @@ function renderAiContextDistribution(usage) {
|
|
|
6836
7367
|
}
|
|
6837
7368
|
|
|
6838
7369
|
function setAiContextMeter(usage) {
|
|
7370
|
+
const displayUsage = resolveAiContextUsage(latestAiContextUsage, usage);
|
|
7371
|
+
latestAiContextUsage = displayUsage;
|
|
6839
7372
|
const meter = $("#ai-context-meter");
|
|
6840
7373
|
const value = meter.querySelector("b");
|
|
6841
|
-
renderAiContextDistribution(
|
|
6842
|
-
if (!
|
|
7374
|
+
renderAiContextDistribution(displayUsage);
|
|
7375
|
+
if (!displayUsage) {
|
|
6843
7376
|
meter.classList.add("is-empty");
|
|
6844
7377
|
meter.classList.remove("is-warning", "is-danger");
|
|
6845
7378
|
meter.style.setProperty("--context-usage", "0");
|
|
@@ -6849,17 +7382,22 @@ function setAiContextMeter(usage) {
|
|
|
6849
7382
|
meter.setAttribute("aria-label", tooltip);
|
|
6850
7383
|
return;
|
|
6851
7384
|
}
|
|
6852
|
-
const percent = Math.max(0, Math.min(100, Number(
|
|
7385
|
+
const percent = Math.max(0, Math.min(100, Number(displayUsage.usagePercent) || 0));
|
|
6853
7386
|
meter.classList.remove("is-empty");
|
|
6854
7387
|
meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
|
|
6855
7388
|
meter.classList.toggle("is-danger", percent >= 90);
|
|
6856
7389
|
meter.style.setProperty("--context-usage", String(percent));
|
|
6857
7390
|
value.textContent = `${percent}%`;
|
|
6858
|
-
const tooltip = formatAiContextUsageTooltip(
|
|
7391
|
+
const tooltip = formatAiContextUsageTooltip(displayUsage);
|
|
6859
7392
|
meter.dataset.tooltip = tooltip;
|
|
6860
7393
|
meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
|
|
6861
7394
|
}
|
|
6862
7395
|
|
|
7396
|
+
function resetAiContextMeter() {
|
|
7397
|
+
latestAiContextUsage = null;
|
|
7398
|
+
setAiContextMeter(null);
|
|
7399
|
+
}
|
|
7400
|
+
|
|
6863
7401
|
function setAiContextDistributionVisible(visible) {
|
|
6864
7402
|
const meter = $("#ai-context-meter");
|
|
6865
7403
|
const popover = $("#ai-context-popover");
|
|
@@ -6912,7 +7450,7 @@ async function ensureAiReferencesLoaded() {
|
|
|
6912
7450
|
|
|
6913
7451
|
function field(name, label, type = "text", value = "", options = []) {
|
|
6914
7452
|
if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
|
|
6915
|
-
if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
|
|
7453
|
+
if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-attachment-module="${esc(options.attachmentModule ?? "settings")}" data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
|
|
6916
7454
|
if (type === "item-list") {
|
|
6917
7455
|
const values = Array.isArray(value) && value.length ? value : [""];
|
|
6918
7456
|
return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
|
|
@@ -7219,9 +7757,39 @@ function bindWorkCoverControls(work) {
|
|
|
7219
7757
|
});
|
|
7220
7758
|
}
|
|
7221
7759
|
|
|
7222
|
-
function downloadWorkManuscript(work) {
|
|
7760
|
+
function downloadWorkManuscript(work, format = "markdown") {
|
|
7223
7761
|
if (!work?.id) return;
|
|
7224
|
-
|
|
7762
|
+
const exportFormat = format === "docx" ? "docx" : "markdown";
|
|
7763
|
+
window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=${exportFormat}`;
|
|
7764
|
+
}
|
|
7765
|
+
|
|
7766
|
+
let manuscriptExportWork = null;
|
|
7767
|
+
|
|
7768
|
+
function closeManuscriptExportMenu() {
|
|
7769
|
+
const menu = $("#manuscript-export-menu");
|
|
7770
|
+
if (!menu) return;
|
|
7771
|
+
menu.classList.add("hidden");
|
|
7772
|
+
manuscriptExportWork = null;
|
|
7773
|
+
$("#export-button")?.setAttribute("aria-expanded", "false");
|
|
7774
|
+
$("#work-export-button")?.setAttribute("aria-expanded", "false");
|
|
7775
|
+
}
|
|
7776
|
+
|
|
7777
|
+
function showManuscriptExportMenu(anchor, work) {
|
|
7778
|
+
if (!work?.id || !anchor) return;
|
|
7779
|
+
const menu = $("#manuscript-export-menu");
|
|
7780
|
+
if (!menu) return;
|
|
7781
|
+
manuscriptExportWork = work;
|
|
7782
|
+
menu.classList.remove("hidden");
|
|
7783
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
7784
|
+
const menuRect = menu.getBoundingClientRect();
|
|
7785
|
+
const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - menuRect.width - 8));
|
|
7786
|
+
const top = Math.max(8, Math.min(anchorRect.bottom + 6, window.innerHeight - menuRect.height - 8));
|
|
7787
|
+
menu.style.left = `${left}px`;
|
|
7788
|
+
menu.style.top = `${top}px`;
|
|
7789
|
+
if (anchor.id === "export-button" || anchor.id === "work-export-button") {
|
|
7790
|
+
anchor.setAttribute("aria-expanded", "true");
|
|
7791
|
+
}
|
|
7792
|
+
menu.querySelector("button[data-export-format]")?.focus();
|
|
7225
7793
|
}
|
|
7226
7794
|
|
|
7227
7795
|
function openWorkSettingsDialog(work) {
|
|
@@ -7241,8 +7809,8 @@ function openWorkSettingsDialog(work) {
|
|
|
7241
7809
|
<button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
|
|
7242
7810
|
</section>`;
|
|
7243
7811
|
const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
|
|
7244
|
-
<div><strong id="work-export-settings-title">导出正文</strong><small
|
|
7245
|
-
<button id="work-export-button" class="ghost-button" type="button"
|
|
7812
|
+
<div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择导出 Markdown ZIP 或 DOCX(书名、分卷、章节为一级至三级标题;若已设置封面则嵌入为首页)。不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
|
|
7813
|
+
<button id="work-export-button" class="ghost-button" type="button" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false">导出正文</button>
|
|
7246
7814
|
</section>`;
|
|
7247
7815
|
const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
|
|
7248
7816
|
<div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
|
|
@@ -7276,7 +7844,11 @@ function openWorkSettingsDialog(work) {
|
|
|
7276
7844
|
$("#form-dialog").close();
|
|
7277
7845
|
void openImportHistory();
|
|
7278
7846
|
});
|
|
7279
|
-
$("#work-export-button")?.addEventListener("click", () =>
|
|
7847
|
+
$("#work-export-button")?.addEventListener("click", (event) => {
|
|
7848
|
+
event.preventDefault();
|
|
7849
|
+
event.stopPropagation();
|
|
7850
|
+
showManuscriptExportMenu(event.currentTarget, work);
|
|
7851
|
+
});
|
|
7280
7852
|
$("#chapter-recycle-bin-button")?.addEventListener("click", () => {
|
|
7281
7853
|
$("#form-dialog").close();
|
|
7282
7854
|
void openChapterRecycleBin();
|
|
@@ -7598,10 +8170,10 @@ function markdownImageLabel(file, fallback = "图片附件") {
|
|
|
7598
8170
|
return String(file?.name ?? "").replace(/[\[\]\r\n]/gu, "").trim() || fallback;
|
|
7599
8171
|
}
|
|
7600
8172
|
|
|
7601
|
-
async function uploadMarkdownAttachment(file) {
|
|
8173
|
+
async function uploadMarkdownAttachment(file, module = "settings") {
|
|
7602
8174
|
const body = new FormData();
|
|
7603
8175
|
body.append("file", file);
|
|
7604
|
-
const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
|
|
8176
|
+
const attachment = await api(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body });
|
|
7605
8177
|
if (!attachment.deduplicated) markdownEditorPendingAttachments.push(String(attachment.id));
|
|
7606
8178
|
return { attachment, imageLabel: markdownImageLabel(file) };
|
|
7607
8179
|
}
|
|
@@ -7632,7 +8204,7 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
|
|
|
7632
8204
|
};
|
|
7633
8205
|
}
|
|
7634
8206
|
|
|
7635
|
-
function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment =
|
|
8207
|
+
function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
|
|
7636
8208
|
if (!window.Vditor) {
|
|
7637
8209
|
toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
|
|
7638
8210
|
return null;
|
|
@@ -7663,7 +8235,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
7663
8235
|
accept: "image/*",
|
|
7664
8236
|
max: 10 * 1024 * 1024,
|
|
7665
8237
|
multiple: true,
|
|
7666
|
-
handler: createVditorUploadHandler(uploadAttachment, () => editor)
|
|
8238
|
+
handler: createVditorUploadHandler(uploadAttachment ?? ((file) => uploadMarkdownAttachment(file, attachmentModule)), () => editor)
|
|
7667
8239
|
},
|
|
7668
8240
|
input: (markdown) => {
|
|
7669
8241
|
normalizeVditorAttachmentImages(editor);
|
|
@@ -7892,6 +8464,7 @@ function bindVditorEditors(container) {
|
|
|
7892
8464
|
if (valueField) valueField.value = markdown;
|
|
7893
8465
|
markEntityEditorDirty();
|
|
7894
8466
|
},
|
|
8467
|
+
attachmentModule: host.dataset.attachmentModule ?? "settings",
|
|
7895
8468
|
placeholder: host.dataset.placeholder ?? "",
|
|
7896
8469
|
readOnly: Boolean(valueField?.readOnly)
|
|
7897
8470
|
});
|
|
@@ -7907,7 +8480,7 @@ function characterSectionImageLabel(file, fallback = "图片附件") {
|
|
|
7907
8480
|
async function uploadCharacterSectionAttachment(file) {
|
|
7908
8481
|
const body = new FormData();
|
|
7909
8482
|
body.append("file", file);
|
|
7910
|
-
const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
|
|
8483
|
+
const attachment = await api(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body });
|
|
7911
8484
|
if (!attachment.deduplicated) characterSectionPendingAttachments.push(String(attachment.id));
|
|
7912
8485
|
return {
|
|
7913
8486
|
attachment,
|
|
@@ -7980,6 +8553,7 @@ async function openKnowledgeSectionEditor(index = null) {
|
|
|
7980
8553
|
host.querySelectorAll("input, textarea").forEach((control) => control.addEventListener("input", () => { knowledgeSectionEditorDirty = true; }));
|
|
7981
8554
|
knowledgeSectionVditor = createVditorEditor($("#knowledge-section-markdown"), section?.contentMarkdown ?? "", {
|
|
7982
8555
|
onInput: () => { knowledgeSectionEditorDirty = true; },
|
|
8556
|
+
attachmentModule: knowledgeEditorKind === "race" ? "races" : "organizations",
|
|
7983
8557
|
placeholder: "从这里开始写 Markdown 设定…",
|
|
7984
8558
|
width: "100%"
|
|
7985
8559
|
});
|
|
@@ -9212,6 +9786,7 @@ async function sendAi() {
|
|
|
9212
9786
|
try {
|
|
9213
9787
|
await ensureAiModelsLoaded();
|
|
9214
9788
|
} catch (error) {
|
|
9789
|
+
setAiAssistantStatus("error");
|
|
9215
9790
|
return toast(`创作助手加载失败:${error.message}`, "error");
|
|
9216
9791
|
}
|
|
9217
9792
|
const modelId = $("#ai-model").value;
|
|
@@ -9222,12 +9797,14 @@ async function sendAi() {
|
|
|
9222
9797
|
if (!requestScope) return toast("请先选择章节", "error");
|
|
9223
9798
|
const { taskType, scope, selection } = requestScope;
|
|
9224
9799
|
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
9800
|
+
setAiAssistantStatus("ready");
|
|
9225
9801
|
const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
9226
9802
|
let persistedUserMessage = null;
|
|
9227
9803
|
if (taskType !== "chat") {
|
|
9228
9804
|
try {
|
|
9229
9805
|
persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
|
|
9230
9806
|
} catch (error) {
|
|
9807
|
+
setAiAssistantStatus("error");
|
|
9231
9808
|
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
9232
9809
|
}
|
|
9233
9810
|
state.aiPromptSent = true;
|
|
@@ -9253,6 +9830,10 @@ async function sendAi() {
|
|
|
9253
9830
|
applyAiConversationTitle(streamed.conversationTitle);
|
|
9254
9831
|
} else {
|
|
9255
9832
|
suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
|
|
9833
|
+
const suggestionFailed = suggestion.guard?.status === "failed"
|
|
9834
|
+
|| suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
9835
|
+
|| suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
9836
|
+
if (suggestionFailed) setAiAssistantStatus("error");
|
|
9256
9837
|
setAiContextMeter(suggestion.contextUsage);
|
|
9257
9838
|
assistantContent = suggestion.content;
|
|
9258
9839
|
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
|
|
@@ -9275,10 +9856,12 @@ async function sendAi() {
|
|
|
9275
9856
|
} else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
|
|
9276
9857
|
}
|
|
9277
9858
|
} catch (error) {
|
|
9859
|
+
setAiAssistantStatus("error");
|
|
9278
9860
|
if (suggestion) appendSuggestion(suggestion);
|
|
9279
9861
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
9280
9862
|
}
|
|
9281
9863
|
} catch (error) {
|
|
9864
|
+
setAiAssistantStatus("error");
|
|
9282
9865
|
const failureMessage = formatAiFailureMessage(error);
|
|
9283
9866
|
let persistedFailureMessage = null;
|
|
9284
9867
|
try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
@@ -9406,6 +9989,7 @@ async function streamChat(body) {
|
|
|
9406
9989
|
const toolCall = { ...payload };
|
|
9407
9990
|
const round = toolCall.round;
|
|
9408
9991
|
delete toolCall.round;
|
|
9992
|
+
if (toolCall.status === "failed") setAiAssistantStatus("error");
|
|
9409
9993
|
toolCalls.push(toolCall);
|
|
9410
9994
|
processSteps.push(aiToolProcessStep(toolCall, round));
|
|
9411
9995
|
renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
|
|
@@ -9433,6 +10017,7 @@ async function streamChat(body) {
|
|
|
9433
10017
|
attachAssistantCopyAction(message, streamedText);
|
|
9434
10018
|
scrollAiFeedToBottom();
|
|
9435
10019
|
} else if (eventName === "error") {
|
|
10020
|
+
setAiAssistantStatus("error");
|
|
9436
10021
|
streamError = createClientError(payload, "AI 流式调用失败", response.status);
|
|
9437
10022
|
}
|
|
9438
10023
|
};
|
|
@@ -9877,6 +10462,28 @@ $("#onboarding-dialog").addEventListener("cancel", (event) => {
|
|
|
9877
10462
|
event.preventDefault();
|
|
9878
10463
|
completeOnboarding();
|
|
9879
10464
|
});
|
|
10465
|
+
$("#system-restart-dialog").addEventListener("cancel", (event) => {
|
|
10466
|
+
event.preventDefault();
|
|
10467
|
+
});
|
|
10468
|
+
function hasUnsavedEditorChanges() {
|
|
10469
|
+
return state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty;
|
|
10470
|
+
}
|
|
10471
|
+
|
|
10472
|
+
function redirectToLoginAfterSystemRestart() {
|
|
10473
|
+
state.user = null;
|
|
10474
|
+
state.csrfToken = null;
|
|
10475
|
+
moduleRequestCache.clear();
|
|
10476
|
+
document.documentElement.classList.remove("dev-auth-bypass");
|
|
10477
|
+
document.documentElement.classList.add("login-route");
|
|
10478
|
+
window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
|
|
10479
|
+
const toastRegion = $("#toast-region");
|
|
10480
|
+
toastRegion.replaceChildren();
|
|
10481
|
+
if (typeof toastRegion.hidePopover === "function" && toastRegion.matches(":popover-open")) toastRegion.hidePopover();
|
|
10482
|
+
$("#system-restart-dialog").close();
|
|
10483
|
+
showAuth(false);
|
|
10484
|
+
}
|
|
10485
|
+
|
|
10486
|
+
$("#system-restart-confirm").addEventListener("click", redirectToLoginAfterSystemRestart);
|
|
9880
10487
|
$("#onboarding-dialog").addEventListener("keydown", (event) => {
|
|
9881
10488
|
if (event.key === "Escape") {
|
|
9882
10489
|
event.preventDefault();
|
|
@@ -10381,6 +10988,7 @@ $("#register-form").addEventListener("submit", async (event) => {
|
|
|
10381
10988
|
username: form.get("username"),
|
|
10382
10989
|
password: form.get("password"),
|
|
10383
10990
|
passwordConfirmation: form.get("passwordConfirmation"),
|
|
10991
|
+
setupToken: form.get("setupToken") || undefined,
|
|
10384
10992
|
captchaId: form.get("captchaId"),
|
|
10385
10993
|
captchaAnswer: form.get("captchaAnswer")
|
|
10386
10994
|
}
|
|
@@ -10414,10 +11022,20 @@ $("#writing-progress-button").addEventListener("click", () => openWritingProgres
|
|
|
10414
11022
|
$("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
|
|
10415
11023
|
$("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
|
|
10416
11024
|
$("#writing-goal-form").addEventListener("submit", saveWritingGoal);
|
|
10417
|
-
$("#work-audit-button").addEventListener("click", () =>
|
|
10418
|
-
$("#work-audit-
|
|
10419
|
-
$("#work-audit-
|
|
10420
|
-
$("#work-audit-refresh")
|
|
11025
|
+
$("#work-audit-button").addEventListener("click", () => showWorkAudit().catch((error) => toast(error.message, "error")));
|
|
11026
|
+
$("#work-audit-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button").catch((error) => toast(error.message, "error")));
|
|
11027
|
+
$("#work-audit-refresh").addEventListener("click", async () => {
|
|
11028
|
+
const button = $("#work-audit-refresh");
|
|
11029
|
+
button.disabled = true;
|
|
11030
|
+
try {
|
|
11031
|
+
await loadWorkAuditPage();
|
|
11032
|
+
toast("操作记录已刷新");
|
|
11033
|
+
} catch (error) {
|
|
11034
|
+
toast(error.message, "error");
|
|
11035
|
+
} finally {
|
|
11036
|
+
button.disabled = false;
|
|
11037
|
+
}
|
|
11038
|
+
});
|
|
10421
11039
|
$("#work-audit-load-more").addEventListener("click", () => {
|
|
10422
11040
|
if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
|
|
10423
11041
|
});
|
|
@@ -10480,6 +11098,7 @@ $("#form-dialog").addEventListener("close", () => {
|
|
|
10480
11098
|
formDialogVditors.forEach(destroyVditorEditor);
|
|
10481
11099
|
formDialogVditors = [];
|
|
10482
11100
|
void discardPendingMarkdownAttachments();
|
|
11101
|
+
closeManuscriptExportMenu();
|
|
10483
11102
|
if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
|
|
10484
11103
|
});
|
|
10485
11104
|
$("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
|
|
@@ -10839,6 +11458,9 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
10839
11458
|
if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
|
|
10840
11459
|
if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
|
|
10841
11460
|
if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
|
|
11461
|
+
if (!event.target.closest("#manuscript-export-menu") && !event.target.closest("#export-button") && !event.target.closest("#work-export-button")) {
|
|
11462
|
+
closeManuscriptExportMenu();
|
|
11463
|
+
}
|
|
10842
11464
|
if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
|
|
10843
11465
|
if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
|
|
10844
11466
|
if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
|
|
@@ -10861,6 +11483,7 @@ document.addEventListener("keydown", (event) => {
|
|
|
10861
11483
|
closeChapterTypeMenu();
|
|
10862
11484
|
closeLineCitationMenu();
|
|
10863
11485
|
closeMarkdownTableMenu(true);
|
|
11486
|
+
closeManuscriptExportMenu();
|
|
10864
11487
|
hideAiMentionMenu();
|
|
10865
11488
|
setAiContextDistributionVisible(false);
|
|
10866
11489
|
}
|
|
@@ -11013,8 +11636,39 @@ $("#search-form").addEventListener("submit", async (event) => {
|
|
|
11013
11636
|
$("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
|
|
11014
11637
|
});
|
|
11015
11638
|
});
|
|
11016
|
-
$("#export-button").addEventListener("click", () =>
|
|
11017
|
-
|
|
11639
|
+
$("#export-button").addEventListener("click", (event) => {
|
|
11640
|
+
event.preventDefault();
|
|
11641
|
+
event.stopPropagation();
|
|
11642
|
+
if (!state.work) return;
|
|
11643
|
+
const menu = $("#manuscript-export-menu");
|
|
11644
|
+
const expanded = menu && !menu.classList.contains("hidden") && manuscriptExportWork?.id === state.work.id;
|
|
11645
|
+
if (expanded) {
|
|
11646
|
+
closeManuscriptExportMenu();
|
|
11647
|
+
return;
|
|
11648
|
+
}
|
|
11649
|
+
showManuscriptExportMenu(event.currentTarget, state.work);
|
|
11650
|
+
});
|
|
11651
|
+
$("#manuscript-export-menu").addEventListener("click", (event) => {
|
|
11652
|
+
const option = event.target.closest("[data-export-format]");
|
|
11653
|
+
if (!option || !manuscriptExportWork) return;
|
|
11654
|
+
const format = option.getAttribute("data-export-format") === "docx" ? "docx" : "markdown";
|
|
11655
|
+
const work = manuscriptExportWork;
|
|
11656
|
+
closeManuscriptExportMenu();
|
|
11657
|
+
downloadWorkManuscript(work, format);
|
|
11658
|
+
});
|
|
11659
|
+
document.addEventListener("visibilitychange", () => {
|
|
11660
|
+
if (document.visibilityState !== "visible") return;
|
|
11661
|
+
if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
|
|
11662
|
+
void refreshSystemHealth();
|
|
11663
|
+
});
|
|
11664
|
+
window.addEventListener("beforeunload", (event) => {
|
|
11665
|
+
if (hasUnsavedEditorChanges()) event.preventDefault();
|
|
11666
|
+
});
|
|
11667
|
+
window.addEventListener("online", () => {
|
|
11668
|
+
updateSystemHealth({ status: "checking" });
|
|
11669
|
+
void refreshSystemHealth();
|
|
11670
|
+
});
|
|
11671
|
+
window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
|
|
11018
11672
|
|
|
11019
11673
|
initializePage().catch((error) => {
|
|
11020
11674
|
restoringPageRoute = false;
|