Files
-----/tools/epub_review_editor/static/app.js
T

2728 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = {};
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"),
bookshelfMeta: $("bookshelfMeta"),
bookshelfList: $("bookshelfList"),
translationBookMeta: $("translationBookMeta"),
translationBackBookshelfBtn: $("translationBackBookshelfBtn"),
translationOpenReviewBtn: $("translationOpenReviewBtn"),
translationForm: $("translationForm"),
translationSourceMeta: $("translationSourceMeta"),
translationOutputModeInput: $("translationOutputModeInput"),
translationRangeModeInput: $("translationRangeModeInput"),
translationLimitInput: $("translationLimitInput"),
translationTemperatureInput: $("translationTemperatureInput"),
translationSample: $("translationSample"),
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 renderSessions(sessions = [], activeId = "") {
dom.sessionList.innerHTML = "";
dom.sessionListMeta.textContent = sessions.length ? `${sessions.length} 个会话` : "暂无会话";
if (!sessions.length) {
dom.sessionList.innerHTML = '<div class="emptySession">还没有审校会话。上传 EPUB 后会自动创建。</div>';
return;
}
const frag = document.createDocumentFragment();
sessions.forEach((session) => {
const card = document.createElement("article");
card.className = "sessionItem";
if (session.id === activeId) card.classList.add("active");
card.innerHTML = `
<div class="sessionInfo">
<h3>${escapeHtml(session.source_name || session.id)}</h3>
<p>${escapeHtml(session.session_root || "")}</p>
<div class="sessionFacts">
<span>${session.row_count || 0} 段</span>
<span>记录 ${session.touched_count || 0}</span>
<span>标记 ${session.marked_count || 0}</span>
${session.updated_at ? `<span>${escapeHtml(formatDateTime(session.updated_at))}</span>` : ""}
</div>
</div>
<button type="button" data-session-id="${escapeHtml(session.id)}">${session.id === activeId ? "继续审校" : "打开"}</button>
`;
card.querySelector("button").addEventListener("click", () => openSession(session.id));
frag.appendChild(card);
});
dom.sessionList.appendChild(frag);
}
async function refreshSessions() {
const data = await api("/api/sessions");
setVersionBadge(data.version);
renderSessions(data.sessions || [], data.active_session_id || "");
return data;
}
function readingPositionLabel(position = {}) {
if (!position || !position.scope || position.scope === "none") return "尚无阅读记录";
if (position.scope === "image") return "上次阅读:插图";
if (position.part_id) return `上次阅读:${position.part_id}`;
if (position.chapter_id) return `上次阅读:${position.chapter_id}`;
if (position.row_id) return `上次阅读:${position.row_id}`;
return "已有阅读记录";
}
function renderBookshelf(sessions = [], activeId = "") {
dom.bookshelfList.innerHTML = "";
dom.bookshelfMeta.textContent = sessions.length ? `${sessions.length} 本历史书籍` : "暂无历史书籍";
if (!sessions.length) {
dom.bookshelfList.innerHTML = `
<div class="emptyBookshelf">
<h3>书架还是空的</h3>
<p>上传第一本中日双语 EPUB 后,它会出现在这里,之后可以直接回到上次阅读位置。</p>
<button type="button" class="primary" data-bookshelf-upload>打开新的 EPUB</button>
</div>
`;
dom.bookshelfList.querySelector("[data-bookshelf-upload]").addEventListener("click", () => showStartScreen());
return;
}
const frag = document.createDocumentFragment();
sessions.forEach((session) => {
const card = document.createElement("article");
card.className = "bookshelfItem";
card.tabIndex = 0;
if (session.id === activeId) card.classList.add("active");
const title = session.source_name || session.id;
const updatedAt = session.updated_at || session.created_at;
const initial = String(title || "").trim().slice(0, 1) || "书";
card.innerHTML = `
<div class="bookSpine" aria-hidden="true">${escapeHtml(initial)}</div>
<div class="bookInfo">
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(readingPositionLabel(session.reading_position))}</p>
<div class="bookFacts">
<span>${session.row_count || 0} 段</span>
<span>记录 ${session.touched_count || 0}</span>
<span>标记 ${session.marked_count || 0}</span>
${updatedAt ? `<span>${escapeHtml(formatDateTime(updatedAt))}</span>` : ""}
</div>
</div>
<div class="bookActions">
<button type="button" data-open-review>${session.id === activeId ? "继续审校" : "进入审校"}</button>
<button type="button" data-open-translation>翻译</button>
</div>
`;
const open = () => openSession(session.id);
const reviewButton = card.querySelector("[data-open-review]");
const translateButton = card.querySelector("[data-open-translation]");
reviewButton.addEventListener("click", (event) => {
event.stopPropagation();
open();
});
translateButton.addEventListener("click", (event) => {
event.stopPropagation();
showTranslationScreen(session.id).catch((error) => toast(`打开翻译页失败:${error.message}`, 7000));
});
card.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
open();
});
card.addEventListener("keydown", (event) => {
if (event.target.closest("button")) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
open();
});
frag.appendChild(card);
});
dom.bookshelfList.appendChild(frag);
}
async function refreshBookshelf() {
dom.bookshelfMeta.textContent = "正在读取书架……";
const data = await api("/api/sessions");
setVersionBadge(data.version);
renderBookshelf(data.sessions || [], data.active_session_id || "");
return data;
}
async function showBookshelf() {
if (!(await leaveReviewShell())) return;
clearTranslationPoll();
resetReviewState();
setShellMode(false);
dom.startScreen.hidden = true;
if (dom.translationScreen) dom.translationScreen.hidden = true;
dom.bookshelfScreen.hidden = false;
document.body.classList.add("bookshelfOpen");
dom.bookshelfBtn.classList.add("active");
dom.sessionMeta.textContent = "书架 · 历史审校书籍";
try {
await refreshBookshelf();
} catch (error) {
dom.bookshelfMeta.textContent = "书架读取失败";
dom.bookshelfList.innerHTML = `<div class="emptyBookshelf">读取书架失败:${escapeHtml(error.message)}</div>`;
}
}
async function showStartScreen(sessionPayload = null) {
clearTranslationPoll();
resetReviewState();
setShellMode(false);
dom.bookshelfScreen.hidden = true;
if (dom.translationScreen) dom.translationScreen.hidden = true;
dom.startScreen.hidden = false;
if (sessionPayload && Array.isArray(sessionPayload.sessions)) {
renderSessions(sessionPayload.sessions, "");
} else {
try {
await refreshSessions();
} catch (error) {
dom.sessionList.innerHTML = `<div class="emptySession">读取会话失败:${escapeHtml(error.message)}</div>`;
}
}
}
function renderTranslationSource(payload) {
const session = payload.session || {};
const sourceCount = payload.source_count || 0;
dom.translationBookMeta.textContent = `${session.source_name || translationSessionId} · 可翻译日文段落 ${sourceCount} 段`;
dom.translationSourceMeta.textContent = sourceCount ? `检测到 ${sourceCount} 个日文正文段落` : "没有检测到可翻译日文段落";
dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000)));
const sample = payload.sample || [];
if (!sample.length) {
dom.translationSample.innerHTML = '<div class="translationEmpty">暂无可预览段落。请确认这本书是日语 EPUB。</div>';
return;
}
dom.translationSample.innerHTML = sample
.map((item) => `
<div class="translationSampleItem">
<strong>${escapeHtml(item.id)} · ${escapeHtml(item.file || "")}</strong>
<p>${escapeHtml(item.text || "")}</p>
</div>
`)
.join("");
}
async function showTranslationScreen(sessionId) {
if (!(await leaveReviewShell())) return;
clearTranslationPoll();
resetReviewState();
setShellMode(false);
translationSessionId = sessionId;
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"),
]);
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;
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,
};
}
function renderTranslationJob(job) {
const progress = job.progress || {};
const percent = Math.max(0, Math.min(100, Number(progress.percent || 0)));
dom.translationProgressBar.style.width = `${percent}%`;
dom.translationJobMeta.textContent = job.id ? `${job.status || "unknown"} · ${percent.toFixed(percent % 1 ? 1 : 0)}%` : "尚未开始";
const total = progress.total || 0;
const current = progress.current || 0;
const failed = progress.failed || 0;
dom.translationProgressText.textContent = [
progress.message || "正在处理……",
total ? `进度:${current}/${total}` : "",
progress.current_file ? `当前文件:${progress.current_file}` : "",
failed ? `失败段落:${failed}` : "",
job.error ? `错误:${job.error}` : "",
].filter(Boolean).join("\n");
const logs = (job.logs || []).slice(-12).reverse();
dom.translationLogs.innerHTML = logs.map((log) => `
<div class="translationLog ${escapeHtml(log.level || "info")}">
<span>${escapeHtml(formatDateTime(log.ts) || log.ts || "")}</span>
<p>${escapeHtml(log.message || "")}</p>
</div>
`).join("");
if (job.status === "completed") {
translationOutputSessionId = job.output_session_id || "";
dom.translationOpenReviewBtn.disabled = !translationOutputSessionId;
dom.translationOutput.hidden = false;
dom.translationOutput.innerHTML = `
<strong>已生成 EPUB 并加入书架</strong>
<p>${escapeHtml(job.output_epub || "")}</p>
`;
refreshBookshelf().catch(() => {});
}
}
async function pollTranslationProgress() {
if (!translationJobId) return;
try {
const payload = await api(`/api/translation/jobs/${encodeURIComponent(translationJobId)}`);
const job = payload.job || {};
renderTranslationJob(job);
if (["completed", "failed", "cancelled"].includes(job.status)) {
clearTranslationPoll();
dom.translationStartBtn.disabled = false;
dom.translationStartBtn.textContent = "开始翻译";
if (job.status === "completed") toast("翻译完成,输出 EPUB 已加入书架。", 7000);
if (job.status === "failed") toast(`翻译失败:${job.error || "未知错误"}`, 9000);
return;
}
translationPollTimer = setTimeout(pollTranslationProgress, 1200);
} catch (error) {
clearTranslationPoll();
dom.translationProgressText.textContent = `读取进度失败:${error.message}`;
dom.translationStartBtn.disabled = false;
dom.translationStartBtn.textContent = "开始翻译";
}
}
async function startTranslationJob(event) {
event.preventDefault();
if (!translationSessionId) return;
if (dom.translationRangeModeInput.value === "all" && !window.confirm("全书翻译可能消耗较长时间和较多 API 额度。确认开始吗?")) {
return;
}
try {
dom.translationStartBtn.disabled = true;
dom.translationStartBtn.textContent = "启动中……";
dom.translationProgressText.textContent = "正在保存设置并创建翻译任务……";
await saveTranslationGptConfig();
const payload = await api("/api/translation/start", {
method: "POST",
body: JSON.stringify(collectTranslationOptions()),
});
translationJobId = payload.job_id;
renderTranslationJob(payload.job || {});
dom.translationStartBtn.textContent = "翻译运行中……";
clearTranslationPoll();
translationPollTimer = setTimeout(pollTranslationProgress, 600);
toast("翻译任务已启动。");
} catch (error) {
dom.translationStartBtn.disabled = false;
dom.translationStartBtn.textContent = "开始翻译";
dom.translationProgressText.textContent = `启动失败:${error.message}`;
toast(`启动翻译失败:${error.message}`, 9000);
}
}
function rowCurrentText(row) {
return stripTags(row.current_html || row.cn_html || row.cn_text || "");
}
function rowById(rowId) {
return rows.find((item) => item.id === rowId) || null;
}
function itemKind(item) {
return item && item.kind === "image" ? "image" : "text";
}
function isImageItem(item) {
return itemKind(item) === "image";
}
function allParts() {
return (structure.chapters || []).flatMap((chapter) => chapter.parts || []);
}
function isImageChapter(chapter) {
return chapterKind(chapter) === "image";
}
function chapterItems(chapter) {
if (!chapter) return [];
if (Array.isArray(chapter.items) && chapter.items.length) return chapter.items;
if (isImageChapter(chapter)) return [{ ...chapter, id: `${chapter.id}:image`, parent_chapter_id: chapter.id, display_level: 1 }];
return chapter.parts || [];
}
function chapterImages(chapter) {
if (!chapter) return [];
if (isImageChapter(chapter)) return chapter.images || [];
return chapterItems(chapter)
.filter(isImageItem)
.flatMap((item) => item.images || []);
}
function allChapterScopes() {
return (structure.chapters || []).map((chapter) => ({
type: "chapter",
id: chapter.id,
chapter,
}));
}
function activeChapterScopeId() {
if (currentImageItem && currentChapter) return currentChapter.id;
return currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id;
}
function readingScopeId(chapter, item = null) {
if (!chapter) return "";
if (!item) return `chapter:${chapter.id}`;
return isImageItem(item) ? `image:${chapter.id}:${item.id}` : `part:${item.id}`;
}
function scopeForItem(chapter, item) {
if (!item) return null;
if (isImageItem(item)) {
return { type: "image", id: readingScopeId(chapter, item), chapter, item };
}
return { type: "part", id: readingScopeId(chapter, item), chapter, part: item };
}
function allReadingScopes() {
const scopes = [];
for (const chapter of structure.chapters || []) {
const items = chapterItems(chapter);
if (!items.length) {
scopes.push({ type: "chapter", id: readingScopeId(chapter), chapter });
continue;
}
for (const item of items) {
scopes.push(scopeForItem(chapter, item));
}
}
return scopes;
}
function visibleReadingScopeId(chapter) {
if (!chapter || !dom.readerPane || dom.readerPane.hidden) return "";
const paneRect = dom.readerPane.getBoundingClientRect();
const top = readerViewportTop(paneRect);
const items = Array.from(dom.readingFlow.querySelectorAll("[data-reading-scope-id]"));
let active = null;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (rect.bottom >= top) {
active = item;
break;
}
}
return active?.dataset.readingScopeId || "";
}
function activeReadingScopeId() {
if (currentImageItem && currentChapter) return readingScopeId(currentChapter, currentImageItem);
if (currentPart) return readingScopeId(chapterForPart(currentPart), currentPart);
if (currentChapter) {
const visibleScope = visibleReadingScopeId(currentChapter);
if (visibleScope) return visibleScope;
const anchor = readerAnchor();
const anchorRow = anchor && anchor.rowId ? rowById(anchor.rowId) : null;
const anchorPart = partForRow(anchorRow);
if (anchorPart && chapterForPart(anchorPart)?.id === currentChapter.id) {
return readingScopeId(currentChapter, anchorPart);
}
const firstItem = chapterItems(currentChapter)[0] || null;
if (firstItem) return readingScopeId(currentChapter, firstItem);
return readingScopeId(currentChapter);
}
return "";
}
function chapterIsExpanded(chapter) {
if (!chapter) return false;
return expandedChapters.has(chapter.id);
}
function chapterHasChildren(chapter) {
return chapterItems(chapter).length > 0;
}
function toggleChapterExpanded(chapter) {
if (!chapter) return;
if (expandedChapters.has(chapter.id)) {
expandedChapters.delete(chapter.id);
} else {
expandedChapters.add(chapter.id);
}
renderToc();
}
function expandAllChapters() {
expandedChapters = new Set((structure.chapters || []).map((chapter) => chapter.id));
renderToc();
}
function collapseAllChapters() {
expandedChapters = new Set();
const activeId = activeChapterScopeId();
if (activeId) expandedChapters.add(activeId);
renderToc();
}
function partById(partId) {
return allParts().find((part) => part.id === partId) || null;
}
function chapterById(chapterId) {
return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null;
}
function chapterKind(chapter) {
return chapter && chapter.kind === "image" ? "image" : "text";
}
function partRows(part) {
if (!part) return [];
const ids = new Set(part.row_ids || []);
return rows.filter((row) => ids.has(row.id));
}
function partForRow(row) {
if (!row) return null;
return allParts().find((part) => (part.row_ids || []).includes(row.id)) || null;
}
function chapterForPart(part) {
if (!part) return null;
return (structure.chapters || []).find((chapter) => (chapter.parts || []).some((item) => item.id === part.id)) || null;
}
function chapterRows(chapter) {
if (!chapter) return [];
if (isImageChapter(chapter)) return [];
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return rows.filter((row) => ids.has(row.id));
}
function searchBaseRows() {
return searchScope === "book" ? rows : scopeRows();
}
function visibleRowsForScope() {
return filteredRows;
}
function updateSearchScopeButtons() {
dom.searchScopeCurrentBtn.classList.toggle("active", searchScope === "current");
dom.searchScopeBookBtn.classList.toggle("active", searchScope === "book");
}
function setSearchScope(scope) {
searchScope = scope === "book" ? "book" : "current";
updateSearchScopeButtons();
if (searchScope === "current" && (currentImageItem || (isImageChapter(currentChapter) && !currentPart))) {
currentRow = null;
currentIndex = -1;
}
applyFilter(true);
}
function scopeCoversRow(row) {
if (!row) return false;
if (currentPart) return (currentPart.row_ids || []).includes(row.id);
if (currentChapter) {
return (currentChapter.parts || []).some((part) => (part.row_ids || []).includes(row.id));
}
return true;
}
function syncScopeToRow(row, forcePart = false) {
const nextPart = partForRow(row);
if (!nextPart) return;
const nextChapter = chapterForPart(nextPart);
if (!nextChapter) return;
currentImageItem = null;
if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) {
currentChapter = nextChapter;
currentPart = nextPart;
expandedChapters.add(nextChapter.id);
return;
}
if (currentPart && currentPart.id !== nextPart.id) {
currentPart = nextPart;
}
}
function partVisibleRows(part) {
const ids = new Set((part && part.row_ids) || []);
return filteredRows.filter((row) => ids.has(row.id));
}
function chapterVisibleRowsInFilter(chapter) {
if (!chapter) return filteredRows;
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return filteredRows.filter((row) => ids.has(row.id));
}
function chapterVisibleRows(chapter) {
if (!chapter) return filteredRows;
if (searchScope === "book") {
return chapterVisibleRowsInFilter(chapter);
}
return filteredRows;
}
function scopeRows() {
if (currentPart) return partRows(currentPart);
if (currentChapter) return chapterRows(currentChapter);
return rows;
}
function scopeVisibleRows() {
if (searchScope === "book") {
if (currentPart) return partVisibleRows(currentPart);
if (currentChapter) return chapterVisibleRows(currentChapter);
return filteredRows;
}
return filteredRows;
}
function imageChapterMatchesSearch(chapter) {
if (!isImageChapter(chapter) && !isImageItem(chapter)) return false;
if (showTouchedOnly) return false;
const q = dom.searchInput.value.trim().toLowerCase();
if (!q) return true;
const haystack = [
chapter.id,
chapter.title,
chapter.file_label,
...(chapter.images || []).flatMap((image) => [image.asset_name, image.alt, image.asset_path]),
]
.join("\n")
.toLowerCase();
return haystack.includes(q);
}
async function loadSession() {
const session = await api("/api/session");
setVersionBadge(session.version);
if (!session.has_session) {
await showStartScreen(session);
return session;
}
savedReadingPosition = session.reading_position || null;
setShellMode(true);
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
const partCount = structure.part_count || allParts().length;
const imageCount = structure.image_count || 0;
const sourceName = session.source_name || session.source_epub;
dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part / ${imageCount} 张图 · 已记录 ${session.touched_count} 段`;
return session;
}
async function loadStructure() {
structure = await api("/api/structure");
}
async function loadRows(keepSelection = false) {
const oldId = currentRow && currentRow.id;
const payload = await api("/api/rows");
rows = payload.rows || [];
applyFilter(false);
if (keepSelection && oldId) {
const nextRow = rowById(oldId);
if (nextRow) {
currentRow = nextRow;
currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id);
if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) {
syncScopeToRow(nextRow);
}
renderAll();
return;
}
}
if (!currentPart) {
currentChapter = (structure.chapters || [])[0] || null;
currentPart = currentChapter
&& !isImageChapter(currentChapter)
&& (currentChapter.parts || []).length === 1
&& chapterImages(currentChapter).length === 0
? currentChapter.parts[0]
: null;
applyFilter(false);
}
if (filteredRows.length > 0 && currentIndex < 0) {
currentRow = filteredRows[0];
currentIndex = 0;
}
renderAll();
}
function applySavedReadingPosition(position) {
if (!position || !(structure.chapters || []).length) return false;
restoringReadingPosition = true;
try {
const row = position.row_id ? rowById(position.row_id) : null;
const chapter = chapterById(position.chapter_id);
const part = partById(position.part_id);
const imageItem = imageItemById(chapter, position.image_item_id);
if (position.scope === "image" && chapter && imageItem) {
currentChapter = chapter;
currentPart = null;
currentImageItem = imageItem;
currentRow = null;
currentIndex = -1;
expandedChapters.add(chapter.id);
} else if (part) {
currentPart = part;
currentChapter = chapterForPart(part) || chapter;
currentImageItem = null;
if (currentChapter) expandedChapters.add(currentChapter.id);
currentRow = row && (part.row_ids || []).includes(row.id) ? row : partRows(part)[0] || null;
currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
} else if (chapter) {
currentChapter = chapter;
currentPart = null;
currentImageItem = null;
expandedChapters.add(chapter.id);
const scopedRows = chapterRows(chapter);
currentRow = row && scopedRows.some((item) => item.id === row.id) ? row : scopedRows[0] || null;
currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
} else if (row) {
currentRow = row;
syncScopeToRow(row);
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
} else {
return false;
}
currentMode = position.mode === "polish" && currentRow ? "polish" : "reading";
applyFilter(false);
currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
renderAll();
return true;
} finally {
restoringReadingPosition = false;
}
}
function restoreReadingPositionScroll(position) {
if (!position || currentMode !== "reading") return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const rowId = position.row_id || "";
const target = rowId ? dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`) : null;
if (target) {
const paneRect = dom.readerPane.getBoundingClientRect();
const nextOffset = target.getBoundingClientRect().top - paneRect.top;
dom.readerPane.scrollTop += nextOffset - (position.offset || 0);
} else {
dom.readerPane.scrollTop = Math.max(0, position.scroll_top || 0);
}
});
});
}
async function loadActiveSession(options = {}) {
resetReviewState();
currentMode = "reading";
setShellMode(true);
await loadStructure();
await loadRows(Boolean(options.keepSelection));
const session = await loadSession();
const restored = applySavedReadingPosition(session.reading_position);
if (restored) restoreReadingPositionScroll(session.reading_position);
readingPositionReady = true;
await refreshSessions().catch(() => {});
if (options.message) toast(options.message, 4200);
}
async function uploadEpub(event) {
event.preventDefault();
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
if (!file) {
toast("请先选择一个 EPUB 文件。");
return;
}
const formData = new FormData();
formData.append("epub", file);
dom.uploadBtn.disabled = true;
dom.uploadBtn.textContent = "正在解析……";
try {
await apiForm("/api/upload", formData);
dom.epubFileInput.value = "";
dom.selectedFileName.textContent = "尚未选择文件";
await loadActiveSession({ message: "EPUB 已载入,可以开始审校。" });
} catch (error) {
toast(`上传失败:${error.message}`, 9000);
} finally {
dom.uploadBtn.disabled = false;
dom.uploadBtn.textContent = "上传并开始审校";
}
}
async function openSession(sessionId) {
if (!sessionId) return;
if (hasSession && !(await maybeSaveBeforeSwitch())) return;
try {
await api("/api/session/open", {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
await loadActiveSession({ message: "已打开审校会话。" });
} catch (error) {
toast(`打开会话失败:${error.message}`, 9000);
}
}
function updateStructureCountsFromRows() {
for (const chapter of structure.chapters || []) {
chapter.row_count = 0;
chapter.touched_count = 0;
chapter.marked_count = 0;
if (isImageChapter(chapter)) {
chapter.row_count = 0;
chapter.touched_count = 0;
chapter.marked_count = 0;
chapter.items = chapterItems(chapter);
continue;
}
for (const part of chapter.parts || []) {
const partItems = partRows(part);
part.row_count = partItems.length;
part.touched_count = partItems.filter(rowTouched).length;
part.marked_count = partItems.filter((row) => row.marked).length;
chapter.row_count += part.row_count;
chapter.touched_count += part.touched_count;
chapter.marked_count += part.marked_count;
}
for (const item of chapter.items || []) {
if (!isImageItem(item)) {
const match = (chapter.parts || []).find((part) => part.id === item.id);
if (match) Object.assign(item, match);
}
}
}
}
function rowMatchesSearch(row, q) {
if (showTouchedOnly && !rowTouched(row)) return false;
if (!q) return true;
const haystack = [
row.id,
row.file_label,
row.document_title,
row.jp_text,
row.cn_text,
rowCurrentText(row),
row.comment,
row.learn_note,
row.tags,
row.issue_type,
]
.join("\n")
.toLowerCase();
return haystack.includes(q);
}
function applyFilter(shouldRender = true) {
const q = dom.searchInput.value.trim().toLowerCase();
filteredRows = searchBaseRows().filter((row) => rowMatchesSearch(row, q));
if (currentRow) {
currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id);
}
if (shouldRender) renderAll();
}
function renderAll() {
if (!hasSession) return;
updateStructureCountsFromRows();
renderToc();
renderList();
renderMode();
if (currentMode === "reading") {
renderReading();
} else {
renderEditor();
}
}
function renderMode() {
dom.readerPane.hidden = currentMode !== "reading";
dom.editorPane.hidden = currentMode !== "polish";
dom.readingModeBtn.classList.toggle("active", currentMode === "reading");
dom.polishModeBtn.classList.toggle("active", currentMode === "polish");
dom.polishModeBtn.disabled = !hasSession || ((currentImageItem || isImageChapter(currentChapter)) && !currentPart);
document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden);
if (sidePanelMode) {
dom.tocPanel.hidden = sidePanelMode !== "toc";
dom.searchPanel.hidden = sidePanelMode !== "search";
}
}
function renderToc() {
dom.toc.innerHTML = "";
const frag = document.createDocumentFragment();
for (const chapter of structure.chapters || []) {
const items = chapterItems(chapter);
const parts = chapter.parts || [];
const imageItems = items.filter(isImageItem);
const chapterIsImage = isImageChapter(chapter);
const isCurrentChapter = currentChapter && chapter.id === currentChapter.id;
const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id);
const expanded = chapterIsExpanded(chapter) || containsCurrentPart || (isCurrentChapter && (!currentPart || currentImageItem));
const group = document.createElement("section");
group.className = "tocChapter";
if (chapterIsImage) group.classList.add("imageChapter");
if (expanded) group.classList.add("expanded");
const row = document.createElement("div");
row.className = "tocChapterRow";
if ((isCurrentChapter && !currentPart) || containsCurrentPart) {
row.classList.add("active");
}
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tocToggle";
toggle.textContent = expanded ? "▾" : "▸";
toggle.disabled = !chapterHasChildren(chapter);
toggle.setAttribute("aria-label", expanded ? "收起章节" : "展开章节");
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
toggle.addEventListener("click", (event) => {
event.stopPropagation();
toggleChapterExpanded(chapter);
});
row.appendChild(toggle);
const title = document.createElement("button");
title.type = "button";
title.className = "tocChapterButton";
if (
(isCurrentChapter && !currentPart)
|| containsCurrentPart
) {
title.classList.add("active");
}
title.innerHTML = `
<span class="tocTitle">${escapeHtml(chapter.title || chapter.id)}</span>
<span class="tocCount">${chapterIsImage ? `${chapterImages(chapter).length || 1} 图` : `${chapter.row_count || 0}${imageItems.length ? ` · ${imageItems.length} 图` : ""}`}</span>
`;
title.addEventListener("click", () => selectChapter(chapter));
row.appendChild(title);
group.appendChild(row);
const partList = document.createElement("div");
partList.className = "tocParts";
partList.hidden = !expanded;
for (const item of items) {
if (isImageItem(item)) {
const button = document.createElement("button");
button.type = "button";
button.className = "tocImageButton";
const imageActive = isCurrentChapter && !currentPart && (
currentImageItem ? currentImageItem.id === item.id : chapterIsImage
);
if (imageActive) button.classList.add("active");
button.innerHTML = `
<span class="tocTitle">${escapeHtml(item.toc_title || item.title || item.file_label || "插图")}</span>
<span class="tocCount">${(item.images || []).length || 1} 图</span>
`;
button.addEventListener("click", () => selectImageItem(chapter, item));
partList.appendChild(button);
} else {
const part = item;
const button = document.createElement("button");
button.type = "button";
button.className = "tocPartButton";
if (currentPart && part.id === currentPart.id) button.classList.add("active");
const badges = [];
if (part.touched_count) badges.push(`记 ${part.touched_count}`);
if (part.marked_count) badges.push(`标 ${part.marked_count}`);
button.innerHTML = `
<span class="tocTitle">${escapeHtml(part.title || part.file_label || part.id)}</span>
<span class="tocCount">${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""}</span>
`;
button.addEventListener("click", () => selectPart(part));
partList.appendChild(button);
}
}
group.appendChild(partList);
frag.appendChild(group);
}
dom.toc.appendChild(frag);
requestAnimationFrame(() => {
dom.toc.querySelector(".tocPartButton.active, .tocImageButton.active, .tocChapterButton.active")?.scrollIntoView({ block: "nearest" });
});
}
function renderList() {
const touched = rows.filter(rowTouched).length;
const marked = rows.filter((row) => row.marked).length;
const visibleRows = visibleRowsForScope();
const visibleInScope = scopeVisibleRows();
const imageCount = structure.image_count || 0;
const isBrowsingImage = !dom.searchInput.value.trim() && !showTouchedOnly;
const isCurrentImage = searchScope !== "book" && isBrowsingImage && !currentPart && (Boolean(currentImageItem) || isImageChapter(currentChapter));
const scopeLabel = searchScope === "book" ? "全书" : "当前范围";
dom.stats.textContent = `${scopeLabel}显示 ${visibleRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`;
dom.rowList.innerHTML = "";
const frag = document.createDocumentFragment();
if (isCurrentImage) {
const item = document.createElement("div");
item.className = "rowItem imageRowItem active";
item.innerHTML = `
<div class="rowMeta">
<span>${escapeHtml(currentChapter.id)}</span>
<span class="rowBadge">插图</span>
<span>${escapeHtml(currentChapter.file_label || "")}</span>
</div>
<div class="rowPreview">${escapeHtml((currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}</div>
`;
frag.appendChild(item);
dom.rowList.appendChild(frag);
return;
}
if (!visibleRows.length) {
const item = document.createElement("div");
item.className = "rowItem emptyRowItem";
item.textContent = dom.searchInput.value || showTouchedOnly ? "当前检索范围没有命中段落。" : "当前范围没有可检索段落。";
frag.appendChild(item);
dom.rowList.appendChild(frag);
return;
}
visibleRows.forEach((row) => {
const button = document.createElement("button");
button.type = "button";
button.className = "rowItem";
if (currentRow && row.id === currentRow.id) button.classList.add("active");
if (rowTouched(row)) button.classList.add("touched");
if (row.marked) button.classList.add("marked");
const badge = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "未记录";
button.innerHTML = `
<div class="rowMeta">
<span>${escapeHtml(row.id)}</span>
<span class="rowBadge">${escapeHtml(badge)}</span>
<span>${escapeHtml(row.file_label)} · #${row.file_row_index || row.cn_p_index}</span>
</div>
<div class="rowPreview">${escapeHtml(rowCurrentText(row))}</div>
`;
button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", openQuick: currentMode === "reading", scroll: true }));
frag.appendChild(button);
});
dom.rowList.appendChild(frag);
}
function renderImageChapter(chapter) {
const items = chapterItems(chapter).filter(isImageItem);
if (items.length > 1) {
renderMixedChapterImages(chapter, items);
return;
}
const imageScopeItem = items[0] || null;
const images = chapterImages(chapter);
dom.chapterKicker.textContent = chapter.file_label || "插图";
dom.readerTitle.textContent = chapter.title || "插图";
dom.readingFlow.innerHTML = "";
if (!imageChapterMatchesSearch(chapter)) {
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有插图。</div>';
return;
}
const frag = document.createDocumentFragment();
images.forEach((image, index) => {
const figure = document.createElement("figure");
figure.className = "imageChapterFigure";
figure.dataset.readingScopeId = imageScopeItem ? readingScopeId(chapter, imageScopeItem) : readingScopeId(chapter);
figure.innerHTML = `
<img src="${escapeHtml(image.asset_url || "")}" alt="${escapeHtml(image.alt || chapter.title || "")}" loading="lazy">
<figcaption>
<span>${escapeHtml(chapter.title || `插图 ${index + 1}`)}</span>
<span>${escapeHtml(image.asset_name || image.asset_path || "")}</span>
</figcaption>
`;
frag.appendChild(figure);
});
dom.readingFlow.appendChild(frag);
}
function createImageFigure(item) {
const frag = document.createDocumentFragment();
(item.images || []).forEach((image, index) => {
const figure = document.createElement("figure");
figure.className = "imageChapterFigure inlineImageFigure";
const parentChapter = chapterById(item.parent_chapter_id) || currentChapter;
if (parentChapter) figure.dataset.readingScopeId = readingScopeId(parentChapter, item);
figure.innerHTML = `
<img src="${escapeHtml(image.asset_url || "")}" alt="${escapeHtml(image.alt || item.title || "")}" loading="lazy">
<figcaption>
<span>${escapeHtml(item.title || `插图 ${index + 1}`)}</span>
<span>${escapeHtml(image.asset_name || image.asset_path || "")}</span>
</figcaption>
`;
frag.appendChild(figure);
});
return frag;
}
function renderMixedChapterImages(chapter, imageItems, forceShow = false) {
dom.chapterKicker.textContent = chapter ? chapter.title : "插图";
dom.readerTitle.textContent = imageItems.length === 1
? imageItems[0].title || "插图"
: chapter ? chapter.title : "插图";
dom.readingFlow.innerHTML = "";
const frag = document.createDocumentFragment();
imageItems.forEach((item) => {
if (forceShow || imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item));
});
if (!frag.childNodes.length) {
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有插图。</div>';
return;
}
dom.readingFlow.appendChild(frag);
}
function rowPassesCurrentFilters(row) {
const q = dom.searchInput.value.trim().toLowerCase();
return rowMatchesSearch(row, q);
}
function rowsForReadingPart(part) {
const visible = searchScope === "book"
? partRows(part).filter(rowPassesCurrentFilters)
: partVisibleRows(part);
if (visible.length || dom.searchInput.value || showTouchedOnly) return visible;
return partRows(part);
}
function appendReadBlock(frag, row) {
const block = document.createElement("section");
block.className = "readBlock";
block.dataset.rowId = row.id;
const part = partForRow(row);
if (part) block.dataset.readingScopeId = readingScopeId(chapterForPart(part), part);
if (currentRow && row.id === currentRow.id) block.classList.add("active");
if (rowTouched(row)) block.classList.add("touched");
if (row.marked) block.classList.add("marked");
block.innerHTML = `
<div class="readText">
<div class="readSource">${row.jp_html || escapeHtml(row.jp_text)}</div>
<div class="readCn">${row.current_html || row.cn_html || escapeHtml(row.cn_text)}</div>
</div>
`;
block.addEventListener("click", () => {
maybeSelectRow(row, { openQuick: true, scroll: false });
});
frag.appendChild(block);
}
function renderReading() {
const selectedChapter = currentChapter || chapterForPart(currentPart);
if (!currentPart && !selectedChapter) {
dom.chapterKicker.textContent = "目录";
dom.readerTitle.textContent = "选择章节开始阅读";
dom.readingFlow.innerHTML = '<div class="emptyState">没有可阅读段落。</div>';
return;
}
const selectedPart = currentPart;
if (!selectedPart && currentImageItem) {
renderMixedChapterImages(selectedChapter, [currentImageItem], true);
return;
}
if (!selectedPart && isImageChapter(selectedChapter)) {
renderImageChapter(selectedChapter);
return;
}
dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || "";
dom.readerTitle.textContent = selectedPart
? selectedPart.title || selectedPart.file_label || "未命名 part"
: selectedChapter.title || "未命名章节";
dom.readingFlow.innerHTML = "";
const frag = document.createDocumentFragment();
if (selectedPart) {
rowsForReadingPart(selectedPart).forEach((row) => appendReadBlock(frag, row));
} else {
for (const item of chapterItems(selectedChapter)) {
if (isImageItem(item)) {
if (imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item));
continue;
}
const partRowsToRender = rowsForReadingPart(item);
if (!partRowsToRender.length) continue;
const divider = document.createElement("h3");
divider.className = "partDivider";
divider.textContent = item.title || item.file_label || item.file || "part";
frag.appendChild(divider);
partRowsToRender.forEach((row) => appendReadBlock(frag, row));
}
}
if (!frag.childNodes.length) {
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有内容。</div>';
return;
}
dom.readingFlow.appendChild(frag);
if (!restoringReadingPosition) saveReadingPositionSoon();
}
function renderEditor() {
if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) {
dom.emptyState.hidden = false;
dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。";
dom.editorCard.hidden = true;
return;
}
if (currentRow && !scopeCoversRow(currentRow)) {
const visible = scopeVisibleRows();
const fallback = scopeRows();
currentRow = visible[0] || fallback[0] || null;
}
if (!currentRow) {
dom.emptyState.hidden = false;
dom.emptyState.textContent = "选择左侧段落开始审校。";
dom.editorCard.hidden = true;
return;
}
dom.emptyState.hidden = true;
dom.editorCard.hidden = false;
fillFullEditor(currentRow);
}
function fillFullEditor(row) {
dom.rowId.textContent = row.id;
dom.rowPath.textContent = `${row.file} · 日文 P${row.ja_p_index} / 中文 P${row.cn_p_index}`;
dom.markedInput.checked = Boolean(row.marked);
dom.jpText.innerHTML = row.jp_html || escapeHtml(row.jp_text);
dom.cnEditor.innerHTML = row.current_html || row.cn_html || "";
dom.issueTypeInput.value = row.issue_type || "";
dom.severityInput.value = row.severity || "";
dom.tagsInput.value = row.tags || "";
dom.commentInput.value = row.comment || "";
dom.learnNoteInput.value = row.learn_note || "";
}
function fillQuickEditor(row) {
const hasRow = Boolean(row);
dom.quickRowId.textContent = hasRow ? row.id : "未选择段落";
dom.quickRowPath.textContent = hasRow ? `${row.file_label} · #${row.file_row_index || row.cn_p_index}` : "可先配置 AI、术语表或执行反馈与交付操作";
dom.quickJpText.innerHTML = hasRow ? row.jp_html || escapeHtml(row.jp_text) : '<div class="emptyState">在阅读区点击段落后,这里会显示日文原文。</div>';
dom.quickCnEditor.innerHTML = hasRow ? row.current_html || row.cn_html || "" : "";
dom.quickMarkedInput.checked = hasRow ? Boolean(row.marked) : false;
dom.quickIssueTypeInput.value = hasRow ? row.issue_type || "" : "";
dom.quickSeverityInput.value = hasRow ? row.severity || "" : "";
dom.quickTagsInput.value = hasRow ? row.tags || "" : "";
dom.quickCommentInput.value = hasRow ? row.comment || "" : "";
dom.quickLearnNoteInput.value = hasRow ? row.learn_note || "" : "";
[
dom.quickCnEditor,
dom.quickMarkedInput,
dom.quickIssueTypeInput,
dom.quickSeverityInput,
dom.quickTagsInput,
dom.quickCommentInput,
dom.quickLearnNoteInput,
dom.quickMarkSelectionBtn,
dom.quickSaveBtn,
dom.quickOpenFullBtn,
dom.gptInstructionInput,
dom.retranslateBtn,
].forEach((el) => {
if (el) el.disabled = !hasRow;
if (el === dom.quickCnEditor) el.contentEditable = hasRow ? "true" : "false";
});
gptCandidateHtml = "";
dom.gptCandidate.hidden = true;
dom.gptCandidate.innerHTML = "";
dom.applyRetranslationBtn.disabled = true;
quickDirty = false;
}
function collectCurrent() {
if (!currentRow) return null;
if (!dom.quickEditor.hidden && (currentMode === "reading" || quickDirty)) {
return collectQuick();
}
return collectFull();
}
function collectFull() {
return {
current_html: dom.cnEditor.innerHTML,
marked: dom.markedInput.checked,
issue_type: dom.issueTypeInput.value,
severity: dom.severityInput.value,
tags: dom.tagsInput.value,
comment: dom.commentInput.value,
learn_note: dom.learnNoteInput.value,
};
}
function collectQuick() {
return {
current_html: dom.quickCnEditor.innerHTML,
marked: dom.quickMarkedInput.checked,
issue_type: dom.quickIssueTypeInput.value,
severity: dom.quickSeverityInput.value,
tags: dom.quickTagsInput.value,
comment: dom.quickCommentInput.value,
learn_note: dom.quickLearnNoteInput.value,
};
}
function applyRowData(row, data, updatedAt = "") {
row.current_html = data.current_html;
row.marked = data.marked;
row.issue_type = data.issue_type;
row.severity = data.severity;
row.tags = data.tags;
row.comment = data.comment;
row.learn_note = data.learn_note;
row.edited = data.current_html !== row.cn_html;
row.updated_at = updatedAt || row.updated_at || "";
}
async function saveRow(row, data, silent = false) {
if (!row) return true;
try {
const result = await api(`/api/row/${encodeURIComponent(row.id)}`, {
method: "POST",
body: JSON.stringify(data),
});
const target = rowById(row.id);
if (target) applyRowData(target, data, result.updated_at);
currentRow = target || row;
dirty = false;
quickDirty = false;
applyFilter(false);
withReaderAnchor(() => renderAll());
await loadSession();
if (!silent) toast("已保存本段记录。");
return true;
} catch (error) {
toast(`保存失败:${error.message}`, 7000);
return false;
}
}
async function saveCurrent(silent = false) {
if (!hasSession) return true;
if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) return true;
if (!currentRow) return true;
return saveRow(currentRow, collectCurrent(), silent);
}
async function maybeSaveBeforeSwitch() {
await flushReadingPosition();
if (quickDirty && currentRow && !dom.quickEditor.hidden) {
return saveCurrent(true);
}
if (currentMode === "polish" && dirty && currentRow) {
return saveCurrent(true);
}
return true;
}
async function maybeSelectRow(row, options = {}) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
selectRow(row, options);
}
function selectRow(row, options = {}) {
if (!row) return;
currentRow = row;
if (isImageChapter(currentChapter) && !currentPart) currentChapter = null;
currentImageItem = null;
syncScopeToRow(row, Boolean(options.forcePart));
applyFilter(false);
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
dirty = false;
if (options.openEditor) {
setMode("polish", false);
} else if (options.openQuick) {
openQuickEditor(row);
} else {
renderAll();
}
if (options.scroll) scrollRowIntoView(row.id);
saveReadingPositionSoon();
}
async function selectChapter(chapter) {
if (!chapter) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapter;
currentPart = null;
currentImageItem = null;
expandedChapters.add(chapter.id);
applyFilter(false);
if (isImageChapter(chapter)) {
currentRow = null;
currentIndex = -1;
await closeQuickEditor(true);
currentMode = "reading";
} else {
const visible = searchScope === "book" ? chapterVisibleRowsInFilter(chapter) : filteredRows;
const fallback = scopeRows();
currentRow = visible[0] || fallback[0] || null;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
}
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
saveReadingPositionSoon();
}
async function selectPart(part) {
if (!part) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapterForPart(part);
currentPart = part;
currentImageItem = null;
if (currentChapter) expandedChapters.add(currentChapter.id);
applyFilter(false);
const visible = partVisibleRows(part);
const fallback = partRows(part);
currentRow = visible[0] || fallback[0] || null;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
saveReadingPositionSoon();
}
async function selectImageItem(chapter, item) {
if (!chapter || !item) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapter;
currentPart = null;
currentImageItem = item;
currentRow = null;
currentIndex = -1;
if (chapter) expandedChapters.add(chapter.id);
applyFilter(false);
await closeQuickEditor(true);
currentMode = "reading";
renderAll();
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
saveReadingPositionSoon();
}
function scrollRowIntoView(rowId) {
if (currentMode === "reading") {
requestAnimationFrame(() => {
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`);
if (target) target.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
}
async function setMode(mode, saveBefore = true) {
if (saveBefore) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
}
if (mode === "polish" && (currentImageItem || isImageChapter(currentChapter)) && !currentPart) {
toast("插图章节没有可编辑译文,已保持阅读模式。");
mode = "reading";
}
currentMode = mode;
if (mode === "reading" && currentPart && isImageChapter(currentChapter)) {
currentPart = null;
}
if (mode === "polish") {
await closeQuickEditor(true);
if (!scopeCoversRow(currentRow)) currentRow = null;
if (!currentRow) currentRow = visibleRowsForScope()[0] || scopeRows()[0] || filteredRows[0] || null;
}
withReaderAnchor(() => renderAll());
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
saveReadingPositionSoon();
}
function openQuickEditor(row) {
withReaderAnchor(() => {
if (row) {
currentRow = row;
if (isImageChapter(currentChapter) && !currentPart) currentChapter = null;
currentImageItem = null;
syncScopeToRow(row);
applyFilter(false);
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
}
fillQuickEditor(row);
dom.quickEditor.hidden = false;
document.body.classList.add("quickOpen");
dom.reviewToolsBtn.classList.add("active");
renderAll();
});
loadGptConfig().catch(() => {});
saveReadingPositionSoon();
}
async function closeQuickEditor(save = false) {
if (quickDirty && currentRow) {
if (save) {
const ok = await saveCurrent(true);
if (!ok) return;
} else if (!window.confirm("当前段有未保存修改,关闭审校工具会放弃这些修改。继续吗?")) {
return;
}
}
withReaderAnchor(() => {
dom.quickEditor.hidden = true;
document.body.classList.remove("quickOpen");
dom.reviewToolsBtn.classList.remove("active");
quickDirty = false;
renderAll();
});
saveReadingPositionSoon();
}
function markDirty() {
dirty = true;
}
function markQuickDirty() {
quickDirty = true;
}
function renderGptConfig(config) {
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
dom.gptModelInput.value = config.model || "gpt-4o-mini";
dom.gptApiKeyInput.value = "";
dom.glossaryPathInput.value = config.glossary_path || "";
dom.gptTranslationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.gptFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.gptCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
dom.resetGptPromptsBtn.dataset.translationPrompt = (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.resetGptPromptsBtn.dataset.formatPrompt = (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.resetGptPromptsBtn.dataset.characterPrompt = (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
}
function renderTranslationGptConfig(config) {
if (!dom.translationBaseUrlInput) return;
dom.translationBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
dom.translationModelInput.value = config.model || "gpt-4o-mini";
dom.translationApiKeyInput.value = "";
dom.translationGlossaryPathInput.value = config.glossary_path || "";
dom.translationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.translationFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.translationCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
translationPromptDefaults = {
translation_prompt: (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "",
format_prompt: (config.prompt_defaults && config.prompt_defaults.format_prompt) || "",
character_prompt: (config.prompt_defaults && config.prompt_defaults.character_prompt) || "",
};
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
dom.translationGptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key。";
}
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 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,
glossary_path: dom.translationGlossaryPathInput.value,
translation_prompt: dom.translationPromptInput.value,
format_prompt: dom.translationFormatPromptInput.value,
character_prompt: dom.translationCharacterPromptInput.value,
keep_existing: true,
}),
});
renderTranslationGptConfig(config);
renderGptConfig(config);
gptConfigLoaded = true;
glossaryLoaded = false;
toast("AI 设置已保存,整书翻译会使用这套配置。");
return config;
} catch (error) {
toast(`保存 API 设置失败:${error.message}`, 7000);
throw error;
}
}
function resetTranslationPrompts() {
dom.translationPromptInput.value = translationPromptDefaults.translation_prompt || "";
dom.translationFormatPromptInput.value = translationPromptDefaults.format_prompt || "";
dom.translationCharacterPromptInput.value = translationPromptDefaults.character_prompt || "";
toast("已恢复翻译页预设提示词。");
}
function glossaryFilteredEntries() {
const q = (dom.glossarySearchInput.value || "").trim().toLowerCase();
if (!q) return glossaryEntries.map((entry, index) => ({ entry, index }));
return glossaryEntries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => {
return [entry.source, entry.target, entry.clean_target]
.join("\n")
.toLowerCase()
.includes(q);
});
}
function updateGlossaryMeta(shownCount = null, matchCount = null) {
const matches = shownCount === null || matchCount === null ? glossaryFilteredEntries() : null;
const shown = shownCount === null ? Math.min(80, matches.length) : shownCount;
const matched = matchCount === null ? matches.length : matchCount;
const path = dom.glossaryPathInput.value || "未设置";
const dirtyText = glossaryDirty ? " · 有未保存修改" : "";
dom.glossaryMeta.textContent = `当前术语表:${path} · ${glossaryEntries.length} 条 · 显示 ${shown}/${matched}${dirtyText}`;
}
function setGlossaryDirty(value) {
glossaryDirty = value;
dom.saveGlossaryBtn.disabled = !glossaryDirty;
if (glossaryLoaded) updateGlossaryMeta();
}
function renderGlossary(payload = null) {
if (payload) {
glossaryEntries = (payload.entries || []).map((entry) => ({
source: entry.source || "",
target: entry.target || "",
clean_target: entry.clean_target || "",
}));
glossaryLoaded = true;
setGlossaryDirty(false);
if (payload.path) dom.glossaryPathInput.value = payload.path;
}
const matches = glossaryFilteredEntries();
const shown = matches.slice(0, 80);
updateGlossaryMeta(shown.length, matches.length);
dom.glossaryList.innerHTML = "";
if (!glossaryLoaded) {
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">展开后会读取术语表。</div>';
return;
}
if (!shown.length) {
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">没有匹配的术语。</div>';
return;
}
const frag = document.createDocumentFragment();
shown.forEach(({ entry, index }) => {
const row = document.createElement("div");
row.className = "glossaryRow";
row.dataset.index = String(index);
row.innerHTML = `
<input class="glossarySource" type="text" value="${escapeHtml(entry.source)}" aria-label="术语原文">
<input class="glossaryTarget" type="text" value="${escapeHtml(entry.target)}" aria-label="术语译名">
<button class="glossaryDelete" type="button" aria-label="删除术语">删除</button>
`;
row.querySelector(".glossarySource").addEventListener("input", (event) => {
glossaryEntries[index].source = event.target.value;
setGlossaryDirty(true);
});
row.querySelector(".glossaryTarget").addEventListener("input", (event) => {
glossaryEntries[index].target = event.target.value;
glossaryEntries[index].clean_target = event.target.value.split("#", 1)[0].trim();
setGlossaryDirty(true);
});
row.querySelector(".glossaryDelete").addEventListener("click", () => {
glossaryEntries.splice(index, 1);
setGlossaryDirty(true);
renderGlossary();
});
frag.appendChild(row);
});
dom.glossaryList.appendChild(frag);
}
async function loadGlossary(force = false) {
if (glossaryLoaded && !force) return;
const payload = await api("/api/glossary");
renderGlossary(payload);
}
function addGlossaryEntry() {
glossaryEntries.unshift({ source: "", target: "", clean_target: "" });
dom.glossarySearchInput.value = "";
glossaryLoaded = true;
setGlossaryDirty(true);
renderGlossary();
requestAnimationFrame(() => dom.glossaryList.querySelector(".glossarySource")?.focus());
}
async function saveGlossary() {
try {
const payload = await api("/api/glossary", {
method: "POST",
body: JSON.stringify({
path: dom.glossaryPathInput.value,
entries: glossaryEntries,
}),
});
renderGlossary(payload);
toast("术语表已保存,下一次重翻会实时使用最新术语。");
} catch (error) {
toast(`保存术语表失败:${error.message}`, 9000);
}
}
function glossaryMatchedText(data) {
const matches = data.glossary_matches || [];
if (!matches.length) return "";
return `<div class="gptCandidateMeta">术语命中:${escapeHtml(matches.slice(0, 10).join(" "))}${matches.length > 10 ? ` 等 ${matches.length} 条` : ""}</div>`;
}
function resetGptPrompts() {
dom.gptTranslationPromptInput.value = dom.resetGptPromptsBtn.dataset.translationPrompt || "";
dom.gptFormatPromptInput.value = dom.resetGptPromptsBtn.dataset.formatPrompt || "";
dom.gptCharacterPromptInput.value = dom.resetGptPromptsBtn.dataset.characterPrompt || "";
toast("已恢复预设提示词,保存后生效。");
}
async function retranslateCurrentRow() {
if (!currentRow) return;
if (quickDirty) {
const ok = await saveCurrent(true);
if (!ok) return;
}
dom.retranslateBtn.disabled = true;
dom.retranslateBtn.textContent = "重翻中……";
dom.applyRetranslationBtn.disabled = true;
dom.gptCandidate.hidden = false;
dom.gptCandidate.textContent = "正在请求 GPT……";
try {
const data = await api(`/api/row/${encodeURIComponent(currentRow.id)}/retranslate`, {
method: "POST",
body: JSON.stringify({
instruction: dom.gptInstructionInput.value,
}),
});
gptCandidateHtml = data.candidate_html || "";
dom.gptCandidate.innerHTML = `${glossaryMatchedText(data)}${gptCandidateHtml || escapeHtml(data.candidate_text || "")}`;
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
toast("已生成重翻候选,可确认后应用。");
} catch (error) {
dom.gptCandidate.textContent = `重翻失败:${error.message}`;
toast(`重翻失败:${error.message}`, 9000);
} finally {
dom.retranslateBtn.disabled = false;
dom.retranslateBtn.textContent = "重翻本段";
}
}
function applyRetranslation() {
if (!gptCandidateHtml) return;
dom.quickCnEditor.innerHTML = gptCandidateHtml;
quickDirty = true;
dom.applyRetranslationBtn.disabled = true;
toast("已应用到编辑框,保存后才会写入审校记录。");
}
function resetCurrentText() {
if (!currentRow) return;
dom.cnEditor.innerHTML = currentRow.cn_html || "";
markDirty();
}
function markSelectionInEditor(editor, markedInput, issueInput) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
toast("请先在中文译文中选中要标记的文字。");
return false;
}
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) {
toast("只能标记中文译文区域内的文字。");
return false;
}
const mark = document.createElement("mark");
mark.className = "review-mark";
mark.setAttribute("data-review-mark", "bad-translation");
try {
range.surroundContents(mark);
} catch (_error) {
const fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
}
selection.removeAllRanges();
markedInput.checked = true;
if (!issueInput.value) issueInput.value = "翻译腔";
return true;
}
function markSelection() {
if (markSelectionInEditor(dom.cnEditor, dom.markedInput, dom.issueTypeInput)) markDirty();
}
function markQuickSelection() {
if (markSelectionInEditor(dom.quickCnEditor, dom.quickMarkedInput, dom.quickIssueTypeInput)) markQuickDirty();
}
function clearMarks() {
dom.cnEditor.querySelectorAll("mark, .review-mark, [data-review-mark]").forEach((node) => {
const parent = node.parentNode;
while (node.firstChild) parent.insertBefore(node.firstChild, node);
parent.removeChild(node);
parent.normalize();
});
markDirty();
}
async function go(delta) {
const candidates = visibleRowsForScope();
if (!candidates.length) return;
const index = Math.max(0, candidates.findIndex((row) => currentRow && row.id === currentRow.id));
const next = Math.max(0, Math.min(candidates.length - 1, index + delta));
if (next === index) return;
await maybeSelectRow(candidates[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true });
}
async function goPart(delta) {
const scopes = allReadingScopes();
if (!scopes.length) return;
const currentScopeId = activeReadingScopeId();
const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId));
const next = Math.max(0, Math.min(scopes.length - 1, index + delta));
if (next === index) return;
const scope = scopes[next];
if (scope.type === "part") {
await selectPart(scope.part);
} else if (scope.type === "image") {
await selectImageItem(scope.chapter, scope.item);
} else {
await selectChapter(scope.chapter);
}
}
async function generateFeedback() {
if (!hasSession) return;
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/feedback");
const downloadText = data.feedback_md_url ? `\n可在当前浏览器下载:${data.feedback_md_url}` : "";
toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}${downloadText}`, 9000);
}
async function applyBack() {
if (!hasSession) return;
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/apply", { method: "POST", body: "{}" });
toast(`已写回 EPUB 解包内容。\n修改文件数:${data.modified_files.length}\n反馈:${data.feedback_md}`, 9000);
}
async function exportEpub() {
if (!hasSession) return;
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/export", { method: "POST", body: "{}" });
toast(`已导出审校 EPUB\n${data.output}${data.download_url ? `\n可在当前浏览器下载:${data.download_url}` : ""}`, 10000);
}
function initEvents() {
readStoredReaderSettings();
applyReaderSettings(false);
initChromeMetrics();
[
dom.markedInput,
dom.cnEditor,
dom.issueTypeInput,
dom.severityInput,
dom.tagsInput,
dom.commentInput,
dom.learnNoteInput,
].forEach((el) => {
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
[
dom.quickMarkedInput,
dom.quickCnEditor,
dom.quickIssueTypeInput,
dom.quickSeverityInput,
dom.quickTagsInput,
dom.quickCommentInput,
dom.quickLearnNoteInput,
].forEach((el) => {
el.addEventListener("input", markQuickDirty);
el.addEventListener("change", markQuickDirty);
});
dom.searchInput.addEventListener("input", () => {
currentIndex = -1;
applyFilter(true);
});
dom.searchScopeCurrentBtn.addEventListener("click", () => setSearchScope("current"));
dom.searchScopeBookBtn.addEventListener("click", () => setSearchScope("book"));
dom.tocPanelBtn.addEventListener("click", () => {
if (sidePanelMode === "toc") {
closeSidePanel();
} else {
openSidePanel("toc");
}
});
dom.searchPanelBtn.addEventListener("click", () => {
if (sidePanelMode === "search") {
closeSidePanel();
} else {
openSidePanel("search");
}
});
dom.reviewToolsBtn.addEventListener("click", async () => {
if (!hasSession) return;
if (!dom.quickEditor.hidden) {
await closeQuickEditor(false);
return;
}
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
openQuickEditor(null);
});
dom.closeSidebarBtn.addEventListener("click", closeSidePanel);
dom.bookshelfBtn.addEventListener("click", () => {
showBookshelf().catch((error) => toast(`打开书架失败:${error.message}`, 7000));
});
dom.showTouchedBtn.addEventListener("click", () => {
showTouchedOnly = !showTouchedOnly;
dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly);
applyFilter(true);
});
dom.expandTocBtn.addEventListener("click", expandAllChapters);
dom.collapseTocBtn.addEventListener("click", collapseAllChapters);
dom.readingModeBtn.addEventListener("click", () => setMode("reading"));
dom.polishModeBtn.addEventListener("click", () => setMode("polish"));
dom.openLibraryBtn.addEventListener("click", async () => {
if (!(await leaveReviewShell())) return;
await showStartScreen();
});
[
dom.themeDefaultBtn,
dom.themeEyeBtn,
dom.themeNightBtn,
].forEach((button) => {
button.addEventListener("click", () => setReaderTheme(button.dataset.readerTheme));
});
dom.readerFontSizeInput.addEventListener("input", () => setReaderFontSize(dom.readerFontSizeInput.value));
dom.readerLineHeightInput.addEventListener("input", () => setReaderLineHeight(dom.readerLineHeightInput.value));
dom.prevPartBtn.addEventListener("click", () => goPart(-1));
dom.nextPartBtn.addEventListener("click", () => goPart(1));
dom.resetTextBtn.addEventListener("click", resetCurrentText);
dom.markSelectionBtn.addEventListener("click", markSelection);
dom.clearMarksBtn.addEventListener("click", clearMarks);
dom.saveBtn.addEventListener("click", () => saveCurrent(false));
dom.prevBtn.addEventListener("click", () => go(-1));
dom.nextBtn.addEventListener("click", () => go(1));
dom.feedbackBtn.addEventListener("click", generateFeedback);
dom.applyBtn.addEventListener("click", applyBack);
dom.exportBtn.addEventListener("click", exportEpub);
dom.uploadForm.addEventListener("submit", uploadEpub);
dom.epubFileInput.addEventListener("change", () => {
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
dom.selectedFileName.textContent = file ? file.name : "尚未选择文件";
});
dom.refreshSessionsBtn.addEventListener("click", () => {
refreshSessions().catch((error) => toast(`刷新失败:${error.message}`, 7000));
});
dom.bookshelfRefreshBtn.addEventListener("click", () => {
refreshBookshelf().catch((error) => toast(`刷新书架失败:${error.message}`, 7000));
});
dom.bookshelfOpenUploadBtn.addEventListener("click", async () => {
if (!(await leaveReviewShell())) return;
await showStartScreen();
});
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();