let rows = []; let filteredRows = []; let structure = { chapters: [] }; let currentIndex = -1; let currentRow = null; let currentChapter = null; let currentPart = null; let currentMode = "reading"; let showTouchedOnly = false; let dirty = false; let quickDirty = false; let hasSession = false; let expandedChapters = new Set(); let sidePanelMode = null; let gptConfigLoaded = false; let gptCandidateHtml = ""; const $ = (id) => document.getElementById(id); const dom = { startScreen: $("startScreen"), reviewLayout: $("reviewLayout"), appVersion: $("appVersion"), sessionMeta: $("sessionMeta"), readingModeBtn: $("readingModeBtn"), polishModeBtn: $("polishModeBtn"), openLibraryBtn: $("openLibraryBtn"), showTouchedBtn: $("showTouchedBtn"), feedbackBtn: $("feedbackBtn"), applyBtn: $("applyBtn"), exportBtn: $("exportBtn"), tocPanelBtn: $("tocPanelBtn"), searchPanelBtn: $("searchPanelBtn"), sidebar: $("sidebar"), sideTitle: $("sideTitle"), closeSidebarBtn: $("closeSidebarBtn"), tocPanel: $("tocPanel"), searchPanel: $("searchPanel"), searchInput: $("searchInput"), stats: $("stats"), expandTocBtn: $("expandTocBtn"), collapseTocBtn: $("collapseTocBtn"), toc: $("toc"), rowList: $("rowList"), readerPane: $("readerPane"), editorPane: $("editorPane"), chapterKicker: $("chapterKicker"), readerTitle: $("readerTitle"), readingFlow: $("readingFlow"), prevPartBtn: $("prevPartBtn"), nextPartBtn: $("nextPartBtn"), 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"), toggleGptConfigBtn: $("toggleGptConfigBtn"), gptStatus: $("gptStatus"), gptConfig: $("gptConfig"), gptBaseUrlInput: $("gptBaseUrlInput"), gptModelInput: $("gptModelInput"), gptApiKeyInput: $("gptApiKeyInput"), saveGptConfigBtn: $("saveGptConfigBtn"), gptInstructionInput: $("gptInstructionInput"), retranslateBtn: $("retranslateBtn"), applyRetranslationBtn: $("applyRetranslationBtn"), gptCandidate: $("gptCandidate"), uploadForm: $("uploadForm"), epubFileInput: $("epubFileInput"), selectedFileName: $("selectedFileName"), uploadBtn: $("uploadBtn"), refreshSessionsBtn: $("refreshSessionsBtn"), sessionList: $("sessionList"), sessionListMeta: $("sessionListMeta"), 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 readerAnchor() { if (currentMode !== "reading" || !dom.readerPane || dom.readerPane.hidden) return null; const paneRect = dom.readerPane.getBoundingClientRect(); const headerRect = document.querySelector(".readerHeader")?.getBoundingClientRect(); const top = headerRect ? headerRect.bottom + 8 : paneRect.top + 8; 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 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; showTouchedOnly = false; dirty = false; quickDirty = false; expandedChapters = new Set(); sidePanelMode = null; gptCandidateHtml = ""; gptConfigLoaded = false; dom.showTouchedBtn.classList.remove("primary"); dom.searchInput.value = ""; 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"); } function setShellMode(active) { hasSession = active; dom.startScreen.hidden = active; dom.reviewLayout.hidden = !active; dom.readingModeBtn.disabled = !active; dom.polishModeBtn.disabled = !active; dom.openLibraryBtn.disabled = false; 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); if (!active) closeSidePanel(); if (!active) { dom.sessionMeta.textContent = "未打开 EPUB"; } } 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 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 || ""); } async function showStartScreen(sessionPayload = null) { resetReviewState(); setShellMode(false); if (sessionPayload && Array.isArray(sessionPayload.sessions)) { renderSessions(sessionPayload.sessions, ""); } else { try { await refreshSessions(); } catch (error) { dom.sessionList.innerHTML = `
读取会话失败:${escapeHtml(error.message)}
`; } } } 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 allParts() { return (structure.chapters || []).flatMap((chapter) => chapter.parts || []); } function allChapterScopes() { return (structure.chapters || []).map((chapter) => ({ type: "chapter", id: chapter.id, chapter, })); } function chapterIsExpanded(chapter) { if (!chapter) return false; return expandedChapters.has(chapter.id); } 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(); if (currentChapter) expandedChapters.add(currentChapter.id); 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 isImageChapter(chapter) { return chapterKind(chapter) === "image"; } 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 []; const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); return rows.filter((row) => ids.has(row.id)); } 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; 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 chapterVisibleRows(chapter) { if (!chapter) return []; const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); return filteredRows.filter((row) => ids.has(row.id)); } function scopeRows() { if (currentPart) return partRows(currentPart); if (currentChapter) return chapterRows(currentChapter); return rows; } function scopeVisibleRows() { if (currentPart) return partVisibleRows(currentPart); if (currentChapter) return chapterVisibleRows(currentChapter); return filteredRows; } function imageChapterMatchesSearch(chapter) { if (!isImageChapter(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; } 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 ? currentChapter.parts[0] : null; } if (filteredRows.length > 0 && currentIndex < 0) { currentRow = filteredRows[0]; currentIndex = 0; } renderAll(); } async function loadActiveSession(options = {}) { resetReviewState(); currentMode = "reading"; setShellMode(true); await loadStructure(); await loadRows(Boolean(options.keepSelection)); await loadSession(); 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)) 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; } } } 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 = rows.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 || (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 parts = chapter.parts || []; const chapterIsImage = isImageChapter(chapter); const isCurrentChapter = currentChapter && chapter.id === currentChapter.id; const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id); const expanded = !chapterIsImage && (chapterIsExpanded(chapter) || containsCurrentPart); 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 = chapterIsImage ? "图" : expanded ? "▾" : "▸"; toggle.disabled = chapterIsImage || parts.length === 0; toggle.setAttribute("aria-label", chapterIsImage ? "插图章节" : expanded ? "收起章节" : "展开章节"); toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); toggle.addEventListener("click", (event) => { event.stopPropagation(); if (chapterIsImage) return; 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 ? `${(chapter.images || []).length || 1} 图` : `${chapter.row_count || 0} 段`} `; title.addEventListener("click", () => selectChapter(chapter)); row.appendChild(title); group.appendChild(row); const partList = document.createElement("div"); partList.className = "tocParts"; partList.hidden = !expanded; if (!chapterIsImage) for (const part of parts) { 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); } function renderList() { const touched = rows.filter(rowTouched).length; const marked = rows.filter((row) => row.marked).length; const visibleInScope = scopeVisibleRows(); const imageCount = structure.image_count || 0; const isCurrentImage = isImageChapter(currentChapter) && !currentPart; dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentChapter.images || []).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"; const images = currentChapter.images || []; item.innerHTML = `
${escapeHtml(currentChapter.id)} 插图 ${escapeHtml(currentChapter.file_label || "")}
${escapeHtml(images.map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}
`; frag.appendChild(item); dom.rowList.appendChild(frag); return; } visibleInScope.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 images = chapter.images || []; 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.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 renderReading() { const selectedChapter = currentChapter || chapterForPart(currentPart); if (!currentPart && !selectedChapter) { dom.chapterKicker.textContent = "目录"; dom.readerTitle.textContent = "选择章节开始阅读"; dom.readingFlow.innerHTML = '
没有可阅读段落。
'; return; } const selectedPart = currentPart; 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 visibleRows = scopeVisibleRows(); const sourceRows = visibleRows.length || dom.searchInput.value || showTouchedOnly ? visibleRows : scopeRows(); if (!sourceRows.length) { dom.readingFlow.innerHTML = '
当前筛选条件下没有段落。
'; return; } const frag = document.createDocumentFragment(); let lastFile = ""; sourceRows.forEach((row) => { if (!selectedPart && row.file !== lastFile) { const part = partForRow(row); if (part) { const divider = document.createElement("h3"); divider.className = "partDivider"; divider.textContent = part.title || part.file_label || row.file; frag.appendChild(divider); } lastFile = row.file; } const block = document.createElement("section"); block.className = "readBlock"; block.dataset.rowId = row.id; 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); }); dom.readingFlow.appendChild(frag); } function renderEditor() { if (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) { dom.quickRowId.textContent = row.id; dom.quickRowPath.textContent = `${row.file_label} · #${row.file_row_index || row.cn_p_index}`; dom.quickJpText.innerHTML = row.jp_html || escapeHtml(row.jp_text); dom.quickCnEditor.innerHTML = row.current_html || row.cn_html || ""; dom.quickMarkedInput.checked = Boolean(row.marked); dom.quickIssueTypeInput.value = row.issue_type || ""; dom.quickSeverityInput.value = row.severity || ""; dom.quickTagsInput.value = row.tags || ""; dom.quickCommentInput.value = row.comment || ""; dom.quickLearnNoteInput.value = row.learn_note || ""; gptCandidateHtml = ""; dom.gptCandidate.hidden = true; dom.gptCandidate.innerHTML = ""; dom.applyRetranslationBtn.disabled = true; quickDirty = false; } function collectCurrent() { if (!currentRow) return null; if (currentMode === "reading" && !dom.quickEditor.hidden) { 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 (isImageChapter(currentChapter) && !currentPart) return true; if (!currentRow) return true; return saveRow(currentRow, collectCurrent(), silent); } async function maybeSaveBeforeSwitch() { if (currentMode === "reading" && 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; currentIndex = filteredRows.findIndex((item) => item.id === row.id); syncScopeToRow(row, Boolean(options.forcePart)); dirty = false; if (options.openEditor) { setMode("polish", false); } else if (options.openQuick) { openQuickEditor(row); } else { renderAll(); } if (options.scroll) scrollRowIntoView(row.id); } async function selectChapter(chapter) { if (!chapter) return; const ok = await maybeSaveBeforeSwitch(); if (!ok) return; currentChapter = chapter; currentPart = null; expandedChapters.add(chapter.id); if (isImageChapter(chapter)) { currentRow = null; currentIndex = -1; closeQuickEditor(false); currentMode = "reading"; } else { const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); const visible = filteredRows.filter((row) => ids.has(row.id)); const fallback = rows.filter((row) => ids.has(row.id)); currentRow = visible[0] || fallback[0] || currentRow; currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1; } renderAll(); if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } if (sidePanelMode === "toc") closeSidePanel(); } async function selectPart(part) { if (!part) return; const ok = await maybeSaveBeforeSwitch(); if (!ok) return; currentChapter = chapterForPart(part); currentPart = part; if (currentChapter) expandedChapters.add(currentChapter.id); const visible = partVisibleRows(part); const fallback = partRows(part); currentRow = visible[0] || fallback[0] || currentRow; currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1; renderAll(); if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } if (sidePanelMode === "toc") closeSidePanel(); } 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" && isImageChapter(currentChapter) && !currentPart) { toast("插图章节没有可编辑译文,已保持阅读模式。"); mode = "reading"; } currentMode = mode; if (mode === "reading" && currentPart && isImageChapter(currentChapter)) { currentPart = null; } if (mode === "polish") { closeQuickEditor(false); if (!scopeCoversRow(currentRow)) currentRow = null; if (!currentRow) currentRow = scopeVisibleRows()[0] || scopeRows()[0] || filteredRows[0] || null; } withReaderAnchor(() => renderAll()); if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id); } function openQuickEditor(row) { withReaderAnchor(() => { currentRow = row; currentIndex = filteredRows.findIndex((item) => item.id === row.id); syncScopeToRow(row); fillQuickEditor(row); dom.quickEditor.hidden = false; document.body.classList.add("quickOpen"); renderAll(); }); loadGptConfig().catch(() => {}); } function closeQuickEditor(save = false) { if (save && quickDirty) { saveCurrent(true); return; } withReaderAnchor(() => { dom.quickEditor.hidden = true; document.body.classList.remove("quickOpen"); quickDirty = false; renderAll(); }); } 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 = ""; const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置"; dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。"; } async function loadGptConfig(force = false) { if (gptConfigLoaded && !force) return; try { const config = await api("/api/gpt/config"); renderGptConfig(config); gptConfigLoaded = true; } 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, keep_existing: true, }), }); renderGptConfig(config); gptConfigLoaded = true; toast("GPT API 设置已保存。"); } catch (error) { toast(`保存 GPT 设置失败:${error.message}`, 7000); } } 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 = 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) { if (!filteredRows.length) return; const index = currentIndex >= 0 ? currentIndex : filteredRows.findIndex((row) => currentRow && row.id === currentRow.id); const next = Math.max(0, Math.min(filteredRows.length - 1, index + delta)); if (next === index) return; await maybeSelectRow(filteredRows[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true }); } async function goPart(delta) { const scopes = allChapterScopes(); if (!scopes.length) return; const currentScopeId = currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id; 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; await selectChapter(scopes[next].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() { [ 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.tocPanelBtn.addEventListener("click", () => { if (sidePanelMode === "toc") { closeSidePanel(); } else { openSidePanel("toc"); } }); dom.searchPanelBtn.addEventListener("click", () => { if (sidePanelMode === "search") { closeSidePanel(); } else { openSidePanel("search"); } }); dom.closeSidebarBtn.addEventListener("click", closeSidePanel); 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 (hasSession && !(await maybeSaveBeforeSwitch())) return; await showStartScreen(); }); 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.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false)); dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false)); dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection); dom.toggleGptConfigBtn.addEventListener("click", () => { dom.gptConfig.hidden = !dom.gptConfig.hidden; if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {}); }); dom.saveGptConfigBtn.addEventListener("click", saveGptConfig); 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); return; } if (event.key === "Escape" && sidePanelMode) { event.preventDefault(); closeSidePanel(); } }); window.addEventListener("beforeunload", (event) => { if (!dirty && !quickDirty) return; event.preventDefault(); event.returnValue = ""; }); } 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();