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 = '
还没有审校会话。上传 EPUB 后会自动创建。
'; 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 = `

${escapeHtml(session.source_name || session.id)}

${escapeHtml(session.session_root || "")}

${session.row_count || 0} 段 记录 ${session.touched_count || 0} 标记 ${session.marked_count || 0} ${session.updated_at ? `${escapeHtml(formatDateTime(session.updated_at))}` : ""}
`; 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 ` `; }).join(""); return `
${escapeHtml(title)}
${rows || '
暂无
'}
`; } dom.bookshelfFacetList.innerHTML = ` ${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 = `

书架还是空的

上传第一本中日双语 EPUB 后,它会出现在这里,之后可以直接回到上次阅读位置。

`; dom.bookshelfList.querySelector("[data-bookshelf-upload]").addEventListener("click", () => showStartScreen()); return; } if (!filtered.length) { dom.bookshelfList.innerHTML = `

没有匹配书籍

换个分类或搜索词试试。

`; 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 = `
${coverUrl ? `` : `
${escapeHtml(initial)}
`}

${escapeHtml(title)}

${escapeHtml(subtitle)}

${session.row_count || 0} 审校段 ${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")} ${updatedAt ? `${escapeHtml(formatDateTime(updatedAt))}` : ""}

${escapeHtml(title)}

${escapeHtml(bookSeries(session))}

`; 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 = `
读取书架失败:${escapeHtml(error.message)}
`; } } 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 = `
读取会话失败:${escapeHtml(error.message)}
`; } } } 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 = `
${chips.map(([label, value]) => `${escapeHtml(value)}${escapeHtml(label)}`).join("")}
${stats.note ? `

${escapeHtml(stats.note)}

` : ""} `; } dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000))); const sample = payload.sample || []; if (!sample.length) { dom.translationSample.innerHTML = '
暂无可预览段落。请确认这本书是日语 EPUB。
'; return; } dom.translationSample.innerHTML = sample .map((item) => `
${escapeHtml(item.id)} · ${escapeHtml(item.file || "")}

${escapeHtml(item.text || "")}

`) .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) => `
${escapeHtml(formatDateTime(log.ts) || log.ts || "")}

${escapeHtml(log.message || "")}

`).join(""); if (job.status === "completed") { translationOutputSessionId = job.output_session_id || ""; dom.translationOpenReviewBtn.disabled = !translationOutputSessionId; dom.translationOutput.hidden = false; dom.translationOutput.innerHTML = ` 已生成 EPUB 并加入书架

${escapeHtml(job.output_epub || "")}

`; 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 = ` ${escapeHtml(chapter.title || chapter.id)} ${chapterIsImage ? `${chapterImages(chapter).length || 1} 图` : `${chapter.row_count || 0} 段${imageItems.length ? ` · ${imageItems.length} 图` : ""}`} `; 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 = ` ${escapeHtml(item.toc_title || item.title || item.file_label || "插图")} ${(item.images || []).length || 1} 图 `; 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 = ` ${escapeHtml(part.title || part.file_label || part.id)} ${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""} `; 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 = `
${escapeHtml(currentChapter.id)} 插图 ${escapeHtml(currentChapter.file_label || "")}
${escapeHtml((currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}
`; 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 = `
${escapeHtml(row.id)} ${escapeHtml(badge)} ${escapeHtml(row.file_label)} · #${row.file_row_index || row.cn_p_index}
${escapeHtml(rowCurrentText(row))}
`; 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 = '
当前筛选条件下没有插图。
'; 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 = ` ${escapeHtml(image.alt || chapter.title ||
${escapeHtml(chapter.title || `插图 ${index + 1}`)} ${escapeHtml(image.asset_name || image.asset_path || "")}
`; 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 = ` ${escapeHtml(image.alt || item.title ||
${escapeHtml(item.title || `插图 ${index + 1}`)} ${escapeHtml(image.asset_name || image.asset_path || "")}
`; 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 = '
当前筛选条件下没有插图。
'; 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 = `
${row.jp_html || escapeHtml(row.jp_text)}
${row.current_html || row.cn_html || escapeHtml(row.cn_text)}
`; 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 = '
没有可阅读段落。
'; 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 = '
当前筛选条件下没有内容。
'; 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) : '
在阅读区点击段落后,这里会显示日文原文。
'; 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 = '
展开后会读取术语表。
'; return; } if (!shown.length) { dom.glossaryList.innerHTML = '
没有匹配的术语。
'; return; } const frag = document.createDocumentFragment(); shown.forEach(({ entry, index }) => { const row = document.createElement("div"); row.className = "glossaryRow"; row.dataset.index = String(index); row.innerHTML = ` `; 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 `
术语命中:${escapeHtml(matches.slice(0, 10).join("; "))}${matches.length > 10 ? ` 等 ${matches.length} 条` : ""}
`; } 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();