Files
-----/tools/epub_review_editor/static/app.js
T
2026-07-07 13:59:33 +08:00

1468 lines
48 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 currentMode = "reading";
let showTouchedOnly = false;
let dirty = false;
let quickDirty = false;
let hasSession = false;
let expandedChapters = new Set();
let sidePanelMode = null;
let gptConfigLoaded = false;
let gptCandidateHtml = "";
const $ = (id) => document.getElementById(id);
const dom = {
startScreen: $("startScreen"),
reviewLayout: $("reviewLayout"),
appVersion: $("appVersion"),
sessionMeta: $("sessionMeta"),
readingModeBtn: $("readingModeBtn"),
polishModeBtn: $("polishModeBtn"),
openLibraryBtn: $("openLibraryBtn"),
showTouchedBtn: $("showTouchedBtn"),
feedbackBtn: $("feedbackBtn"),
applyBtn: $("applyBtn"),
exportBtn: $("exportBtn"),
tocPanelBtn: $("tocPanelBtn"),
searchPanelBtn: $("searchPanelBtn"),
sidebar: $("sidebar"),
sideTitle: $("sideTitle"),
closeSidebarBtn: $("closeSidebarBtn"),
tocPanel: $("tocPanel"),
searchPanel: $("searchPanel"),
searchInput: $("searchInput"),
stats: $("stats"),
expandTocBtn: $("expandTocBtn"),
collapseTocBtn: $("collapseTocBtn"),
toc: $("toc"),
rowList: $("rowList"),
readerPane: $("readerPane"),
editorPane: $("editorPane"),
chapterKicker: $("chapterKicker"),
readerTitle: $("readerTitle"),
readingFlow: $("readingFlow"),
prevPartBtn: $("prevPartBtn"),
nextPartBtn: $("nextPartBtn"),
emptyState: $("emptyState"),
editorCard: $("editorCard"),
rowId: $("rowId"),
rowPath: $("rowPath"),
markedInput: $("markedInput"),
jpText: $("jpText"),
cnEditor: $("cnEditor"),
markSelectionBtn: $("markSelectionBtn"),
clearMarksBtn: $("clearMarksBtn"),
resetTextBtn: $("resetTextBtn"),
issueTypeInput: $("issueTypeInput"),
severityInput: $("severityInput"),
tagsInput: $("tagsInput"),
commentInput: $("commentInput"),
learnNoteInput: $("learnNoteInput"),
prevBtn: $("prevBtn"),
saveBtn: $("saveBtn"),
nextBtn: $("nextBtn"),
quickEditor: $("quickEditor"),
quickRowId: $("quickRowId"),
quickRowPath: $("quickRowPath"),
quickJpText: $("quickJpText"),
quickCnEditor: $("quickCnEditor"),
quickMarkedInput: $("quickMarkedInput"),
quickIssueTypeInput: $("quickIssueTypeInput"),
quickSeverityInput: $("quickSeverityInput"),
quickTagsInput: $("quickTagsInput"),
quickCommentInput: $("quickCommentInput"),
quickLearnNoteInput: $("quickLearnNoteInput"),
closeQuickEditorBtn: $("closeQuickEditorBtn"),
quickMarkSelectionBtn: $("quickMarkSelectionBtn"),
quickSaveBtn: $("quickSaveBtn"),
quickOpenFullBtn: $("quickOpenFullBtn"),
toggleGptConfigBtn: $("toggleGptConfigBtn"),
gptStatus: $("gptStatus"),
gptConfig: $("gptConfig"),
gptBaseUrlInput: $("gptBaseUrlInput"),
gptModelInput: $("gptModelInput"),
gptApiKeyInput: $("gptApiKeyInput"),
saveGptConfigBtn: $("saveGptConfigBtn"),
gptInstructionInput: $("gptInstructionInput"),
retranslateBtn: $("retranslateBtn"),
applyRetranslationBtn: $("applyRetranslationBtn"),
gptCandidate: $("gptCandidate"),
uploadForm: $("uploadForm"),
epubFileInput: $("epubFileInput"),
selectedFileName: $("selectedFileName"),
uploadBtn: $("uploadBtn"),
refreshSessionsBtn: $("refreshSessionsBtn"),
sessionList: $("sessionList"),
sessionListMeta: $("sessionListMeta"),
toast: $("toast"),
};
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&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 readerAnchor() {
if (currentMode !== "reading" || !dom.readerPane || dom.readerPane.hidden) return null;
const paneRect = dom.readerPane.getBoundingClientRect();
const headerRect = document.querySelector(".readerHeader")?.getBoundingClientRect();
const top = headerRect ? headerRect.bottom + 8 : paneRect.top + 8;
const blocks = Array.from(dom.readingFlow.querySelectorAll("[data-row-id]"));
if (!blocks.length) {
return { rowId: "", offset: 0, scrollTop: dom.readerPane.scrollTop };
}
let target = blocks[0];
for (const block of blocks) {
const rect = block.getBoundingClientRect();
if (rect.bottom >= top) {
target = block;
break;
}
}
return {
rowId: target.dataset.rowId || "",
offset: target.getBoundingClientRect().top - paneRect.top,
scrollTop: dom.readerPane.scrollTop,
};
}
function restoreReaderAnchor(anchor) {
if (!anchor || currentMode !== "reading") return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!anchor.rowId) {
dom.readerPane.scrollTop = anchor.scrollTop || 0;
return;
}
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(anchor.rowId)}"]`);
if (!target) {
dom.readerPane.scrollTop = anchor.scrollTop || 0;
return;
}
const paneRect = dom.readerPane.getBoundingClientRect();
const nextOffset = target.getBoundingClientRect().top - paneRect.top;
dom.readerPane.scrollTop += nextOffset - anchor.offset;
});
});
}
function withReaderAnchor(action) {
const anchor = readerAnchor();
const result = action();
restoreReaderAnchor(anchor);
return result;
}
function openSidePanel(mode) {
if (!hasSession) return;
sidePanelMode = mode;
dom.sidebar.hidden = false;
dom.tocPanel.hidden = mode !== "toc";
dom.searchPanel.hidden = mode !== "search";
dom.sideTitle.textContent = mode === "search" ? "检索" : "目录";
dom.tocPanelBtn.classList.toggle("active", mode === "toc");
dom.searchPanelBtn.classList.toggle("active", mode === "search");
document.body.classList.add("sidebarOpen");
if (mode === "search") {
requestAnimationFrame(() => dom.searchInput.focus());
}
}
function closeSidePanel() {
sidePanelMode = null;
if (dom.sidebar) dom.sidebar.hidden = true;
if (dom.tocPanel) dom.tocPanel.hidden = true;
if (dom.searchPanel) dom.searchPanel.hidden = true;
document.body.classList.remove("sidebarOpen");
if (dom.tocPanelBtn) dom.tocPanelBtn.classList.remove("active");
if (dom.searchPanelBtn) dom.searchPanelBtn.classList.remove("active");
}
function resetReviewState() {
rows = [];
filteredRows = [];
structure = { chapters: [] };
currentIndex = -1;
currentRow = null;
currentChapter = null;
currentPart = null;
showTouchedOnly = false;
dirty = false;
quickDirty = false;
expandedChapters = new Set();
sidePanelMode = null;
gptCandidateHtml = "";
gptConfigLoaded = false;
dom.showTouchedBtn.classList.remove("primary");
dom.searchInput.value = "";
dom.sidebar.hidden = true;
dom.tocPanel.hidden = true;
dom.searchPanel.hidden = true;
dom.tocPanelBtn.classList.remove("active");
dom.searchPanelBtn.classList.remove("active");
if (dom.quickEditor) {
dom.quickEditor.hidden = true;
}
document.body.classList.remove("quickOpen");
}
function setShellMode(active) {
hasSession = active;
dom.startScreen.hidden = active;
dom.reviewLayout.hidden = !active;
dom.readingModeBtn.disabled = !active;
dom.polishModeBtn.disabled = !active;
dom.openLibraryBtn.disabled = false;
dom.showTouchedBtn.disabled = !active;
dom.feedbackBtn.disabled = !active;
dom.applyBtn.disabled = !active;
dom.exportBtn.disabled = !active;
dom.tocPanelBtn.disabled = !active;
dom.searchPanelBtn.disabled = !active;
dom.closeSidebarBtn.disabled = !active;
dom.expandTocBtn.disabled = !active;
dom.collapseTocBtn.disabled = !active;
document.body.classList.toggle("startOpen", !active);
if (!active) closeSidePanel();
if (!active) {
dom.sessionMeta.textContent = "未打开 EPUB";
}
}
function setVersionBadge(value) {
if (!value) return;
dom.appVersion.textContent = `v${value}`;
}
function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function renderSessions(sessions = [], activeId = "") {
dom.sessionList.innerHTML = "";
dom.sessionListMeta.textContent = sessions.length ? `${sessions.length} 个会话` : "暂无会话";
if (!sessions.length) {
dom.sessionList.innerHTML = '<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 || "");
}
async function showStartScreen(sessionPayload = null) {
resetReviewState();
setShellMode(false);
if (sessionPayload && Array.isArray(sessionPayload.sessions)) {
renderSessions(sessionPayload.sessions, "");
} else {
try {
await refreshSessions();
} catch (error) {
dom.sessionList.innerHTML = `<div class="emptySession">读取会话失败:${escapeHtml(error.message)}</div>`;
}
}
}
function rowCurrentText(row) {
return stripTags(row.current_html || row.cn_html || row.cn_text || "");
}
function rowById(rowId) {
return rows.find((item) => item.id === rowId) || null;
}
function allParts() {
return (structure.chapters || []).flatMap((chapter) => chapter.parts || []);
}
function allChapterScopes() {
return (structure.chapters || []).map((chapter) => ({
type: "chapter",
id: chapter.id,
chapter,
}));
}
function chapterIsExpanded(chapter) {
if (!chapter) return false;
return expandedChapters.has(chapter.id);
}
function toggleChapterExpanded(chapter) {
if (!chapter) return;
if (expandedChapters.has(chapter.id)) {
expandedChapters.delete(chapter.id);
} else {
expandedChapters.add(chapter.id);
}
renderToc();
}
function expandAllChapters() {
expandedChapters = new Set((structure.chapters || []).map((chapter) => chapter.id));
renderToc();
}
function collapseAllChapters() {
expandedChapters = new Set();
if (currentChapter) expandedChapters.add(currentChapter.id);
renderToc();
}
function partById(partId) {
return allParts().find((part) => part.id === partId) || null;
}
function chapterById(chapterId) {
return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null;
}
function chapterKind(chapter) {
return chapter && chapter.kind === "image" ? "image" : "text";
}
function isImageChapter(chapter) {
return chapterKind(chapter) === "image";
}
function partRows(part) {
if (!part) return [];
const ids = new Set(part.row_ids || []);
return rows.filter((row) => ids.has(row.id));
}
function partForRow(row) {
if (!row) return null;
return allParts().find((part) => (part.row_ids || []).includes(row.id)) || null;
}
function chapterForPart(part) {
if (!part) return null;
return (structure.chapters || []).find((chapter) => (chapter.parts || []).some((item) => item.id === part.id)) || null;
}
function chapterRows(chapter) {
if (!chapter) return [];
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return rows.filter((row) => ids.has(row.id));
}
function scopeCoversRow(row) {
if (!row) return false;
if (currentPart) return (currentPart.row_ids || []).includes(row.id);
if (currentChapter) {
return (currentChapter.parts || []).some((part) => (part.row_ids || []).includes(row.id));
}
return true;
}
function syncScopeToRow(row, forcePart = false) {
const nextPart = partForRow(row);
if (!nextPart) return;
const nextChapter = chapterForPart(nextPart);
if (!nextChapter) return;
if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) {
currentChapter = nextChapter;
currentPart = nextPart;
expandedChapters.add(nextChapter.id);
return;
}
if (currentPart && currentPart.id !== nextPart.id) {
currentPart = nextPart;
}
}
function partVisibleRows(part) {
const ids = new Set((part && part.row_ids) || []);
return filteredRows.filter((row) => ids.has(row.id));
}
function chapterVisibleRows(chapter) {
if (!chapter) return [];
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return filteredRows.filter((row) => ids.has(row.id));
}
function scopeRows() {
if (currentPart) return partRows(currentPart);
if (currentChapter) return chapterRows(currentChapter);
return rows;
}
function scopeVisibleRows() {
if (currentPart) return partVisibleRows(currentPart);
if (currentChapter) return chapterVisibleRows(currentChapter);
return filteredRows;
}
function imageChapterMatchesSearch(chapter) {
if (!isImageChapter(chapter)) return false;
if (showTouchedOnly) return false;
const q = dom.searchInput.value.trim().toLowerCase();
if (!q) return true;
const haystack = [
chapter.id,
chapter.title,
chapter.file_label,
...(chapter.images || []).flatMap((image) => [image.asset_name, image.alt, image.asset_path]),
]
.join("\n")
.toLowerCase();
return haystack.includes(q);
}
async function loadSession() {
const session = await api("/api/session");
setVersionBadge(session.version);
if (!session.has_session) {
await showStartScreen(session);
return session;
}
setShellMode(true);
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
const partCount = structure.part_count || allParts().length;
const imageCount = structure.image_count || 0;
const sourceName = session.source_name || session.source_epub;
dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part / ${imageCount} 张图 · 已记录 ${session.touched_count} 段`;
return session;
}
async function loadStructure() {
structure = await api("/api/structure");
}
async function loadRows(keepSelection = false) {
const oldId = currentRow && currentRow.id;
const payload = await api("/api/rows");
rows = payload.rows || [];
applyFilter(false);
if (keepSelection && oldId) {
const nextRow = rowById(oldId);
if (nextRow) {
currentRow = nextRow;
currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id);
if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) {
syncScopeToRow(nextRow);
}
renderAll();
return;
}
}
if (!currentPart) {
currentChapter = (structure.chapters || [])[0] || null;
currentPart = currentChapter && !isImageChapter(currentChapter) && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null;
}
if (filteredRows.length > 0 && currentIndex < 0) {
currentRow = filteredRows[0];
currentIndex = 0;
}
renderAll();
}
async function loadActiveSession(options = {}) {
resetReviewState();
currentMode = "reading";
setShellMode(true);
await loadStructure();
await loadRows(Boolean(options.keepSelection));
await loadSession();
await refreshSessions().catch(() => {});
if (options.message) toast(options.message, 4200);
}
async function uploadEpub(event) {
event.preventDefault();
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
if (!file) {
toast("请先选择一个 EPUB 文件。");
return;
}
const formData = new FormData();
formData.append("epub", file);
dom.uploadBtn.disabled = true;
dom.uploadBtn.textContent = "正在解析……";
try {
await apiForm("/api/upload", formData);
dom.epubFileInput.value = "";
dom.selectedFileName.textContent = "尚未选择文件";
await loadActiveSession({ message: "EPUB 已载入,可以开始审校。" });
} catch (error) {
toast(`上传失败:${error.message}`, 9000);
} finally {
dom.uploadBtn.disabled = false;
dom.uploadBtn.textContent = "上传并开始审校";
}
}
async function openSession(sessionId) {
if (!sessionId) return;
if (hasSession && !(await maybeSaveBeforeSwitch())) return;
try {
await api("/api/session/open", {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
await loadActiveSession({ message: "已打开审校会话。" });
} catch (error) {
toast(`打开会话失败:${error.message}`, 9000);
}
}
function updateStructureCountsFromRows() {
for (const chapter of structure.chapters || []) {
chapter.row_count = 0;
chapter.touched_count = 0;
chapter.marked_count = 0;
if (isImageChapter(chapter)) continue;
for (const part of chapter.parts || []) {
const partItems = partRows(part);
part.row_count = partItems.length;
part.touched_count = partItems.filter(rowTouched).length;
part.marked_count = partItems.filter((row) => row.marked).length;
chapter.row_count += part.row_count;
chapter.touched_count += part.touched_count;
chapter.marked_count += part.marked_count;
}
}
}
function rowMatchesSearch(row, q) {
if (showTouchedOnly && !rowTouched(row)) return false;
if (!q) return true;
const haystack = [
row.id,
row.file_label,
row.document_title,
row.jp_text,
row.cn_text,
rowCurrentText(row),
row.comment,
row.learn_note,
row.tags,
row.issue_type,
]
.join("\n")
.toLowerCase();
return haystack.includes(q);
}
function applyFilter(shouldRender = true) {
const q = dom.searchInput.value.trim().toLowerCase();
filteredRows = rows.filter((row) => rowMatchesSearch(row, q));
if (currentRow) {
currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id);
}
if (shouldRender) renderAll();
}
function renderAll() {
if (!hasSession) return;
updateStructureCountsFromRows();
renderToc();
renderList();
renderMode();
if (currentMode === "reading") {
renderReading();
} else {
renderEditor();
}
}
function renderMode() {
dom.readerPane.hidden = currentMode !== "reading";
dom.editorPane.hidden = currentMode !== "polish";
dom.readingModeBtn.classList.toggle("active", currentMode === "reading");
dom.polishModeBtn.classList.toggle("active", currentMode === "polish");
dom.polishModeBtn.disabled = !hasSession || (isImageChapter(currentChapter) && !currentPart);
document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden);
if (sidePanelMode) {
dom.tocPanel.hidden = sidePanelMode !== "toc";
dom.searchPanel.hidden = sidePanelMode !== "search";
}
}
function renderToc() {
dom.toc.innerHTML = "";
const frag = document.createDocumentFragment();
for (const chapter of structure.chapters || []) {
const parts = chapter.parts || [];
const chapterIsImage = isImageChapter(chapter);
const isCurrentChapter = currentChapter && chapter.id === currentChapter.id;
const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id);
const expanded = !chapterIsImage && (chapterIsExpanded(chapter) || containsCurrentPart);
const group = document.createElement("section");
group.className = "tocChapter";
if (chapterIsImage) group.classList.add("imageChapter");
if (expanded) group.classList.add("expanded");
const row = document.createElement("div");
row.className = "tocChapterRow";
if ((isCurrentChapter && !currentPart) || containsCurrentPart) {
row.classList.add("active");
}
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tocToggle";
toggle.textContent = chapterIsImage ? "图" : expanded ? "▾" : "▸";
toggle.disabled = chapterIsImage || parts.length === 0;
toggle.setAttribute("aria-label", chapterIsImage ? "插图章节" : expanded ? "收起章节" : "展开章节");
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (chapterIsImage) return;
toggleChapterExpanded(chapter);
});
row.appendChild(toggle);
const title = document.createElement("button");
title.type = "button";
title.className = "tocChapterButton";
if (
(isCurrentChapter && !currentPart)
|| containsCurrentPart
) {
title.classList.add("active");
}
title.innerHTML = `
<span class="tocTitle">${escapeHtml(chapter.title || chapter.id)}</span>
<span class="tocCount">${chapterIsImage ? `${(chapter.images || []).length || 1} 图` : `${chapter.row_count || 0} 段`}</span>
`;
title.addEventListener("click", () => selectChapter(chapter));
row.appendChild(title);
group.appendChild(row);
const partList = document.createElement("div");
partList.className = "tocParts";
partList.hidden = !expanded;
if (!chapterIsImage) for (const part of parts) {
const button = document.createElement("button");
button.type = "button";
button.className = "tocPartButton";
if (currentPart && part.id === currentPart.id) button.classList.add("active");
const badges = [];
if (part.touched_count) badges.push(`记 ${part.touched_count}`);
if (part.marked_count) badges.push(`标 ${part.marked_count}`);
button.innerHTML = `
<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);
}
function renderList() {
const touched = rows.filter(rowTouched).length;
const marked = rows.filter((row) => row.marked).length;
const visibleInScope = scopeVisibleRows();
const imageCount = structure.image_count || 0;
const isCurrentImage = isImageChapter(currentChapter) && !currentPart;
dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentChapter.images || []).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`;
dom.rowList.innerHTML = "";
const frag = document.createDocumentFragment();
if (isCurrentImage) {
const item = document.createElement("div");
item.className = "rowItem imageRowItem active";
const images = currentChapter.images || [];
item.innerHTML = `
<div class="rowMeta">
<span>${escapeHtml(currentChapter.id)}</span>
<span class="rowBadge">插图</span>
<span>${escapeHtml(currentChapter.file_label || "")}</span>
</div>
<div class="rowPreview">${escapeHtml(images.map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}</div>
`;
frag.appendChild(item);
dom.rowList.appendChild(frag);
return;
}
visibleInScope.forEach((row) => {
const button = document.createElement("button");
button.type = "button";
button.className = "rowItem";
if (currentRow && row.id === currentRow.id) button.classList.add("active");
if (rowTouched(row)) button.classList.add("touched");
if (row.marked) button.classList.add("marked");
const badge = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "未记录";
button.innerHTML = `
<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 images = chapter.images || [];
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.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 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 && isImageChapter(selectedChapter)) {
renderImageChapter(selectedChapter);
return;
}
dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || "";
dom.readerTitle.textContent = selectedPart
? selectedPart.title || selectedPart.file_label || "未命名 part"
: selectedChapter.title || "未命名章节";
dom.readingFlow.innerHTML = "";
const visibleRows = scopeVisibleRows();
const sourceRows = visibleRows.length || dom.searchInput.value || showTouchedOnly ? visibleRows : scopeRows();
if (!sourceRows.length) {
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有段落。</div>';
return;
}
const frag = document.createDocumentFragment();
let lastFile = "";
sourceRows.forEach((row) => {
if (!selectedPart && row.file !== lastFile) {
const part = partForRow(row);
if (part) {
const divider = document.createElement("h3");
divider.className = "partDivider";
divider.textContent = part.title || part.file_label || row.file;
frag.appendChild(divider);
}
lastFile = row.file;
}
const block = document.createElement("section");
block.className = "readBlock";
block.dataset.rowId = row.id;
if (currentRow && row.id === currentRow.id) block.classList.add("active");
if (rowTouched(row)) block.classList.add("touched");
if (row.marked) block.classList.add("marked");
block.innerHTML = `
<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);
});
dom.readingFlow.appendChild(frag);
}
function renderEditor() {
if (isImageChapter(currentChapter) && !currentPart) {
dom.emptyState.hidden = false;
dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。";
dom.editorCard.hidden = true;
return;
}
if (currentRow && !scopeCoversRow(currentRow)) {
const visible = scopeVisibleRows();
const fallback = scopeRows();
currentRow = visible[0] || fallback[0] || null;
}
if (!currentRow) {
dom.emptyState.hidden = false;
dom.emptyState.textContent = "选择左侧段落开始审校。";
dom.editorCard.hidden = true;
return;
}
dom.emptyState.hidden = true;
dom.editorCard.hidden = false;
fillFullEditor(currentRow);
}
function fillFullEditor(row) {
dom.rowId.textContent = row.id;
dom.rowPath.textContent = `${row.file} · 日文 P${row.ja_p_index} / 中文 P${row.cn_p_index}`;
dom.markedInput.checked = Boolean(row.marked);
dom.jpText.innerHTML = row.jp_html || escapeHtml(row.jp_text);
dom.cnEditor.innerHTML = row.current_html || row.cn_html || "";
dom.issueTypeInput.value = row.issue_type || "";
dom.severityInput.value = row.severity || "";
dom.tagsInput.value = row.tags || "";
dom.commentInput.value = row.comment || "";
dom.learnNoteInput.value = row.learn_note || "";
}
function fillQuickEditor(row) {
dom.quickRowId.textContent = row.id;
dom.quickRowPath.textContent = `${row.file_label} · #${row.file_row_index || row.cn_p_index}`;
dom.quickJpText.innerHTML = row.jp_html || escapeHtml(row.jp_text);
dom.quickCnEditor.innerHTML = row.current_html || row.cn_html || "";
dom.quickMarkedInput.checked = Boolean(row.marked);
dom.quickIssueTypeInput.value = row.issue_type || "";
dom.quickSeverityInput.value = row.severity || "";
dom.quickTagsInput.value = row.tags || "";
dom.quickCommentInput.value = row.comment || "";
dom.quickLearnNoteInput.value = row.learn_note || "";
gptCandidateHtml = "";
dom.gptCandidate.hidden = true;
dom.gptCandidate.innerHTML = "";
dom.applyRetranslationBtn.disabled = true;
quickDirty = false;
}
function collectCurrent() {
if (!currentRow) return null;
if (currentMode === "reading" && !dom.quickEditor.hidden) {
return collectQuick();
}
return collectFull();
}
function collectFull() {
return {
current_html: dom.cnEditor.innerHTML,
marked: dom.markedInput.checked,
issue_type: dom.issueTypeInput.value,
severity: dom.severityInput.value,
tags: dom.tagsInput.value,
comment: dom.commentInput.value,
learn_note: dom.learnNoteInput.value,
};
}
function collectQuick() {
return {
current_html: dom.quickCnEditor.innerHTML,
marked: dom.quickMarkedInput.checked,
issue_type: dom.quickIssueTypeInput.value,
severity: dom.quickSeverityInput.value,
tags: dom.quickTagsInput.value,
comment: dom.quickCommentInput.value,
learn_note: dom.quickLearnNoteInput.value,
};
}
function applyRowData(row, data, updatedAt = "") {
row.current_html = data.current_html;
row.marked = data.marked;
row.issue_type = data.issue_type;
row.severity = data.severity;
row.tags = data.tags;
row.comment = data.comment;
row.learn_note = data.learn_note;
row.edited = data.current_html !== row.cn_html;
row.updated_at = updatedAt || row.updated_at || "";
}
async function saveRow(row, data, silent = false) {
if (!row) return true;
try {
const result = await api(`/api/row/${encodeURIComponent(row.id)}`, {
method: "POST",
body: JSON.stringify(data),
});
const target = rowById(row.id);
if (target) applyRowData(target, data, result.updated_at);
currentRow = target || row;
dirty = false;
quickDirty = false;
applyFilter(false);
withReaderAnchor(() => renderAll());
await loadSession();
if (!silent) toast("已保存本段记录。");
return true;
} catch (error) {
toast(`保存失败:${error.message}`, 7000);
return false;
}
}
async function saveCurrent(silent = false) {
if (!hasSession) return true;
if (isImageChapter(currentChapter) && !currentPart) return true;
if (!currentRow) return true;
return saveRow(currentRow, collectCurrent(), silent);
}
async function maybeSaveBeforeSwitch() {
if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) {
return saveCurrent(true);
}
if (currentMode === "polish" && dirty && currentRow) {
return saveCurrent(true);
}
return true;
}
async function maybeSelectRow(row, options = {}) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
selectRow(row, options);
}
function selectRow(row, options = {}) {
if (!row) return;
currentRow = row;
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
syncScopeToRow(row, Boolean(options.forcePart));
dirty = false;
if (options.openEditor) {
setMode("polish", false);
} else if (options.openQuick) {
openQuickEditor(row);
} else {
renderAll();
}
if (options.scroll) scrollRowIntoView(row.id);
}
async function selectChapter(chapter) {
if (!chapter) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapter;
currentPart = null;
expandedChapters.add(chapter.id);
if (isImageChapter(chapter)) {
currentRow = null;
currentIndex = -1;
closeQuickEditor(false);
currentMode = "reading";
} else {
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
const visible = filteredRows.filter((row) => ids.has(row.id));
const fallback = rows.filter((row) => ids.has(row.id));
currentRow = visible[0] || fallback[0] || currentRow;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
}
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
if (sidePanelMode === "toc") closeSidePanel();
}
async function selectPart(part) {
if (!part) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapterForPart(part);
currentPart = part;
if (currentChapter) expandedChapters.add(currentChapter.id);
const visible = partVisibleRows(part);
const fallback = partRows(part);
currentRow = visible[0] || fallback[0] || currentRow;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
if (sidePanelMode === "toc") closeSidePanel();
}
function scrollRowIntoView(rowId) {
if (currentMode === "reading") {
requestAnimationFrame(() => {
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`);
if (target) target.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
}
async function setMode(mode, saveBefore = true) {
if (saveBefore) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
}
if (mode === "polish" && isImageChapter(currentChapter) && !currentPart) {
toast("插图章节没有可编辑译文,已保持阅读模式。");
mode = "reading";
}
currentMode = mode;
if (mode === "reading" && currentPart && isImageChapter(currentChapter)) {
currentPart = null;
}
if (mode === "polish") {
closeQuickEditor(false);
if (!scopeCoversRow(currentRow)) currentRow = null;
if (!currentRow) currentRow = scopeVisibleRows()[0] || scopeRows()[0] || filteredRows[0] || null;
}
withReaderAnchor(() => renderAll());
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
}
function openQuickEditor(row) {
withReaderAnchor(() => {
currentRow = row;
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
syncScopeToRow(row);
fillQuickEditor(row);
dom.quickEditor.hidden = false;
document.body.classList.add("quickOpen");
renderAll();
});
loadGptConfig().catch(() => {});
}
function closeQuickEditor(save = false) {
if (save && quickDirty) {
saveCurrent(true);
return;
}
withReaderAnchor(() => {
dom.quickEditor.hidden = true;
document.body.classList.remove("quickOpen");
quickDirty = false;
renderAll();
});
}
function markDirty() {
dirty = true;
}
function markQuickDirty() {
quickDirty = true;
}
function renderGptConfig(config) {
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
dom.gptModelInput.value = config.model || "gpt-4o-mini";
dom.gptApiKeyInput.value = "";
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
}
async function loadGptConfig(force = false) {
if (gptConfigLoaded && !force) return;
try {
const config = await api("/api/gpt/config");
renderGptConfig(config);
gptConfigLoaded = true;
} catch (error) {
dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`;
}
}
async function saveGptConfig() {
try {
const config = await api("/api/gpt/config", {
method: "POST",
body: JSON.stringify({
base_url: dom.gptBaseUrlInput.value,
model: dom.gptModelInput.value,
api_key: dom.gptApiKeyInput.value,
keep_existing: true,
}),
});
renderGptConfig(config);
gptConfigLoaded = true;
toast("GPT API 设置已保存。");
} catch (error) {
toast(`保存 GPT 设置失败:${error.message}`, 7000);
}
}
async function retranslateCurrentRow() {
if (!currentRow) return;
if (quickDirty) {
const ok = await saveCurrent(true);
if (!ok) return;
}
dom.retranslateBtn.disabled = true;
dom.retranslateBtn.textContent = "重翻中……";
dom.applyRetranslationBtn.disabled = true;
dom.gptCandidate.hidden = false;
dom.gptCandidate.textContent = "正在请求 GPT……";
try {
const data = await api(`/api/row/${encodeURIComponent(currentRow.id)}/retranslate`, {
method: "POST",
body: JSON.stringify({
instruction: dom.gptInstructionInput.value,
}),
});
gptCandidateHtml = data.candidate_html || "";
dom.gptCandidate.innerHTML = gptCandidateHtml || escapeHtml(data.candidate_text || "");
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
toast("已生成重翻候选,可确认后应用。");
} catch (error) {
dom.gptCandidate.textContent = `重翻失败:${error.message}`;
toast(`重翻失败:${error.message}`, 9000);
} finally {
dom.retranslateBtn.disabled = false;
dom.retranslateBtn.textContent = "重翻本段";
}
}
function applyRetranslation() {
if (!gptCandidateHtml) return;
dom.quickCnEditor.innerHTML = gptCandidateHtml;
quickDirty = true;
dom.applyRetranslationBtn.disabled = true;
toast("已应用到编辑框,保存后才会写入审校记录。");
}
function resetCurrentText() {
if (!currentRow) return;
dom.cnEditor.innerHTML = currentRow.cn_html || "";
markDirty();
}
function markSelectionInEditor(editor, markedInput, issueInput) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
toast("请先在中文译文中选中要标记的文字。");
return false;
}
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) {
toast("只能标记中文译文区域内的文字。");
return false;
}
const mark = document.createElement("mark");
mark.className = "review-mark";
mark.setAttribute("data-review-mark", "bad-translation");
try {
range.surroundContents(mark);
} catch (_error) {
const fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
}
selection.removeAllRanges();
markedInput.checked = true;
if (!issueInput.value) issueInput.value = "翻译腔";
return true;
}
function markSelection() {
if (markSelectionInEditor(dom.cnEditor, dom.markedInput, dom.issueTypeInput)) markDirty();
}
function markQuickSelection() {
if (markSelectionInEditor(dom.quickCnEditor, dom.quickMarkedInput, dom.quickIssueTypeInput)) markQuickDirty();
}
function clearMarks() {
dom.cnEditor.querySelectorAll("mark, .review-mark, [data-review-mark]").forEach((node) => {
const parent = node.parentNode;
while (node.firstChild) parent.insertBefore(node.firstChild, node);
parent.removeChild(node);
parent.normalize();
});
markDirty();
}
async function go(delta) {
if (!filteredRows.length) return;
const index = currentIndex >= 0 ? currentIndex : filteredRows.findIndex((row) => currentRow && row.id === currentRow.id);
const next = Math.max(0, Math.min(filteredRows.length - 1, index + delta));
if (next === index) return;
await maybeSelectRow(filteredRows[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true });
}
async function goPart(delta) {
const scopes = allChapterScopes();
if (!scopes.length) return;
const currentScopeId = currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id;
const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId));
const next = Math.max(0, Math.min(scopes.length - 1, index + delta));
if (next === index) return;
await selectChapter(scopes[next].chapter);
}
async function generateFeedback() {
if (!hasSession) return;
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/feedback");
const downloadText = data.feedback_md_url ? `\n可在当前浏览器下载:${data.feedback_md_url}` : "";
toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}${downloadText}`, 9000);
}
async function applyBack() {
if (!hasSession) return;
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/apply", { method: "POST", body: "{}" });
toast(`已写回 EPUB 解包内容。\n修改文件数:${data.modified_files.length}\n反馈:${data.feedback_md}`, 9000);
}
async function exportEpub() {
if (!hasSession) return;
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/export", { method: "POST", body: "{}" });
toast(`已导出审校 EPUB\n${data.output}${data.download_url ? `\n可在当前浏览器下载:${data.download_url}` : ""}`, 10000);
}
function initEvents() {
[
dom.markedInput,
dom.cnEditor,
dom.issueTypeInput,
dom.severityInput,
dom.tagsInput,
dom.commentInput,
dom.learnNoteInput,
].forEach((el) => {
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
[
dom.quickMarkedInput,
dom.quickCnEditor,
dom.quickIssueTypeInput,
dom.quickSeverityInput,
dom.quickTagsInput,
dom.quickCommentInput,
dom.quickLearnNoteInput,
].forEach((el) => {
el.addEventListener("input", markQuickDirty);
el.addEventListener("change", markQuickDirty);
});
dom.searchInput.addEventListener("input", () => {
currentIndex = -1;
applyFilter(true);
});
dom.tocPanelBtn.addEventListener("click", () => {
if (sidePanelMode === "toc") {
closeSidePanel();
} else {
openSidePanel("toc");
}
});
dom.searchPanelBtn.addEventListener("click", () => {
if (sidePanelMode === "search") {
closeSidePanel();
} else {
openSidePanel("search");
}
});
dom.closeSidebarBtn.addEventListener("click", closeSidePanel);
dom.showTouchedBtn.addEventListener("click", () => {
showTouchedOnly = !showTouchedOnly;
dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly);
applyFilter(true);
});
dom.expandTocBtn.addEventListener("click", expandAllChapters);
dom.collapseTocBtn.addEventListener("click", collapseAllChapters);
dom.readingModeBtn.addEventListener("click", () => setMode("reading"));
dom.polishModeBtn.addEventListener("click", () => setMode("polish"));
dom.openLibraryBtn.addEventListener("click", async () => {
if (hasSession && !(await maybeSaveBeforeSwitch())) return;
await showStartScreen();
});
dom.prevPartBtn.addEventListener("click", () => goPart(-1));
dom.nextPartBtn.addEventListener("click", () => goPart(1));
dom.resetTextBtn.addEventListener("click", resetCurrentText);
dom.markSelectionBtn.addEventListener("click", markSelection);
dom.clearMarksBtn.addEventListener("click", clearMarks);
dom.saveBtn.addEventListener("click", () => saveCurrent(false));
dom.prevBtn.addEventListener("click", () => go(-1));
dom.nextBtn.addEventListener("click", () => go(1));
dom.feedbackBtn.addEventListener("click", generateFeedback);
dom.applyBtn.addEventListener("click", applyBack);
dom.exportBtn.addEventListener("click", exportEpub);
dom.uploadForm.addEventListener("submit", uploadEpub);
dom.epubFileInput.addEventListener("change", () => {
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
dom.selectedFileName.textContent = file ? file.name : "尚未选择文件";
});
dom.refreshSessionsBtn.addEventListener("click", () => {
refreshSessions().catch((error) => toast(`刷新失败:${error.message}`, 7000));
});
dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false));
dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false));
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
dom.toggleGptConfigBtn.addEventListener("click", () => {
dom.gptConfig.hidden = !dom.gptConfig.hidden;
if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {});
});
dom.saveGptConfigBtn.addEventListener("click", saveGptConfig);
dom.retranslateBtn.addEventListener("click", retranslateCurrentRow);
dom.applyRetranslationBtn.addEventListener("click", applyRetranslation);
dom.quickOpenFullBtn.addEventListener("click", async () => {
if (quickDirty) {
const ok = await saveCurrent(true);
if (!ok) return;
}
await setMode("polish", false);
});
document.addEventListener("keydown", async (event) => {
if (event.ctrlKey && event.key.toLowerCase() === "s") {
event.preventDefault();
await saveCurrent(false);
return;
}
if (event.target && ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return;
if (event.altKey && event.key === "ArrowLeft") {
event.preventDefault();
go(-1);
}
if (event.altKey && event.key === "ArrowRight") {
event.preventDefault();
go(1);
}
if (event.altKey && event.key === "ArrowUp") {
event.preventDefault();
goPart(-1);
}
if (event.altKey && event.key === "ArrowDown") {
event.preventDefault();
goPart(1);
}
if (event.key === "Escape" && !dom.quickEditor.hidden) {
event.preventDefault();
closeQuickEditor(false);
return;
}
if (event.key === "Escape" && sidePanelMode) {
event.preventDefault();
closeSidePanel();
}
});
window.addEventListener("beforeunload", (event) => {
if (!dirty && !quickDirty) return;
event.preventDefault();
event.returnValue = "";
});
}
async function main() {
initEvents();
try {
const session = await loadSession();
if (!session || !session.has_session) {
return;
}
await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可打开精修与重翻面板。" });
} catch (error) {
await showStartScreen();
toast(`启动时未载入 EPUB${error.message}`, 7000);
}
}
main();