1468 lines
48 KiB
JavaScript
1468 lines
48 KiB
JavaScript
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 = '<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();
|