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