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(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(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();