3059 lines
111 KiB
JavaScript
3059 lines
111 KiB
JavaScript
let rows = [];
|
||
let filteredRows = [];
|
||
let structure = { chapters: [] };
|
||
let currentIndex = -1;
|
||
let currentRow = null;
|
||
let currentChapter = null;
|
||
let currentPart = null;
|
||
let currentImageItem = null;
|
||
let currentMode = "reading";
|
||
let showTouchedOnly = false;
|
||
let dirty = false;
|
||
let quickDirty = false;
|
||
let hasSession = false;
|
||
let expandedChapters = new Set();
|
||
let sidePanelMode = null;
|
||
let searchScope = "current";
|
||
let gptConfigLoaded = false;
|
||
let gptCandidateHtml = "";
|
||
let glossaryLoaded = false;
|
||
let glossaryEntries = [];
|
||
let glossaryDirty = false;
|
||
let savedReadingPosition = null;
|
||
let restoringReadingPosition = false;
|
||
let readingPositionSaveTimer = null;
|
||
let readingPositionInFlight = false;
|
||
let readingPositionSavePromise = null;
|
||
let pendingReadingPosition = null;
|
||
let readingPositionReady = false;
|
||
let readerSettings = { theme: "default", fontSize: 17, lineHeight: 1.95 };
|
||
let chromeHideTimer = null;
|
||
let chromeResizeObserver = null;
|
||
let chromePointerNearTop = false;
|
||
let translationSessionId = "";
|
||
let translationJobId = "";
|
||
let translationOutputSessionId = "";
|
||
let translationPollTimer = null;
|
||
let translationPromptDefaults = {};
|
||
let librarySessions = [];
|
||
let librarySeries = [];
|
||
let bookshelfFilter = { type: "all", value: "" };
|
||
let bookshelfSearch = "";
|
||
let activeSeriesId = "";
|
||
let activeBookshelfSessionId = "";
|
||
let activeSeriesConfig = null;
|
||
let translationUsesSeriesConfig = false;
|
||
|
||
const READER_SETTINGS_KEY = "epubReviewReaderSettings";
|
||
const READER_THEMES = new Set(["default", "eye", "night"]);
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const dom = {
|
||
startScreen: $("startScreen"),
|
||
bookshelfScreen: $("bookshelfScreen"),
|
||
translationScreen: $("translationScreen"),
|
||
reviewLayout: $("reviewLayout"),
|
||
appVersion: $("appVersion"),
|
||
sessionMeta: $("sessionMeta"),
|
||
bookshelfBtn: $("bookshelfBtn"),
|
||
readingModeBtn: $("readingModeBtn"),
|
||
polishModeBtn: $("polishModeBtn"),
|
||
openLibraryBtn: $("openLibraryBtn"),
|
||
reviewToolsBtn: $("reviewToolsBtn"),
|
||
showTouchedBtn: $("showTouchedBtn"),
|
||
feedbackBtn: $("feedbackBtn"),
|
||
applyBtn: $("applyBtn"),
|
||
exportBtn: $("exportBtn"),
|
||
tocPanelBtn: $("tocPanelBtn"),
|
||
searchPanelBtn: $("searchPanelBtn"),
|
||
sidebar: $("sidebar"),
|
||
sideTitle: $("sideTitle"),
|
||
closeSidebarBtn: $("closeSidebarBtn"),
|
||
tocPanel: $("tocPanel"),
|
||
searchPanel: $("searchPanel"),
|
||
searchInput: $("searchInput"),
|
||
searchScopeCurrentBtn: $("searchScopeCurrentBtn"),
|
||
searchScopeBookBtn: $("searchScopeBookBtn"),
|
||
stats: $("stats"),
|
||
expandTocBtn: $("expandTocBtn"),
|
||
collapseTocBtn: $("collapseTocBtn"),
|
||
toc: $("toc"),
|
||
rowList: $("rowList"),
|
||
readerPane: $("readerPane"),
|
||
editorPane: $("editorPane"),
|
||
chapterKicker: $("chapterKicker"),
|
||
readerTitle: $("readerTitle"),
|
||
readingFlow: $("readingFlow"),
|
||
prevPartBtn: $("prevPartBtn"),
|
||
nextPartBtn: $("nextPartBtn"),
|
||
themeDefaultBtn: $("themeDefaultBtn"),
|
||
themeEyeBtn: $("themeEyeBtn"),
|
||
themeNightBtn: $("themeNightBtn"),
|
||
readerFontSizeInput: $("readerFontSizeInput"),
|
||
readerFontSizeValue: $("readerFontSizeValue"),
|
||
readerLineHeightInput: $("readerLineHeightInput"),
|
||
readerLineHeightValue: $("readerLineHeightValue"),
|
||
emptyState: $("emptyState"),
|
||
editorCard: $("editorCard"),
|
||
rowId: $("rowId"),
|
||
rowPath: $("rowPath"),
|
||
markedInput: $("markedInput"),
|
||
jpText: $("jpText"),
|
||
cnEditor: $("cnEditor"),
|
||
markSelectionBtn: $("markSelectionBtn"),
|
||
clearMarksBtn: $("clearMarksBtn"),
|
||
resetTextBtn: $("resetTextBtn"),
|
||
issueTypeInput: $("issueTypeInput"),
|
||
severityInput: $("severityInput"),
|
||
tagsInput: $("tagsInput"),
|
||
commentInput: $("commentInput"),
|
||
learnNoteInput: $("learnNoteInput"),
|
||
prevBtn: $("prevBtn"),
|
||
saveBtn: $("saveBtn"),
|
||
nextBtn: $("nextBtn"),
|
||
quickEditor: $("quickEditor"),
|
||
quickRowId: $("quickRowId"),
|
||
quickRowPath: $("quickRowPath"),
|
||
quickJpText: $("quickJpText"),
|
||
quickCnEditor: $("quickCnEditor"),
|
||
quickMarkedInput: $("quickMarkedInput"),
|
||
quickIssueTypeInput: $("quickIssueTypeInput"),
|
||
quickSeverityInput: $("quickSeverityInput"),
|
||
quickTagsInput: $("quickTagsInput"),
|
||
quickCommentInput: $("quickCommentInput"),
|
||
quickLearnNoteInput: $("quickLearnNoteInput"),
|
||
closeQuickEditorBtn: $("closeQuickEditorBtn"),
|
||
quickMarkSelectionBtn: $("quickMarkSelectionBtn"),
|
||
quickSaveBtn: $("quickSaveBtn"),
|
||
quickOpenFullBtn: $("quickOpenFullBtn"),
|
||
gptStatus: $("gptStatus"),
|
||
gptConfig: $("gptConfig"),
|
||
gptBaseUrlInput: $("gptBaseUrlInput"),
|
||
gptModelInput: $("gptModelInput"),
|
||
gptApiKeyInput: $("gptApiKeyInput"),
|
||
glossaryPathInput: $("glossaryPathInput"),
|
||
gptTranslationPromptInput: $("gptTranslationPromptInput"),
|
||
gptFormatPromptInput: $("gptFormatPromptInput"),
|
||
gptCharacterPromptInput: $("gptCharacterPromptInput"),
|
||
glossaryMeta: $("glossaryMeta"),
|
||
glossarySearchInput: $("glossarySearchInput"),
|
||
reloadGlossaryBtn: $("reloadGlossaryBtn"),
|
||
addGlossaryEntryBtn: $("addGlossaryEntryBtn"),
|
||
saveGlossaryBtn: $("saveGlossaryBtn"),
|
||
glossaryList: $("glossaryList"),
|
||
saveGptConfigBtn: $("saveGptConfigBtn"),
|
||
resetGptPromptsBtn: $("resetGptPromptsBtn"),
|
||
gptInstructionInput: $("gptInstructionInput"),
|
||
retranslateBtn: $("retranslateBtn"),
|
||
applyRetranslationBtn: $("applyRetranslationBtn"),
|
||
gptCandidate: $("gptCandidate"),
|
||
uploadForm: $("uploadForm"),
|
||
epubFileInput: $("epubFileInput"),
|
||
selectedFileName: $("selectedFileName"),
|
||
uploadBtn: $("uploadBtn"),
|
||
refreshSessionsBtn: $("refreshSessionsBtn"),
|
||
sessionList: $("sessionList"),
|
||
sessionListMeta: $("sessionListMeta"),
|
||
bookshelfRefreshBtn: $("bookshelfRefreshBtn"),
|
||
bookshelfOpenUploadBtn: $("bookshelfOpenUploadBtn"),
|
||
bookshelfOpenUploadBtnInline: $("bookshelfOpenUploadBtnInline"),
|
||
bookshelfTitle: $("bookshelfTitle"),
|
||
bookshelfDescription: $("bookshelfDescription"),
|
||
bookshelfSearchInput: $("bookshelfSearchInput"),
|
||
bookshelfFacetList: $("bookshelfFacetList"),
|
||
seriesConfigBtn: $("seriesConfigBtn"),
|
||
seriesConfigDrawer: $("seriesConfigDrawer"),
|
||
seriesConfigTitle: $("seriesConfigTitle"),
|
||
seriesConfigMeta: $("seriesConfigMeta"),
|
||
closeSeriesConfigBtn: $("closeSeriesConfigBtn"),
|
||
seriesGlossaryPathInput: $("seriesGlossaryPathInput"),
|
||
seriesTranslationPromptInput: $("seriesTranslationPromptInput"),
|
||
seriesFormatPromptInput: $("seriesFormatPromptInput"),
|
||
seriesCharacterPromptInput: $("seriesCharacterPromptInput"),
|
||
saveSeriesConfigBtn: $("saveSeriesConfigBtn"),
|
||
bookshelfMeta: $("bookshelfMeta"),
|
||
bookshelfList: $("bookshelfList"),
|
||
translationBookMeta: $("translationBookMeta"),
|
||
translationBackBookshelfBtn: $("translationBackBookshelfBtn"),
|
||
translationOpenReviewBtn: $("translationOpenReviewBtn"),
|
||
translationForm: $("translationForm"),
|
||
translationSourceMeta: $("translationSourceMeta"),
|
||
translationOutputModeInput: $("translationOutputModeInput"),
|
||
translationRangeModeInput: $("translationRangeModeInput"),
|
||
translationLimitInput: $("translationLimitInput"),
|
||
translationTemperatureInput: $("translationTemperatureInput"),
|
||
translationSample: $("translationSample"),
|
||
translationStats: $("translationStats"),
|
||
translationGptStatus: $("translationGptStatus"),
|
||
translationBaseUrlInput: $("translationBaseUrlInput"),
|
||
translationModelInput: $("translationModelInput"),
|
||
translationApiKeyInput: $("translationApiKeyInput"),
|
||
translationGlossaryPathInput: $("translationGlossaryPathInput"),
|
||
translationSaveConfigBtn: $("translationSaveConfigBtn"),
|
||
translationResetPromptsBtn: $("translationResetPromptsBtn"),
|
||
translationPromptInput: $("translationPromptInput"),
|
||
translationFormatPromptInput: $("translationFormatPromptInput"),
|
||
translationCharacterPromptInput: $("translationCharacterPromptInput"),
|
||
translationStartBtn: $("translationStartBtn"),
|
||
translationJobMeta: $("translationJobMeta"),
|
||
translationProgressBar: $("translationProgressBar"),
|
||
translationProgressText: $("translationProgressText"),
|
||
translationOutput: $("translationOutput"),
|
||
translationLogs: $("translationLogs"),
|
||
toast: $("toast"),
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value || "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function stripTags(value) {
|
||
const div = document.createElement("div");
|
||
div.innerHTML = value || "";
|
||
return div.textContent || div.innerText || "";
|
||
}
|
||
|
||
function toast(message, ms = 3600) {
|
||
dom.toast.textContent = message;
|
||
dom.toast.hidden = false;
|
||
clearTimeout(toast.timer);
|
||
toast.timer = setTimeout(() => {
|
||
dom.toast.hidden = true;
|
||
}, ms);
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const response = await fetch(path, {
|
||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||
...options,
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(data.error || `HTTP ${response.status}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function apiForm(path, formData) {
|
||
const response = await fetch(path, {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(data.error || `HTTP ${response.status}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function rowTouched(row) {
|
||
return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags);
|
||
}
|
||
|
||
function clampNumber(value, min, max, fallback) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
return Math.min(max, Math.max(min, parsed));
|
||
}
|
||
|
||
function readStoredReaderSettings() {
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem(READER_SETTINGS_KEY) || "{}");
|
||
readerSettings = {
|
||
theme: READER_THEMES.has(stored.theme) ? stored.theme : "default",
|
||
fontSize: clampNumber(stored.fontSize, 15, 24, 17),
|
||
lineHeight: clampNumber(stored.lineHeight, 1.6, 2.4, 1.95),
|
||
};
|
||
} catch (_error) {
|
||
readerSettings = { theme: "default", fontSize: 17, lineHeight: 1.95 };
|
||
}
|
||
}
|
||
|
||
function persistReaderSettings() {
|
||
try {
|
||
localStorage.setItem(READER_SETTINGS_KEY, JSON.stringify(readerSettings));
|
||
} catch (_error) {
|
||
// Browser storage can be unavailable in restricted contexts; reading still works.
|
||
}
|
||
}
|
||
|
||
function applyReaderSettings(save = false) {
|
||
document.body.dataset.readerTheme = readerSettings.theme;
|
||
document.documentElement.style.setProperty("--reader-font-size", `${readerSettings.fontSize}px`);
|
||
document.documentElement.style.setProperty("--reader-line-height", readerSettings.lineHeight.toFixed(2));
|
||
[
|
||
dom.themeDefaultBtn,
|
||
dom.themeEyeBtn,
|
||
dom.themeNightBtn,
|
||
].forEach((button) => {
|
||
if (!button) return;
|
||
const active = button.dataset.readerTheme === readerSettings.theme;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||
});
|
||
if (dom.readerFontSizeInput) dom.readerFontSizeInput.value = String(readerSettings.fontSize);
|
||
if (dom.readerFontSizeValue) dom.readerFontSizeValue.textContent = String(readerSettings.fontSize);
|
||
if (dom.readerLineHeightInput) dom.readerLineHeightInput.value = readerSettings.lineHeight.toFixed(2);
|
||
if (dom.readerLineHeightValue) dom.readerLineHeightValue.textContent = readerSettings.lineHeight.toFixed(2);
|
||
if (save) persistReaderSettings();
|
||
}
|
||
|
||
function setReaderTheme(theme) {
|
||
if (!READER_THEMES.has(theme)) return;
|
||
withReaderAnchor(() => {
|
||
readerSettings.theme = theme;
|
||
applyReaderSettings(true);
|
||
});
|
||
}
|
||
|
||
function setReaderFontSize(value) {
|
||
withReaderAnchor(() => {
|
||
readerSettings.fontSize = Math.round(clampNumber(value, 15, 24, 17));
|
||
applyReaderSettings(true);
|
||
});
|
||
}
|
||
|
||
function setReaderLineHeight(value) {
|
||
withReaderAnchor(() => {
|
||
readerSettings.lineHeight = clampNumber(value, 1.6, 2.4, 1.95);
|
||
applyReaderSettings(true);
|
||
});
|
||
}
|
||
|
||
function showChromeBriefly() {
|
||
document.body.classList.add("chromeVisible");
|
||
clearTimeout(chromeHideTimer);
|
||
chromeHideTimer = setTimeout(() => {
|
||
if (
|
||
chromePointerNearTop
|
||
|| document.querySelector(".topbar:hover, .topbar:focus-within, .readerHeader:hover, .readerHeader:focus-within")
|
||
) {
|
||
showChromeBriefly();
|
||
return;
|
||
}
|
||
document.body.classList.remove("chromeVisible");
|
||
}, 1600);
|
||
}
|
||
|
||
function updateChromeVisibility(event) {
|
||
if (!hasSession || document.body.classList.contains("startOpen")) {
|
||
chromePointerNearTop = false;
|
||
document.body.classList.remove("chromeVisible");
|
||
return;
|
||
}
|
||
chromePointerNearTop = event.clientY <= 104;
|
||
if (chromePointerNearTop) {
|
||
showChromeBriefly();
|
||
}
|
||
}
|
||
|
||
function syncChromeMetrics() {
|
||
const topbar = document.querySelector(".topbar");
|
||
if (!topbar) return;
|
||
const height = Math.max(0, Math.round(topbar.getBoundingClientRect().height || 0));
|
||
if (height) document.documentElement.style.setProperty("--topbar-live-height", `${height}px`);
|
||
}
|
||
|
||
function initChromeMetrics() {
|
||
syncChromeMetrics();
|
||
window.addEventListener("resize", syncChromeMetrics);
|
||
if ("ResizeObserver" in window) {
|
||
chromeResizeObserver = new ResizeObserver(syncChromeMetrics);
|
||
const topbar = document.querySelector(".topbar");
|
||
if (topbar) chromeResizeObserver.observe(topbar);
|
||
}
|
||
}
|
||
|
||
function readerViewportTop(paneRect) {
|
||
const header = document.querySelector(".readerHeader");
|
||
if (!header) return paneRect.top + 8;
|
||
const headerRect = header.getBoundingClientRect();
|
||
const headerVisible = Number.parseFloat(getComputedStyle(header).opacity || "0") > 0.05;
|
||
return headerVisible && headerRect.bottom > paneRect.top
|
||
? headerRect.bottom + 8
|
||
: paneRect.top + 8;
|
||
}
|
||
|
||
function readerAnchor() {
|
||
if (currentMode !== "reading" || !dom.readerPane || dom.readerPane.hidden) return null;
|
||
const paneRect = dom.readerPane.getBoundingClientRect();
|
||
const top = readerViewportTop(paneRect);
|
||
const blocks = Array.from(dom.readingFlow.querySelectorAll("[data-row-id]"));
|
||
if (!blocks.length) {
|
||
return { rowId: "", offset: 0, scrollTop: dom.readerPane.scrollTop };
|
||
}
|
||
let target = blocks[0];
|
||
for (const block of blocks) {
|
||
const rect = block.getBoundingClientRect();
|
||
if (rect.bottom >= top) {
|
||
target = block;
|
||
break;
|
||
}
|
||
}
|
||
return {
|
||
rowId: target.dataset.rowId || "",
|
||
offset: target.getBoundingClientRect().top - paneRect.top,
|
||
scrollTop: dom.readerPane.scrollTop,
|
||
};
|
||
}
|
||
|
||
function restoreReaderAnchor(anchor) {
|
||
if (!anchor || currentMode !== "reading") return;
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
if (!anchor.rowId) {
|
||
dom.readerPane.scrollTop = anchor.scrollTop || 0;
|
||
return;
|
||
}
|
||
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(anchor.rowId)}"]`);
|
||
if (!target) {
|
||
dom.readerPane.scrollTop = anchor.scrollTop || 0;
|
||
return;
|
||
}
|
||
const paneRect = dom.readerPane.getBoundingClientRect();
|
||
const nextOffset = target.getBoundingClientRect().top - paneRect.top;
|
||
dom.readerPane.scrollTop += nextOffset - anchor.offset;
|
||
});
|
||
});
|
||
}
|
||
|
||
function withReaderAnchor(action) {
|
||
const anchor = readerAnchor();
|
||
const result = action();
|
||
restoreReaderAnchor(anchor);
|
||
return result;
|
||
}
|
||
|
||
function imageItemById(chapter, imageItemId) {
|
||
if (!chapter || !imageItemId) return null;
|
||
return chapterItems(chapter).find((item) => isImageItem(item) && item.id === imageItemId) || null;
|
||
}
|
||
|
||
function currentReadingPosition() {
|
||
const anchor = readerAnchor();
|
||
const chapter = currentChapter || chapterForPart(currentPart);
|
||
const scope = currentImageItem ? "image" : currentPart ? "part" : chapter ? "chapter" : "none";
|
||
return {
|
||
mode: currentMode,
|
||
scope,
|
||
chapter_id: chapter ? chapter.id : "",
|
||
part_id: currentPart ? currentPart.id : "",
|
||
image_item_id: currentImageItem ? currentImageItem.id : "",
|
||
row_id: (anchor && anchor.rowId) || (currentRow && currentRow.id) || "",
|
||
scroll_top: dom.readerPane ? Math.max(0, Math.round(dom.readerPane.scrollTop || 0)) : 0,
|
||
offset: anchor ? Math.max(0, Math.round(anchor.offset || 0)) : 0,
|
||
updated_at: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
async function postReadingPosition(position) {
|
||
await api("/api/reading-position", {
|
||
method: "POST",
|
||
body: JSON.stringify(position),
|
||
});
|
||
}
|
||
|
||
async function saveReadingPositionNow(position) {
|
||
readingPositionInFlight = true;
|
||
const promise = postReadingPosition(position);
|
||
readingPositionSavePromise = promise;
|
||
try {
|
||
await promise;
|
||
savedReadingPosition = position;
|
||
} catch (_error) {
|
||
pendingReadingPosition = position;
|
||
} finally {
|
||
if (readingPositionSavePromise === promise) readingPositionSavePromise = null;
|
||
readingPositionInFlight = false;
|
||
if (pendingReadingPosition) saveReadingPositionSoon();
|
||
}
|
||
}
|
||
|
||
function saveReadingPositionSoon() {
|
||
if (!hasSession || !readingPositionReady || restoringReadingPosition) return;
|
||
const position = currentReadingPosition();
|
||
pendingReadingPosition = position;
|
||
clearTimeout(readingPositionSaveTimer);
|
||
readingPositionSaveTimer = setTimeout(async () => {
|
||
if (readingPositionInFlight || !pendingReadingPosition) return;
|
||
const next = pendingReadingPosition;
|
||
pendingReadingPosition = null;
|
||
await saveReadingPositionNow(next);
|
||
}, 650);
|
||
}
|
||
|
||
async function flushReadingPosition() {
|
||
if (!hasSession || !readingPositionReady || restoringReadingPosition) return;
|
||
clearTimeout(readingPositionSaveTimer);
|
||
if (readingPositionSavePromise) {
|
||
await readingPositionSavePromise.catch(() => {});
|
||
}
|
||
const position = pendingReadingPosition || currentReadingPosition();
|
||
pendingReadingPosition = null;
|
||
await saveReadingPositionNow(position);
|
||
}
|
||
|
||
function sendReadingPositionBeacon() {
|
||
if (!hasSession || !readingPositionReady || restoringReadingPosition || !navigator.sendBeacon) return;
|
||
const blob = new Blob([JSON.stringify(currentReadingPosition())], { type: "application/json" });
|
||
navigator.sendBeacon("/api/reading-position", blob);
|
||
}
|
||
|
||
function openSidePanel(mode) {
|
||
if (!hasSession) return;
|
||
sidePanelMode = mode;
|
||
dom.sidebar.hidden = false;
|
||
dom.tocPanel.hidden = mode !== "toc";
|
||
dom.searchPanel.hidden = mode !== "search";
|
||
dom.sideTitle.textContent = mode === "search" ? "检索" : "目录";
|
||
dom.tocPanelBtn.classList.toggle("active", mode === "toc");
|
||
dom.searchPanelBtn.classList.toggle("active", mode === "search");
|
||
document.body.classList.add("sidebarOpen");
|
||
if (mode === "search") {
|
||
requestAnimationFrame(() => dom.searchInput.focus());
|
||
}
|
||
}
|
||
|
||
function closeSidePanel() {
|
||
sidePanelMode = null;
|
||
if (dom.sidebar) dom.sidebar.hidden = true;
|
||
if (dom.tocPanel) dom.tocPanel.hidden = true;
|
||
if (dom.searchPanel) dom.searchPanel.hidden = true;
|
||
document.body.classList.remove("sidebarOpen");
|
||
if (dom.tocPanelBtn) dom.tocPanelBtn.classList.remove("active");
|
||
if (dom.searchPanelBtn) dom.searchPanelBtn.classList.remove("active");
|
||
}
|
||
|
||
function resetReviewState() {
|
||
rows = [];
|
||
filteredRows = [];
|
||
structure = { chapters: [] };
|
||
currentIndex = -1;
|
||
currentRow = null;
|
||
currentChapter = null;
|
||
currentPart = null;
|
||
currentImageItem = null;
|
||
showTouchedOnly = false;
|
||
dirty = false;
|
||
quickDirty = false;
|
||
expandedChapters = new Set();
|
||
sidePanelMode = null;
|
||
searchScope = "current";
|
||
gptCandidateHtml = "";
|
||
gptConfigLoaded = false;
|
||
glossaryLoaded = false;
|
||
glossaryEntries = [];
|
||
glossaryDirty = false;
|
||
savedReadingPosition = null;
|
||
restoringReadingPosition = false;
|
||
pendingReadingPosition = null;
|
||
readingPositionInFlight = false;
|
||
readingPositionSavePromise = null;
|
||
readingPositionReady = false;
|
||
clearTimeout(readingPositionSaveTimer);
|
||
readingPositionSaveTimer = null;
|
||
dom.showTouchedBtn.classList.remove("primary");
|
||
dom.searchScopeCurrentBtn.classList.add("active");
|
||
dom.searchScopeBookBtn.classList.remove("active");
|
||
dom.searchInput.value = "";
|
||
if (dom.glossarySearchInput) dom.glossarySearchInput.value = "";
|
||
if (dom.glossaryList) dom.glossaryList.innerHTML = "";
|
||
if (dom.glossaryMeta) dom.glossaryMeta.textContent = "未读取术语表";
|
||
dom.sidebar.hidden = true;
|
||
dom.tocPanel.hidden = true;
|
||
dom.searchPanel.hidden = true;
|
||
dom.tocPanelBtn.classList.remove("active");
|
||
dom.searchPanelBtn.classList.remove("active");
|
||
if (dom.quickEditor) {
|
||
dom.quickEditor.hidden = true;
|
||
}
|
||
document.body.classList.remove("quickOpen");
|
||
if (dom.reviewToolsBtn) dom.reviewToolsBtn.classList.remove("active");
|
||
}
|
||
|
||
function closeReviewPanels() {
|
||
closeSidePanel();
|
||
if (dom.quickEditor) dom.quickEditor.hidden = true;
|
||
if (dom.reviewToolsBtn) dom.reviewToolsBtn.classList.remove("active");
|
||
document.body.classList.remove("quickOpen");
|
||
}
|
||
|
||
function clearTranslationPoll() {
|
||
if (translationPollTimer) {
|
||
clearTimeout(translationPollTimer);
|
||
translationPollTimer = null;
|
||
}
|
||
}
|
||
|
||
function setShellMode(active) {
|
||
hasSession = active;
|
||
document.body.classList.remove("bookshelfOpen");
|
||
document.body.classList.remove("translationOpen");
|
||
if (dom.bookshelfBtn) dom.bookshelfBtn.classList.remove("active");
|
||
dom.startScreen.hidden = active;
|
||
if (active && dom.bookshelfScreen) dom.bookshelfScreen.hidden = true;
|
||
if (active && dom.translationScreen) dom.translationScreen.hidden = true;
|
||
dom.reviewLayout.hidden = !active;
|
||
dom.readingModeBtn.disabled = !active;
|
||
dom.polishModeBtn.disabled = !active;
|
||
dom.bookshelfBtn.disabled = false;
|
||
dom.openLibraryBtn.disabled = false;
|
||
dom.reviewToolsBtn.disabled = !active;
|
||
dom.showTouchedBtn.disabled = !active;
|
||
dom.feedbackBtn.disabled = !active;
|
||
dom.applyBtn.disabled = !active;
|
||
dom.exportBtn.disabled = !active;
|
||
dom.tocPanelBtn.disabled = !active;
|
||
dom.searchPanelBtn.disabled = !active;
|
||
dom.closeSidebarBtn.disabled = !active;
|
||
dom.expandTocBtn.disabled = !active;
|
||
dom.collapseTocBtn.disabled = !active;
|
||
document.body.classList.toggle("startOpen", !active);
|
||
document.body.classList.remove("chromeVisible");
|
||
if (!active) closeSidePanel();
|
||
if (!active) {
|
||
dom.sessionMeta.textContent = "未打开 EPUB";
|
||
}
|
||
}
|
||
|
||
async function leaveReviewShell() {
|
||
if (!hasSession) return true;
|
||
if (!(await maybeSaveBeforeSwitch())) return false;
|
||
await flushReadingPosition().catch(() => {});
|
||
closeReviewPanels();
|
||
return true;
|
||
}
|
||
|
||
function setVersionBadge(value) {
|
||
if (!value) return;
|
||
dom.appVersion.textContent = `v${value}`;
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function bookTitle(session) {
|
||
return session.title || (session.metadata && session.metadata.title) || session.source_name || session.id || "未命名书籍";
|
||
}
|
||
|
||
function bookSeries(session) {
|
||
return session.series || (session.metadata && session.metadata.series) || "未分类";
|
||
}
|
||
|
||
function bookSeriesId(session) {
|
||
return session.series_id || (session.metadata && session.metadata.series_id) || "uncategorized";
|
||
}
|
||
|
||
function seriesTitleById(seriesId) {
|
||
const item = (librarySeries || []).find((series) => series.id === seriesId);
|
||
return item ? item.title : seriesId || "未分类";
|
||
}
|
||
|
||
function bookAuthors(session) {
|
||
return Array.isArray(session.authors) ? session.authors : [];
|
||
}
|
||
|
||
function formatBookSubtitle(session) {
|
||
const parts = [];
|
||
const authors = bookAuthors(session);
|
||
if (authors.length) parts.push(authors.join(" / "));
|
||
if (session.publisher) parts.push(session.publisher);
|
||
if (session.volume) parts.push(`第 ${session.volume} 卷`);
|
||
return parts.join(" · ") || bookSeries(session);
|
||
}
|
||
|
||
function matchesBookshelfSearch(session) {
|
||
const query = bookshelfSearch.trim().toLowerCase();
|
||
if (!query) return true;
|
||
const haystack = [
|
||
bookTitle(session),
|
||
bookSeries(session),
|
||
...(bookAuthors(session)),
|
||
session.publisher || "",
|
||
session.source_name || "",
|
||
].join(" ").toLowerCase();
|
||
return haystack.includes(query);
|
||
}
|
||
|
||
function sessionMatchesFilter(session) {
|
||
if (!matchesBookshelfSearch(session)) return false;
|
||
if (bookshelfFilter.type === "series") return bookSeriesId(session) === bookshelfFilter.value;
|
||
if (bookshelfFilter.type === "author") return bookAuthors(session).includes(bookshelfFilter.value);
|
||
if (bookshelfFilter.type === "publisher") return (session.publisher || "未填写") === bookshelfFilter.value;
|
||
if (bookshelfFilter.type === "language") return (session.language || "未知") === bookshelfFilter.value;
|
||
return true;
|
||
}
|
||
|
||
function facetCountMap(sessions, getter) {
|
||
const map = new Map();
|
||
sessions.forEach((session) => {
|
||
const values = getter(session);
|
||
(Array.isArray(values) ? values : [values]).forEach((value) => {
|
||
const key = value || "未填写";
|
||
map.set(key, (map.get(key) || 0) + 1);
|
||
});
|
||
});
|
||
return [...map.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), "zh-CN"));
|
||
}
|
||
|
||
function setBookshelfFilter(type, value = "") {
|
||
bookshelfFilter = { type, value };
|
||
renderBookshelfFromState(activeBookshelfSessionId);
|
||
}
|
||
|
||
function renderSessions(sessions = [], activeId = "") {
|
||
dom.sessionList.innerHTML = "";
|
||
dom.sessionListMeta.textContent = sessions.length ? `${sessions.length} 个会话` : "暂无会话";
|
||
if (!sessions.length) {
|
||
dom.sessionList.innerHTML = '<div class="emptySession">还没有审校会话。上传 EPUB 后会自动创建。</div>';
|
||
return;
|
||
}
|
||
const frag = document.createDocumentFragment();
|
||
sessions.forEach((session) => {
|
||
const card = document.createElement("article");
|
||
card.className = "sessionItem";
|
||
if (session.id === activeId) card.classList.add("active");
|
||
card.innerHTML = `
|
||
<div class="sessionInfo">
|
||
<h3>${escapeHtml(session.source_name || session.id)}</h3>
|
||
<p>${escapeHtml(session.session_root || "")}</p>
|
||
<div class="sessionFacts">
|
||
<span>${session.row_count || 0} 段</span>
|
||
<span>记录 ${session.touched_count || 0}</span>
|
||
<span>标记 ${session.marked_count || 0}</span>
|
||
${session.updated_at ? `<span>${escapeHtml(formatDateTime(session.updated_at))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<button type="button" data-session-id="${escapeHtml(session.id)}">${session.id === activeId ? "继续审校" : "打开"}</button>
|
||
`;
|
||
card.querySelector("button").addEventListener("click", () => openSession(session.id));
|
||
frag.appendChild(card);
|
||
});
|
||
dom.sessionList.appendChild(frag);
|
||
}
|
||
|
||
async function refreshSessions() {
|
||
const data = await api("/api/sessions");
|
||
setVersionBadge(data.version);
|
||
renderSessions(data.sessions || [], data.active_session_id || "");
|
||
return data;
|
||
}
|
||
|
||
function readingPositionLabel(position = {}) {
|
||
if (!position || !position.scope || position.scope === "none") return "尚无阅读记录";
|
||
if (position.scope === "image") return "上次阅读:插图";
|
||
if (position.part_id) return `上次阅读:${position.part_id}`;
|
||
if (position.chapter_id) return `上次阅读:${position.chapter_id}`;
|
||
if (position.row_id) return `上次阅读:${position.row_id}`;
|
||
return "已有阅读记录";
|
||
}
|
||
|
||
function renderBookshelf(sessions = [], activeId = "") {
|
||
librarySessions = sessions || [];
|
||
activeBookshelfSessionId = activeId || activeBookshelfSessionId || "";
|
||
renderBookshelfFromState(activeId);
|
||
}
|
||
|
||
function renderBookshelfFacets(sessions) {
|
||
if (!dom.bookshelfFacetList) return;
|
||
const authorCount = facetCountMap(sessions, (session) => bookAuthors(session));
|
||
const publisherCount = facetCountMap(sessions, (session) => session.publisher || "未填写");
|
||
const languageCount = facetCountMap(sessions, (session) => session.language || "未知");
|
||
const seriesItems = (librarySeries || []).map((item) => [item.title, item.count, item.id]);
|
||
const activeKey = `${bookshelfFilter.type}:${bookshelfFilter.value}`;
|
||
|
||
function group(title, items, type, limit = 12) {
|
||
const rows = items.slice(0, limit).map((item) => {
|
||
const label = item[0];
|
||
const count = item[1];
|
||
const value = item[2] || label;
|
||
const key = `${type}:${value}`;
|
||
return `
|
||
<button class="facetButton ${activeKey === key ? "active" : ""}" type="button" data-facet-type="${escapeHtml(type)}" data-facet-value="${escapeHtml(value)}">
|
||
<span>${escapeHtml(label)}</span>
|
||
<b>${count}</b>
|
||
</button>
|
||
`;
|
||
}).join("");
|
||
return `
|
||
<section class="facetGroup">
|
||
<div class="facetGroupTitle">${escapeHtml(title)}</div>
|
||
${rows || '<div class="facetEmpty">暂无</div>'}
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
dom.bookshelfFacetList.innerHTML = `
|
||
<button class="facetButton primaryFacet ${bookshelfFilter.type === "all" ? "active" : ""}" type="button" data-facet-type="all" data-facet-value="">
|
||
<span>全部书籍</span>
|
||
<b>${sessions.length}</b>
|
||
</button>
|
||
${group("同系列", seriesItems, "series", 18)}
|
||
${group("作者", authorCount, "author", 10)}
|
||
${group("出版社", publisherCount, "publisher", 10)}
|
||
${group("语言", languageCount, "language", 8)}
|
||
`;
|
||
dom.bookshelfFacetList.querySelectorAll("[data-facet-type]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
setBookshelfFilter(button.dataset.facetType || "all", button.dataset.facetValue || "");
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderBookshelfFromState(activeId = "") {
|
||
dom.bookshelfList.innerHTML = "";
|
||
const sessions = librarySessions || [];
|
||
renderBookshelfFacets(sessions);
|
||
const filtered = sessions.filter(sessionMatchesFilter);
|
||
const filterText = bookshelfFilter.type === "all"
|
||
? "全部书籍"
|
||
: bookshelfFilter.type === "series"
|
||
? seriesTitleById(bookshelfFilter.value)
|
||
: bookshelfFilter.value || "筛选结果";
|
||
activeSeriesId = bookshelfFilter.type === "series" ? bookshelfFilter.value : "";
|
||
dom.bookshelfTitle.textContent = filterText;
|
||
dom.bookshelfDescription.textContent = activeSeriesId
|
||
? "同系列书籍可以共用翻译提示词、角色口吻和术语表。"
|
||
: "按 EPUB 元数据建立书架,封面悬停可直接进入审校或翻译。";
|
||
if (dom.seriesConfigBtn) {
|
||
dom.seriesConfigBtn.disabled = !activeSeriesId;
|
||
}
|
||
dom.bookshelfMeta.textContent = sessions.length
|
||
? `显示 ${filtered.length} / ${sessions.length} 本 · ${librarySeries.length || 0} 个系列`
|
||
: "暂无历史书籍";
|
||
if (!sessions.length) {
|
||
dom.bookshelfList.innerHTML = `
|
||
<div class="emptyBookshelf">
|
||
<h3>书架还是空的</h3>
|
||
<p>上传第一本中日双语 EPUB 后,它会出现在这里,之后可以直接回到上次阅读位置。</p>
|
||
<button type="button" class="primary" data-bookshelf-upload>打开新的 EPUB</button>
|
||
</div>
|
||
`;
|
||
dom.bookshelfList.querySelector("[data-bookshelf-upload]").addEventListener("click", () => showStartScreen());
|
||
return;
|
||
}
|
||
if (!filtered.length) {
|
||
dom.bookshelfList.innerHTML = `
|
||
<div class="emptyBookshelf">
|
||
<h3>没有匹配书籍</h3>
|
||
<p>换个分类或搜索词试试。</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const frag = document.createDocumentFragment();
|
||
filtered.forEach((session) => {
|
||
const card = document.createElement("article");
|
||
card.className = "bookshelfItem";
|
||
card.tabIndex = 0;
|
||
if (session.id === activeId) card.classList.add("active");
|
||
const title = bookTitle(session);
|
||
const updatedAt = session.updated_at || session.created_at;
|
||
const initial = String(title || "").trim().slice(0, 1) || "书";
|
||
const coverUrl = session.cover_url || "";
|
||
const subtitle = formatBookSubtitle(session);
|
||
card.innerHTML = `
|
||
<div class="bookCover">
|
||
${coverUrl ? `<img src="${escapeHtml(coverUrl)}" alt="">` : `<div class="bookSpine">${escapeHtml(initial)}</div>`}
|
||
<div class="bookOverlay">
|
||
<h3>${escapeHtml(title)}</h3>
|
||
<p>${escapeHtml(subtitle)}</p>
|
||
<div class="bookFacts overlayFacts">
|
||
<span>${session.row_count || 0} 审校段</span>
|
||
<span>${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")}</span>
|
||
${updatedAt ? `<span>${escapeHtml(formatDateTime(updatedAt))}</span>` : ""}
|
||
</div>
|
||
<div class="bookActions">
|
||
<button type="button" data-open-review>${session.id === activeId ? "继续校对" : "校对"}</button>
|
||
<button type="button" data-open-translation>翻译</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bookCaption">
|
||
<h3>${escapeHtml(title)}</h3>
|
||
<p>${escapeHtml(bookSeries(session))}</p>
|
||
</div>
|
||
`;
|
||
const open = () => openSession(session.id);
|
||
const reviewButton = card.querySelector("[data-open-review]");
|
||
const translateButton = card.querySelector("[data-open-translation]");
|
||
reviewButton.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
open();
|
||
});
|
||
translateButton.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
showTranslationScreen(session.id).catch((error) => toast(`打开翻译页失败:${error.message}`, 7000));
|
||
});
|
||
card.addEventListener("click", (event) => {
|
||
if (event.target.closest("button")) return;
|
||
open();
|
||
});
|
||
card.addEventListener("keydown", (event) => {
|
||
if (event.target.closest("button")) return;
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
open();
|
||
});
|
||
frag.appendChild(card);
|
||
});
|
||
dom.bookshelfList.appendChild(frag);
|
||
}
|
||
|
||
async function refreshBookshelf() {
|
||
dom.bookshelfMeta.textContent = "正在读取书架……";
|
||
const data = await api("/api/sessions");
|
||
setVersionBadge(data.version);
|
||
librarySeries = data.series || [];
|
||
renderBookshelf(data.sessions || [], data.active_session_id || "");
|
||
return data;
|
||
}
|
||
|
||
async function showBookshelf() {
|
||
if (!(await leaveReviewShell())) return;
|
||
clearTranslationPoll();
|
||
resetReviewState();
|
||
setShellMode(false);
|
||
dom.startScreen.hidden = true;
|
||
if (dom.translationScreen) dom.translationScreen.hidden = true;
|
||
dom.bookshelfScreen.hidden = false;
|
||
document.body.classList.add("bookshelfOpen");
|
||
dom.bookshelfBtn.classList.add("active");
|
||
dom.sessionMeta.textContent = "书架 · 历史审校书籍";
|
||
try {
|
||
await refreshBookshelf();
|
||
} catch (error) {
|
||
dom.bookshelfMeta.textContent = "书架读取失败";
|
||
dom.bookshelfList.innerHTML = `<div class="emptyBookshelf">读取书架失败:${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function showStartScreen(sessionPayload = null) {
|
||
clearTranslationPoll();
|
||
resetReviewState();
|
||
setShellMode(false);
|
||
dom.bookshelfScreen.hidden = true;
|
||
if (dom.translationScreen) dom.translationScreen.hidden = true;
|
||
dom.startScreen.hidden = false;
|
||
if (sessionPayload && Array.isArray(sessionPayload.sessions)) {
|
||
renderSessions(sessionPayload.sessions, "");
|
||
} else {
|
||
try {
|
||
await refreshSessions();
|
||
} catch (error) {
|
||
dom.sessionList.innerHTML = `<div class="emptySession">读取会话失败:${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderTranslationSource(payload) {
|
||
const session = payload.session || {};
|
||
const sourceCount = payload.source_count || 0;
|
||
dom.translationBookMeta.textContent = `${session.source_name || translationSessionId} · 可翻译日文正文块 ${sourceCount} 个`;
|
||
dom.translationSourceMeta.textContent = sourceCount ? `检测到 ${sourceCount} 个可翻译日文正文块` : "没有检测到可翻译日文正文块";
|
||
const stats = payload.stats || {};
|
||
if (dom.translationStats) {
|
||
const chips = [
|
||
["可翻译", sourceCount],
|
||
["HTML", stats.html_file_count],
|
||
["文本块", stats.total_blocks],
|
||
["正文段", stats.total_paragraphs],
|
||
["标题", stats.total_headings],
|
||
["图片块", stats.image_blocks],
|
||
["目录跳过", stats.skipped_nav_blocks],
|
||
["空白跳过", stats.skipped_blank],
|
||
["非日文跳过", stats.skipped_no_japanese],
|
||
].filter((item) => item[1] !== undefined && item[1] !== null && item[1] !== "");
|
||
dom.translationStats.innerHTML = `
|
||
<div class="statChipRow">
|
||
${chips.map(([label, value]) => `<span class="statChip"><b>${escapeHtml(value)}</b>${escapeHtml(label)}</span>`).join("")}
|
||
</div>
|
||
${stats.note ? `<p>${escapeHtml(stats.note)}</p>` : ""}
|
||
`;
|
||
}
|
||
dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000)));
|
||
const sample = payload.sample || [];
|
||
if (!sample.length) {
|
||
dom.translationSample.innerHTML = '<div class="translationEmpty">暂无可预览段落。请确认这本书是日语 EPUB。</div>';
|
||
return;
|
||
}
|
||
dom.translationSample.innerHTML = sample
|
||
.map((item) => `
|
||
<div class="translationSampleItem">
|
||
<strong>${escapeHtml(item.id)} · ${escapeHtml(item.file || "")}</strong>
|
||
<p>${escapeHtml(item.text || "")}</p>
|
||
</div>
|
||
`)
|
||
.join("");
|
||
}
|
||
|
||
async function showTranslationScreen(sessionId) {
|
||
if (!(await leaveReviewShell())) return;
|
||
clearTranslationPoll();
|
||
resetReviewState();
|
||
setShellMode(false);
|
||
translationSessionId = sessionId;
|
||
activeSeriesId = "";
|
||
activeSeriesConfig = null;
|
||
translationUsesSeriesConfig = false;
|
||
translationJobId = "";
|
||
translationOutputSessionId = "";
|
||
dom.startScreen.hidden = true;
|
||
dom.bookshelfScreen.hidden = true;
|
||
dom.reviewLayout.hidden = true;
|
||
dom.translationScreen.hidden = false;
|
||
dom.translationOpenReviewBtn.disabled = true;
|
||
dom.translationOutput.hidden = true;
|
||
dom.translationOutput.textContent = "";
|
||
dom.translationProgressBar.style.width = "0%";
|
||
dom.translationProgressText.textContent = "正在读取源书与 API 配置……";
|
||
dom.translationJobMeta.textContent = "尚未开始";
|
||
dom.translationLogs.innerHTML = "";
|
||
document.body.classList.add("translationOpen");
|
||
dom.bookshelfBtn.classList.remove("active");
|
||
dom.sessionMeta.textContent = "AI 翻译 EPUB";
|
||
const [sourcePayload, defaultsPayload] = await Promise.all([
|
||
api(`/api/session/${encodeURIComponent(sessionId)}/translation-source`),
|
||
api("/api/translation/defaults"),
|
||
]);
|
||
activeSeriesId = (sourcePayload.session && bookSeriesId(sourcePayload.session)) || "";
|
||
setVersionBadge(defaultsPayload.version);
|
||
renderTranslationGptConfig(defaultsPayload.gpt || {});
|
||
const defaults = defaultsPayload.defaults || {};
|
||
dom.translationOutputModeInput.value = defaults.output_mode || "bilingual";
|
||
dom.translationRangeModeInput.value = defaults.range_mode || "limit";
|
||
dom.translationLimitInput.value = defaults.limit || 20;
|
||
dom.translationTemperatureInput.value = defaults.temperature ?? 0.2;
|
||
applySeriesConfigToTranslation(sourcePayload.series_config || {});
|
||
renderTranslationSource(sourcePayload);
|
||
dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 段试译。";
|
||
}
|
||
|
||
function collectTranslationOptions() {
|
||
return {
|
||
session_id: translationSessionId,
|
||
output_mode: dom.translationOutputModeInput.value,
|
||
range_mode: dom.translationRangeModeInput.value,
|
||
limit: dom.translationLimitInput.value,
|
||
temperature: dom.translationTemperatureInput.value,
|
||
base_url: dom.translationBaseUrlInput.value,
|
||
model: dom.translationModelInput.value,
|
||
glossary_path: dom.translationGlossaryPathInput.value,
|
||
translation_prompt: dom.translationPromptInput.value,
|
||
format_prompt: dom.translationFormatPromptInput.value,
|
||
character_prompt: dom.translationCharacterPromptInput.value,
|
||
use_series_glossary_path: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.glossary_path === dom.translationGlossaryPathInput.value,
|
||
use_series_translation_prompt: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.translation_prompt === dom.translationPromptInput.value,
|
||
use_series_format_prompt: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.format_prompt === dom.translationFormatPromptInput.value,
|
||
use_series_character_prompt: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.character_prompt === dom.translationCharacterPromptInput.value,
|
||
};
|
||
}
|
||
|
||
function renderTranslationJob(job) {
|
||
const progress = job.progress || {};
|
||
const percent = Math.max(0, Math.min(100, Number(progress.percent || 0)));
|
||
dom.translationProgressBar.style.width = `${percent}%`;
|
||
dom.translationJobMeta.textContent = job.id ? `${job.status || "unknown"} · ${percent.toFixed(percent % 1 ? 1 : 0)}%` : "尚未开始";
|
||
const total = progress.total || 0;
|
||
const current = progress.current || 0;
|
||
const failed = progress.failed || 0;
|
||
dom.translationProgressText.textContent = [
|
||
progress.message || "正在处理……",
|
||
total ? `进度:${current}/${total}` : "",
|
||
progress.current_file ? `当前文件:${progress.current_file}` : "",
|
||
failed ? `失败段落:${failed}` : "",
|
||
job.error ? `错误:${job.error}` : "",
|
||
].filter(Boolean).join("\n");
|
||
const logs = (job.logs || []).slice(-12).reverse();
|
||
dom.translationLogs.innerHTML = logs.map((log) => `
|
||
<div class="translationLog ${escapeHtml(log.level || "info")}">
|
||
<span>${escapeHtml(formatDateTime(log.ts) || log.ts || "")}</span>
|
||
<p>${escapeHtml(log.message || "")}</p>
|
||
</div>
|
||
`).join("");
|
||
if (job.status === "completed") {
|
||
translationOutputSessionId = job.output_session_id || "";
|
||
dom.translationOpenReviewBtn.disabled = !translationOutputSessionId;
|
||
dom.translationOutput.hidden = false;
|
||
dom.translationOutput.innerHTML = `
|
||
<strong>已生成 EPUB 并加入书架</strong>
|
||
<p>${escapeHtml(job.output_epub || "")}</p>
|
||
`;
|
||
refreshBookshelf().catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function openSeriesConfigDrawer(seriesId = activeSeriesId) {
|
||
if (!seriesId) {
|
||
toast("请先在左侧选择一个系列。");
|
||
return null;
|
||
}
|
||
activeSeriesId = seriesId;
|
||
const title = seriesTitleById(seriesId);
|
||
dom.seriesConfigTitle.textContent = `${title} · 系列设置`;
|
||
dom.seriesConfigMeta.textContent = "同系列书籍会优先共用这里的术语表和提示词。API Key 仍保存在全局 API 设置里。";
|
||
dom.seriesConfigDrawer.hidden = false;
|
||
const config = await api(`/api/series/${encodeURIComponent(seriesId)}/config`);
|
||
activeSeriesConfig = config;
|
||
dom.seriesGlossaryPathInput.value = config.glossary_path || "";
|
||
dom.seriesTranslationPromptInput.value = config.translation_prompt || "";
|
||
dom.seriesFormatPromptInput.value = config.format_prompt || "";
|
||
dom.seriesCharacterPromptInput.value = config.character_prompt || "";
|
||
return config;
|
||
}
|
||
|
||
function closeSeriesConfigDrawer() {
|
||
if (dom.seriesConfigDrawer) dom.seriesConfigDrawer.hidden = true;
|
||
}
|
||
|
||
async function saveActiveSeriesConfig(showToast = true) {
|
||
const seriesId = activeSeriesId || (activeSeriesConfig && activeSeriesConfig.series_id) || "";
|
||
if (!seriesId) return null;
|
||
if (showToast === false && activeSeriesConfig) {
|
||
updateSeriesDrawerFromTranslation();
|
||
}
|
||
const payload = {
|
||
glossary_path: dom.seriesGlossaryPathInput.value,
|
||
translation_prompt: dom.seriesTranslationPromptInput.value,
|
||
format_prompt: dom.seriesFormatPromptInput.value,
|
||
character_prompt: dom.seriesCharacterPromptInput.value,
|
||
};
|
||
const config = await api(`/api/series/${encodeURIComponent(seriesId)}/config`, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
});
|
||
activeSeriesConfig = config;
|
||
if (showToast) toast("系列设置已保存,同系列翻译会优先使用它。");
|
||
return config;
|
||
}
|
||
|
||
async function pollTranslationProgress() {
|
||
if (!translationJobId) return;
|
||
try {
|
||
const payload = await api(`/api/translation/jobs/${encodeURIComponent(translationJobId)}`);
|
||
const job = payload.job || {};
|
||
renderTranslationJob(job);
|
||
if (["completed", "failed", "cancelled"].includes(job.status)) {
|
||
clearTranslationPoll();
|
||
dom.translationStartBtn.disabled = false;
|
||
dom.translationStartBtn.textContent = "开始翻译";
|
||
if (job.status === "completed") toast("翻译完成,输出 EPUB 已加入书架。", 7000);
|
||
if (job.status === "failed") toast(`翻译失败:${job.error || "未知错误"}`, 9000);
|
||
return;
|
||
}
|
||
translationPollTimer = setTimeout(pollTranslationProgress, 1200);
|
||
} catch (error) {
|
||
clearTranslationPoll();
|
||
dom.translationProgressText.textContent = `读取进度失败:${error.message}`;
|
||
dom.translationStartBtn.disabled = false;
|
||
dom.translationStartBtn.textContent = "开始翻译";
|
||
}
|
||
}
|
||
|
||
async function startTranslationJob(event) {
|
||
event.preventDefault();
|
||
if (!translationSessionId) return;
|
||
if (dom.translationRangeModeInput.value === "all" && !window.confirm("全书翻译可能消耗较长时间和较多 API 额度。确认开始吗?")) {
|
||
return;
|
||
}
|
||
try {
|
||
dom.translationStartBtn.disabled = true;
|
||
dom.translationStartBtn.textContent = "启动中……";
|
||
dom.translationProgressText.textContent = "正在保存设置并创建翻译任务……";
|
||
await saveTranslationGptConfig();
|
||
const payload = await api("/api/translation/start", {
|
||
method: "POST",
|
||
body: JSON.stringify(collectTranslationOptions()),
|
||
});
|
||
translationJobId = payload.job_id;
|
||
renderTranslationJob(payload.job || {});
|
||
dom.translationStartBtn.textContent = "翻译运行中……";
|
||
clearTranslationPoll();
|
||
translationPollTimer = setTimeout(pollTranslationProgress, 600);
|
||
toast("翻译任务已启动。");
|
||
} catch (error) {
|
||
dom.translationStartBtn.disabled = false;
|
||
dom.translationStartBtn.textContent = "开始翻译";
|
||
dom.translationProgressText.textContent = `启动失败:${error.message}`;
|
||
toast(`启动翻译失败:${error.message}`, 9000);
|
||
}
|
||
}
|
||
|
||
function rowCurrentText(row) {
|
||
return stripTags(row.current_html || row.cn_html || row.cn_text || "");
|
||
}
|
||
|
||
function rowById(rowId) {
|
||
return rows.find((item) => item.id === rowId) || null;
|
||
}
|
||
|
||
function itemKind(item) {
|
||
return item && item.kind === "image" ? "image" : "text";
|
||
}
|
||
|
||
function isImageItem(item) {
|
||
return itemKind(item) === "image";
|
||
}
|
||
|
||
function allParts() {
|
||
return (structure.chapters || []).flatMap((chapter) => chapter.parts || []);
|
||
}
|
||
|
||
function isImageChapter(chapter) {
|
||
return chapterKind(chapter) === "image";
|
||
}
|
||
|
||
function chapterItems(chapter) {
|
||
if (!chapter) return [];
|
||
if (Array.isArray(chapter.items) && chapter.items.length) return chapter.items;
|
||
if (isImageChapter(chapter)) return [{ ...chapter, id: `${chapter.id}:image`, parent_chapter_id: chapter.id, display_level: 1 }];
|
||
return chapter.parts || [];
|
||
}
|
||
|
||
function chapterImages(chapter) {
|
||
if (!chapter) return [];
|
||
if (isImageChapter(chapter)) return chapter.images || [];
|
||
return chapterItems(chapter)
|
||
.filter(isImageItem)
|
||
.flatMap((item) => item.images || []);
|
||
}
|
||
|
||
function allChapterScopes() {
|
||
return (structure.chapters || []).map((chapter) => ({
|
||
type: "chapter",
|
||
id: chapter.id,
|
||
chapter,
|
||
}));
|
||
}
|
||
|
||
function activeChapterScopeId() {
|
||
if (currentImageItem && currentChapter) return currentChapter.id;
|
||
return currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id;
|
||
}
|
||
|
||
function readingScopeId(chapter, item = null) {
|
||
if (!chapter) return "";
|
||
if (!item) return `chapter:${chapter.id}`;
|
||
return isImageItem(item) ? `image:${chapter.id}:${item.id}` : `part:${item.id}`;
|
||
}
|
||
|
||
function scopeForItem(chapter, item) {
|
||
if (!item) return null;
|
||
if (isImageItem(item)) {
|
||
return { type: "image", id: readingScopeId(chapter, item), chapter, item };
|
||
}
|
||
return { type: "part", id: readingScopeId(chapter, item), chapter, part: item };
|
||
}
|
||
|
||
function allReadingScopes() {
|
||
const scopes = [];
|
||
for (const chapter of structure.chapters || []) {
|
||
const items = chapterItems(chapter);
|
||
if (!items.length) {
|
||
scopes.push({ type: "chapter", id: readingScopeId(chapter), chapter });
|
||
continue;
|
||
}
|
||
for (const item of items) {
|
||
scopes.push(scopeForItem(chapter, item));
|
||
}
|
||
}
|
||
return scopes;
|
||
}
|
||
|
||
function visibleReadingScopeId(chapter) {
|
||
if (!chapter || !dom.readerPane || dom.readerPane.hidden) return "";
|
||
const paneRect = dom.readerPane.getBoundingClientRect();
|
||
const top = readerViewportTop(paneRect);
|
||
const items = Array.from(dom.readingFlow.querySelectorAll("[data-reading-scope-id]"));
|
||
let active = null;
|
||
for (const item of items) {
|
||
const rect = item.getBoundingClientRect();
|
||
if (rect.bottom >= top) {
|
||
active = item;
|
||
break;
|
||
}
|
||
}
|
||
return active?.dataset.readingScopeId || "";
|
||
}
|
||
|
||
function activeReadingScopeId() {
|
||
if (currentImageItem && currentChapter) return readingScopeId(currentChapter, currentImageItem);
|
||
if (currentPart) return readingScopeId(chapterForPart(currentPart), currentPart);
|
||
if (currentChapter) {
|
||
const visibleScope = visibleReadingScopeId(currentChapter);
|
||
if (visibleScope) return visibleScope;
|
||
const anchor = readerAnchor();
|
||
const anchorRow = anchor && anchor.rowId ? rowById(anchor.rowId) : null;
|
||
const anchorPart = partForRow(anchorRow);
|
||
if (anchorPart && chapterForPart(anchorPart)?.id === currentChapter.id) {
|
||
return readingScopeId(currentChapter, anchorPart);
|
||
}
|
||
const firstItem = chapterItems(currentChapter)[0] || null;
|
||
if (firstItem) return readingScopeId(currentChapter, firstItem);
|
||
return readingScopeId(currentChapter);
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function chapterIsExpanded(chapter) {
|
||
if (!chapter) return false;
|
||
return expandedChapters.has(chapter.id);
|
||
}
|
||
|
||
function chapterHasChildren(chapter) {
|
||
return chapterItems(chapter).length > 0;
|
||
}
|
||
|
||
function toggleChapterExpanded(chapter) {
|
||
if (!chapter) return;
|
||
if (expandedChapters.has(chapter.id)) {
|
||
expandedChapters.delete(chapter.id);
|
||
} else {
|
||
expandedChapters.add(chapter.id);
|
||
}
|
||
renderToc();
|
||
}
|
||
|
||
function expandAllChapters() {
|
||
expandedChapters = new Set((structure.chapters || []).map((chapter) => chapter.id));
|
||
renderToc();
|
||
}
|
||
|
||
function collapseAllChapters() {
|
||
expandedChapters = new Set();
|
||
const activeId = activeChapterScopeId();
|
||
if (activeId) expandedChapters.add(activeId);
|
||
renderToc();
|
||
}
|
||
|
||
function partById(partId) {
|
||
return allParts().find((part) => part.id === partId) || null;
|
||
}
|
||
|
||
function chapterById(chapterId) {
|
||
return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null;
|
||
}
|
||
|
||
function chapterKind(chapter) {
|
||
return chapter && chapter.kind === "image" ? "image" : "text";
|
||
}
|
||
|
||
function partRows(part) {
|
||
if (!part) return [];
|
||
const ids = new Set(part.row_ids || []);
|
||
return rows.filter((row) => ids.has(row.id));
|
||
}
|
||
|
||
function partForRow(row) {
|
||
if (!row) return null;
|
||
return allParts().find((part) => (part.row_ids || []).includes(row.id)) || null;
|
||
}
|
||
|
||
function chapterForPart(part) {
|
||
if (!part) return null;
|
||
return (structure.chapters || []).find((chapter) => (chapter.parts || []).some((item) => item.id === part.id)) || null;
|
||
}
|
||
|
||
function chapterRows(chapter) {
|
||
if (!chapter) return [];
|
||
if (isImageChapter(chapter)) return [];
|
||
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
|
||
return rows.filter((row) => ids.has(row.id));
|
||
}
|
||
|
||
function searchBaseRows() {
|
||
return searchScope === "book" ? rows : scopeRows();
|
||
}
|
||
|
||
function visibleRowsForScope() {
|
||
return filteredRows;
|
||
}
|
||
|
||
function updateSearchScopeButtons() {
|
||
dom.searchScopeCurrentBtn.classList.toggle("active", searchScope === "current");
|
||
dom.searchScopeBookBtn.classList.toggle("active", searchScope === "book");
|
||
}
|
||
|
||
function setSearchScope(scope) {
|
||
searchScope = scope === "book" ? "book" : "current";
|
||
updateSearchScopeButtons();
|
||
if (searchScope === "current" && (currentImageItem || (isImageChapter(currentChapter) && !currentPart))) {
|
||
currentRow = null;
|
||
currentIndex = -1;
|
||
}
|
||
applyFilter(true);
|
||
}
|
||
|
||
function scopeCoversRow(row) {
|
||
if (!row) return false;
|
||
if (currentPart) return (currentPart.row_ids || []).includes(row.id);
|
||
if (currentChapter) {
|
||
return (currentChapter.parts || []).some((part) => (part.row_ids || []).includes(row.id));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function syncScopeToRow(row, forcePart = false) {
|
||
const nextPart = partForRow(row);
|
||
if (!nextPart) return;
|
||
const nextChapter = chapterForPart(nextPart);
|
||
if (!nextChapter) return;
|
||
currentImageItem = null;
|
||
if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) {
|
||
currentChapter = nextChapter;
|
||
currentPart = nextPart;
|
||
expandedChapters.add(nextChapter.id);
|
||
return;
|
||
}
|
||
if (currentPart && currentPart.id !== nextPart.id) {
|
||
currentPart = nextPart;
|
||
}
|
||
}
|
||
|
||
function partVisibleRows(part) {
|
||
const ids = new Set((part && part.row_ids) || []);
|
||
return filteredRows.filter((row) => ids.has(row.id));
|
||
}
|
||
|
||
function chapterVisibleRowsInFilter(chapter) {
|
||
if (!chapter) return filteredRows;
|
||
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
|
||
return filteredRows.filter((row) => ids.has(row.id));
|
||
}
|
||
|
||
function chapterVisibleRows(chapter) {
|
||
if (!chapter) return filteredRows;
|
||
if (searchScope === "book") {
|
||
return chapterVisibleRowsInFilter(chapter);
|
||
}
|
||
return filteredRows;
|
||
}
|
||
|
||
function scopeRows() {
|
||
if (currentPart) return partRows(currentPart);
|
||
if (currentChapter) return chapterRows(currentChapter);
|
||
return rows;
|
||
}
|
||
|
||
function scopeVisibleRows() {
|
||
if (searchScope === "book") {
|
||
if (currentPart) return partVisibleRows(currentPart);
|
||
if (currentChapter) return chapterVisibleRows(currentChapter);
|
||
return filteredRows;
|
||
}
|
||
return filteredRows;
|
||
}
|
||
|
||
function imageChapterMatchesSearch(chapter) {
|
||
if (!isImageChapter(chapter) && !isImageItem(chapter)) return false;
|
||
if (showTouchedOnly) return false;
|
||
const q = dom.searchInput.value.trim().toLowerCase();
|
||
if (!q) return true;
|
||
const haystack = [
|
||
chapter.id,
|
||
chapter.title,
|
||
chapter.file_label,
|
||
...(chapter.images || []).flatMap((image) => [image.asset_name, image.alt, image.asset_path]),
|
||
]
|
||
.join("\n")
|
||
.toLowerCase();
|
||
return haystack.includes(q);
|
||
}
|
||
|
||
async function loadSession() {
|
||
const session = await api("/api/session");
|
||
setVersionBadge(session.version);
|
||
if (!session.has_session) {
|
||
await showStartScreen(session);
|
||
return session;
|
||
}
|
||
savedReadingPosition = session.reading_position || null;
|
||
setShellMode(true);
|
||
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
|
||
const partCount = structure.part_count || allParts().length;
|
||
const imageCount = structure.image_count || 0;
|
||
const sourceName = session.source_name || session.source_epub;
|
||
dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part / ${imageCount} 张图 · 已记录 ${session.touched_count} 段`;
|
||
return session;
|
||
}
|
||
|
||
async function loadStructure() {
|
||
structure = await api("/api/structure");
|
||
}
|
||
|
||
async function loadRows(keepSelection = false) {
|
||
const oldId = currentRow && currentRow.id;
|
||
const payload = await api("/api/rows");
|
||
rows = payload.rows || [];
|
||
applyFilter(false);
|
||
|
||
if (keepSelection && oldId) {
|
||
const nextRow = rowById(oldId);
|
||
if (nextRow) {
|
||
currentRow = nextRow;
|
||
currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id);
|
||
if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) {
|
||
syncScopeToRow(nextRow);
|
||
}
|
||
renderAll();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!currentPart) {
|
||
currentChapter = (structure.chapters || [])[0] || null;
|
||
currentPart = currentChapter
|
||
&& !isImageChapter(currentChapter)
|
||
&& (currentChapter.parts || []).length === 1
|
||
&& chapterImages(currentChapter).length === 0
|
||
? currentChapter.parts[0]
|
||
: null;
|
||
applyFilter(false);
|
||
}
|
||
if (filteredRows.length > 0 && currentIndex < 0) {
|
||
currentRow = filteredRows[0];
|
||
currentIndex = 0;
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
function applySavedReadingPosition(position) {
|
||
if (!position || !(structure.chapters || []).length) return false;
|
||
restoringReadingPosition = true;
|
||
try {
|
||
const row = position.row_id ? rowById(position.row_id) : null;
|
||
const chapter = chapterById(position.chapter_id);
|
||
const part = partById(position.part_id);
|
||
const imageItem = imageItemById(chapter, position.image_item_id);
|
||
if (position.scope === "image" && chapter && imageItem) {
|
||
currentChapter = chapter;
|
||
currentPart = null;
|
||
currentImageItem = imageItem;
|
||
currentRow = null;
|
||
currentIndex = -1;
|
||
expandedChapters.add(chapter.id);
|
||
} else if (part) {
|
||
currentPart = part;
|
||
currentChapter = chapterForPart(part) || chapter;
|
||
currentImageItem = null;
|
||
if (currentChapter) expandedChapters.add(currentChapter.id);
|
||
currentRow = row && (part.row_ids || []).includes(row.id) ? row : partRows(part)[0] || null;
|
||
currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
|
||
} else if (chapter) {
|
||
currentChapter = chapter;
|
||
currentPart = null;
|
||
currentImageItem = null;
|
||
expandedChapters.add(chapter.id);
|
||
const scopedRows = chapterRows(chapter);
|
||
currentRow = row && scopedRows.some((item) => item.id === row.id) ? row : scopedRows[0] || null;
|
||
currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
|
||
} else if (row) {
|
||
currentRow = row;
|
||
syncScopeToRow(row);
|
||
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
|
||
} else {
|
||
return false;
|
||
}
|
||
currentMode = position.mode === "polish" && currentRow ? "polish" : "reading";
|
||
applyFilter(false);
|
||
currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
|
||
renderAll();
|
||
return true;
|
||
} finally {
|
||
restoringReadingPosition = false;
|
||
}
|
||
}
|
||
|
||
function restoreReadingPositionScroll(position) {
|
||
if (!position || currentMode !== "reading") return;
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const rowId = position.row_id || "";
|
||
const target = rowId ? dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`) : null;
|
||
if (target) {
|
||
const paneRect = dom.readerPane.getBoundingClientRect();
|
||
const nextOffset = target.getBoundingClientRect().top - paneRect.top;
|
||
dom.readerPane.scrollTop += nextOffset - (position.offset || 0);
|
||
} else {
|
||
dom.readerPane.scrollTop = Math.max(0, position.scroll_top || 0);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadActiveSession(options = {}) {
|
||
resetReviewState();
|
||
currentMode = "reading";
|
||
setShellMode(true);
|
||
await loadStructure();
|
||
await loadRows(Boolean(options.keepSelection));
|
||
const session = await loadSession();
|
||
const restored = applySavedReadingPosition(session.reading_position);
|
||
if (restored) restoreReadingPositionScroll(session.reading_position);
|
||
readingPositionReady = true;
|
||
await refreshSessions().catch(() => {});
|
||
if (options.message) toast(options.message, 4200);
|
||
}
|
||
|
||
async function uploadEpub(event) {
|
||
event.preventDefault();
|
||
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
|
||
if (!file) {
|
||
toast("请先选择一个 EPUB 文件。");
|
||
return;
|
||
}
|
||
const formData = new FormData();
|
||
formData.append("epub", file);
|
||
dom.uploadBtn.disabled = true;
|
||
dom.uploadBtn.textContent = "正在解析……";
|
||
try {
|
||
await apiForm("/api/upload", formData);
|
||
dom.epubFileInput.value = "";
|
||
dom.selectedFileName.textContent = "尚未选择文件";
|
||
await loadActiveSession({ message: "EPUB 已载入,可以开始审校。" });
|
||
} catch (error) {
|
||
toast(`上传失败:${error.message}`, 9000);
|
||
} finally {
|
||
dom.uploadBtn.disabled = false;
|
||
dom.uploadBtn.textContent = "上传并开始审校";
|
||
}
|
||
}
|
||
|
||
async function openSession(sessionId) {
|
||
if (!sessionId) return;
|
||
if (hasSession && !(await maybeSaveBeforeSwitch())) return;
|
||
try {
|
||
await api("/api/session/open", {
|
||
method: "POST",
|
||
body: JSON.stringify({ session_id: sessionId }),
|
||
});
|
||
await loadActiveSession({ message: "已打开审校会话。" });
|
||
} catch (error) {
|
||
toast(`打开会话失败:${error.message}`, 9000);
|
||
}
|
||
}
|
||
|
||
function updateStructureCountsFromRows() {
|
||
for (const chapter of structure.chapters || []) {
|
||
chapter.row_count = 0;
|
||
chapter.touched_count = 0;
|
||
chapter.marked_count = 0;
|
||
if (isImageChapter(chapter)) {
|
||
chapter.row_count = 0;
|
||
chapter.touched_count = 0;
|
||
chapter.marked_count = 0;
|
||
chapter.items = chapterItems(chapter);
|
||
continue;
|
||
}
|
||
for (const part of chapter.parts || []) {
|
||
const partItems = partRows(part);
|
||
part.row_count = partItems.length;
|
||
part.touched_count = partItems.filter(rowTouched).length;
|
||
part.marked_count = partItems.filter((row) => row.marked).length;
|
||
chapter.row_count += part.row_count;
|
||
chapter.touched_count += part.touched_count;
|
||
chapter.marked_count += part.marked_count;
|
||
}
|
||
for (const item of chapter.items || []) {
|
||
if (!isImageItem(item)) {
|
||
const match = (chapter.parts || []).find((part) => part.id === item.id);
|
||
if (match) Object.assign(item, match);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function rowMatchesSearch(row, q) {
|
||
if (showTouchedOnly && !rowTouched(row)) return false;
|
||
if (!q) return true;
|
||
const haystack = [
|
||
row.id,
|
||
row.file_label,
|
||
row.document_title,
|
||
row.jp_text,
|
||
row.cn_text,
|
||
rowCurrentText(row),
|
||
row.comment,
|
||
row.learn_note,
|
||
row.tags,
|
||
row.issue_type,
|
||
]
|
||
.join("\n")
|
||
.toLowerCase();
|
||
return haystack.includes(q);
|
||
}
|
||
|
||
function applyFilter(shouldRender = true) {
|
||
const q = dom.searchInput.value.trim().toLowerCase();
|
||
filteredRows = searchBaseRows().filter((row) => rowMatchesSearch(row, q));
|
||
if (currentRow) {
|
||
currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id);
|
||
}
|
||
if (shouldRender) renderAll();
|
||
}
|
||
|
||
function renderAll() {
|
||
if (!hasSession) return;
|
||
updateStructureCountsFromRows();
|
||
renderToc();
|
||
renderList();
|
||
renderMode();
|
||
if (currentMode === "reading") {
|
||
renderReading();
|
||
} else {
|
||
renderEditor();
|
||
}
|
||
}
|
||
|
||
function renderMode() {
|
||
dom.readerPane.hidden = currentMode !== "reading";
|
||
dom.editorPane.hidden = currentMode !== "polish";
|
||
dom.readingModeBtn.classList.toggle("active", currentMode === "reading");
|
||
dom.polishModeBtn.classList.toggle("active", currentMode === "polish");
|
||
dom.polishModeBtn.disabled = !hasSession || ((currentImageItem || isImageChapter(currentChapter)) && !currentPart);
|
||
document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden);
|
||
if (sidePanelMode) {
|
||
dom.tocPanel.hidden = sidePanelMode !== "toc";
|
||
dom.searchPanel.hidden = sidePanelMode !== "search";
|
||
}
|
||
}
|
||
|
||
function renderToc() {
|
||
dom.toc.innerHTML = "";
|
||
const frag = document.createDocumentFragment();
|
||
for (const chapter of structure.chapters || []) {
|
||
const items = chapterItems(chapter);
|
||
const parts = chapter.parts || [];
|
||
const imageItems = items.filter(isImageItem);
|
||
const chapterIsImage = isImageChapter(chapter);
|
||
const isCurrentChapter = currentChapter && chapter.id === currentChapter.id;
|
||
const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id);
|
||
const expanded = chapterIsExpanded(chapter) || containsCurrentPart || (isCurrentChapter && (!currentPart || currentImageItem));
|
||
const group = document.createElement("section");
|
||
group.className = "tocChapter";
|
||
if (chapterIsImage) group.classList.add("imageChapter");
|
||
if (expanded) group.classList.add("expanded");
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "tocChapterRow";
|
||
if ((isCurrentChapter && !currentPart) || containsCurrentPart) {
|
||
row.classList.add("active");
|
||
}
|
||
|
||
const toggle = document.createElement("button");
|
||
toggle.type = "button";
|
||
toggle.className = "tocToggle";
|
||
toggle.textContent = expanded ? "▾" : "▸";
|
||
toggle.disabled = !chapterHasChildren(chapter);
|
||
toggle.setAttribute("aria-label", expanded ? "收起章节" : "展开章节");
|
||
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||
toggle.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
toggleChapterExpanded(chapter);
|
||
});
|
||
row.appendChild(toggle);
|
||
|
||
const title = document.createElement("button");
|
||
title.type = "button";
|
||
title.className = "tocChapterButton";
|
||
if (
|
||
(isCurrentChapter && !currentPart)
|
||
|| containsCurrentPart
|
||
) {
|
||
title.classList.add("active");
|
||
}
|
||
title.innerHTML = `
|
||
<span class="tocTitle">${escapeHtml(chapter.title || chapter.id)}</span>
|
||
<span class="tocCount">${chapterIsImage ? `${chapterImages(chapter).length || 1} 图` : `${chapter.row_count || 0} 段${imageItems.length ? ` · ${imageItems.length} 图` : ""}`}</span>
|
||
`;
|
||
title.addEventListener("click", () => selectChapter(chapter));
|
||
row.appendChild(title);
|
||
group.appendChild(row);
|
||
|
||
const partList = document.createElement("div");
|
||
partList.className = "tocParts";
|
||
partList.hidden = !expanded;
|
||
for (const item of items) {
|
||
if (isImageItem(item)) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "tocImageButton";
|
||
const imageActive = isCurrentChapter && !currentPart && (
|
||
currentImageItem ? currentImageItem.id === item.id : chapterIsImage
|
||
);
|
||
if (imageActive) button.classList.add("active");
|
||
button.innerHTML = `
|
||
<span class="tocTitle">${escapeHtml(item.toc_title || item.title || item.file_label || "插图")}</span>
|
||
<span class="tocCount">${(item.images || []).length || 1} 图</span>
|
||
`;
|
||
button.addEventListener("click", () => selectImageItem(chapter, item));
|
||
partList.appendChild(button);
|
||
} else {
|
||
const part = item;
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "tocPartButton";
|
||
if (currentPart && part.id === currentPart.id) button.classList.add("active");
|
||
const badges = [];
|
||
if (part.touched_count) badges.push(`记 ${part.touched_count}`);
|
||
if (part.marked_count) badges.push(`标 ${part.marked_count}`);
|
||
button.innerHTML = `
|
||
<span class="tocTitle">${escapeHtml(part.title || part.file_label || part.id)}</span>
|
||
<span class="tocCount">${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""}</span>
|
||
`;
|
||
button.addEventListener("click", () => selectPart(part));
|
||
partList.appendChild(button);
|
||
}
|
||
}
|
||
group.appendChild(partList);
|
||
frag.appendChild(group);
|
||
}
|
||
dom.toc.appendChild(frag);
|
||
requestAnimationFrame(() => {
|
||
dom.toc.querySelector(".tocPartButton.active, .tocImageButton.active, .tocChapterButton.active")?.scrollIntoView({ block: "nearest" });
|
||
});
|
||
}
|
||
|
||
function renderList() {
|
||
const touched = rows.filter(rowTouched).length;
|
||
const marked = rows.filter((row) => row.marked).length;
|
||
const visibleRows = visibleRowsForScope();
|
||
const visibleInScope = scopeVisibleRows();
|
||
const imageCount = structure.image_count || 0;
|
||
const isBrowsingImage = !dom.searchInput.value.trim() && !showTouchedOnly;
|
||
const isCurrentImage = searchScope !== "book" && isBrowsingImage && !currentPart && (Boolean(currentImageItem) || isImageChapter(currentChapter));
|
||
const scopeLabel = searchScope === "book" ? "全书" : "当前范围";
|
||
dom.stats.textContent = `${scopeLabel}显示 ${visibleRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`;
|
||
dom.rowList.innerHTML = "";
|
||
|
||
const frag = document.createDocumentFragment();
|
||
if (isCurrentImage) {
|
||
const item = document.createElement("div");
|
||
item.className = "rowItem imageRowItem active";
|
||
item.innerHTML = `
|
||
<div class="rowMeta">
|
||
<span>${escapeHtml(currentChapter.id)}</span>
|
||
<span class="rowBadge">插图</span>
|
||
<span>${escapeHtml(currentChapter.file_label || "")}</span>
|
||
</div>
|
||
<div class="rowPreview">${escapeHtml((currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}</div>
|
||
`;
|
||
frag.appendChild(item);
|
||
dom.rowList.appendChild(frag);
|
||
return;
|
||
}
|
||
if (!visibleRows.length) {
|
||
const item = document.createElement("div");
|
||
item.className = "rowItem emptyRowItem";
|
||
item.textContent = dom.searchInput.value || showTouchedOnly ? "当前检索范围没有命中段落。" : "当前范围没有可检索段落。";
|
||
frag.appendChild(item);
|
||
dom.rowList.appendChild(frag);
|
||
return;
|
||
}
|
||
visibleRows.forEach((row) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "rowItem";
|
||
if (currentRow && row.id === currentRow.id) button.classList.add("active");
|
||
if (rowTouched(row)) button.classList.add("touched");
|
||
if (row.marked) button.classList.add("marked");
|
||
|
||
const badge = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "未记录";
|
||
button.innerHTML = `
|
||
<div class="rowMeta">
|
||
<span>${escapeHtml(row.id)}</span>
|
||
<span class="rowBadge">${escapeHtml(badge)}</span>
|
||
<span>${escapeHtml(row.file_label)} · #${row.file_row_index || row.cn_p_index}</span>
|
||
</div>
|
||
<div class="rowPreview">${escapeHtml(rowCurrentText(row))}</div>
|
||
`;
|
||
button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", openQuick: currentMode === "reading", scroll: true }));
|
||
frag.appendChild(button);
|
||
});
|
||
dom.rowList.appendChild(frag);
|
||
}
|
||
|
||
function renderImageChapter(chapter) {
|
||
const items = chapterItems(chapter).filter(isImageItem);
|
||
if (items.length > 1) {
|
||
renderMixedChapterImages(chapter, items);
|
||
return;
|
||
}
|
||
const imageScopeItem = items[0] || null;
|
||
const images = chapterImages(chapter);
|
||
dom.chapterKicker.textContent = chapter.file_label || "插图";
|
||
dom.readerTitle.textContent = chapter.title || "插图";
|
||
dom.readingFlow.innerHTML = "";
|
||
if (!imageChapterMatchesSearch(chapter)) {
|
||
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有插图。</div>';
|
||
return;
|
||
}
|
||
const frag = document.createDocumentFragment();
|
||
images.forEach((image, index) => {
|
||
const figure = document.createElement("figure");
|
||
figure.className = "imageChapterFigure";
|
||
figure.dataset.readingScopeId = imageScopeItem ? readingScopeId(chapter, imageScopeItem) : readingScopeId(chapter);
|
||
figure.innerHTML = `
|
||
<img src="${escapeHtml(image.asset_url || "")}" alt="${escapeHtml(image.alt || chapter.title || "")}" loading="lazy">
|
||
<figcaption>
|
||
<span>${escapeHtml(chapter.title || `插图 ${index + 1}`)}</span>
|
||
<span>${escapeHtml(image.asset_name || image.asset_path || "")}</span>
|
||
</figcaption>
|
||
`;
|
||
frag.appendChild(figure);
|
||
});
|
||
dom.readingFlow.appendChild(frag);
|
||
}
|
||
|
||
function createImageFigure(item) {
|
||
const frag = document.createDocumentFragment();
|
||
(item.images || []).forEach((image, index) => {
|
||
const figure = document.createElement("figure");
|
||
figure.className = "imageChapterFigure inlineImageFigure";
|
||
const parentChapter = chapterById(item.parent_chapter_id) || currentChapter;
|
||
if (parentChapter) figure.dataset.readingScopeId = readingScopeId(parentChapter, item);
|
||
figure.innerHTML = `
|
||
<img src="${escapeHtml(image.asset_url || "")}" alt="${escapeHtml(image.alt || item.title || "")}" loading="lazy">
|
||
<figcaption>
|
||
<span>${escapeHtml(item.title || `插图 ${index + 1}`)}</span>
|
||
<span>${escapeHtml(image.asset_name || image.asset_path || "")}</span>
|
||
</figcaption>
|
||
`;
|
||
frag.appendChild(figure);
|
||
});
|
||
return frag;
|
||
}
|
||
|
||
function renderMixedChapterImages(chapter, imageItems, forceShow = false) {
|
||
dom.chapterKicker.textContent = chapter ? chapter.title : "插图";
|
||
dom.readerTitle.textContent = imageItems.length === 1
|
||
? imageItems[0].title || "插图"
|
||
: chapter ? chapter.title : "插图";
|
||
dom.readingFlow.innerHTML = "";
|
||
const frag = document.createDocumentFragment();
|
||
imageItems.forEach((item) => {
|
||
if (forceShow || imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item));
|
||
});
|
||
if (!frag.childNodes.length) {
|
||
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有插图。</div>';
|
||
return;
|
||
}
|
||
dom.readingFlow.appendChild(frag);
|
||
}
|
||
|
||
function rowPassesCurrentFilters(row) {
|
||
const q = dom.searchInput.value.trim().toLowerCase();
|
||
return rowMatchesSearch(row, q);
|
||
}
|
||
|
||
function rowsForReadingPart(part) {
|
||
const visible = searchScope === "book"
|
||
? partRows(part).filter(rowPassesCurrentFilters)
|
||
: partVisibleRows(part);
|
||
if (visible.length || dom.searchInput.value || showTouchedOnly) return visible;
|
||
return partRows(part);
|
||
}
|
||
|
||
function appendReadBlock(frag, row) {
|
||
const block = document.createElement("section");
|
||
block.className = "readBlock";
|
||
block.dataset.rowId = row.id;
|
||
const part = partForRow(row);
|
||
if (part) block.dataset.readingScopeId = readingScopeId(chapterForPart(part), part);
|
||
if (currentRow && row.id === currentRow.id) block.classList.add("active");
|
||
if (rowTouched(row)) block.classList.add("touched");
|
||
if (row.marked) block.classList.add("marked");
|
||
|
||
block.innerHTML = `
|
||
<div class="readText">
|
||
<div class="readSource">${row.jp_html || escapeHtml(row.jp_text)}</div>
|
||
<div class="readCn">${row.current_html || row.cn_html || escapeHtml(row.cn_text)}</div>
|
||
</div>
|
||
`;
|
||
block.addEventListener("click", () => {
|
||
maybeSelectRow(row, { openQuick: true, scroll: false });
|
||
});
|
||
frag.appendChild(block);
|
||
}
|
||
|
||
function renderReading() {
|
||
const selectedChapter = currentChapter || chapterForPart(currentPart);
|
||
if (!currentPart && !selectedChapter) {
|
||
dom.chapterKicker.textContent = "目录";
|
||
dom.readerTitle.textContent = "选择章节开始阅读";
|
||
dom.readingFlow.innerHTML = '<div class="emptyState">没有可阅读段落。</div>';
|
||
return;
|
||
}
|
||
|
||
const selectedPart = currentPart;
|
||
if (!selectedPart && currentImageItem) {
|
||
renderMixedChapterImages(selectedChapter, [currentImageItem], true);
|
||
return;
|
||
}
|
||
if (!selectedPart && isImageChapter(selectedChapter)) {
|
||
renderImageChapter(selectedChapter);
|
||
return;
|
||
}
|
||
dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || "";
|
||
dom.readerTitle.textContent = selectedPart
|
||
? selectedPart.title || selectedPart.file_label || "未命名 part"
|
||
: selectedChapter.title || "未命名章节";
|
||
dom.readingFlow.innerHTML = "";
|
||
|
||
const frag = document.createDocumentFragment();
|
||
|
||
if (selectedPart) {
|
||
rowsForReadingPart(selectedPart).forEach((row) => appendReadBlock(frag, row));
|
||
} else {
|
||
for (const item of chapterItems(selectedChapter)) {
|
||
if (isImageItem(item)) {
|
||
if (imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item));
|
||
continue;
|
||
}
|
||
const partRowsToRender = rowsForReadingPart(item);
|
||
if (!partRowsToRender.length) continue;
|
||
const divider = document.createElement("h3");
|
||
divider.className = "partDivider";
|
||
divider.textContent = item.title || item.file_label || item.file || "part";
|
||
frag.appendChild(divider);
|
||
partRowsToRender.forEach((row) => appendReadBlock(frag, row));
|
||
}
|
||
}
|
||
if (!frag.childNodes.length) {
|
||
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有内容。</div>';
|
||
return;
|
||
}
|
||
dom.readingFlow.appendChild(frag);
|
||
if (!restoringReadingPosition) saveReadingPositionSoon();
|
||
}
|
||
|
||
function renderEditor() {
|
||
if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) {
|
||
dom.emptyState.hidden = false;
|
||
dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。";
|
||
dom.editorCard.hidden = true;
|
||
return;
|
||
}
|
||
if (currentRow && !scopeCoversRow(currentRow)) {
|
||
const visible = scopeVisibleRows();
|
||
const fallback = scopeRows();
|
||
currentRow = visible[0] || fallback[0] || null;
|
||
}
|
||
if (!currentRow) {
|
||
dom.emptyState.hidden = false;
|
||
dom.emptyState.textContent = "选择左侧段落开始审校。";
|
||
dom.editorCard.hidden = true;
|
||
return;
|
||
}
|
||
dom.emptyState.hidden = true;
|
||
dom.editorCard.hidden = false;
|
||
fillFullEditor(currentRow);
|
||
}
|
||
|
||
function fillFullEditor(row) {
|
||
dom.rowId.textContent = row.id;
|
||
dom.rowPath.textContent = `${row.file} · 日文 P${row.ja_p_index} / 中文 P${row.cn_p_index}`;
|
||
dom.markedInput.checked = Boolean(row.marked);
|
||
dom.jpText.innerHTML = row.jp_html || escapeHtml(row.jp_text);
|
||
dom.cnEditor.innerHTML = row.current_html || row.cn_html || "";
|
||
dom.issueTypeInput.value = row.issue_type || "";
|
||
dom.severityInput.value = row.severity || "";
|
||
dom.tagsInput.value = row.tags || "";
|
||
dom.commentInput.value = row.comment || "";
|
||
dom.learnNoteInput.value = row.learn_note || "";
|
||
}
|
||
|
||
function fillQuickEditor(row) {
|
||
const hasRow = Boolean(row);
|
||
dom.quickRowId.textContent = hasRow ? row.id : "未选择段落";
|
||
dom.quickRowPath.textContent = hasRow ? `${row.file_label} · #${row.file_row_index || row.cn_p_index}` : "可先配置 AI、术语表或执行反馈与交付操作";
|
||
dom.quickJpText.innerHTML = hasRow ? row.jp_html || escapeHtml(row.jp_text) : '<div class="emptyState">在阅读区点击段落后,这里会显示日文原文。</div>';
|
||
dom.quickCnEditor.innerHTML = hasRow ? row.current_html || row.cn_html || "" : "";
|
||
dom.quickMarkedInput.checked = hasRow ? Boolean(row.marked) : false;
|
||
dom.quickIssueTypeInput.value = hasRow ? row.issue_type || "" : "";
|
||
dom.quickSeverityInput.value = hasRow ? row.severity || "" : "";
|
||
dom.quickTagsInput.value = hasRow ? row.tags || "" : "";
|
||
dom.quickCommentInput.value = hasRow ? row.comment || "" : "";
|
||
dom.quickLearnNoteInput.value = hasRow ? row.learn_note || "" : "";
|
||
[
|
||
dom.quickCnEditor,
|
||
dom.quickMarkedInput,
|
||
dom.quickIssueTypeInput,
|
||
dom.quickSeverityInput,
|
||
dom.quickTagsInput,
|
||
dom.quickCommentInput,
|
||
dom.quickLearnNoteInput,
|
||
dom.quickMarkSelectionBtn,
|
||
dom.quickSaveBtn,
|
||
dom.quickOpenFullBtn,
|
||
dom.gptInstructionInput,
|
||
dom.retranslateBtn,
|
||
].forEach((el) => {
|
||
if (el) el.disabled = !hasRow;
|
||
if (el === dom.quickCnEditor) el.contentEditable = hasRow ? "true" : "false";
|
||
});
|
||
gptCandidateHtml = "";
|
||
dom.gptCandidate.hidden = true;
|
||
dom.gptCandidate.innerHTML = "";
|
||
dom.applyRetranslationBtn.disabled = true;
|
||
quickDirty = false;
|
||
}
|
||
|
||
function collectCurrent() {
|
||
if (!currentRow) return null;
|
||
if (!dom.quickEditor.hidden && (currentMode === "reading" || quickDirty)) {
|
||
return collectQuick();
|
||
}
|
||
return collectFull();
|
||
}
|
||
|
||
function collectFull() {
|
||
return {
|
||
current_html: dom.cnEditor.innerHTML,
|
||
marked: dom.markedInput.checked,
|
||
issue_type: dom.issueTypeInput.value,
|
||
severity: dom.severityInput.value,
|
||
tags: dom.tagsInput.value,
|
||
comment: dom.commentInput.value,
|
||
learn_note: dom.learnNoteInput.value,
|
||
};
|
||
}
|
||
|
||
function collectQuick() {
|
||
return {
|
||
current_html: dom.quickCnEditor.innerHTML,
|
||
marked: dom.quickMarkedInput.checked,
|
||
issue_type: dom.quickIssueTypeInput.value,
|
||
severity: dom.quickSeverityInput.value,
|
||
tags: dom.quickTagsInput.value,
|
||
comment: dom.quickCommentInput.value,
|
||
learn_note: dom.quickLearnNoteInput.value,
|
||
};
|
||
}
|
||
|
||
function applyRowData(row, data, updatedAt = "") {
|
||
row.current_html = data.current_html;
|
||
row.marked = data.marked;
|
||
row.issue_type = data.issue_type;
|
||
row.severity = data.severity;
|
||
row.tags = data.tags;
|
||
row.comment = data.comment;
|
||
row.learn_note = data.learn_note;
|
||
row.edited = data.current_html !== row.cn_html;
|
||
row.updated_at = updatedAt || row.updated_at || "";
|
||
}
|
||
|
||
async function saveRow(row, data, silent = false) {
|
||
if (!row) return true;
|
||
try {
|
||
const result = await api(`/api/row/${encodeURIComponent(row.id)}`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
const target = rowById(row.id);
|
||
if (target) applyRowData(target, data, result.updated_at);
|
||
currentRow = target || row;
|
||
dirty = false;
|
||
quickDirty = false;
|
||
applyFilter(false);
|
||
withReaderAnchor(() => renderAll());
|
||
await loadSession();
|
||
if (!silent) toast("已保存本段记录。");
|
||
return true;
|
||
} catch (error) {
|
||
toast(`保存失败:${error.message}`, 7000);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function saveCurrent(silent = false) {
|
||
if (!hasSession) return true;
|
||
if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) return true;
|
||
if (!currentRow) return true;
|
||
return saveRow(currentRow, collectCurrent(), silent);
|
||
}
|
||
|
||
async function maybeSaveBeforeSwitch() {
|
||
await flushReadingPosition();
|
||
if (quickDirty && currentRow && !dom.quickEditor.hidden) {
|
||
return saveCurrent(true);
|
||
}
|
||
if (currentMode === "polish" && dirty && currentRow) {
|
||
return saveCurrent(true);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function maybeSelectRow(row, options = {}) {
|
||
const ok = await maybeSaveBeforeSwitch();
|
||
if (!ok) return;
|
||
selectRow(row, options);
|
||
}
|
||
|
||
function selectRow(row, options = {}) {
|
||
if (!row) return;
|
||
currentRow = row;
|
||
if (isImageChapter(currentChapter) && !currentPart) currentChapter = null;
|
||
currentImageItem = null;
|
||
syncScopeToRow(row, Boolean(options.forcePart));
|
||
applyFilter(false);
|
||
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
|
||
dirty = false;
|
||
|
||
if (options.openEditor) {
|
||
setMode("polish", false);
|
||
} else if (options.openQuick) {
|
||
openQuickEditor(row);
|
||
} else {
|
||
renderAll();
|
||
}
|
||
|
||
if (options.scroll) scrollRowIntoView(row.id);
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
async function selectChapter(chapter) {
|
||
if (!chapter) return;
|
||
const ok = await maybeSaveBeforeSwitch();
|
||
if (!ok) return;
|
||
currentChapter = chapter;
|
||
currentPart = null;
|
||
currentImageItem = null;
|
||
expandedChapters.add(chapter.id);
|
||
applyFilter(false);
|
||
if (isImageChapter(chapter)) {
|
||
currentRow = null;
|
||
currentIndex = -1;
|
||
await closeQuickEditor(true);
|
||
currentMode = "reading";
|
||
} else {
|
||
const visible = searchScope === "book" ? chapterVisibleRowsInFilter(chapter) : filteredRows;
|
||
const fallback = scopeRows();
|
||
currentRow = visible[0] || fallback[0] || null;
|
||
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
|
||
}
|
||
renderAll();
|
||
if (currentMode === "reading") {
|
||
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
async function selectPart(part) {
|
||
if (!part) return;
|
||
const ok = await maybeSaveBeforeSwitch();
|
||
if (!ok) return;
|
||
currentChapter = chapterForPart(part);
|
||
currentPart = part;
|
||
currentImageItem = null;
|
||
if (currentChapter) expandedChapters.add(currentChapter.id);
|
||
applyFilter(false);
|
||
const visible = partVisibleRows(part);
|
||
const fallback = partRows(part);
|
||
currentRow = visible[0] || fallback[0] || null;
|
||
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
|
||
renderAll();
|
||
if (currentMode === "reading") {
|
||
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
async function selectImageItem(chapter, item) {
|
||
if (!chapter || !item) return;
|
||
const ok = await maybeSaveBeforeSwitch();
|
||
if (!ok) return;
|
||
currentChapter = chapter;
|
||
currentPart = null;
|
||
currentImageItem = item;
|
||
currentRow = null;
|
||
currentIndex = -1;
|
||
if (chapter) expandedChapters.add(chapter.id);
|
||
applyFilter(false);
|
||
await closeQuickEditor(true);
|
||
currentMode = "reading";
|
||
renderAll();
|
||
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
function scrollRowIntoView(rowId) {
|
||
if (currentMode === "reading") {
|
||
requestAnimationFrame(() => {
|
||
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`);
|
||
if (target) target.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
});
|
||
}
|
||
}
|
||
|
||
async function setMode(mode, saveBefore = true) {
|
||
if (saveBefore) {
|
||
const ok = await maybeSaveBeforeSwitch();
|
||
if (!ok) return;
|
||
}
|
||
if (mode === "polish" && (currentImageItem || isImageChapter(currentChapter)) && !currentPart) {
|
||
toast("插图章节没有可编辑译文,已保持阅读模式。");
|
||
mode = "reading";
|
||
}
|
||
currentMode = mode;
|
||
if (mode === "reading" && currentPart && isImageChapter(currentChapter)) {
|
||
currentPart = null;
|
||
}
|
||
if (mode === "polish") {
|
||
await closeQuickEditor(true);
|
||
if (!scopeCoversRow(currentRow)) currentRow = null;
|
||
if (!currentRow) currentRow = visibleRowsForScope()[0] || scopeRows()[0] || filteredRows[0] || null;
|
||
}
|
||
withReaderAnchor(() => renderAll());
|
||
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
function openQuickEditor(row) {
|
||
withReaderAnchor(() => {
|
||
if (row) {
|
||
currentRow = row;
|
||
if (isImageChapter(currentChapter) && !currentPart) currentChapter = null;
|
||
currentImageItem = null;
|
||
syncScopeToRow(row);
|
||
applyFilter(false);
|
||
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
|
||
}
|
||
fillQuickEditor(row);
|
||
dom.quickEditor.hidden = false;
|
||
document.body.classList.add("quickOpen");
|
||
dom.reviewToolsBtn.classList.add("active");
|
||
renderAll();
|
||
});
|
||
loadGptConfig().catch(() => {});
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
async function closeQuickEditor(save = false) {
|
||
if (quickDirty && currentRow) {
|
||
if (save) {
|
||
const ok = await saveCurrent(true);
|
||
if (!ok) return;
|
||
} else if (!window.confirm("当前段有未保存修改,关闭审校工具会放弃这些修改。继续吗?")) {
|
||
return;
|
||
}
|
||
}
|
||
withReaderAnchor(() => {
|
||
dom.quickEditor.hidden = true;
|
||
document.body.classList.remove("quickOpen");
|
||
dom.reviewToolsBtn.classList.remove("active");
|
||
quickDirty = false;
|
||
renderAll();
|
||
});
|
||
saveReadingPositionSoon();
|
||
}
|
||
|
||
function markDirty() {
|
||
dirty = true;
|
||
}
|
||
|
||
function markQuickDirty() {
|
||
quickDirty = true;
|
||
}
|
||
|
||
function renderGptConfig(config) {
|
||
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
|
||
dom.gptModelInput.value = config.model || "gpt-4o-mini";
|
||
dom.gptApiKeyInput.value = "";
|
||
dom.glossaryPathInput.value = config.glossary_path || "";
|
||
dom.gptTranslationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
|
||
dom.gptFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
|
||
dom.gptCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
|
||
dom.resetGptPromptsBtn.dataset.translationPrompt = (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
|
||
dom.resetGptPromptsBtn.dataset.formatPrompt = (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
|
||
dom.resetGptPromptsBtn.dataset.characterPrompt = (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
|
||
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
|
||
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
|
||
}
|
||
|
||
function renderTranslationGptConfig(config) {
|
||
if (!dom.translationBaseUrlInput) return;
|
||
dom.translationBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
|
||
dom.translationModelInput.value = config.model || "gpt-4o-mini";
|
||
dom.translationApiKeyInput.value = "";
|
||
dom.translationGlossaryPathInput.value = config.glossary_path || "";
|
||
dom.translationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
|
||
dom.translationFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
|
||
dom.translationCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
|
||
translationPromptDefaults = {
|
||
translation_prompt: (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "",
|
||
format_prompt: (config.prompt_defaults && config.prompt_defaults.format_prompt) || "",
|
||
character_prompt: (config.prompt_defaults && config.prompt_defaults.character_prompt) || "",
|
||
};
|
||
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
|
||
dom.translationGptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key。";
|
||
}
|
||
|
||
function updateSeriesDrawerFromTranslation() {
|
||
if (!activeSeriesConfig) return;
|
||
dom.seriesGlossaryPathInput.value = dom.translationGlossaryPathInput.value;
|
||
dom.seriesTranslationPromptInput.value = dom.translationPromptInput.value;
|
||
dom.seriesFormatPromptInput.value = dom.translationFormatPromptInput.value;
|
||
dom.seriesCharacterPromptInput.value = dom.translationCharacterPromptInput.value;
|
||
}
|
||
|
||
function applySeriesConfigToTranslation(seriesConfig = {}) {
|
||
activeSeriesConfig = seriesConfig || {};
|
||
const applied = [];
|
||
if (activeSeriesConfig.glossary_path) {
|
||
dom.translationGlossaryPathInput.value = activeSeriesConfig.glossary_path;
|
||
applied.push("术语表");
|
||
}
|
||
if (activeSeriesConfig.translation_prompt) {
|
||
dom.translationPromptInput.value = activeSeriesConfig.translation_prompt;
|
||
applied.push("翻译提示词");
|
||
}
|
||
if (activeSeriesConfig.format_prompt) {
|
||
dom.translationFormatPromptInput.value = activeSeriesConfig.format_prompt;
|
||
applied.push("格式提示词");
|
||
}
|
||
if (activeSeriesConfig.character_prompt) {
|
||
dom.translationCharacterPromptInput.value = activeSeriesConfig.character_prompt;
|
||
applied.push("角色口吻");
|
||
}
|
||
translationUsesSeriesConfig = applied.length > 0;
|
||
if (translationUsesSeriesConfig) {
|
||
dom.translationGptStatus.textContent = `${dom.translationGptStatus.textContent} · 已套用系列${applied.join("、")}`;
|
||
}
|
||
}
|
||
|
||
async function loadGptConfig(force = false) {
|
||
if (gptConfigLoaded && !force) {
|
||
if (!glossaryLoaded) loadGlossary(false).catch(() => {});
|
||
return;
|
||
}
|
||
try {
|
||
const config = await api("/api/gpt/config");
|
||
renderGptConfig(config);
|
||
gptConfigLoaded = true;
|
||
loadGlossary(force).catch((error) => {
|
||
dom.glossaryMeta.textContent = `读取术语表失败:${error.message}`;
|
||
});
|
||
} catch (error) {
|
||
dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`;
|
||
}
|
||
}
|
||
|
||
async function saveGptConfig() {
|
||
try {
|
||
const config = await api("/api/gpt/config", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
base_url: dom.gptBaseUrlInput.value,
|
||
model: dom.gptModelInput.value,
|
||
api_key: dom.gptApiKeyInput.value,
|
||
glossary_path: dom.glossaryPathInput.value,
|
||
translation_prompt: dom.gptTranslationPromptInput.value,
|
||
format_prompt: dom.gptFormatPromptInput.value,
|
||
character_prompt: dom.gptCharacterPromptInput.value,
|
||
keep_existing: true,
|
||
}),
|
||
});
|
||
renderGptConfig(config);
|
||
gptConfigLoaded = true;
|
||
glossaryLoaded = false;
|
||
await loadGlossary(true);
|
||
toast("AI 设置已保存。");
|
||
} catch (error) {
|
||
toast(`保存 GPT 设置失败:${error.message}`, 7000);
|
||
}
|
||
}
|
||
|
||
async function saveTranslationGptConfig() {
|
||
try {
|
||
const pageConfig = {
|
||
glossary_path: dom.translationGlossaryPathInput.value,
|
||
translation_prompt: dom.translationPromptInput.value,
|
||
format_prompt: dom.translationFormatPromptInput.value,
|
||
character_prompt: dom.translationCharacterPromptInput.value,
|
||
};
|
||
const config = await api("/api/gpt/config", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
base_url: dom.translationBaseUrlInput.value,
|
||
model: dom.translationModelInput.value,
|
||
api_key: dom.translationApiKeyInput.value,
|
||
keep_existing: true,
|
||
}),
|
||
});
|
||
renderTranslationGptConfig(config);
|
||
dom.translationGlossaryPathInput.value = pageConfig.glossary_path;
|
||
dom.translationPromptInput.value = pageConfig.translation_prompt;
|
||
dom.translationFormatPromptInput.value = pageConfig.format_prompt;
|
||
dom.translationCharacterPromptInput.value = pageConfig.character_prompt;
|
||
if (translationUsesSeriesConfig) {
|
||
dom.translationGptStatus.textContent = `${dom.translationGptStatus.textContent} · 已套用系列配置`;
|
||
}
|
||
renderGptConfig(config);
|
||
gptConfigLoaded = true;
|
||
glossaryLoaded = false;
|
||
toast("AI 设置已保存,整书翻译会使用这套配置。");
|
||
return config;
|
||
} catch (error) {
|
||
toast(`保存 API 设置失败:${error.message}`, 7000);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function resetTranslationPrompts() {
|
||
if (activeSeriesConfig && (
|
||
activeSeriesConfig.translation_prompt
|
||
|| activeSeriesConfig.format_prompt
|
||
|| activeSeriesConfig.character_prompt
|
||
|| activeSeriesConfig.glossary_path
|
||
)) {
|
||
applySeriesConfigToTranslation(activeSeriesConfig);
|
||
toast("已恢复同系列配置。");
|
||
return;
|
||
}
|
||
dom.translationPromptInput.value = translationPromptDefaults.translation_prompt || "";
|
||
dom.translationFormatPromptInput.value = translationPromptDefaults.format_prompt || "";
|
||
dom.translationCharacterPromptInput.value = translationPromptDefaults.character_prompt || "";
|
||
translationUsesSeriesConfig = false;
|
||
toast("已恢复翻译页预设提示词。");
|
||
}
|
||
|
||
function glossaryFilteredEntries() {
|
||
const q = (dom.glossarySearchInput.value || "").trim().toLowerCase();
|
||
if (!q) return glossaryEntries.map((entry, index) => ({ entry, index }));
|
||
return glossaryEntries
|
||
.map((entry, index) => ({ entry, index }))
|
||
.filter(({ entry }) => {
|
||
return [entry.source, entry.target, entry.clean_target]
|
||
.join("\n")
|
||
.toLowerCase()
|
||
.includes(q);
|
||
});
|
||
}
|
||
|
||
function updateGlossaryMeta(shownCount = null, matchCount = null) {
|
||
const matches = shownCount === null || matchCount === null ? glossaryFilteredEntries() : null;
|
||
const shown = shownCount === null ? Math.min(80, matches.length) : shownCount;
|
||
const matched = matchCount === null ? matches.length : matchCount;
|
||
const path = dom.glossaryPathInput.value || "未设置";
|
||
const dirtyText = glossaryDirty ? " · 有未保存修改" : "";
|
||
dom.glossaryMeta.textContent = `当前术语表:${path} · ${glossaryEntries.length} 条 · 显示 ${shown}/${matched}${dirtyText}`;
|
||
}
|
||
|
||
function setGlossaryDirty(value) {
|
||
glossaryDirty = value;
|
||
dom.saveGlossaryBtn.disabled = !glossaryDirty;
|
||
if (glossaryLoaded) updateGlossaryMeta();
|
||
}
|
||
|
||
function renderGlossary(payload = null) {
|
||
if (payload) {
|
||
glossaryEntries = (payload.entries || []).map((entry) => ({
|
||
source: entry.source || "",
|
||
target: entry.target || "",
|
||
clean_target: entry.clean_target || "",
|
||
}));
|
||
glossaryLoaded = true;
|
||
setGlossaryDirty(false);
|
||
if (payload.path) dom.glossaryPathInput.value = payload.path;
|
||
}
|
||
|
||
const matches = glossaryFilteredEntries();
|
||
const shown = matches.slice(0, 80);
|
||
updateGlossaryMeta(shown.length, matches.length);
|
||
dom.glossaryList.innerHTML = "";
|
||
if (!glossaryLoaded) {
|
||
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">展开后会读取术语表。</div>';
|
||
return;
|
||
}
|
||
if (!shown.length) {
|
||
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">没有匹配的术语。</div>';
|
||
return;
|
||
}
|
||
|
||
const frag = document.createDocumentFragment();
|
||
shown.forEach(({ entry, index }) => {
|
||
const row = document.createElement("div");
|
||
row.className = "glossaryRow";
|
||
row.dataset.index = String(index);
|
||
row.innerHTML = `
|
||
<input class="glossarySource" type="text" value="${escapeHtml(entry.source)}" aria-label="术语原文">
|
||
<input class="glossaryTarget" type="text" value="${escapeHtml(entry.target)}" aria-label="术语译名">
|
||
<button class="glossaryDelete" type="button" aria-label="删除术语">删除</button>
|
||
`;
|
||
row.querySelector(".glossarySource").addEventListener("input", (event) => {
|
||
glossaryEntries[index].source = event.target.value;
|
||
setGlossaryDirty(true);
|
||
});
|
||
row.querySelector(".glossaryTarget").addEventListener("input", (event) => {
|
||
glossaryEntries[index].target = event.target.value;
|
||
glossaryEntries[index].clean_target = event.target.value.split("#", 1)[0].trim();
|
||
setGlossaryDirty(true);
|
||
});
|
||
row.querySelector(".glossaryDelete").addEventListener("click", () => {
|
||
glossaryEntries.splice(index, 1);
|
||
setGlossaryDirty(true);
|
||
renderGlossary();
|
||
});
|
||
frag.appendChild(row);
|
||
});
|
||
dom.glossaryList.appendChild(frag);
|
||
}
|
||
|
||
async function loadGlossary(force = false) {
|
||
if (glossaryLoaded && !force) return;
|
||
const payload = await api("/api/glossary");
|
||
renderGlossary(payload);
|
||
}
|
||
|
||
function addGlossaryEntry() {
|
||
glossaryEntries.unshift({ source: "", target: "", clean_target: "" });
|
||
dom.glossarySearchInput.value = "";
|
||
glossaryLoaded = true;
|
||
setGlossaryDirty(true);
|
||
renderGlossary();
|
||
requestAnimationFrame(() => dom.glossaryList.querySelector(".glossarySource")?.focus());
|
||
}
|
||
|
||
async function saveGlossary() {
|
||
try {
|
||
const payload = await api("/api/glossary", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
path: dom.glossaryPathInput.value,
|
||
entries: glossaryEntries,
|
||
}),
|
||
});
|
||
renderGlossary(payload);
|
||
toast("术语表已保存,下一次重翻会实时使用最新术语。");
|
||
} catch (error) {
|
||
toast(`保存术语表失败:${error.message}`, 9000);
|
||
}
|
||
}
|
||
|
||
function glossaryMatchedText(data) {
|
||
const matches = data.glossary_matches || [];
|
||
if (!matches.length) return "";
|
||
return `<div class="gptCandidateMeta">术语命中:${escapeHtml(matches.slice(0, 10).join("; "))}${matches.length > 10 ? ` 等 ${matches.length} 条` : ""}</div>`;
|
||
}
|
||
|
||
function resetGptPrompts() {
|
||
dom.gptTranslationPromptInput.value = dom.resetGptPromptsBtn.dataset.translationPrompt || "";
|
||
dom.gptFormatPromptInput.value = dom.resetGptPromptsBtn.dataset.formatPrompt || "";
|
||
dom.gptCharacterPromptInput.value = dom.resetGptPromptsBtn.dataset.characterPrompt || "";
|
||
toast("已恢复预设提示词,保存后生效。");
|
||
}
|
||
|
||
async function retranslateCurrentRow() {
|
||
if (!currentRow) return;
|
||
if (quickDirty) {
|
||
const ok = await saveCurrent(true);
|
||
if (!ok) return;
|
||
}
|
||
dom.retranslateBtn.disabled = true;
|
||
dom.retranslateBtn.textContent = "重翻中……";
|
||
dom.applyRetranslationBtn.disabled = true;
|
||
dom.gptCandidate.hidden = false;
|
||
dom.gptCandidate.textContent = "正在请求 GPT……";
|
||
try {
|
||
const data = await api(`/api/row/${encodeURIComponent(currentRow.id)}/retranslate`, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
instruction: dom.gptInstructionInput.value,
|
||
}),
|
||
});
|
||
gptCandidateHtml = data.candidate_html || "";
|
||
dom.gptCandidate.innerHTML = `${glossaryMatchedText(data)}${gptCandidateHtml || escapeHtml(data.candidate_text || "")}`;
|
||
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
|
||
toast("已生成重翻候选,可确认后应用。");
|
||
} catch (error) {
|
||
dom.gptCandidate.textContent = `重翻失败:${error.message}`;
|
||
toast(`重翻失败:${error.message}`, 9000);
|
||
} finally {
|
||
dom.retranslateBtn.disabled = false;
|
||
dom.retranslateBtn.textContent = "重翻本段";
|
||
}
|
||
}
|
||
|
||
function applyRetranslation() {
|
||
if (!gptCandidateHtml) return;
|
||
dom.quickCnEditor.innerHTML = gptCandidateHtml;
|
||
quickDirty = true;
|
||
dom.applyRetranslationBtn.disabled = true;
|
||
toast("已应用到编辑框,保存后才会写入审校记录。");
|
||
}
|
||
|
||
function resetCurrentText() {
|
||
if (!currentRow) return;
|
||
dom.cnEditor.innerHTML = currentRow.cn_html || "";
|
||
markDirty();
|
||
}
|
||
|
||
function markSelectionInEditor(editor, markedInput, issueInput) {
|
||
const selection = window.getSelection();
|
||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||
toast("请先在中文译文中选中要标记的文字。");
|
||
return false;
|
||
}
|
||
const range = selection.getRangeAt(0);
|
||
if (!editor.contains(range.commonAncestorContainer)) {
|
||
toast("只能标记中文译文区域内的文字。");
|
||
return false;
|
||
}
|
||
const mark = document.createElement("mark");
|
||
mark.className = "review-mark";
|
||
mark.setAttribute("data-review-mark", "bad-translation");
|
||
try {
|
||
range.surroundContents(mark);
|
||
} catch (_error) {
|
||
const fragment = range.extractContents();
|
||
mark.appendChild(fragment);
|
||
range.insertNode(mark);
|
||
}
|
||
selection.removeAllRanges();
|
||
markedInput.checked = true;
|
||
if (!issueInput.value) issueInput.value = "翻译腔";
|
||
return true;
|
||
}
|
||
|
||
function markSelection() {
|
||
if (markSelectionInEditor(dom.cnEditor, dom.markedInput, dom.issueTypeInput)) markDirty();
|
||
}
|
||
|
||
function markQuickSelection() {
|
||
if (markSelectionInEditor(dom.quickCnEditor, dom.quickMarkedInput, dom.quickIssueTypeInput)) markQuickDirty();
|
||
}
|
||
|
||
function clearMarks() {
|
||
dom.cnEditor.querySelectorAll("mark, .review-mark, [data-review-mark]").forEach((node) => {
|
||
const parent = node.parentNode;
|
||
while (node.firstChild) parent.insertBefore(node.firstChild, node);
|
||
parent.removeChild(node);
|
||
parent.normalize();
|
||
});
|
||
markDirty();
|
||
}
|
||
|
||
async function go(delta) {
|
||
const candidates = visibleRowsForScope();
|
||
if (!candidates.length) return;
|
||
const index = Math.max(0, candidates.findIndex((row) => currentRow && row.id === currentRow.id));
|
||
const next = Math.max(0, Math.min(candidates.length - 1, index + delta));
|
||
if (next === index) return;
|
||
await maybeSelectRow(candidates[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true });
|
||
}
|
||
|
||
async function goPart(delta) {
|
||
const scopes = allReadingScopes();
|
||
if (!scopes.length) return;
|
||
const currentScopeId = activeReadingScopeId();
|
||
const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId));
|
||
const next = Math.max(0, Math.min(scopes.length - 1, index + delta));
|
||
if (next === index) return;
|
||
const scope = scopes[next];
|
||
if (scope.type === "part") {
|
||
await selectPart(scope.part);
|
||
} else if (scope.type === "image") {
|
||
await selectImageItem(scope.chapter, scope.item);
|
||
} else {
|
||
await selectChapter(scope.chapter);
|
||
}
|
||
}
|
||
|
||
async function generateFeedback() {
|
||
if (!hasSession) return;
|
||
if (!(await maybeSaveBeforeSwitch())) return;
|
||
const data = await api("/api/feedback");
|
||
const downloadText = data.feedback_md_url ? `\n可在当前浏览器下载:${data.feedback_md_url}` : "";
|
||
toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}${downloadText}`, 9000);
|
||
}
|
||
|
||
async function applyBack() {
|
||
if (!hasSession) return;
|
||
if (!(await maybeSaveBeforeSwitch())) return;
|
||
const data = await api("/api/apply", { method: "POST", body: "{}" });
|
||
toast(`已写回 EPUB 解包内容。\n修改文件数:${data.modified_files.length}\n反馈:${data.feedback_md}`, 9000);
|
||
}
|
||
|
||
async function exportEpub() {
|
||
if (!hasSession) return;
|
||
if (!(await maybeSaveBeforeSwitch())) return;
|
||
const data = await api("/api/export", { method: "POST", body: "{}" });
|
||
toast(`已导出审校 EPUB:\n${data.output}${data.download_url ? `\n可在当前浏览器下载:${data.download_url}` : ""}`, 10000);
|
||
}
|
||
|
||
function initEvents() {
|
||
readStoredReaderSettings();
|
||
applyReaderSettings(false);
|
||
initChromeMetrics();
|
||
|
||
[
|
||
dom.markedInput,
|
||
dom.cnEditor,
|
||
dom.issueTypeInput,
|
||
dom.severityInput,
|
||
dom.tagsInput,
|
||
dom.commentInput,
|
||
dom.learnNoteInput,
|
||
].forEach((el) => {
|
||
el.addEventListener("input", markDirty);
|
||
el.addEventListener("change", markDirty);
|
||
});
|
||
|
||
[
|
||
dom.quickMarkedInput,
|
||
dom.quickCnEditor,
|
||
dom.quickIssueTypeInput,
|
||
dom.quickSeverityInput,
|
||
dom.quickTagsInput,
|
||
dom.quickCommentInput,
|
||
dom.quickLearnNoteInput,
|
||
].forEach((el) => {
|
||
el.addEventListener("input", markQuickDirty);
|
||
el.addEventListener("change", markQuickDirty);
|
||
});
|
||
|
||
dom.searchInput.addEventListener("input", () => {
|
||
currentIndex = -1;
|
||
applyFilter(true);
|
||
});
|
||
dom.searchScopeCurrentBtn.addEventListener("click", () => setSearchScope("current"));
|
||
dom.searchScopeBookBtn.addEventListener("click", () => setSearchScope("book"));
|
||
dom.tocPanelBtn.addEventListener("click", () => {
|
||
if (sidePanelMode === "toc") {
|
||
closeSidePanel();
|
||
} else {
|
||
openSidePanel("toc");
|
||
}
|
||
});
|
||
dom.searchPanelBtn.addEventListener("click", () => {
|
||
if (sidePanelMode === "search") {
|
||
closeSidePanel();
|
||
} else {
|
||
openSidePanel("search");
|
||
}
|
||
});
|
||
dom.reviewToolsBtn.addEventListener("click", async () => {
|
||
if (!hasSession) return;
|
||
if (!dom.quickEditor.hidden) {
|
||
await closeQuickEditor(false);
|
||
return;
|
||
}
|
||
const ok = await maybeSaveBeforeSwitch();
|
||
if (!ok) return;
|
||
openQuickEditor(null);
|
||
});
|
||
dom.closeSidebarBtn.addEventListener("click", closeSidePanel);
|
||
dom.bookshelfBtn.addEventListener("click", () => {
|
||
showBookshelf().catch((error) => toast(`打开书架失败:${error.message}`, 7000));
|
||
});
|
||
dom.showTouchedBtn.addEventListener("click", () => {
|
||
showTouchedOnly = !showTouchedOnly;
|
||
dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly);
|
||
applyFilter(true);
|
||
});
|
||
dom.expandTocBtn.addEventListener("click", expandAllChapters);
|
||
dom.collapseTocBtn.addEventListener("click", collapseAllChapters);
|
||
dom.readingModeBtn.addEventListener("click", () => setMode("reading"));
|
||
dom.polishModeBtn.addEventListener("click", () => setMode("polish"));
|
||
dom.openLibraryBtn.addEventListener("click", async () => {
|
||
if (!(await leaveReviewShell())) return;
|
||
await showStartScreen();
|
||
});
|
||
[
|
||
dom.themeDefaultBtn,
|
||
dom.themeEyeBtn,
|
||
dom.themeNightBtn,
|
||
].forEach((button) => {
|
||
button.addEventListener("click", () => setReaderTheme(button.dataset.readerTheme));
|
||
});
|
||
dom.readerFontSizeInput.addEventListener("input", () => setReaderFontSize(dom.readerFontSizeInput.value));
|
||
dom.readerLineHeightInput.addEventListener("input", () => setReaderLineHeight(dom.readerLineHeightInput.value));
|
||
dom.prevPartBtn.addEventListener("click", () => goPart(-1));
|
||
dom.nextPartBtn.addEventListener("click", () => goPart(1));
|
||
dom.resetTextBtn.addEventListener("click", resetCurrentText);
|
||
dom.markSelectionBtn.addEventListener("click", markSelection);
|
||
dom.clearMarksBtn.addEventListener("click", clearMarks);
|
||
dom.saveBtn.addEventListener("click", () => saveCurrent(false));
|
||
dom.prevBtn.addEventListener("click", () => go(-1));
|
||
dom.nextBtn.addEventListener("click", () => go(1));
|
||
dom.feedbackBtn.addEventListener("click", generateFeedback);
|
||
dom.applyBtn.addEventListener("click", applyBack);
|
||
dom.exportBtn.addEventListener("click", exportEpub);
|
||
dom.uploadForm.addEventListener("submit", uploadEpub);
|
||
dom.epubFileInput.addEventListener("change", () => {
|
||
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
|
||
dom.selectedFileName.textContent = file ? file.name : "尚未选择文件";
|
||
});
|
||
dom.refreshSessionsBtn.addEventListener("click", () => {
|
||
refreshSessions().catch((error) => toast(`刷新失败:${error.message}`, 7000));
|
||
});
|
||
dom.bookshelfRefreshBtn.addEventListener("click", () => {
|
||
refreshBookshelf().catch((error) => toast(`刷新书架失败:${error.message}`, 7000));
|
||
});
|
||
dom.bookshelfOpenUploadBtn.addEventListener("click", async () => {
|
||
if (!(await leaveReviewShell())) return;
|
||
await showStartScreen();
|
||
});
|
||
if (dom.bookshelfOpenUploadBtnInline) {
|
||
dom.bookshelfOpenUploadBtnInline.addEventListener("click", async () => {
|
||
if (!(await leaveReviewShell())) return;
|
||
await showStartScreen();
|
||
});
|
||
}
|
||
if (dom.bookshelfSearchInput) {
|
||
dom.bookshelfSearchInput.addEventListener("input", () => {
|
||
bookshelfSearch = dom.bookshelfSearchInput.value || "";
|
||
renderBookshelfFromState(activeBookshelfSessionId);
|
||
});
|
||
}
|
||
if (dom.seriesConfigBtn) {
|
||
dom.seriesConfigBtn.addEventListener("click", () => {
|
||
openSeriesConfigDrawer().catch((error) => toast(`读取系列设置失败:${error.message}`, 7000));
|
||
});
|
||
}
|
||
if (dom.closeSeriesConfigBtn) {
|
||
dom.closeSeriesConfigBtn.addEventListener("click", closeSeriesConfigDrawer);
|
||
}
|
||
if (dom.saveSeriesConfigBtn) {
|
||
dom.saveSeriesConfigBtn.addEventListener("click", () => {
|
||
saveActiveSeriesConfig(true).catch((error) => toast(`保存系列设置失败:${error.message}`, 9000));
|
||
});
|
||
}
|
||
dom.translationBackBookshelfBtn.addEventListener("click", () => {
|
||
showBookshelf().catch((error) => toast(`返回书架失败:${error.message}`, 7000));
|
||
});
|
||
dom.translationOpenReviewBtn.addEventListener("click", () => {
|
||
if (!translationOutputSessionId) return;
|
||
openSession(translationOutputSessionId).catch((error) => toast(`打开输出审校失败:${error.message}`, 7000));
|
||
});
|
||
dom.translationSaveConfigBtn.addEventListener("click", () => {
|
||
saveTranslationGptConfig().catch(() => {});
|
||
});
|
||
dom.translationResetPromptsBtn.addEventListener("click", resetTranslationPrompts);
|
||
dom.translationForm.addEventListener("submit", startTranslationJob);
|
||
dom.translationRangeModeInput.addEventListener("change", () => {
|
||
dom.translationLimitInput.disabled = dom.translationRangeModeInput.value === "all";
|
||
});
|
||
dom.closeQuickEditorBtn.addEventListener("click", () => {
|
||
closeQuickEditor(false).catch((error) => toast(`关闭失败:${error.message}`, 7000));
|
||
});
|
||
dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false));
|
||
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
|
||
dom.saveGptConfigBtn.addEventListener("click", saveGptConfig);
|
||
dom.resetGptPromptsBtn.addEventListener("click", resetGptPrompts);
|
||
dom.reloadGlossaryBtn.addEventListener("click", () => {
|
||
if (glossaryDirty && !window.confirm("术语表有未保存修改,重新读取会丢失这些修改。继续吗?")) return;
|
||
loadGlossary(true)
|
||
.then(() => toast("术语表已重新读取。"))
|
||
.catch((error) => toast(`读取术语表失败:${error.message}`, 9000));
|
||
});
|
||
dom.addGlossaryEntryBtn.addEventListener("click", addGlossaryEntry);
|
||
dom.saveGlossaryBtn.addEventListener("click", saveGlossary);
|
||
dom.glossarySearchInput.addEventListener("input", () => renderGlossary());
|
||
dom.glossaryPathInput.addEventListener("change", async () => {
|
||
if (glossaryDirty && !window.confirm("术语表有未保存修改,切换路径会丢失这些修改。继续吗?")) return;
|
||
try {
|
||
await api("/api/glossary", {
|
||
method: "POST",
|
||
body: JSON.stringify({ path: dom.glossaryPathInput.value }),
|
||
});
|
||
glossaryLoaded = false;
|
||
await loadGlossary(true);
|
||
toast("已切换术语表路径。");
|
||
} catch (error) {
|
||
toast(`切换术语表失败:${error.message}`, 9000);
|
||
}
|
||
});
|
||
dom.retranslateBtn.addEventListener("click", retranslateCurrentRow);
|
||
dom.applyRetranslationBtn.addEventListener("click", applyRetranslation);
|
||
dom.quickOpenFullBtn.addEventListener("click", async () => {
|
||
if (quickDirty) {
|
||
const ok = await saveCurrent(true);
|
||
if (!ok) return;
|
||
}
|
||
await setMode("polish", false);
|
||
});
|
||
|
||
document.addEventListener("keydown", async (event) => {
|
||
if (event.ctrlKey && event.key.toLowerCase() === "s") {
|
||
event.preventDefault();
|
||
await saveCurrent(false);
|
||
return;
|
||
}
|
||
if (event.target && ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return;
|
||
if (event.altKey && event.key === "ArrowLeft") {
|
||
event.preventDefault();
|
||
go(-1);
|
||
}
|
||
if (event.altKey && event.key === "ArrowRight") {
|
||
event.preventDefault();
|
||
go(1);
|
||
}
|
||
if (event.altKey && event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
goPart(-1);
|
||
}
|
||
if (event.altKey && event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
goPart(1);
|
||
}
|
||
if (event.key === "Escape" && !dom.quickEditor.hidden) {
|
||
event.preventDefault();
|
||
closeQuickEditor(false).catch((error) => toast(`关闭失败:${error.message}`, 7000));
|
||
return;
|
||
}
|
||
if (event.key === "Escape" && sidePanelMode) {
|
||
event.preventDefault();
|
||
closeSidePanel();
|
||
}
|
||
});
|
||
|
||
document.addEventListener("mousemove", updateChromeVisibility, { passive: true });
|
||
document.addEventListener("focusin", (event) => {
|
||
if (event.target && (event.target.closest(".topbar") || event.target.closest(".readerHeader"))) {
|
||
showChromeBriefly();
|
||
}
|
||
});
|
||
window.addEventListener("beforeunload", (event) => {
|
||
sendReadingPositionBeacon();
|
||
if (!dirty && !quickDirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
});
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "hidden") {
|
||
sendReadingPositionBeacon();
|
||
} else {
|
||
saveReadingPositionSoon();
|
||
}
|
||
});
|
||
dom.readerPane.addEventListener("scroll", saveReadingPositionSoon, { passive: true });
|
||
}
|
||
|
||
async function main() {
|
||
initEvents();
|
||
try {
|
||
const session = await loadSession();
|
||
if (!session || !session.has_session) {
|
||
return;
|
||
}
|
||
await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可打开精修与重翻面板。" });
|
||
} catch (error) {
|
||
await showStartScreen();
|
||
toast(`启动时未载入 EPUB:${error.message}`, 7000);
|
||
}
|
||
}
|
||
|
||
main();
|