bha-Search / static /app.js
vomebook's picture
Update PDF viewer and Service Worker
18cbf04 verified
Raw
History Blame Contribute Delete
213 kB
const STATE = {
q: "",
page: 1,
pageSize: 20,
exact: true,
previewHighlight: true,
fulltext: true,
sort: "relevance",
sources: [],
authors: [],
tags: [],
excludeSources: [],
excludeAuthors: [],
excludeTags: [],
archiveId: "",
publicationType: "",
dateFromYear: "",
dateFromMonth: "",
dateFromDay: "",
dateToYear: "",
dateToMonth: "",
dateToDay: "",
dateTerms: [{ year: "", month: "", day: "" }],
total: 0,
results: [],
facets: { sources: [], authors: [], tags: [], archives: [], types: [], years: [] },
facetTotals: { source: 0, author: 0, tag: 0 },
facetHasMore: { source: false, author: false, tag: false },
leftOpen: true,
preview: "",
compare: "",
proofread: false,
proofreadFile: "",
proofreadPage: "",
parseMode: false,
parsePage: 1,
parsePageSize: 100,
parseTotal: 0,
parseItems: [],
parseItem: null,
parseCurrentPage: 1,
isDark: true,
isMobile: false,
filterSearch: { source: "", author: "", tag: "" },
filterPage: { source: 1, author: 1, tag: 1 },
filterCollapsed: {},
sidebarExpanded: false,
};
const PERIOD_PRESETS = [
{ label: "新民主主义革命时期", from: "19190504", to: "19490930" },
{ label: "社会主义革命时期", from: "19491001", to: "19561231" },
{ label: "全面建设社会主义时期", from: "19560915", to: "19660515" },
{ label: "无产阶级文化大革命时期", from: "19660516", to: "19761006" },
{ label: "改开-南巡", from: "19781218", to: "19920117" },
{ label: "南巡-2012", from: "19920118", to: "20121114" },
{ label: "2012-", from: "20121115", to: "" },
];
const TAG_TYPES = ["文稿大类", "文稿类型", "地点", "人物", "出版方/发行方", "主题/事件", "记录", "审核", "翻译", "翻印/传抄"];
const PART_TYPES = ["appellation", "title", "authors", "place", "subtitle", "subtitle2", "subtitle3", "subtitle4", "subtitle5", "subdate", "paragraph", "quotation", "signature", "image", "image_description"];
const DOM = {};
const $ = (selector) => document.querySelector(selector);
const THEME_ICON = {
dark: '<svg class="theme-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 15.5A8 8 0 1 1 8.5 4 6.5 6.5 0 0 0 20 15.5Z"/></svg>',
light: '<svg class="theme-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg>',
};
let searchTimer = null;
let facetRetryTimer = null;
let progressTimer = null;
let searchRequestSeq = 0;
let searchAbortController = null;
let searchPrefetchAbortController = null;
let searchPrefetchCacheKey = "";
let bootstrapPending = false;
let pdfjsPromise = null;
let diffMatchPatchPromise = null;
const DIFF_MATCH_PATCH_URL = "https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js";
const DIFF_MATCH_PATCH_INTEGRITY = "sha512-5YZxUisfaneUbwv58nPp10qwt6DefHuJ+iAfvPoABZAYwLg4WGJHITaLpyyxJdrljfc0ggUoWc87Z0lfoDS84Q==";
const pdfDocumentCache = new Map();
const PDF_DOCUMENT_CACHE_LIMIT = 8;
const PDF_DOCUMENT_MOBILE_CACHE_LIMIT = 3;
const PDF_DOCUMENT_CACHE_IDLE_MS = 15 * 60 * 1000;
let pdfCacheCleanupTimer = 0;
let activePDFViewer = null;
let activeImageResizeObserver = null;
let activeImageFitFrame = 0;
let parseDraftTimer = 0;
let proofreadDraftTimer = 0;
const prefetchedPageImages = new Set();
let cancelPageImagePrefetch = null;
let pageImagePrefetchController = null;
const searchCache = new Map();
const SEARCH_CACHE_LIMIT = 60;
const SEARCH_CACHE_TTL = 8 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 12000;
const KEEPALIVE_INTERVAL_MS = 45 * 1000;
const KEEPALIVE_MIN_GAP_MS = 30 * 1000;
const facetPending = new Map();
const facetRetryCounts = new Map();
const facetInputTimers = { source: null, author: null, tag: null };
const facetRequestSeq = { source: 0, author: 0, tag: 0 };
const facetAbortControllers = { source: null, author: null, tag: null };
let sourcesAbortController = null;
const previewCache = new Map();
const parseOCRCache = new Map();
const PARSE_OCR_CACHE_LIMIT = 64;
const PREVIEW_CACHE_LIMIT = 20;
const PREVIEW_CACHE_MAX_CHARS = 2 * 1024 * 1024;
const PREVIEW_CACHE_TTL = 10 * 60 * 1000;
let previewCacheChars = 0;
let pageRecoveryTimer = null;
let previewRequestSeq = 0;
let previewAbortController = null;
let compareRequestSeq = 0;
let compareAbortController = null;
let randomRequestSeq = 0;
let randomAbortController = null;
let previewCloseUrl = "/";
let previewResultsScrollTop = 0;
let animateNextPreview = false;
let previewAnimationCleanup = null;
let parseCropDrag = null;
let activeYearInput = null;
let yearPickerPointer = null;
let searchComposing = false;
let keepalivePending = null;
let lastKeepaliveAt = Date.now();
function escapeHTML(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[ch]);
}
function showToast(message) {
DOM.toast.textContent = message;
DOM.toast.hidden = false;
DOM.toast.style.display = "";
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => {
DOM.toast.hidden = true;
DOM.toast.style.display = "none";
}, 2400);
}
function motionAllowed() {
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function parseUrl() {
const params = new URLSearchParams(location.search);
STATE.q = params.get("q") || "";
STATE.page = Math.max(1, parseInt(params.get("page") || "1", 10) || 1);
const requestedPageSize = parseInt(params.get("page_size") || "20", 10);
STATE.pageSize = [10, 20, 50].includes(requestedPageSize) ? requestedPageSize : 20;
STATE.exact = params.get("exact") !== "0";
STATE.previewHighlight = params.get("preview_highlight") !== "0";
STATE.fulltext = params.get("fulltext") !== "0";
STATE.sort = params.get("sort") || "relevance";
STATE.sources = params.getAll("source");
STATE.authors = params.getAll("author");
STATE.tags = params.getAll("tag");
STATE.excludeSources = params.getAll("exclude_source");
STATE.excludeAuthors = params.getAll("exclude_author");
STATE.excludeTags = params.getAll("exclude_tag");
STATE.archiveId = params.get("archive") || "";
STATE.publicationType = params.get("type") || "";
setDateParts("from", params.get("date_from") || "");
setDateParts("to", params.get("date_to") || "");
if (!params.has("date_from")) setDateRangeParts("from", params.get("date_range_from") || "");
if (!params.has("date_to")) setDateRangeParts("to", params.get("date_range_to") || "");
const dateTerms = params.getAll("date_term").map(parseDateTermParam).filter(Boolean);
STATE.dateTerms = uniqDateTerms(dateTerms);
if (!STATE.dateTerms.length) STATE.dateTerms = [{ year: "", month: "", day: "" }];
STATE.leftOpen = params.get("sidebar") !== "0";
STATE.sidebarExpanded = params.get("sidebar_wide") === "1";
if (!STATE.leftOpen) STATE.sidebarExpanded = false;
STATE.filterPage.source = Math.max(1, parseInt(params.get("source_page") || "1", 10) || 1);
STATE.filterPage.author = Math.max(1, parseInt(params.get("author_page") || "1", 10) || 1);
STATE.filterPage.tag = Math.max(1, parseInt(params.get("tag_page") || "1", 10) || 1);
STATE.preview = params.get("preview") || "";
STATE.compare = params.get("compare") || "";
STATE.proofread = params.get("proofread") === "1";
STATE.proofreadFile = params.get("source_file") || "";
STATE.proofreadPage = params.get("proofread_page") || "";
STATE.parseMode = params.get("parse") === "1";
STATE.parsePage = Math.max(1, parseInt(params.get("parse_page") || "1", 10) || 1);
const requestedParsePageSize = parseInt(params.get("parse_page_size") || "100", 10);
STATE.parsePageSize = [10, 20, 50, 100].includes(requestedParsePageSize) ? requestedParsePageSize : 100;
if (STATE.parseMode) {
STATE.leftOpen = !STATE.isMobile;
STATE.sidebarExpanded = false;
}
}
function syncUrl(replace = true) {
if (STATE.parseMode) {
const params = new URLSearchParams();
params.set("parse", "1");
if (STATE.parsePage > 1) params.set("parse_page", String(STATE.parsePage));
if (STATE.parsePageSize !== 100) params.set("parse_page_size", String(STATE.parsePageSize));
history[replace ? "replaceState" : "pushState"]({}, "", `/?${params.toString()}`);
return;
}
const params = new URLSearchParams();
if (STATE.q) params.set("q", STATE.q);
if (STATE.page > 1) params.set("page", String(STATE.page));
if (STATE.pageSize !== 20) params.set("page_size", String(STATE.pageSize));
if (!STATE.exact) params.set("exact", "0");
if (!STATE.previewHighlight) params.set("preview_highlight", "0");
if (!STATE.fulltext) params.set("fulltext", "0");
if (STATE.sort !== "relevance") params.set("sort", STATE.sort);
STATE.sources.forEach((source) => params.append("source", source));
STATE.authors.forEach((author) => params.append("author", author));
STATE.tags.forEach((tag) => params.append("tag", tag));
STATE.excludeSources.forEach((source) => params.append("exclude_source", source));
STATE.excludeAuthors.forEach((author) => params.append("exclude_author", author));
STATE.excludeTags.forEach((tag) => params.append("exclude_tag", tag));
if (STATE.archiveId) params.set("archive", STATE.archiveId);
if (STATE.publicationType) params.set("type", STATE.publicationType);
const dateFrom = dateValue("from");
const dateTo = dateValue("to");
if (dateFrom) params.set("date_from", String(dateFrom));
if (dateTo) params.set("date_to", String(dateTo));
if (!dateFrom && dateRangeParam("from")) params.set("date_range_from", dateRangeParam("from"));
if (!dateTo && dateRangeParam("to")) params.set("date_range_to", dateRangeParam("to"));
uniqDateTerms(STATE.dateTerms).map(dateTermParam).filter(Boolean).forEach((term) => params.append("date_term", term));
if (!STATE.leftOpen) params.set("sidebar", "0");
if (STATE.sidebarExpanded) params.set("sidebar_wide", "1");
if (STATE.filterPage.source > 1) params.set("source_page", String(STATE.filterPage.source));
if (STATE.filterPage.author > 1) params.set("author_page", String(STATE.filterPage.author));
if (STATE.filterPage.tag > 1) params.set("tag_page", String(STATE.filterPage.tag));
if (STATE.preview) params.set("preview", STATE.preview);
if (STATE.compare) params.set("compare", STATE.compare);
if (STATE.proofread) params.set("proofread", "1");
if (STATE.proofreadFile) params.set("source_file", STATE.proofreadFile);
if (STATE.proofreadPage) params.set("proofread_page", STATE.proofreadPage);
const url = params.toString() ? `/?${params.toString()}` : "/";
if (STATE.preview) {
const closeParams = new URLSearchParams(params);
closeParams.delete("preview");
previewCloseUrl = closeParams.toString() ? `/?${closeParams.toString()}` : "/";
}
const previewEntry = Boolean(STATE.preview && (!replace || (history.state && history.state.previewEntry === true)));
history[replace ? "replaceState" : "pushState"]({ previewEntry }, "", url);
}
function applyStateToDom() {
document.body.classList.toggle("light", !STATE.isDark);
document.body.classList.toggle("mobile", STATE.isMobile);
document.body.classList.toggle("force-desktop", !STATE.isMobile);
document.body.classList.toggle("preview-open", Boolean(STATE.preview));
document.body.classList.toggle("parse-mode", STATE.parseMode);
if (DOM.parseBtn) DOM.parseBtn.classList.toggle("primary", STATE.parseMode);
if (DOM.sidebarTitle) DOM.sidebarTitle.textContent = STATE.parseMode ? "解析队列" : "搜索过滤";
if (DOM.parsePageSizeOption) DOM.parsePageSizeOption.hidden = !STATE.parseMode;
DOM.themeBtn.innerHTML = THEME_ICON[STATE.isDark ? "dark" : "light"];
DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
if (DOM.mobileToggleIcon) DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop";
DOM.searchInput.value = STATE.q;
DOM.exactToggle.checked = STATE.exact;
DOM.previewHighlightToggle.checked = STATE.previewHighlight;
DOM.fulltextToggle.checked = STATE.fulltext;
DOM.sortFilter.value = STATE.sort;
DOM.pageSize.value = String(STATE.parseMode ? STATE.parsePageSize : STATE.pageSize);
DOM.archiveFilter.value = STATE.archiveId;
DOM.typeFilter.value = STATE.publicationType;
applyDateStateToDom();
DOM.clearFilters.hidden = !hasActiveFilters();
DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftOpen);
DOM.leftSidebar.classList.toggle("open", STATE.leftOpen);
DOM.leftSidebar.classList.toggle("expanded-wide", STATE.leftOpen && STATE.sidebarExpanded);
const sidebarOverlayOpen = STATE.isMobile && STATE.leftOpen;
DOM.overlay.style.display = "";
DOM.overlay.classList.toggle("open", sidebarOverlayOpen);
for (const [key, collapsed] of Object.entries(STATE.filterCollapsed)) {
const body = document.querySelector(`[data-filter-body="${key}"]`);
const toggle = document.querySelector(`[data-filter-toggle="${key}"]`);
if (body) body.classList.toggle("collapsed", collapsed);
if (toggle) toggle.classList.toggle("collapsed", collapsed);
}
const previewThemeButton = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-preview-theme]") : null;
if (previewThemeButton) {
previewThemeButton.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
previewThemeButton.title = STATE.isDark ? "白天模式" : "黑夜模式";
previewThemeButton.innerHTML = THEME_ICON[STATE.isDark ? "dark" : "light"];
}
const previewMobileButton = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-preview-mobile]") : null;
if (previewMobileButton) {
const mobileLabel = STATE.isMobile ? "切换到电脑模式" : "切换到手机模式";
previewMobileButton.setAttribute("aria-label", mobileLabel);
previewMobileButton.title = mobileLabel;
previewMobileButton.innerHTML = `<span class="ui-icon ${STATE.isMobile ? "ui-icon-phone" : "ui-icon-desktop"}" aria-hidden="true"></span>`;
}
}
function toggleTheme() {
STATE.isDark = !STATE.isDark;
localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
applyStateToDom();
}
function toggleMobileMode() {
STATE.isMobile = !STATE.isMobile;
localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop");
if (STATE.isMobile) {
STATE.leftOpen = false;
STATE.sidebarExpanded = false;
}
applyStateToDom();
relocateFulltextEditor();
trimPDFDocumentCache();
syncUrl();
}
function relocateFulltextEditor() {
if (!STATE.proofread) return;
const form = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-proofread-form]") : null;
if (!form) return;
const box = form.querySelector("[data-fulltext-box]");
const toggle = form.querySelector("[data-fulltext-toggle]");
if (!box || !toggle) return;
const header = form.querySelector(".proofread-pane-header");
if (STATE.isMobile) {
if (box.closest("[data-fulltext-section]")) return;
const section = document.createElement("div");
section.className = "proofread-fulltext-section";
section.dataset.fulltextSection = "true";
const actions = document.createElement("div");
actions.className = "proofread-form-actions";
actions.append(toggle);
section.append(actions, box);
if (header && header.nextSibling) header.after(section); else form.prepend(section);
} else {
const section = box.closest("[data-fulltext-section]");
if (!section) return;
const fieldset = Array.from(form.querySelectorAll("fieldset")).find((f) => f.querySelector("[data-part-editor]")) || form.querySelector("fieldset");
if (!fieldset) return;
section.remove();
const actions = fieldset.querySelector(".proofread-form-actions");
const partEditor = fieldset.querySelector("[data-part-editor]");
if (actions) {
const partToggle = fieldset.querySelector("[data-part-editor-toggle]");
actions.insertBefore(toggle, partToggle || null);
if (partEditor) fieldset.insertBefore(box, partEditor);
} else {
fieldset.prepend(toggle);
fieldset.insertBefore(box, toggle.nextSibling);
}
}
}
function hasActiveFilters() {
return Boolean(
STATE.sources.length ||
STATE.authors.length ||
STATE.tags.length ||
STATE.excludeSources.length ||
STATE.excludeAuthors.length ||
STATE.excludeTags.length ||
STATE.archiveId ||
STATE.publicationType ||
hasDateFilter()
);
}
function setDateParts(side, value) {
const text = String(value || "").replace(/\D/g, "");
const prefix = side === "from" ? "dateFrom" : "dateTo";
STATE[`${prefix}Year`] = text.length >= 4 ? cleanYear(text.slice(0, 4)) : "";
STATE[`${prefix}Month`] = text.length >= 6 ? String(parseInt(text.slice(4, 6), 10)) : "";
STATE[`${prefix}Day`] = text.length >= 8 ? String(parseInt(text.slice(6, 8), 10)) : "";
}
function cleanYear(value) {
const text = String(value || "").replace(/\D/g, "").slice(0, 4);
const number = parseInt(text, 10);
return Number.isFinite(number) && number >= 1 && number <= 9999 ? String(number) : "";
}
function setDateRangeParts(side, value) {
const term = parseDateTermParam(value);
const prefix = side === "from" ? "dateFrom" : "dateTo";
STATE[`${prefix}Year`] = term ? (term.year || "") : "";
STATE[`${prefix}Month`] = term ? (term.month || "") : "";
STATE[`${prefix}Day`] = term ? (term.day || "") : "";
}
function hasDateFilter() {
return Boolean(
STATE.dateFromYear ||
STATE.dateFromMonth ||
STATE.dateFromDay ||
STATE.dateToYear ||
STATE.dateToMonth ||
STATE.dateToDay ||
STATE.dateTerms.some((term) => term.year || term.month || term.day)
);
}
function daysInMonth(year, month) {
return new Date(Number(year), Number(month), 0).getDate();
}
function padded(value) {
return String(value).padStart(2, "0");
}
function dateValue(side) {
const prefix = side === "from" ? "dateFrom" : "dateTo";
const year = cleanYear(STATE[`${prefix}Year`]);
if (!year || !STATE[`${prefix}Month`] || !STATE[`${prefix}Day`]) return null;
const month = STATE[`${prefix}Month`];
const day = STATE[`${prefix}Day`];
return parseInt(`${year}${padded(month)}${padded(day)}`, 10);
}
function rangeDateValue(side) {
const prefix = side === "from" ? "dateFrom" : "dateTo";
const year = cleanYear(STATE[`${prefix}Year`]);
if (!year) return null;
const month = STATE[`${prefix}Month`] || (side === "from" ? "1" : "12");
const day = STATE[`${prefix}Day`] || (side === "from" ? "1" : String(daysInMonth(year, month)));
return parseInt(`${year}${padded(month)}${padded(day)}`, 10);
}
function dateKey(side) {
const value = dateValue(side);
return value ? String(value) : "";
}
function validateDateRange({ show = true } = {}) {
const from = rangeDateValue("from");
const to = rangeDateValue("to");
const invalid = Boolean(from && to && from > to);
if (DOM.dateError) {
DOM.dateError.hidden = !invalid;
DOM.dateError.textContent = invalid ? "开始年份不能晚于结束年份" : "";
}
if (invalid && show) showToast("开始年份不能晚于结束年份");
return !invalid;
}
function clearDateFilter() {
setDateParts("from", "");
setDateParts("to", "");
STATE.dateTerms = [{ year: "", month: "", day: "" }];
}
function yearItems() {
const isFourDigitYear = (value) => /^\d{4}$/.test(String(value));
const years = (STATE.facets.years || []).map((item) => String(item.value == null ? item.name : item.value)).filter(isFourDigitYear);
const selectedYears = [STATE.dateFromYear, STATE.dateToYear, ...STATE.dateTerms.map((term) => term.year)].filter(isFourDigitYear);
const values = years.length ? years : Array.from({ length: 183 }, (_, index) => String(1843 + index));
return [...new Set([...values, ...selectedYears])].sort((a, b) => Number(a) - Number(b));
}
function selectOptions(values, selected, emptyLabel) {
const options = [`<option value="">${emptyLabel}</option>`];
for (const value of values) {
const text = String(value);
options.push(`<option value="${escapeHTML(text)}" ${String(selected) === text ? "selected" : ""}>${escapeHTML(text)}</option>`);
}
return options.join("");
}
function dayOptions(year, month, selected) {
const max = year && month ? daysInMonth(year, month) : 31;
return selectOptions(Array.from({ length: max }, (_, index) => index + 1), selected, "日");
}
function renderPeriodPresets() {
const currentFrom = dateKey("from");
const currentTo = dateKey("to");
DOM.periodPresets.innerHTML = PERIOD_PRESETS.map((period) => {
const active = currentFrom === period.from && currentTo === period.to;
return `<button type="button" class="period-btn ${active ? "active" : ""}" data-period-from="${period.from}" data-period-to="${period.to}">${escapeHTML(period.label)}</button>`;
}).join("");
}
function renderDateControls() {
DOM.dateFromYear.value = STATE.dateFromYear;
DOM.dateToYear.value = STATE.dateToYear;
DOM.dateFromMonth.innerHTML = selectOptions(Array.from({ length: 12 }, (_, index) => index + 1), STATE.dateFromMonth, "月");
DOM.dateToMonth.innerHTML = selectOptions(Array.from({ length: 12 }, (_, index) => index + 1), STATE.dateToMonth, "月");
DOM.dateFromDay.innerHTML = dayOptions(STATE.dateFromYear, STATE.dateFromMonth, STATE.dateFromDay);
DOM.dateToDay.innerHTML = dayOptions(STATE.dateToYear, STATE.dateToMonth, STATE.dateToDay);
DOM.dateFromMonth.disabled = !STATE.dateFromYear;
DOM.dateToMonth.disabled = !STATE.dateToYear;
DOM.dateFromDay.disabled = !STATE.dateFromYear || !STATE.dateFromMonth;
DOM.dateToDay.disabled = !STATE.dateToYear || !STATE.dateToMonth;
DOM.dateClear.hidden = !hasDateFilter();
renderPeriodPresets();
renderDateTerms();
validateDateRange({ show: false });
}
function parseDateTermParam(value) {
const parts = String(value || "").split("-");
const term = { year: "", month: "", day: "", display: "" };
for (const part of parts) {
const [kind, rawValue] = part.split(":");
if (kind === "display") {
term.display = decodeURIComponent(rawValue || "");
continue;
}
if (!["year", "month", "day"].includes(kind)) continue;
const number = parseInt(rawValue, 10);
if (!Number.isFinite(number)) continue;
if (kind === "year" && number >= 1 && number <= 9999) term.year = String(number);
if (kind === "month" && number >= 1 && number <= 12) term.month = String(number);
if (kind === "day" && number >= 1 && number <= 31) term.day = String(number);
}
return term.year || term.month || term.day || term.display ? term : null;
}
function dateTermParam(term) {
const parts = [];
const year = cleanYear(term.year);
if (year) parts.push(`year:${year}`);
if (term.month) parts.push(`month:${term.month}`);
if (term.day) parts.push(`day:${term.day}`);
if (term.display) parts.push(`display:${encodeURIComponent(term.display)}`);
return parts.join("-");
}
function normalizedDateTerm(term) {
const monthNumber = term && term.month ? parseInt(term.month, 10) : null;
const dayNumber = term && term.day ? parseInt(term.day, 10) : null;
return {
year: cleanYear(term && term.year) || "",
month: monthNumber && monthNumber >= 1 && monthNumber <= 12 ? String(monthNumber) : "",
day: dayNumber && dayNumber >= 1 && dayNumber <= 31 ? String(dayNumber) : "",
display: typeof (term && term.display) === "string" ? term.display : "",
};
}
function dateTermKey(term) {
const normalized = normalizedDateTerm(term);
return normalized.display ? `display|${normalized.display}` : `${normalized.year}|${normalized.month}|${normalized.day}`;
}
function uniqDateTerms(terms) {
const seen = new Set();
const result = [];
for (const term of terms || []) {
const normalized = normalizedDateTerm(term);
if (!(normalized.year || normalized.month || normalized.day || normalized.display)) continue;
const key = dateTermKey(normalized);
if (seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
function parseDisplayDateTerm(value) {
const parts = String(value || "").split("/").map((part) => part.trim());
let year = "";
let month = "";
let day = "";
if (parts.length >= 3) {
year = parts[0] && parts[0] !== "----" ? cleanYear(parts[0]) : "";
const monthNumber = parts[1] && parts[1] !== "--" ? parseInt(parts[1], 10) : null;
const dayNumber = parts[2] && parts[2] !== "--" ? parseInt(parts[2], 10) : null;
month = monthNumber && monthNumber >= 1 && monthNumber <= 12 ? String(monthNumber) : "";
day = dayNumber && dayNumber >= 1 && dayNumber <= 31 ? String(dayNumber) : "";
} else {
const numbers = String(value || "").match(/\d{1,4}/g) || [];
if (numbers.length >= 2) {
const first = parseInt(numbers[0], 10);
const second = parseInt(numbers[1], 10);
if (first >= 1 && first <= 12 && second >= 1 && second <= 31) {
month = String(first);
day = String(second);
} else {
year = cleanYear(numbers[0]);
month = second >= 1 && second <= 12 ? String(second) : "";
}
} else if (numbers.length === 1) {
year = cleanYear(numbers[0]);
}
}
return year || month || day ? { year, month, day } : null;
}
function resultDateTermKey(value) {
const text = String(value || "").trim();
const parsed = parseDisplayDateTerm(text);
return parsed ? dateTermKey(parsed) : (text ? `display|${text}` : "");
}
function resultDateTermPayload(value) {
const display = String(value || "").trim();
if (!display) return null;
return parseDisplayDateTerm(display) || { year: null, month: null, day: null, display };
}
function dateRangeParam(side) {
const prefix = side === "from" ? "dateFrom" : "dateTo";
return dateTermParam({
year: STATE[`${prefix}Year`],
month: STATE[`${prefix}Month`],
day: STATE[`${prefix}Day`],
});
}
function renderDateTerms() {
if (!DOM.dateTerms) return;
DOM.dateTerms.innerHTML = STATE.dateTerms.map((term, index) => {
const monthOptions = selectOptions(Array.from({ length: 12 }, (_, item) => item + 1), term.month || "", "月");
const dayOptionsHtml = selectOptions(Array.from({ length: 31 }, (_, item) => item + 1), term.day || "", "日");
return `<div class="date-term-row" data-date-term-index="${index}"><input class="date-select year-input" data-date-term-field="year" inputmode="numeric" placeholder="年" autocomplete="off" value="${escapeHTML(term.year || "")}"><select class="date-select" data-date-term-field="month">${monthOptions}</select><select class="date-select" data-date-term-field="day">${dayOptionsHtml}</select><button type="button" class="text-btn-sm" data-date-term-remove="${index}">删除</button></div>`;
}).join("");
}
function yearInputFromTarget(target) {
const input = target && target.closest ? target.closest(".year-input") : null;
return input instanceof HTMLInputElement ? input : null;
}
function showYearPicker(input) {
activeYearInput = input;
const keyword = input.value.trim();
const years = yearItems().filter((year) => !keyword || String(year).includes(keyword));
DOM.yearPicker.innerHTML = years.length
? years.map((year) => `<button type="button" class="year-option ${String(year) === input.value ? "active" : ""}" data-year-value="${escapeHTML(year)}">${escapeHTML(year)}</button>`).join("")
: '<div class="date-empty">无匹配年份</div>';
const rect = input.getBoundingClientRect();
const gap = 4;
const viewportPadding = 8;
const width = Math.max(rect.width, 92);
const preferredHeight = Math.min(360, Math.floor(window.innerHeight * 0.52));
const spaceBelow = window.innerHeight - rect.bottom - gap - viewportPadding;
const spaceAbove = rect.top - gap - viewportPadding;
const openAbove = spaceBelow < Math.min(180, preferredHeight) && spaceAbove > spaceBelow;
const availableHeight = Math.max(80, Math.min(preferredHeight, openAbove ? spaceAbove : spaceBelow));
const left = Math.max(viewportPadding, Math.min(rect.left, window.innerWidth - width - viewportPadding));
DOM.yearPicker.style.left = `${left}px`;
DOM.yearPicker.style.width = `${width}px`;
DOM.yearPicker.style.maxHeight = `${availableHeight}px`;
DOM.yearPicker.style.top = openAbove ? "auto" : `${rect.bottom + gap}px`;
DOM.yearPicker.style.bottom = openAbove ? `${window.innerHeight - rect.top + gap}px` : "auto";
DOM.yearPicker.hidden = false;
}
function repositionYearPicker() {
if (activeYearInput && !DOM.yearPicker.hidden) showYearPicker(activeYearInput);
}
function hideYearPicker() {
DOM.yearPicker.hidden = true;
activeYearInput = null;
}
function handleYearPickerScroll(event) {
if (event.target === DOM.yearPicker || DOM.yearPicker.contains(event.target)) return;
if (activeYearInput && document.activeElement === activeYearInput) {
repositionYearPicker();
return;
}
hideYearPicker();
}
function chooseYear(input, year) {
input.value = year;
input.dispatchEvent(new Event("change", { bubbles: true }));
hideYearPicker();
}
function applyDateStateToDom() {
if (DOM.dateFromYear) renderDateControls();
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function queryTerms() {
const q = STATE.q.trim();
return q.split(/\s+/).map((item) => item.trim()).filter(Boolean);
}
function highlightPreviewText(text) {
let html = escapeHTML(text);
if (!STATE.previewHighlight || !STATE.q.trim()) return html;
if (STATE.exact) {
const pattern = new RegExp(escapeRegExp(escapeHTML(STATE.q.trim())), "g");
return html.replace(pattern, (match) => match ? `<mark>${match}</mark>` : match);
}
const terms = [...new Set(queryTerms().map(escapeHTML).filter(Boolean))].sort((a, b) => b.length - a.length);
if (!terms.length) return html;
const pattern = new RegExp(terms.map(escapeRegExp).join("|"), "g");
return html.replace(pattern, (match) => `<mark>${match}</mark>`);
}
function cancelPreviewRequests() {
previewRequestSeq += 1;
compareRequestSeq += 1;
randomRequestSeq += 1;
if (previewAbortController) previewAbortController.abort();
if (compareAbortController) compareAbortController.abort();
if (randomAbortController) randomAbortController.abort();
previewAbortController = null;
compareAbortController = null;
randomAbortController = null;
}
function clearPreviewDom() {
flushProofreadDraft();
clearActiveMediaViewer();
stopPreviewAnimation();
document.body.classList.remove("preview-open");
DOM.previewPanel.hidden = true;
DOM.previewPanel.style.display = "none";
DOM.previewPanel.innerHTML = "";
DOM.previewPanel.classList.remove("proofread-mode");
animateNextPreview = false;
}
function closePreview(useHistory = false) {
flushProofreadDraft();
cancelPreviewRequests();
if (useHistory && history.state && history.state.previewEntry === true) {
history.back();
return;
}
STATE.preview = "";
STATE.compare = "";
STATE.proofread = false;
STATE.proofreadItem = null;
STATE.proofreadFile = "";
STATE.proofreadPage = "";
clearPreviewDom();
renderResults();
syncUrl();
}
function restoreResultsScroll(top) {
requestAnimationFrame(() => {
DOM.results.scrollTop = top;
requestAnimationFrame(() => { DOM.results.scrollTop = top; });
});
}
async function api(path, options) {
const response = await fetch(path, options);
if (!response.ok) {
let detail = "";
try {
const body = await response.json();
if (body && body.error) detail = `:${body.error}`;
} catch (_error) {
// keep the status-only message
}
throw new Error(`HTTP ${response.status}${detail}`);
}
return response.json();
}
async function apiWithTimeout(path, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
const controller = new AbortController();
const externalSignal = options.signal;
const abortFromExternal = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener("abort", abortFromExternal, { once: true });
}
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const nextOptions = Object.assign({}, options, { signal: controller.signal });
try {
return await api(path, nextOptions);
} finally {
clearTimeout(timeoutId);
if (externalSignal) externalSignal.removeEventListener("abort", abortFromExternal);
}
}
async function healthWithTimeout(timeoutMs = REQUEST_TIMEOUT_MS) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch("/api/health", { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
function warmConnection(force = false) {
if (document.hidden || !navigator.onLine || keepalivePending) return keepalivePending;
const now = Date.now();
if (!force && now - lastKeepaliveAt < KEEPALIVE_MIN_GAP_MS) return null;
lastKeepaliveAt = now;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
keepalivePending = fetch("/api/ping", {
cache: "no-store",
signal: controller.signal,
}).catch(() => null).finally(() => {
clearTimeout(timeoutId);
keepalivePending = null;
});
return keepalivePending;
}
function stableStringify(value) {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function getCachedSearch(key) {
if (!searchCache.has(key)) return null;
const entry = searchCache.get(key);
if (!entry || Date.now() - entry.time > SEARCH_CACHE_TTL) {
searchCache.delete(key);
return null;
}
searchCache.delete(key);
searchCache.set(key, entry);
return entry.value;
}
function setCachedSearch(key, value) {
searchCache.set(key, { value, time: Date.now() });
while (searchCache.size > SEARCH_CACHE_LIMIT) {
searchCache.delete(searchCache.keys().next().value);
}
}
function getCachedPreview(docId) {
if (!previewCache.has(docId)) return null;
const entry = previewCache.get(docId);
if (!entry || Date.now() - entry.time > PREVIEW_CACHE_TTL) {
if (entry) previewCacheChars -= String(entry.value && entry.value.content ? entry.value.content : "").length;
previewCache.delete(docId);
return null;
}
previewCache.delete(docId);
previewCache.set(docId, entry);
return entry.value;
}
function setCachedPreview(docId, value) {
const size = String(value && value.content ? value.content : "").length;
if (size > 500000) return;
if (previewCache.has(docId)) {
const previous = previewCache.get(docId);
previewCacheChars -= String(previous.value && previous.value.content ? previous.value.content : "").length;
}
previewCache.set(docId, { value, time: Date.now() });
previewCacheChars += size;
while (previewCache.size > PREVIEW_CACHE_LIMIT || previewCacheChars > PREVIEW_CACHE_MAX_CHARS) {
const firstKey = previewCache.keys().next().value;
const firstValue = previewCache.get(firstKey);
previewCacheChars -= String(firstValue.value && firstValue.value.content ? firstValue.value.content : "").length;
previewCache.delete(firstKey);
}
}
function searchPayload() {
const dateFrom = rangeDateValue("from");
const dateTo = rangeDateValue("to");
return {
q: STATE.q,
page: STATE.page,
page_size: STATE.pageSize,
exact: STATE.exact,
fulltext: STATE.fulltext,
sort: STATE.sort,
sources: STATE.sources,
authors: STATE.authors,
tags: STATE.tags,
exclude_sources: STATE.excludeSources,
exclude_authors: STATE.excludeAuthors,
exclude_tags: STATE.excludeTags,
archive_id: STATE.archiveId ? parseInt(STATE.archiveId, 10) : null,
publication_type: STATE.publicationType || null,
date_from: dateFrom,
date_to: dateTo,
date_terms: STATE.dateTerms
.filter((term) => term.year || term.month || term.day || term.display)
.map((term) => ({
year: term.year ? parseInt(term.year, 10) : null,
month: term.month ? parseInt(term.month, 10) : null,
day: term.day ? parseInt(term.day, 10) : null,
display: term.display || null,
})),
};
}
function abortSearchPrefetch() {
if (searchPrefetchAbortController) searchPrefetchAbortController.abort();
searchPrefetchAbortController = null;
searchPrefetchCacheKey = "";
}
function prefetchNextSearchPage(basePayload) {
if (!STATE.total || STATE.page >= Math.ceil(STATE.total / STATE.pageSize)) return;
const payload = Object.assign({}, basePayload || searchPayload(), { page: STATE.page + 1 });
const cacheKey = stableStringify(payload);
if (getCachedSearch(cacheKey)) return;
if (searchPrefetchAbortController && searchPrefetchCacheKey === cacheKey) return;
abortSearchPrefetch();
const controller = new AbortController();
searchPrefetchAbortController = controller;
searchPrefetchCacheKey = cacheKey;
apiWithTimeout("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}, 25000)
.then((data) => {
if (!controller.signal.aborted && data.index_status === "ready" && !data.indexing) setCachedSearch(cacheKey, data);
})
.catch((error) => {
if (error.name !== "AbortError") console.warn("搜索预取失败", error);
})
.finally(() => {
if (searchPrefetchAbortController === controller) {
searchPrefetchAbortController = null;
searchPrefetchCacheKey = "";
}
});
}
function scheduleNextSearchPagePrefetch(basePayload) {
const cacheKey = stableStringify(basePayload);
const run = () => {
if (cacheKey === stableStringify(searchPayload())) prefetchNextSearchPage(basePayload);
};
if ("requestIdleCallback" in window) window.requestIdleCallback(run, { timeout: 1500 });
else window.setTimeout(run, 0);
}
function canUseInitialBootstrap() {
const payload = searchPayload();
return payload.q === "" && payload.page === 1 && payload.page_size === 20
&& payload.exact === true && payload.fulltext === true && payload.sort === "relevance"
&& !payload.source && !payload.author && !payload.tag && !payload.archive_id
&& !payload.publication_type && !payload.date_from && !payload.date_to
&& !payload.sources.length && !payload.authors.length && !payload.tags.length
&& !payload.exclude_sources.length && !payload.exclude_authors.length && !payload.exclude_tags.length
&& !payload.date_terms.length;
}
async function loadInitialBootstrap() {
bootstrapPending = true;
DOM.statusText.textContent = "搜索中...";
DOM.emptyState.style.display = "none";
try {
const earlyBootstrap = window.__bhaBootstrapPromise;
delete window.__bhaBootstrapPromise;
const data = earlyBootstrap ? await earlyBootstrap : await apiWithTimeout("/api/bootstrap", {}, 25000);
if (!data) throw new Error("bootstrap is unavailable");
const search = data.search || {};
if (search.index_status !== "ready" || search.indexing) throw new Error("bootstrap is not ready");
STATE.results = search.results || [];
STATE.total = Number(search.total || 0);
STATE.pageSize = Number(search.page_size || 20);
const sources = data.sources || {};
const facets = data.facets || {};
STATE.facets = {
sources: sortFacetItems((facets.source || {}).items || []),
authors: sortFacetItems((facets.author || {}).items || []),
tags: sortFacetItems((facets.tag || {}).items || []),
archives: sortFacetItems(sources.archives || []),
types: sortFacetItems(sources.types || []),
years: sortFacetItems(sources.years || []),
};
for (const kind of ["source", "author", "tag"]) {
STATE.facetTotals[kind] = Number((facets[kind] || {}).total || 0);
STATE.facetHasMore[kind] = Boolean((facets[kind] || {}).has_more);
}
setCachedSearch(stableStringify(searchPayload()), search);
renderFilterOptions({ preserveScroll: false });
renderResults({ animate: true });
DOM.results.scrollTop = 0;
DOM.statusText.textContent = "";
DOM.indexProgress.textContent = "";
scheduleNextSearchPagePrefetch(searchPayload());
} catch (_error) {
await Promise.all([doSearch(), loadFacets()]);
} finally {
bootstrapPending = false;
}
}
async function loadFacets() {
const pendingKey = "sources";
if (facetPending.has(pendingKey)) return facetPending.get(pendingKey);
const pending = loadFacetsNow().finally(() => facetPending.delete(pendingKey));
facetPending.set(pendingKey, pending);
return pending;
}
async function loadFacetsNow() {
if (sourcesAbortController) sourcesAbortController.abort();
sourcesAbortController = new AbortController();
const controller = sourcesAbortController;
try {
const data = await apiWithTimeout("/api/sources", { signal: controller.signal });
if (Array.isArray(data)) {
STATE.facets.sources = sortFacetItems(data);
} else {
STATE.facets = {
sources: STATE.facets.sources,
authors: STATE.facets.authors,
tags: STATE.facets.tags,
archives: sortFacetItems(data.archives || []),
types: sortFacetItems(data.types || []),
years: sortFacetItems(data.years || []),
};
}
renderFilterOptions({ preserveScroll: false });
facetRetryCounts.delete("sources");
loadFacetPage("source");
loadFacetPage("author");
loadFacetPage("tag");
clearTimeout(facetRetryTimer);
if (data.index_status === "failed") {
DOM.statusText.textContent = "索引失败,请看日志";
return;
}
if (data.indexing) {
if (!STATE.facets.sources.length && !STATE.facets.authors.length && !STATE.facets.tags.length) {
DOM.sourceFilter.innerHTML = '<div class="sidebar-loading">索引构建中...</div>';
}
clearTimeout(facetRetryTimer);
facetRetryTimer = setTimeout(loadFacets, 5000);
}
} catch (error) {
const tries = facetRetryCounts.get("sources") || 0;
if (tries < 2) {
facetRetryCounts.set("sources", tries + 1);
DOM.sourceFilter.innerHTML = '<div class="sidebar-loading">来源加载失败,正在重试...</div>';
setTimeout(loadFacets, 1200);
} else {
DOM.sourceFilter.innerHTML = '<div class="sidebar-loading">来源加载失败</div>';
}
} finally {
if (sourcesAbortController === controller) sourcesAbortController = null;
}
}
async function loadFacetPage(kind, { preserveScroll = false } = {}) {
const page = STATE.filterPage[kind];
const keyword = STATE.filterSearch[kind] || "";
const key = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags";
const pendingKey = `${kind}|${page}|${keyword}`;
if (facetPending.has(pendingKey)) return facetPending.get(pendingKey);
const requestSeq = (facetRequestSeq[kind] || 0) + 1;
facetRequestSeq[kind] = requestSeq;
let data;
let controller = null;
const pending = (async () => {
try {
if (facetAbortControllers[kind]) facetAbortControllers[kind].abort();
controller = new AbortController();
facetAbortControllers[kind] = controller;
data = await apiWithTimeout(`/api/facet/${kind}?page=${encodeURIComponent(page)}&page_size=200&q=${encodeURIComponent(keyword)}`, { signal: controller.signal });
} catch (error) {
if (requestSeq !== facetRequestSeq[kind]) return;
const tries = facetRetryCounts.get(pendingKey) || 0;
if (tries < 2) {
facetRetryCounts.set(pendingKey, tries + 1);
const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter;
filter.innerHTML = '<div class="sidebar-loading">筛选加载失败,正在重试...</div>';
setTimeout(() => loadFacetPage(kind, { preserveScroll }), 1200);
} else if (!STATE.facets[key].length) {
const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter;
filter.innerHTML = '<div class="sidebar-loading">筛选加载失败</div>';
}
return;
}
if (requestSeq !== facetRequestSeq[kind]) return;
facetRetryCounts.delete(pendingKey);
STATE.facets[key] = sortFacetItems(data.items || []);
STATE.facetTotals[kind] = Number(data.total || 0);
STATE.facetHasMore[kind] = Boolean(data.has_more);
renderFacetFilter(kind, { preserveScroll });
})().finally(() => {
facetPending.delete(pendingKey);
if (facetAbortControllers[kind] === controller) facetAbortControllers[kind] = null;
});
facetPending.set(pendingKey, pending);
return pending;
}
function sortFacetItems(items) {
return [...items].sort((a, b) => String(a.name == null ? (a.value == null ? "" : a.value) : a.name).localeCompare(String(b.name == null ? (b.value == null ? "" : b.value) : b.name), "zh-Hans-CN", { numeric: true, sensitivity: "base" }));
}
function optionHTML(items, selected, includeEmpty = true) {
const options = includeEmpty ? ['<option value="">全部</option>'] : [];
for (const item of items) {
const value = String(item.value == null ? item.name : item.value);
options.push(`<option value="${escapeHTML(value)}" ${selected === value ? "selected" : ""}>${escapeHTML(item.name)} (${Number(item.count || 0).toLocaleString()})</option>`);
}
return options.join("");
}
function filterScrollPositions() {
return {
source: (DOM.sourceFilter.querySelector(".filter-choice-options") || { scrollTop: 0 }).scrollTop || 0,
author: (DOM.authorFilter.querySelector(".filter-choice-options") || { scrollTop: 0 }).scrollTop || 0,
tag: (DOM.tagFilter.querySelector(".filter-choice-options") || { scrollTop: 0 }).scrollTop || 0,
};
}
function restoreFilterScrollPositions(positions) {
for (const [kind, top] of Object.entries(positions)) {
const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter;
const options = filter.querySelector(".filter-choice-options");
if (options) options.scrollTop = top;
}
}
function renderFilterOptions({ preserveScroll = true } = {}) {
const scrollPositions = preserveScroll ? filterScrollPositions() : null;
DOM.sourceFilter.innerHTML = choiceListHTML(STATE.facets.sources, STATE.sources, STATE.excludeSources, "source", 200, STATE.filterPage.source, STATE.facetTotals.source, STATE.facetHasMore.source);
DOM.authorFilter.innerHTML = choiceListHTML(STATE.facets.authors, STATE.authors, STATE.excludeAuthors, "author", 200, STATE.filterPage.author, STATE.facetTotals.author, STATE.facetHasMore.author);
DOM.tagFilter.innerHTML = choiceListHTML(STATE.facets.tags, STATE.tags, STATE.excludeTags, "tag", 200, STATE.filterPage.tag, STATE.facetTotals.tag, STATE.facetHasMore.tag);
DOM.archiveFilter.innerHTML = optionHTML(STATE.facets.archives, STATE.archiveId);
DOM.typeFilter.innerHTML = optionHTML(STATE.facets.types, STATE.publicationType);
renderDateControls();
if (scrollPositions) restoreFilterScrollPositions(scrollPositions);
}
function renderFacetFilter(kind, { preserveScroll = true } = {}) {
const dataKey = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags";
const includeKey = dataKey;
const excludeKey = kind === "source" ? "excludeSources" : kind === "author" ? "excludeAuthors" : "excludeTags";
const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter;
const scrollTopNode = filter.querySelector(".filter-choice-options");
const scrollTop = preserveScroll ? (scrollTopNode ? scrollTopNode.scrollTop : 0) : 0;
filter.innerHTML = choiceListHTML(STATE.facets[dataKey], STATE[includeKey], STATE[excludeKey], kind, 200, STATE.filterPage[kind], STATE.facetTotals[kind], STATE.facetHasMore[kind]);
if (preserveScroll) {
const options = filter.querySelector(".filter-choice-options");
if (options) options.scrollTop = scrollTop;
}
}
function choiceListHTML(items, selectedValues, excludedValues, kind, limit = 80, page = 1, remoteTotal = null, hasMore = false) {
const selectedSet = new Set(selectedValues);
const excludedSet = new Set(excludedValues);
const usableItems = items.filter((item) => String(item.name == null ? "" : item.name).trim());
const itemByName = new Map(usableItems.map((item) => [String(item.name == null ? "" : item.name), item]));
const pinnedNames = [...new Set([...selectedValues, ...excludedValues].map((value) => String(value || "")).filter(Boolean))];
const pinnedNameSet = new Set(pinnedNames);
const pinnedItems = pinnedNames.map((name) => itemByName.get(name) || { name });
const visibleItems = usableItems.filter((item) => !pinnedNameSet.has(String(item.name == null ? "" : item.name)));
const total = remoteTotal == null ? usableItems.length : remoteTotal;
const totalPages = remoteTotal === null ? null : (limit ? Math.max(1, Math.ceil(total / limit)) : 1);
const safePage = totalPages ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
if (kind in STATE.filterPage && STATE.filterPage[kind] !== safePage) STATE.filterPage[kind] = safePage;
const pageNames = usableItems.map((item) => String(item.name == null ? "" : item.name)).filter(Boolean);
const allPageSelected = Boolean(pageNames.length) && pageNames.every((name) => selectedSet.has(name));
const allPageExcluded = Boolean(pageNames.length) && pageNames.every((name) => excludedSet.has(name));
const hasState = Boolean(selectedValues.length || excludedValues.length);
let optionsHTML = `<div class="filter-choice-item filter-choice-all filter-choice-page-action ${allPageSelected ? "active" : ""} ${allPageExcluded ? "excluded" : ""}" data-cycle-${kind}-page=""><span>全选本页</span>${hasState ? `<button type="button" class="text-btn-sm" data-clear-${kind}="">取消</button>` : ""}</div>`;
for (const item of [...pinnedItems, ...visibleItems]) {
const name = String(item.name);
const checked = selectedSet.has(name);
const excluded = excludedSet.has(name);
const countHTML = item.count == null ? '<span class="checkbox-count"></span>' : `<span class="checkbox-count">${Number(item.count || 0).toLocaleString()}</span>`;
optionsHTML += `<div class="filter-choice-item ${checked ? "active" : ""} ${excluded ? "excluded" : ""}" data-${kind}="${escapeHTML(name)}"><span class="filter-choice-left"><span class="tri-state-box"></span><span>${escapeHTML(name)}</span></span>${countHTML}</div>`;
}
let html = `<div class="filter-choice-options">${optionsHTML}</div>`;
if (limit && (totalPages === null || total > limit || safePage > 1)) {
html += `<div class="filter-choice-pager"><button type="button" class="text-btn-sm" data-${kind}-page="prev" ${safePage <= 1 ? "disabled" : ""}>上一页</button><span class="page-jump">第 <input class="page-input filter-page-input" type="number" min="1" value="${safePage}" data-${kind}-page-input="" ${totalPages ? `max="${totalPages}"` : ""}> / <span>${totalPages == null ? "?" : totalPages}</span> 页</span><button type="button" class="text-btn-sm" data-${kind}-page="next" ${totalPages ? (safePage >= totalPages ? "disabled" : "") : (!hasMore ? "disabled" : "")}>下一页</button></div>`;
}
return html;
}
function pageFromInput(input, fallback, maxPage = null) {
const raw = parseInt(input.value, 10);
let page = Number.isFinite(raw) ? raw : fallback;
page = Math.max(1, page);
if (maxPage) page = Math.min(page, maxPage);
input.value = String(page);
return page;
}
function commitResultPage() {
const maxPage = Math.max(1, Math.ceil(STATE.total / STATE.pageSize));
const nextPage = pageFromInput(DOM.pageInput, STATE.page, maxPage);
if (nextPage === STATE.page) {
DOM.pageInput.value = String(STATE.page);
return;
}
STATE.page = nextPage;
doSearch();
}
function commitFilterPage(kind, input) {
const total = STATE.facetTotals[kind];
const maxPage = total ? Math.max(1, Math.ceil(total / 200)) : null;
const nextPage = pageFromInput(input, STATE.filterPage[kind], maxPage);
if (nextPage === STATE.filterPage[kind]) {
input.value = String(STATE.filterPage[kind]);
return;
}
STATE.filterPage[kind] = nextPage;
loadFacetPage(kind);
syncUrl();
}
function restoreFilterPageInput(kind, input) {
input.value = String(STATE.filterPage[kind]);
}
function handleFilterPageButton(kind, container, pageButton) {
if (pageButton.disabled) return;
const input = container.querySelector(`[data-${kind}-page-input]`);
if (input) restoreFilterPageInput(kind, input);
STATE.filterPage[kind] += pageButton.dataset[`${kind}Page`] === "next" ? 1 : -1;
loadFacetPage(kind);
syncUrl();
}
function scheduleFacetSearch(kind) {
clearTimeout(facetInputTimers[kind]);
facetInputTimers[kind] = setTimeout(() => {
loadFacetPage(kind, { preserveScroll: false });
}, 150);
}
function recoverPageState() {
if (bootstrapPending) return;
if (DOM.statusText.textContent === "搜索中..." && !searchAbortController) DOM.statusText.textContent = "";
let shouldLoadSources = false;
for (const kind of ["source", "author", "tag"]) {
const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter;
if (!filter.querySelector(".sidebar-loading")) continue;
if (kind === "source" && !STATE.facets.archives.length && !STATE.facets.types.length) shouldLoadSources = true;
else loadFacetPage(kind);
}
if (shouldLoadSources) loadFacets();
prefetchNextSearchPage();
}
function schedulePageRecovery(delay = 0) {
clearTimeout(pageRecoveryTimer);
pageRecoveryTimer = setTimeout(recoverPageState, delay);
}
function toggleValue(list, value) {
if (!value) return [];
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
}
function filterKeys(kind) {
return {
include: kind === "source" ? "sources" : kind === "author" ? "authors" : "tags",
exclude: kind === "source" ? "excludeSources" : kind === "author" ? "excludeAuthors" : "excludeTags",
};
}
function cycleFilterValue(kind, value) {
if (!value) return;
const keys = filterKeys(kind);
const included = STATE[keys.include].includes(value);
const excluded = STATE[keys.exclude].includes(value);
if (included) {
STATE[keys.include] = STATE[keys.include].filter((item) => item !== value);
STATE[keys.exclude] = [...new Set([...STATE[keys.exclude], value])];
} else if (excluded) {
STATE[keys.exclude] = STATE[keys.exclude].filter((item) => item !== value);
} else {
STATE[keys.exclude] = STATE[keys.exclude].filter((item) => item !== value);
STATE[keys.include] = [...new Set([...STATE[keys.include], value])];
}
STATE.page = 1;
renderFilterOptions({ preserveScroll: true });
doSearch();
}
function toggleResultFilter(kind, value) {
if (!value) return;
const keys = filterKeys(kind);
STATE[keys.exclude] = STATE[keys.exclude].filter((item) => item !== value);
STATE[keys.include] = toggleValue(STATE[keys.include], value);
STATE.page = 1;
renderFilterOptions({ preserveScroll: true });
doSearch();
refreshSelectedFacetItem(kind, value);
}
async function refreshSelectedFacetItem(kind, value) {
const dataKey = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags";
const keys = filterKeys(kind);
try {
const data = await apiWithTimeout(`/api/facet/${kind}?page=1&page_size=500&q=${encodeURIComponent(value)}`);
if (!STATE[keys.include].includes(value) && !STATE[keys.exclude].includes(value)) return;
const item = (data.items || []).find((candidate) => String(candidate.name) === value);
if (!item) return;
STATE.facets[dataKey] = sortFacetItems([
...STATE.facets[dataKey].filter((candidate) => String(candidate.name) !== value),
item,
]);
renderFacetFilter(kind);
} catch (error) {
if (error.name !== "AbortError") console.warn("筛选数量加载失败", error);
}
}
function toggleResultDateTerm(value) {
const payload = resultDateTermPayload(value);
if (!payload) return;
const key = dateTermKey(payload);
const activeTerms = uniqDateTerms(STATE.dateTerms);
const exists = activeTerms.some((item) => dateTermKey(item) === key);
STATE.dateTerms = exists
? activeTerms.filter((item) => dateTermKey(item) !== key)
: uniqDateTerms([...activeTerms, payload]);
if (!STATE.dateTerms.length) STATE.dateTerms = [{ year: "", month: "", day: "" }];
STATE.page = 1;
renderDateControls();
doSearch();
}
function cycleFilterPage(kind) {
const key = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags";
const keys = filterKeys(kind);
const values = (STATE.facets[key] || []).map((item) => String(item.name == null ? "" : item.name)).filter(Boolean);
const includedSet = new Set(STATE[keys.include]);
const excludedSet = new Set(STATE[keys.exclude]);
const allIncluded = Boolean(values.length) && values.every((value) => includedSet.has(value));
const allExcluded = Boolean(values.length) && values.every((value) => excludedSet.has(value));
if (allIncluded) {
STATE[keys.include] = STATE[keys.include].filter((value) => !values.includes(value));
} else if (allExcluded) {
STATE[keys.exclude] = STATE[keys.exclude].filter((value) => !values.includes(value));
} else {
STATE[keys.exclude] = STATE[keys.exclude].filter((value) => !values.includes(value));
STATE[keys.include] = [...new Set([...STATE[keys.include], ...values])];
}
STATE.page = 1;
renderFilterOptions({ preserveScroll: true });
doSearch();
}
function clearFilterKind(kind) {
const keys = filterKeys(kind);
STATE[keys.include] = [];
STATE[keys.exclude] = [];
STATE.page = 1;
renderFilterOptions({ preserveScroll: true });
doSearch();
}
async function doSearch() {
if (!validateDateRange()) return;
const requestSeq = searchRequestSeq + 1;
searchRequestSeq = requestSeq;
if (searchAbortController) searchAbortController.abort();
abortSearchPrefetch();
searchAbortController = new AbortController();
applyStateToDom();
syncUrl();
DOM.statusText.textContent = "搜索中...";
const payload = searchPayload();
const cacheKey = stableStringify(payload);
try {
const cached = getCachedSearch(cacheKey);
const data = cached || await apiWithTimeout("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: searchAbortController.signal,
}, 25000);
if (requestSeq !== searchRequestSeq) return;
if (data.index_status === "failed") {
STATE.results = [];
STATE.total = 0;
renderResults();
DOM.statusText.textContent = "索引失败,请看日志";
return;
}
if (data.indexing && !(data.results || []).length) {
STATE.results = [];
STATE.total = 0;
renderResults();
DOM.statusText.textContent = "索引构建中...";
updateProgress();
return;
}
STATE.results = data.results || [];
STATE.total = data.total || 0;
STATE.pageSize = [10, 20, 50].includes(Number(data.page_size)) ? Number(data.page_size) : STATE.pageSize;
if (!cached && data.index_status === "ready" && !data.indexing) setCachedSearch(cacheKey, data);
renderResults({ animate: true });
DOM.results.scrollTop = 0;
if (STATE.preview) previewResultsScrollTop = 0;
DOM.statusText.textContent = "";
if (data.index_status === "ready" && !data.indexing) DOM.indexProgress.textContent = "";
else updateProgress();
scheduleNextSearchPagePrefetch(payload);
} catch (error) {
if (error.name === "AbortError" || requestSeq !== searchRequestSeq) return;
DOM.statusText.textContent = "搜索失败";
showToast("搜索失败");
} finally {
if (requestSeq === searchRequestSeq) searchAbortController = null;
}
}
async function updateProgress() {
try {
const data = await healthWithTimeout();
const progress = data.progress;
if (data.es === false) {
DOM.indexProgress.textContent = "Elasticsearch 不可用,正在重试...";
clearTimeout(progressTimer);
progressTimer = setTimeout(updateProgress, 5000);
return;
}
if (!progress || (data.index_status === "ready" && data.ok !== false)) {
DOM.indexProgress.textContent = "";
clearTimeout(progressTimer);
return;
}
const indexed = Number(progress.indexed || data.document_count || 0).toLocaleString();
const elapsed = progress.elapsed_seconds ? ` / ${Math.floor(progress.elapsed_seconds / 60)}${progress.elapsed_seconds % 60}秒` : "";
const syncing = progress.status === "syncing" && progress.archive_total;
const detail = syncing
? `同步数据仓库:${progress.archive_current} / ${progress.archive_total}`
: `索引${data.index_status === "failed" ? "失败" : "中"}${indexed} 条`;
DOM.indexProgress.textContent = `${detail}${elapsed}`;
clearTimeout(progressTimer);
if (data.index_status !== "failed") progressTimer = setTimeout(updateProgress, 5000);
} catch (error) {
DOM.indexProgress.textContent = "";
}
}
function renderResults({ animate = false } = {}) {
const totalPages = Math.max(1, Math.ceil(STATE.total / STATE.pageSize));
DOM.resultCount.textContent = STATE.total ? `共 ${STATE.total.toLocaleString()} 条结果` : "";
DOM.pageInput.value = String(STATE.page);
DOM.pageInput.max = String(totalPages);
DOM.pageTotal.textContent = String(totalPages);
DOM.prevPage.disabled = STATE.page <= 1;
DOM.nextPage.disabled = STATE.page >= totalPages;
DOM.results.hidden = !STATE.results.length;
DOM.emptyState.style.display = STATE.results.length ? "none" : "flex";
DOM.results.innerHTML = STATE.results.map((item) => resultHTML(item)).join("");
if (animate) animateResultRows(Array.from(DOM.results.children).slice(0, 20));
}
function animateResultRows(rows) {
if (!motionAllowed()) return;
rows.forEach((row, index) => {
row.style.setProperty("--result-enter-delay", `${Math.round(24 * Math.sqrt(index))}ms`);
row.classList.add("result-enter");
row.addEventListener("animationend", () => {
row.classList.remove("result-enter");
row.style.removeProperty("--result-enter-delay");
}, { once: true });
});
}
function animatePreviewPanel() {
if (!motionAllowed()) return;
stopPreviewAnimation();
void DOM.previewPanel.offsetWidth;
DOM.previewPanel.classList.add("preview-enter");
const finish = (event) => {
if (event.target !== DOM.previewPanel) return;
DOM.previewPanel.classList.remove("preview-enter");
DOM.previewPanel.removeEventListener("animationend", finish);
if (previewAnimationCleanup === finish) previewAnimationCleanup = null;
};
previewAnimationCleanup = finish;
DOM.previewPanel.addEventListener("animationend", finish);
}
function stopPreviewAnimation() {
DOM.previewPanel.classList.remove("preview-enter");
if (!previewAnimationCleanup) return;
DOM.previewPanel.removeEventListener("animationend", previewAnimationCleanup);
previewAnimationCleanup = null;
}
function resultHTML(item) {
const type = item.publication_type ? `<span class="path-sep">/</span><span>${escapeHTML(item.publication_type)}</span>` : "";
const dates = item.date_display && item.date_display.length
? `<span class="path-sep">/</span>${item.date_display.map((date) => `<span class="path-folder" data-date-term-key="${escapeHTML(resultDateTermKey(date))}" data-date-term-label="${escapeHTML(date)}">${escapeHTML(date)}</span>`).join('<span class="path-sep">,</span>')}`
: "";
const authorLinks = item.authors && item.authors.length ? `<span class="path-sep">/</span>${item.authors.map((author) => `<span class="path-folder" data-author="${escapeHTML(author)}">${escapeHTML(author)}</span>`).join('<span class="path-sep">,</span>')}` : "";
const tags = item.tag_names && item.tag_names.length ? `<div class="result-meta"><span class="result-size">${item.tag_names.slice(0, 8).map((tag) => `<span class="path-folder" data-tag="${escapeHTML(tag)}">${escapeHTML(tag)}</span>`).join('<span class="path-sep"> / </span>')}</span></div>` : "";
const snippets = (item.snippet || []).map((snippet) => `<div class="result-snippet">${snippet}</div>`).join("");
const title = item.title_highlight || escapeHTML(item.title);
const sourceDownload = item.source_file_count > 1
? `<a class="result-action-btn primary" href="/api/download/source-zip/${encodeURIComponent(item.doc_id)}">打包原文件</a>`
: item.source_file_count === 1
? `<a class="result-action-btn primary" href="/api/download/source/${encodeURIComponent(item.doc_id)}">下载原文件</a>`
: "";
return `<div class="result-item"><div class="result-file-icon"><span class="ui-icon ui-icon-file" aria-hidden="true"></span></div><div class="result-main"><div class="result-line"><div class="result-info"><div class="result-title">${title}</div><div class="result-path"><span class="path-folder" data-source="${escapeHTML(item.publication_name)}">${escapeHTML(item.publication_name)}</span>${authorLinks}${type}${dates}</div></div><div class="result-actions"><button class="result-action-btn" data-preview="${escapeHTML(item.doc_id)}">在线预览</button><a class="result-action-btn primary" href="/api/download/txt/${encodeURIComponent(item.doc_id)}">下载 TXT</a>${sourceDownload}</div></div><div class="result-content-row">${snippets}${tags}</div></div></div>`;
}
function parseQueueHTML(item) {
const start = Number(item.page_start) || 1;
const end = Number(item.page_end) || start;
const dates = (item.date_display || []).join(" / ");
return `<div class="result-item parse-queue-item"><div class="result-file-icon"><span class="ui-icon ui-icon-file" aria-hidden="true"></span></div><div class="result-main"><div class="result-line"><div class="result-info"><div class="result-title">${escapeHTML(item.publication_name || item.title)}</div><div class="result-path"><span>Archive ${escapeHTML(item.archive_id)}</span><span class="path-sep">/</span><span>${start === end ? `第 ${start} 页` : `第 ${start}-${end} 页`}</span>${dates ? `<span class="path-sep">/</span><span>${escapeHTML(dates)}</span>` : ""}</div></div><div class="result-actions"><button class="result-action-btn primary" data-parse-open="${escapeHTML(item.doc_id)}">开始解析</button></div></div></div></div>`;
}
function renderParseQueue({ animate = false } = {}) {
const pageSize = STATE.parsePageSize;
const totalPages = Math.max(1, Math.ceil(STATE.parseTotal / pageSize));
DOM.resultCount.textContent = `待解析 ${STATE.parseTotal.toLocaleString()} 份`;
DOM.statusText.textContent = "Archive 25";
DOM.pageInput.value = String(STATE.parsePage);
DOM.pageInput.max = String(totalPages);
DOM.pageTotal.textContent = String(totalPages);
DOM.prevPage.disabled = STATE.parsePage <= 1;
DOM.nextPage.disabled = STATE.parsePage >= totalPages;
DOM.results.hidden = !STATE.parseItems.length;
DOM.emptyState.style.display = STATE.parseItems.length ? "none" : "flex";
DOM.results.innerHTML = STATE.parseItems.map(parseQueueHTML).join("");
if (animate) animateResultRows(Array.from(DOM.results.children).slice(0, 20));
}
async function loadParseQueue() {
STATE.parseItem = null;
STATE.preview = "";
clearPreviewDom();
DOM.statusText.textContent = "正在加载待解析队列...";
try {
const data = await apiWithTimeout(`/api/parse/queue?page=${STATE.parsePage}&page_size=${STATE.parsePageSize}`);
if (!STATE.parseMode) return;
STATE.parseTotal = Number(data.total) || 0;
STATE.parseItems = Array.isArray(data.items) ? data.items : [];
renderParseQueue({ animate: true });
} catch (error) {
DOM.statusText.textContent = "待解析队列加载失败";
showToast(`待解析队列加载失败:${error.message || "未知错误"}`);
}
}
function parseDateParts(article) {
const date = Array.isArray(article && article.dates) ? article.dates[0] || {} : {};
return { year: date.year || "", month: date.month || "", day: date.day || "" };
}
function parseArticleRowHTML(article, index, maxPage) {
const date = parseDateParts(article);
const ocr = escapeHTML(JSON.stringify(article.ocr || {}));
const exceptions = escapeHTML(JSON.stringify(article.ocr_exceptions || {}));
return `<fieldset class="parse-article" data-parse-article data-parse-ocr="${ocr}" data-parse-ocr-exceptions="${exceptions}"><legend>文章 ${index + 1}</legend><div class="parse-article-actions"><button type="button" class="text-btn-sm" data-parse-crop-open>裁剪</button><button type="button" class="text-btn-sm" data-parse-remove ${index === 0 ? "disabled" : ""}>删除</button></div><label class="proofread-field"><span>标题</span><input data-parse-title value="${escapeHTML(article.title || "")}" required></label><label class="proofread-field"><span>作者</span><input data-parse-authors value="${escapeHTML((article.authors || []).join("、"))}"></label><div class="parse-date-row"><label class="proofread-field"><span>年</span><input data-parse-year type="number" min="1" max="9999" value="${escapeHTML(date.year)}"></label><label class="proofread-field"><span>月</span><input data-parse-month type="number" min="1" max="12" value="${escapeHTML(date.month)}"></label><label class="proofread-field"><span>日</span><input data-parse-day type="number" min="1" max="31" value="${escapeHTML(date.day)}"></label></div><div class="parse-range-row"><label class="proofread-field"><span>开始页</span><input data-parse-start type="number" min="1" max="${maxPage}" value="${article.page_start || 1}" required></label><label class="proofread-field"><span>结束页</span><input data-parse-end type="number" min="1" max="${maxPage}" value="${article.page_end || maxPage}" required></label></div><label class="proofread-field parse-body-field"><span class="parse-body-heading"><span>正文</span><span><span class="parse-body-status" data-parse-body-status></span><button type="button" class="text-btn-sm" data-parse-refresh-body>从 OCR 刷新</button></span></span><textarea data-parse-content required>${escapeHTML(article.content || "")}</textarea></label></fieldset>`;
}
function parseEditorHTML(item) {
const article = item.article || {};
const maxPage = Number(article.page_end) || (item.source_files || []).length || 1;
const initial = { title: "", authors: [], dates: article.dates || [], page_start: 1, page_end: maxPage };
const sourceCount = Number(item.source_file_count) || (item.source_files || []).length;
const sourceDownload = sourceCount > 1
? `<a class="text-btn-sm parse-source-download" href="/api/download/source-zip/${encodeURIComponent(item.doc_id)}">打包下载原文件</a>`
: sourceCount === 1
? `<a class="text-btn-sm parse-source-download" href="/api/download/source/${encodeURIComponent(item.doc_id)}">下载原文件</a>`
: "";
return `<div class="preview-header"><div class="preview-heading"><div class="preview-title">${escapeHTML(item.publication_name || item.title)}</div><div class="preview-compare-note">待解析 / ${maxPage} 页</div></div><div class="preview-actions"><button type="button" class="text-btn-sm" data-parse-back>返回队列</button><button type="button" class="icon-btn-sm" data-parse-close aria-label="关闭">×</button></div></div><div class="parse-workbench"><section class="parse-source-pane"><div class="proofread-pane-header"><span>原图与 OCR</span><label class="proofread-page-control">页码 <input data-parse-page type="number" min="1" max="${maxPage}" value="1"></label></div><div class="parse-page-tools"><button type="button" class="text-btn-sm" data-parse-page-prev disabled>上一页</button><button type="button" class="text-btn-sm" data-parse-page-next ${maxPage === 1 ? "disabled" : ""}>下一页</button><span data-parse-page-status>1 / ${maxPage}</span>${sourceDownload}<button type="button" class="text-btn-sm parse-crop-toggle" data-parse-crop-toggle>裁剪 OCR</button></div><div class="parse-crop-toolbar" data-parse-crop-toolbar hidden><span data-parse-crop-target>文章 1</span><div class="parse-crop-scope" role="group" aria-label="裁剪范围"><button type="button" class="text-btn-sm primary" data-parse-crop-scope="article">整篇</button><button type="button" class="text-btn-sm" data-parse-crop-scope="page">仅本页</button></div><span data-parse-crop-values>上 0% 下 0% 左 0% 右 0%</span><button type="button" class="text-btn-sm" data-parse-crop-reset>重置</button><button type="button" class="text-btn-sm" data-parse-crop-close>完成</button></div><div class="parse-page-content"><div class="parse-image-stage"><div class="parse-image-wrap"><img data-parse-image alt="原文件第 1 页"><div class="parse-crop-overlay" data-parse-crop-overlay hidden><div class="parse-crop-box" data-parse-crop-box></div></div></div></div><pre class="parse-ocr-text" data-parse-ocr>正在加载 OCR...</pre></div></section><form class="proofread-editor parse-editor" data-parse-form data-max-page="${maxPage}"><div class="proofread-pane-header"><span>文章列表</span><button type="submit" class="result-action-btn primary">提交解析</button></div><div data-parse-articles>${parseArticleRowHTML(initial, 0, maxPage)}</div><div class="proofread-form-actions"><button type="button" class="text-btn-sm" data-parse-add>添加文章</button></div><label class="proofread-field"><span>解析备注</span><textarea data-parse-description></textarea></label><div class="date-error" data-parse-error hidden></div></form></div>`;
}
async function loadParsePage(page) {
const item = STATE.parseItem;
if (!item) return;
const maxPage = Number((item.article || {}).page_end) || (item.source_files || []).length || 1;
page = Math.min(maxPage, Math.max(1, Number(page) || 1));
STATE.parseCurrentPage = page;
const cropToolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
if (cropToolbar && !cropToolbar.hidden) {
const selected = parseCropTargetRow();
const coversPage = selected
&& Number(selected.querySelector("[data-parse-start]").value) <= page
&& Number(selected.querySelector("[data-parse-end]").value) >= page;
if (!coversPage) {
if (selected) selected.classList.remove("is-crop-target");
setParseCropTarget(parseCropTargetRow());
}
}
const image = DOM.previewPanel.querySelector("[data-parse-image]");
const text = DOM.previewPanel.querySelector("[data-parse-ocr]");
const input = DOM.previewPanel.querySelector("[data-parse-page]");
const status = DOM.previewPanel.querySelector("[data-parse-page-status]");
if (input) input.value = String(page);
if (status) status.textContent = `${page} / ${maxPage}`;
const previous = DOM.previewPanel.querySelector("[data-parse-page-prev]");
const next = DOM.previewPanel.querySelector("[data-parse-page-next]");
if (previous) previous.disabled = page <= 1;
if (next) next.disabled = page >= maxPage;
if (image) image.src = parseSourceImageURL(item, page);
if (text) text.textContent = "正在加载 OCR...";
try {
const data = await parseOCRPage(item, page);
if (!STATE.parseItem || STATE.parseItem.doc_id !== item.doc_id || STATE.parseCurrentPage !== page) return;
if (text) text.textContent = parseOCRText(data, parseOCRThresholds(parseCropTargetRow(), page));
updateParseCropUI();
} catch (error) {
if (text) text.textContent = `OCR 加载失败:${error.message || "未知错误"}`;
}
}
function parseOCRThresholds(row, page) {
if (!row) return [0, 0, 0, 0];
let ocr = {};
let exceptions = {};
try { ocr = JSON.parse(row.dataset.parseOcr || "{}"); } catch (_error) { /* invalid local state falls back to no crop */ }
try { exceptions = JSON.parse(row.dataset.parseOcrExceptions || "{}"); } catch (_error) { /* invalid local state falls back to no crop */ }
const options = { ...ocr, ...(exceptions[String(page)] || {}) };
const values = Array.isArray(options.content_thresholds) ? options.content_thresholds : [0, 0, 0, 0];
return values.map((value) => Math.min(0.98, Math.max(0, Number(value) || 0)));
}
function parseOCRRows(data, thresholds = [0, 0, 0, 0]) {
const dimensions = data.dimensions || {};
const width = Number(dimensions.width) || 0;
const height = Number(dimensions.height) || 0;
const [top, bottom, left, right] = thresholds;
return (data.ocr_results || []).filter((row) => {
if (!width || !height || !Array.isArray(row.box) || !row.box.length) return true;
const xs = row.box.map((point) => Number(point[0]) || 0);
const ys = row.box.map((point) => Number(point[1]) || 0);
const centerX = (Math.min(...xs) + Math.max(...xs)) / 2;
const centerY = (Math.min(...ys) + Math.max(...ys)) / 2;
return centerX >= left * width && centerX <= (1 - right) * width
&& centerY >= top * height && centerY <= (1 - bottom) * height;
}).slice().sort((a, b) => ((a.box || [[0, 0]])[0][1] - (b.box || [[0, 0]])[0][1]) || ((a.box || [[0, 0]])[0][0] - (b.box || [[0, 0]])[0][0]));
}
function parseOCRText(data, thresholds = [0, 0, 0, 0]) {
return parseOCRRows(data, thresholds)
.map((row) => row.text || "").filter(Boolean).join("\n");
}
function parseCropTargetRow() {
const rows = Array.from(DOM.previewPanel.querySelectorAll("[data-parse-article]"));
const selected = rows.find((row) => row.classList.contains("is-crop-target"));
if (selected) return selected;
return rows.find((row) => {
const start = Number(row.querySelector("[data-parse-start]").value);
const end = Number(row.querySelector("[data-parse-end]").value);
return start <= STATE.parseCurrentPage && end >= STATE.parseCurrentPage;
}) || rows[0] || null;
}
function setParseCropTarget(row) {
DOM.previewPanel.querySelectorAll("[data-parse-article]").forEach((candidate) => candidate.classList.toggle("is-crop-target", candidate === row));
updateParseCropUI();
}
function parseCropScope() {
const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
return toolbar && toolbar.dataset.scope === "page" ? "page" : "article";
}
function setParseCropScope(scope) {
const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
if (!toolbar) return;
toolbar.dataset.scope = scope === "page" ? "page" : "article";
toolbar.querySelectorAll("[data-parse-crop-scope]").forEach((button) => button.classList.toggle("primary", button.dataset.parseCropScope === toolbar.dataset.scope));
updateParseCropUI();
}
function cropOptions(row, scope, page) {
let options = {};
try {
if (scope === "page") {
const exceptions = JSON.parse(row.dataset.parseOcrExceptions || "{}");
options = exceptions[String(page)] || {};
} else {
options = JSON.parse(row.dataset.parseOcr || "{}");
}
} catch (_error) { /* invalid local state falls back to defaults */ }
return options;
}
function setParseCropThresholds(row, scope, page, thresholds, reset = false) {
if (!row) return;
const key = scope === "page" ? "parseOcrExceptions" : "parseOcr";
let value = {};
try { value = JSON.parse(row.dataset[key] || "{}"); } catch (_error) { value = {}; }
if (scope === "page") {
if (reset) delete value[String(page)];
else value[String(page)] = { ...(value[String(page)] || {}), content_thresholds: thresholds };
} else if (reset) {
delete value.content_thresholds;
} else {
value.content_thresholds = thresholds;
}
row.dataset[key] = JSON.stringify(value);
}
function updateParseCropUI() {
const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
const box = DOM.previewPanel.querySelector("[data-parse-crop-box]");
if (!toolbar || !box || toolbar.hidden) return;
const row = parseCropTargetRow();
if (!row) return;
const rows = Array.from(DOM.previewPanel.querySelectorAll("[data-parse-article]"));
const label = toolbar.querySelector("[data-parse-crop-target]");
const scope = parseCropScope();
const page = STATE.parseCurrentPage;
const options = cropOptions(row, scope, page);
const thresholds = Array.isArray(options.content_thresholds)
? options.content_thresholds.map((value) => Number(value) || 0)
: parseOCRThresholds(row, page);
if (label) label.textContent = `文章 ${rows.indexOf(row) + 1}${scope === "page" ? ` / 第 ${page} 页` : ""}`;
box.style.top = `${thresholds[0] * 100}%`;
box.style.bottom = `${thresholds[1] * 100}%`;
box.style.left = `${thresholds[2] * 100}%`;
box.style.right = `${thresholds[3] * 100}%`;
if (!box.querySelector("[data-parse-crop-handle]")) {
box.insertAdjacentHTML("beforeend", '<span data-parse-crop-handle="nw"></span><span data-parse-crop-handle="ne"></span><span data-parse-crop-handle="sw"></span><span data-parse-crop-handle="se"></span>');
}
box.classList.toggle("has-selection", thresholds.some((value) => value > 0));
const values = toolbar.querySelector("[data-parse-crop-values]");
if (values) values.textContent = `上 ${Math.round(thresholds[0] * 100)}% 下 ${Math.round(thresholds[1] * 100)}% 左 ${Math.round(thresholds[2] * 100)}% 右 ${Math.round(thresholds[3] * 100)}%`;
}
function openParseCrop(row = null) {
const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
const overlay = DOM.previewPanel.querySelector("[data-parse-crop-overlay]");
if (!toolbar || !overlay) return;
row = row || parseCropTargetRow();
if (!row) return;
const start = Number(row.querySelector("[data-parse-start]").value) || 1;
const end = Number(row.querySelector("[data-parse-end]").value) || start;
if (STATE.parseCurrentPage < start || STATE.parseCurrentPage > end) loadParsePage(start);
toolbar.hidden = false;
overlay.hidden = false;
setParseCropTarget(row);
setParseCropScope("article");
}
function closeParseCrop() {
const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
const overlay = DOM.previewPanel.querySelector("[data-parse-crop-overlay]");
if (toolbar) toolbar.hidden = true;
if (overlay) overlay.hidden = true;
parseCropDrag = null;
}
async function updateParsePageOCR() {
const item = STATE.parseItem;
const text = DOM.previewPanel.querySelector("[data-parse-ocr]");
if (!item || !text) return;
try {
const page = STATE.parseCurrentPage;
const data = await parseOCRPage(item, page);
if (!STATE.parseItem || STATE.parseItem.doc_id !== item.doc_id || STATE.parseCurrentPage !== page) return;
text.textContent = parseOCRText(data, parseOCRThresholds(parseCropTargetRow(), page));
} catch (error) {
text.textContent = `OCR 加载失败:${error.message || "未知错误"}`;
}
}
function finishParseCropChange(row) {
if (!row) return;
const textarea = row.querySelector("[data-parse-content]");
const status = row.querySelector("[data-parse-body-status]");
if (textarea && textarea.dataset.edited === "1") {
if (status) status.textContent = "裁剪已更改,需刷新正文";
} else {
refreshParseArticleBody(row, true, true);
}
saveParseDraft();
}
function beginParseCropDrag(event) {
const handle = event.target.closest("[data-parse-crop-handle]");
const box = event.target.closest("[data-parse-crop-box]");
const overlay = event.target.closest("[data-parse-crop-overlay]");
const row = parseCropTargetRow();
if (!overlay || !row || event.button !== 0) return;
event.preventDefault();
const page = STATE.parseCurrentPage;
const scope = parseCropScope();
const options = cropOptions(row, scope, page);
const thresholds = Array.isArray(options.content_thresholds)
? options.content_thresholds.map((value) => Number(value) || 0)
: parseOCRThresholds(row, page);
parseCropDrag = {
row, scope, page, thresholds,
mode: handle ? "resize" : box && box.classList.contains("has-selection") ? "move" : "select",
corner: handle ? handle.dataset.parseCropHandle : "",
startX: event.clientX, startY: event.clientY, rect: overlay.getBoundingClientRect(),
changed: false,
};
if (box) box.classList.add("is-adjusting");
if (overlay.setPointerCapture) overlay.setPointerCapture(event.pointerId);
}
function moveParseCrop(event) {
const drag = parseCropDrag;
if (!drag || !drag.rect.width || !drag.rect.height) return;
event.preventDefault();
const minimum = 0.02;
const startX = Math.min(1, Math.max(0, (drag.startX - drag.rect.left) / drag.rect.width));
const startY = Math.min(1, Math.max(0, (drag.startY - drag.rect.top) / drag.rect.height));
const currentX = Math.min(1, Math.max(0, (event.clientX - drag.rect.left) / drag.rect.width));
const currentY = Math.min(1, Math.max(0, (event.clientY - drag.rect.top) / drag.rect.height));
let top = drag.thresholds[0];
let bottom = 1 - drag.thresholds[1];
let left = drag.thresholds[2];
let right = 1 - drag.thresholds[3];
if (drag.mode === "select") {
top = Math.min(startY, currentY);
bottom = Math.max(startY, currentY);
left = Math.min(startX, currentX);
right = Math.max(startX, currentX);
} else if (drag.mode === "move") {
const width = right - left;
const height = bottom - top;
left = Math.min(1 - width, Math.max(0, left + currentX - startX));
top = Math.min(1 - height, Math.max(0, top + currentY - startY));
right = left + width;
bottom = top + height;
} else {
if (drag.corner.includes("n")) top = Math.min(bottom - minimum, currentY);
if (drag.corner.includes("s")) bottom = Math.max(top + minimum, currentY);
if (drag.corner.includes("w")) left = Math.min(right - minimum, currentX);
if (drag.corner.includes("e")) right = Math.max(left + minimum, currentX);
}
if (bottom - top < minimum || right - left < minimum) return;
const values = [top, 1 - bottom, left, 1 - right];
const rounded = values.map((value) => Math.round(value * 10000) / 10000);
setParseCropThresholds(drag.row, drag.scope, drag.page, rounded);
drag.changed = true;
updateParseCropUI();
}
function endParseCrop() {
if (!parseCropDrag) return;
const row = parseCropDrag.row;
const changed = parseCropDrag.changed;
parseCropDrag = null;
const box = DOM.previewPanel.querySelector("[data-parse-crop-box]");
if (box) box.classList.remove("is-adjusting");
if (!changed) return;
updateParsePageOCR();
finishParseCropChange(row);
}
async function parseOCRPage(item, page) {
const key = `${item.doc_id}:${page}`;
if (parseOCRCache.has(key)) {
const cached = parseOCRCache.get(key);
parseOCRCache.delete(key);
parseOCRCache.set(key, cached);
return cached;
}
const data = await apiWithTimeout(`/api/parse/${encodeURIComponent(item.doc_id)}/ocr?page=${page}`, {}, 30000);
parseOCRCache.set(key, data);
while (parseOCRCache.size > PARSE_OCR_CACHE_LIMIT) parseOCRCache.delete(parseOCRCache.keys().next().value);
return data;
}
async function refreshParseArticleBody(row, force = false, persist = false, preserveEdited = false) {
if (!row || !STATE.parseItem) return;
const textarea = row.querySelector("[data-parse-content]");
const status = row.querySelector("[data-parse-body-status]");
if (!force && textarea.dataset.edited === "1") return;
const start = Number(row.querySelector("[data-parse-start]").value);
const end = Number(row.querySelector("[data-parse-end]").value);
const maxPage = Number(row.closest("[data-parse-form]").dataset.maxPage) || 1;
if (!start || !end || start < 1 || end > maxPage || start > end) {
row.dataset.basePartCount = "";
if (status) status.textContent = "页码无效";
return;
}
const sourceValue = textarea.value;
const preserveCurrent = preserveEdited && textarea.dataset.edited === "1";
row.dataset.basePartCount = "";
if (status) status.textContent = "正在读取...";
try {
const pages = await Promise.all(Array.from({ length: end - start + 1 }, (_value, index) => parseOCRPage(STATE.parseItem, start + index)));
const pageTexts = pages.map((data, index) => parseOCRText(data, parseOCRThresholds(row, start + index)));
const body = pageTexts.filter(Boolean).join("\n");
const boxCount = pages.reduce((total, data, index) => total + parseOCRRows(data, parseOCRThresholds(row, start + index)).length, 0);
if (preserveCurrent || (textarea.dataset.edited === "1" && textarea.value !== sourceValue)) {
row.dataset.basePartCount = String(boxCount);
if (status) status.textContent = preserveCurrent ? "已恢复草稿" : "已编辑";
if (persist) saveParseDraft();
return;
}
textarea.value = body;
textarea.dataset.edited = "0";
row.dataset.basePartCount = String(boxCount);
if (status) status.textContent = `${body.length.toLocaleString()} 字`;
if (persist) saveParseDraft();
} catch (error) {
row.dataset.basePartCount = "";
if (status) status.textContent = "OCR 读取失败";
}
}
async function openParseEditor(docId) {
const queueItem = STATE.parseItems.find((item) => item.doc_id === docId);
if (!queueItem) return;
try {
const item = await apiWithTimeout(`/api/preview/${encodeURIComponent(docId)}`, {}, 30000);
STATE.parseItem = item;
STATE.preview = docId;
applyStateToDom();
DOM.previewPanel.hidden = false;
DOM.previewPanel.style.display = "";
DOM.previewPanel.className = "preview-panel parse-panel";
DOM.previewPanel.innerHTML = parseEditorHTML(item);
DOM.results.hidden = true;
DOM.emptyState.style.display = "none";
animatePreviewPanel();
const restoredPage = restoreParseDraft(item, DOM.previewPanel.querySelector("[data-parse-form]"));
loadParsePage(restoredPage || 1);
DOM.previewPanel.querySelectorAll("[data-parse-article]").forEach((row) => {
if (!Number(row.dataset.basePartCount)) refreshParseArticleBody(row, true, false, true);
});
} catch (error) {
showToast(`解析资料加载失败:${error.message || "未知错误"}`);
}
}
function collectParseArticles(form) {
return Array.from(form.querySelectorAll("[data-parse-article]")).map((row) => {
const year = Number(row.querySelector("[data-parse-year]").value) || null;
const month = Number(row.querySelector("[data-parse-month]").value) || null;
const day = Number(row.querySelector("[data-parse-day]").value) || null;
const date = {};
if (year) date.year = year;
if (month) date.month = month;
if (day) date.day = day;
let ocr = {};
let ocrExceptions = {};
try { ocr = JSON.parse(row.dataset.parseOcr || "{}"); } catch (_error) { /* validation reports malformed state */ }
try { ocrExceptions = JSON.parse(row.dataset.parseOcrExceptions || "{}"); } catch (_error) { /* validation reports malformed state */ }
const article = {
title: row.querySelector("[data-parse-title]").value.trim(),
authors: row.querySelector("[data-parse-authors]").value.split(/[、,,/]/).map((value) => value.trim()).filter(Boolean),
dates: Object.keys(date).length ? [date] : [],
is_range_date: false,
page_start: Number(row.querySelector("[data-parse-start]").value),
page_end: Number(row.querySelector("[data-parse-end]").value),
content: row.querySelector("[data-parse-content]").value,
base_part_count: Number(row.dataset.basePartCount) || 0,
};
if (Object.keys(ocr).length) article.ocr = ocr;
if (Object.keys(ocrExceptions).length) article.ocr_exceptions = ocrExceptions;
return article;
});
}
function parseDraftKey(docId) {
return `bha-parse-draft:${docId}`;
}
function collectParseDraft(form) {
if (!form || !STATE.parseItem || form.dataset.parseSubmitted === "1") return null;
const rows = Array.from(form.querySelectorAll("[data-parse-article]"));
const articles = collectParseArticles(form).map((article, index) => ({
...article,
content_edited: rows[index].querySelector("[data-parse-content]").dataset.edited === "1",
}));
return {
content_hash: STATE.parseItem.content_hash,
articles,
description: (form.querySelector("[data-parse-description]") || {}).value || "",
page: STATE.parseCurrentPage,
};
}
function flushParseDraft() {
clearTimeout(parseDraftTimer);
parseDraftTimer = 0;
if (!STATE.parseItem) return;
const draft = collectParseDraft(DOM.previewPanel.querySelector("[data-parse-form]"));
if (draft) safeSessionSet(parseDraftKey(STATE.parseItem.doc_id), JSON.stringify(draft));
}
function saveParseDraft() {
clearTimeout(parseDraftTimer);
parseDraftTimer = setTimeout(flushParseDraft, 400);
}
function clearParseDraft(docId) {
clearTimeout(parseDraftTimer);
parseDraftTimer = 0;
try { sessionStorage.removeItem(parseDraftKey(docId)); } catch (_error) { /* storage is optional */ }
}
function restoreParseDraft(item, form) {
if (!form) return 0;
let draft;
try { draft = JSON.parse(safeSessionGet(parseDraftKey(item.doc_id)) || "null"); } catch (_error) { draft = null; }
if (!draft) return 0;
if (draft.content_hash !== item.content_hash || !Array.isArray(draft.articles) || !draft.articles.length) {
clearParseDraft(item.doc_id);
return 0;
}
const maxPage = Number(form.dataset.maxPage) || 1;
try {
const holder = form.querySelector("[data-parse-articles]");
holder.innerHTML = draft.articles.map((article, index) => parseArticleRowHTML(article, index, maxPage)).join("");
holder.querySelectorAll("[data-parse-article]").forEach((row, index) => {
const article = draft.articles[index];
row.dataset.basePartCount = String(Number(article.base_part_count) || "");
const textarea = row.querySelector("[data-parse-content]");
textarea.dataset.edited = article.content_edited ? "1" : "0";
const status = row.querySelector("[data-parse-body-status]");
if (status) status.textContent = article.content_edited ? "已恢复草稿" : `${textarea.value.length.toLocaleString()} 字`;
});
const description = form.querySelector("[data-parse-description]");
if (description) description.value = typeof draft.description === "string" ? draft.description : "";
return Math.min(maxPage, Math.max(1, Number(draft.page) || 1));
} catch (_error) {
clearParseDraft(item.doc_id);
return 0;
}
}
function parseCoverageError(articles, maxPage) {
if (articles.some((article) => !article.title)) return "每篇文章都需要标题";
if (articles.some((article) => !article.content.trim())) return "每篇文章都需要正文";
if (articles.some((article) => !article.base_part_count)) return "正文 OCR 基线加载失败,请检查网络后重试";
if (articles.some((article) => article.page_start < 1 || article.page_end > maxPage || article.page_start > article.page_end)) return "文章页码范围无效";
const covered = new Set();
articles.forEach((article) => { for (let page = article.page_start; page <= article.page_end; page += 1) covered.add(page); });
const missing = Array.from({ length: maxPage }, (_value, index) => index + 1).filter((page) => !covered.has(page));
return missing.length ? `以下页面尚未被文章覆盖:${missing.join("、")}` : "";
}
function parseArticleThresholds(article, page) {
const options = { ...(article.ocr || {}), ...((article.ocr_exceptions || {})[String(page)] || {}) };
const thresholds = Array.isArray(options.content_thresholds) ? options.content_thresholds.map((value) => Number(value) || 0) : [0, 0, 0, 0];
return thresholds.length === 4 ? thresholds : [0, 0, 0, 0];
}
function parseSourceImageURL(item, page) {
const files = Array.isArray(item.source_files) ? item.source_files : [];
const fileIndex = files.length === 1 ? 0 : page - 1;
const file = files[fileIndex] || {};
const pageCount = Number(file.page_count) || (Array.isArray(file.pages) ? file.pages.length : 0);
if (pageCount) return pageImageURL(item.doc_id, fileIndex, files.length === 1 ? page : 1);
return sourceDownloadURL(item, fileIndex, true);
}
function parseCropLabel(thresholds) {
return `上 ${Math.round(thresholds[0] * 100)}% / 下 ${Math.round(thresholds[1] * 100)}% / 左 ${Math.round(thresholds[2] * 100)}% / 右 ${Math.round(thresholds[3] * 100)}%`;
}
function parseReviewHTML(item, payload) {
const rows = payload.articles.map((article, index) => {
const date = (article.dates || []).map((value) => metadataItemText("dates", value)).filter(Boolean).join("、") || "(空)";
const authors = (article.authors || []).join("、") || "(空)";
const crops = [];
for (let page = article.page_start; page <= article.page_end; page += 1) {
const thresholds = parseArticleThresholds(article, page);
if (!thresholds.some((value) => value > 0)) continue;
crops.push(`<div class="parse-review-crop"><div class="parse-review-crop-label">第 ${page} 页 · ${escapeHTML(parseCropLabel(thresholds))}</div><canvas data-parse-review-crop data-page="${page}" data-thresholds="${escapeHTML(JSON.stringify(thresholds))}" aria-label="第 ${page} 页裁剪图"></canvas><span data-parse-review-crop-error hidden>裁剪图加载失败</span></div>`);
}
return `<div class="proofread-confirm-item parse-review-article"><strong>文章 ${index + 1} · ${escapeHTML(article.title)}</strong><div class="parse-review-meta"><span>作者:${escapeHTML(authors)}</span><span>日期:${escapeHTML(date)}</span><span>页码:${article.page_start === article.page_end ? article.page_start : `${article.page_start}${article.page_end}`}</span><span>OCR 基线:${article.base_part_count} 段</span></div><div class="proofread-confirm-field parse-review-content">${escapeHTML(article.content)}</div>${crops.length ? `<div class="parse-review-crops">${crops.join("")}</div>` : ""}</div>`;
});
if (payload.description) rows.push(`<div class="proofread-confirm-item"><strong>提交说明</strong><div class="proofread-confirm-field">${escapeHTML(payload.description)}</div></div>`);
return rows.join("");
}
function hydrateParseReviewCrops(holder, item) {
holder.querySelectorAll("[data-parse-review-crop]").forEach((canvas) => {
const page = Number(canvas.dataset.page);
let thresholds;
try { thresholds = JSON.parse(canvas.dataset.thresholds); } catch (_error) { thresholds = null; }
const error = canvas.parentElement.querySelector("[data-parse-review-crop-error]");
if (!Array.isArray(thresholds) || thresholds.length !== 4) {
canvas.hidden = true;
if (error) error.hidden = false;
return;
}
const image = new Image();
image.onload = () => {
const [top, bottom, left, right] = thresholds;
const sourceX = Math.floor(left * image.naturalWidth);
const sourceY = Math.floor(top * image.naturalHeight);
const sourceWidth = Math.max(1, Math.ceil((1 - right) * image.naturalWidth) - sourceX);
const sourceHeight = Math.max(1, Math.ceil((1 - bottom) * image.naturalHeight) - sourceY);
const scale = Math.min(1, 1000 / sourceWidth, 700 / sourceHeight);
canvas.width = Math.max(1, Math.round(sourceWidth * scale));
canvas.height = Math.max(1, Math.round(sourceHeight * scale));
canvas.getContext("2d").drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height);
};
image.onerror = () => {
canvas.hidden = true;
if (error) error.hidden = false;
};
image.src = parseSourceImageURL(item, page);
});
}
function removeParseConfirm() {
const panel = document.querySelector("[data-parse-confirm]");
if (panel) panel.remove();
}
function showParseConfirm(form, item, payload) {
removeParseConfirm();
const holder = document.createElement("div");
holder.className = "proofread-confirm";
holder.dataset.parseConfirm = "true";
holder.innerHTML = `<div class="proofread-confirm-panel parse-confirm-panel" role="dialog" aria-modal="true" aria-label="解析提交预览"><div class="proofread-confirm-header"><span>解析提交预览 · ${payload.articles.length} 篇</span><button type="button" class="icon-btn-sm" data-cancel-parse aria-label="关闭预览">×</button></div><div class="proofread-confirm-body">${parseReviewHTML(item, payload)}</div><div class="proofread-confirm-actions"><button type="button" class="text-btn-sm" data-cancel-parse>返回继续编辑</button><button type="button" class="result-action-btn primary" data-confirm-parse>确认提交</button></div></div>`;
document.body.append(holder);
hydrateParseReviewCrops(holder, item);
holder.querySelectorAll("[data-cancel-parse]").forEach((button) => button.addEventListener("click", removeParseConfirm));
holder.querySelector("[data-confirm-parse]").addEventListener("click", async () => {
const button = holder.querySelector("[data-confirm-parse]");
button.disabled = true;
button.textContent = "提交中...";
const submitted = await postParse(form, item, payload);
if (submitted) removeParseConfirm();
else {
button.disabled = false;
button.textContent = "确认提交";
}
});
}
async function postParse(form, item, payload) {
try {
await apiWithTimeout("/api/parse", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc_id: item.doc_id, base_content_hash: item.content_hash, ...payload }) }, 30000);
form.dataset.parseSubmitted = "1";
clearParseDraft(item.doc_id);
showToast("解析已提交,等待人工审核");
form.querySelector('button[type="submit"]').disabled = true;
return true;
} catch (submitError) {
showToast(`解析提交失败:${submitError.message || "未知错误"}`);
return false;
}
}
async function submitParseForm(form) {
const item = STATE.parseItem;
if (!item) return;
let articles = collectParseArticles(form);
const maxPage = Number(form.dataset.maxPage) || 1;
const errorNode = form.querySelector("[data-parse-error]");
if (articles.some((article) => !article.base_part_count)) {
const button = form.querySelector('button[type="submit"]');
const label = button ? button.textContent : "";
if (button) {
button.disabled = true;
button.textContent = "正在读取 OCR 基线...";
}
errorNode.hidden = true;
const rows = Array.from(form.querySelectorAll("[data-parse-article]"));
await Promise.all(rows.filter((row) => !Number(row.dataset.basePartCount)).map((row) => (
refreshParseArticleBody(row, true, false, true)
)));
if (button) {
button.disabled = false;
button.textContent = label;
}
articles = collectParseArticles(form);
}
const error = parseCoverageError(articles, maxPage);
if (error) {
errorNode.textContent = error;
errorNode.hidden = false;
return;
}
errorNode.hidden = true;
flushParseDraft();
showParseConfirm(form, item, { articles, description: form.querySelector("[data-parse-description]").value.trim() });
}
function scheduleSearch() {
if (searchComposing) return;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
STATE.parseMode = false;
STATE.q = DOM.searchInput.value.trim();
STATE.page = 1;
doSearch();
}, 120);
}
async function restorePreview(scroll = true) {
if (!STATE.preview) {
cancelPreviewRequests();
clearPreviewDom();
if (!searchAbortController && !bootstrapPending) renderResults();
return;
}
const docId = STATE.preview;
const requestSeq = previewRequestSeq + 1;
previewRequestSeq = requestSeq;
if (previewAbortController) {
previewAbortController.abort();
previewAbortController = null;
}
clearActiveMediaViewer();
try {
let item = getCachedPreview(docId);
if (!item) {
previewAbortController = new AbortController();
const controller = previewAbortController;
item = await apiWithTimeout(`/api/preview/${encodeURIComponent(docId)}`, { signal: controller.signal });
setCachedPreview(docId, item);
if (previewAbortController === controller) previewAbortController = null;
}
if (requestSeq !== previewRequestSeq || STATE.preview !== docId) return;
clearActiveMediaViewer();
DOM.previewPanel.classList.remove("proofread-mode");
DOM.previewPanel.innerHTML = previewHTML(item);
prefetchPreviewFirstPage(item);
DOM.previewPanel.hidden = false;
DOM.previewPanel.style.display = "flex";
document.body.classList.add("preview-open");
DOM.emptyState.style.display = "none";
if (animateNextPreview) animatePreviewPanel();
animateNextPreview = false;
if (STATE.proofread) {
openProofread(item, null, true);
} else if (STATE.compare) {
comparePreview(STATE.compare, true);
}
const previewBody = DOM.previewPanel.querySelector(".preview-body");
if (scroll && previewBody) previewBody.scrollTo({ top: 0, behavior: "smooth" });
} catch (error) {
if (requestSeq !== previewRequestSeq || STATE.preview !== docId) return;
animateNextPreview = false;
if (error.name === "AbortError") return;
showToast("预览失败");
closePreview(true);
}
}
function openPreview(docId, scroll = true) {
if (!docId) return;
flushProofreadDraft();
const replacingPreview = history.state && history.state.previewEntry === true;
if (!replacingPreview) previewResultsScrollTop = DOM.results.scrollTop;
STATE.preview = docId;
STATE.compare = "";
STATE.proofread = false;
STATE.proofreadFile = "";
STATE.proofreadPage = "";
animateNextPreview = true;
applyStateToDom();
syncUrl(replacingPreview);
restorePreview(scroll);
}
function previewHTML(item, compareItem = null) {
const themeAction = `<button type="button" class="icon-btn-sm preview-theme-btn" data-preview-theme aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${THEME_ICON[STATE.isDark ? "dark" : "light"]}</button>`;
const commands = `${previewDownloadHTML(item)}${compareSelectHTML(item)}`;
const actions = `<div class="preview-commands">${commands}</div><div class="preview-tools"><button type="button" class="icon-btn-sm" data-close-preview aria-label="关闭预览">×</button>${themeAction}</div>`;
if (compareItem) {
const diff = diffHTML(item.content || "", compareItem.content || "");
return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}<div class="preview-compare-note">正在对比:${escapeHTML(item.publication_name)} / ${escapeHTML(compareItem.publication_name)}</div></div><div class="preview-actions">${actions}</div></div><div class="preview-compare-grid"><div>${previewMetaHTML(item)}<pre class="preview-body compare-body">${diff.left}</pre></div><div>${previewMetaHTML(compareItem)}<pre class="preview-body compare-body">${diff.right}</pre></div></div>`;
}
return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}${previewMetaHTML(item)}</div><div class="preview-actions">${actions}</div></div><pre class="preview-body" data-proofread-select>${highlightPreviewText(item.content || "")}</pre>`;
}
function previewDownloadHTML(item) {
if (item.source_file_count > 1) {
return `<a class="result-action-btn primary" href="/api/download/source-zip/${encodeURIComponent(item.doc_id)}">打包原文件</a>`;
}
if (item.source_file_count === 1) {
return `<a class="result-action-btn primary" href="/api/download/source/${encodeURIComponent(item.doc_id)}">下载原文件</a>`;
}
return "";
}
function sourceDownloadURL(item, fileIndex = 0, inline = false) {
const route = inline ? "/api/preview/source/" : "/api/download/source/";
return `${route}${encodeURIComponent(item.doc_id)}?file=${encodeURIComponent(fileIndex)}${inline ? "&inline=true" : ""}`;
}
function pageImageURL(docId, fileIndex, page) {
return `/api/preview/source-page/${encodeURIComponent(docId)}?file=${encodeURIComponent(fileIndex)}&page=${encodeURIComponent(page)}`;
}
function pageImageViewerHTML(file, page, sourceDownload) {
const pages = Array.isArray(file && file.pages) ? file.pages : [];
const pageNumbers = Array.isArray(file && file.page_numbers) ? file.page_numbers : pages.map((_url, index) => index + 1);
const pageCount = Math.max(pages.length, Number(file && file.page_count) || 0);
const pageIndex = Math.min(pageCount - 1, Math.max(0, page - 1));
const fileIndex = Number(file && file.index) || 0;
const initialPage = Number(file && file.initial_page_number) || 0;
const initialURL = file && file.initial_page || "";
const proxyPages = pages.map((url, index) => /^https?:/i.test(url) ? pageImageURL(STATE.preview, fileIndex, pageNumbers[index] || index + 1) : url);
const proxyInitial = /^https?:/i.test(initialURL) ? pageImageURL(STATE.preview, fileIndex, initialPage) : initialURL;
const initialSource = proxyInitial || proxyPages[0] || "";
return `<div class="proofread-image-viewer proofread-page-viewer" data-page-image-viewer data-page-images="${escapeHTML(JSON.stringify(proxyPages))}" data-page-numbers="${escapeHTML(JSON.stringify(pageNumbers))}" data-page-count="${pageCount}" data-page-initial-index="${initialPage - 1}" data-page-initial-url="${escapeHTML(proxyInitial)}" data-page-doc-id="${escapeHTML(STATE.preview)}" data-page-file-index="${fileIndex}" data-page-index="${pageIndex}"><div class="proofread-image-tools"><button type="button" class="text-btn-sm" data-page-image-prev ${pageIndex === 0 ? "disabled" : ""}>上一页</button><button type="button" class="text-btn-sm" data-page-image-next ${pageIndex === pageCount - 1 ? "disabled" : ""}>下一页</button><button type="button" class="text-btn-sm" data-image-zoom="out">缩小</button><button type="button" class="text-btn-sm" data-image-zoom="reset">原始大小</button><button type="button" class="text-btn-sm" data-image-zoom="in">放大</button><label class="proofread-page-control">页码 <input data-page-image-input type="number" min="1" max="${pageCount}" value="${pageIndex + 1}"></label><a class="text-btn-sm proofread-image-download" href="${sourceDownload}">下载原文件</a></div><div class="proofread-image-stage"><img data-proofread-source-image src="${escapeHTML(initialSource)}" alt="原文件第 ${pageIndex + 1} 页" loading="eager" decoding="async" fetchpriority="high" data-image-scale="1"></div></div>`;
}
async function setPageImage(viewer, page) {
let pages;
try { pages = JSON.parse(viewer.dataset.pageImages || "[]"); } catch { pages = []; }
let pageNumbers;
try { pageNumbers = JSON.parse(viewer.dataset.pageNumbers || "[]"); } catch { pageNumbers = []; }
const pageCount = Number(viewer.dataset.pageCount) || pages.length;
if (!pageCount) return;
const index = Math.min(pageCount - 1, Math.max(0, page - 1));
const image = viewer.querySelector("[data-proofread-source-image]");
const input = viewer.querySelector("[data-page-image-input]");
if (!image) return;
if (cancelPageImagePrefetch) {
cancelPageImagePrefetch();
cancelPageImagePrefetch = null;
}
if (pageImagePrefetchController) {
pageImagePrefetchController.abort();
pageImagePrefetchController = null;
}
if (viewer.pageImageAbortController) viewer.pageImageAbortController.abort();
const pageImageAbortController = new AbortController();
viewer.pageImageAbortController = pageImageAbortController;
const requestId = String(Number(viewer.dataset.pageRequest || 0) + 1);
viewer.dataset.pageRequest = requestId;
const knownIndex = pageNumbers.indexOf(index + 1);
let url = knownIndex >= 0 ? pages[knownIndex] || "" : "";
if (!url && index === Number(viewer.dataset.pageInitialIndex)) url = viewer.dataset.pageInitialUrl || "";
if (!url) url = pageImageURL(viewer.dataset.pageDocId, viewer.dataset.pageFileIndex, index + 1);
if (pageImageAbortController.signal.aborted || viewer.dataset.pageRequest !== requestId || !viewer.isConnected) return;
if (viewer.pageImageAbortController === pageImageAbortController) viewer.pageImageAbortController = null;
viewer.dataset.pageIndex = String(index);
image.dataset.imageScale = "1";
delete image.dataset.imageBaseWidth;
delete image.dataset.imageLoadedSrc;
image.style.width = "";
image.style.height = "auto";
image.src = url;
image.alt = `原文件第 ${index + 1} 页`;
if (input) input.value = String(index + 1);
const previous = viewer.querySelector("[data-page-image-prev]");
const next = viewer.querySelector("[data-page-image-next]");
if (previous) previous.disabled = index === 0;
if (next) next.disabled = index === pageCount - 1;
STATE.proofreadPage = String(index + 1);
syncUrl(true);
saveProofreadDraft();
}
function prefetchAdjacentPageImage(viewer) {
if (!viewer) return;
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection && (connection.saveData || /(^|-)2g$/.test(connection.effectiveType || ""))) return;
let pages;
try { pages = JSON.parse(viewer.dataset.pageImages || "[]"); } catch { pages = []; }
let pageNumbers;
try { pageNumbers = JSON.parse(viewer.dataset.pageNumbers || "[]"); } catch { pageNumbers = []; }
const currentIndex = Number(viewer.dataset.pageIndex) || 0;
const pageCount = Number(viewer.dataset.pageCount) || pages.length;
const adjacent = [currentIndex - 1, currentIndex + 1].filter((index) => index >= 0 && index < pageCount);
if (!adjacent.length) return;
const requests = adjacent.map((index) => {
const knownIndex = pageNumbers.indexOf(index + 1);
const url = knownIndex >= 0
? pages[knownIndex] || ""
: pageImageURL(viewer.dataset.pageDocId, viewer.dataset.pageFileIndex, index + 1);
const cacheKey = url || `${viewer.dataset.pageDocId}:${viewer.dataset.pageFileIndex}:${index + 1}`;
if (prefetchedPageImages.has(cacheKey)) return null;
prefetchedPageImages.add(cacheKey);
while (prefetchedPageImages.size > 32) prefetchedPageImages.delete(prefetchedPageImages.values().next().value);
return { index, url };
}).filter(Boolean);
if (!requests.length) return;
if (pageImagePrefetchController) pageImagePrefetchController.abort();
const load = () => {
cancelPageImagePrefetch = null;
const controller = new AbortController();
pageImagePrefetchController = controller;
const latestConnection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!viewer.isConnected || latestConnection && (latestConnection.saveData || /(^|-)2g$/.test(latestConnection.effectiveType || ""))) {
controller.abort();
return;
}
const loadImage = (url) => {
const image = new Image();
image.decoding = "async";
image.fetchPriority = "low";
image.src = url;
if (image.decode) image.decode().catch(() => {});
};
requests.forEach(({ url }) => loadImage(url));
};
if (cancelPageImagePrefetch) cancelPageImagePrefetch();
let idleHandle = null;
const debounceHandle = setTimeout(() => {
if (window.requestIdleCallback) {
idleHandle = window.requestIdleCallback(load, { timeout: 1500 });
} else {
load();
}
}, 800);
cancelPageImagePrefetch = () => {
clearTimeout(debounceHandle);
if (idleHandle !== null && window.cancelIdleCallback) window.cancelIdleCallback(idleHandle);
if (pageImagePrefetchController) pageImagePrefetchController.abort();
};
}
function prefetchPreviewFirstPage(item) {
const file = (item && item.source_files || []).find((candidate) => candidate.initial_page || Array.isArray(candidate.pages) && candidate.pages[0]);
if (!file) return;
const initialPage = Number(file && file.initial_page_number) || (Array.isArray(file && file.page_numbers) && file.page_numbers[0]) || 1;
const firstURL = (file.initial_page || (file.pages || [])[0] || "");
const image = new Image();
image.decoding = "async";
image.fetchPriority = "high";
image.src = /^https?:/i.test(firstURL) ? pageImageURL(item.doc_id, Number(file.index) || 0, initialPage) : firstURL;
}
function isImageSource(url) {
return /\.(?:bmp|gif|jpe?g|png|tiff?|webp)(?:$|\?)/i.test(String(url || ""));
}
function isPDFSource(url) {
return /\.pdf(?:$|\?)/i.test(String(url || ""));
}
function loadPDFJS() {
if (!pdfjsPromise) {
pdfjsPromise = import("https://cdnjs.cloudflare.com/ajax/libs/pdf.js/6.2.108/pdf.min.mjs").then((pdfjs) => {
pdfjs.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/6.2.108/pdf.worker.min.mjs";
return pdfjs;
}).catch((error) => {
pdfjsPromise = null;
throw error;
});
}
return pdfjsPromise;
}
async function readPDFRange(response, begin, end, expectedLength = null) {
if (response.status !== 206) {
if (response.body) await response.body.cancel();
throw new Error(`PDF range request failed: ${response.status}`);
}
const contentRange = response.headers.get("Content-Range") || "";
const match = /^bytes\s+(\d+)-(\d+)\/(\d+)$/.exec(contentRange);
const rangeBegin = match ? Number(match[1]) : -1;
const rangeEnd = match ? Number(match[2]) : -1;
const length = match ? Number(match[3]) : 0;
const expectedEnd = Math.min(end, length) - 1;
if (!Number.isSafeInteger(length) || length <= 0 || (expectedLength != null && length !== expectedLength) || rangeBegin !== begin || rangeEnd !== expectedEnd) {
if (response.body) await response.body.cancel();
throw new Error("PDF range response is invalid");
}
const data = new Uint8Array(await response.arrayBuffer());
if (data.length !== rangeEnd - rangeBegin + 1) throw new Error("PDF range response length is invalid");
return { data, length };
}
async function fetchInitialPDFRange(url, signal) {
const initialResponse = await fetch(url, { headers: { Range: "bytes=0-524287" }, signal });
return readPDFRange(initialResponse, 0, 512 * 1024);
}
async function createPDFRangeTransport(pdfjs, url, signal, initialPromise = null) {
const initial = await (initialPromise || fetchInitialPDFRange(url, signal));
class URLRangeTransport extends pdfjs.PDFDataRangeTransport {
constructor() {
super(initial.length, initial.data, false);
this.controllers = new Set();
this.failurePromise = new Promise((_resolve, reject) => { this.rejectFailure = reject; });
this.failed = false;
}
requestDataRange(begin, end) {
const controller = new AbortController();
this.controllers.add(controller);
fetch(url, {
headers: { Range: `bytes=${begin}-${end - 1}` },
signal: controller.signal,
}).then(async (response) => {
const range = await readPDFRange(response, begin, end, initial.length);
this.onDataRange(begin, range.data);
}).catch((error) => {
this.abort(error);
}).finally(() => {
this.controllers.delete(controller);
});
}
abort(error = null) {
for (const controller of this.controllers) controller.abort();
this.controllers.clear();
if (error && !this.failed) {
this.failed = true;
this.rejectFailure(error);
}
}
}
return new URLRangeTransport();
}
function destroyPDFCacheEntry(url, entry) {
if (pdfDocumentCache.get(url) === entry) pdfDocumentCache.delete(url);
if (entry.destroyed) return;
entry.destroyed = true;
if (entry.initialController) entry.initialController.abort();
if (entry.range) entry.range.abort();
if (entry.loadingTask) entry.loadingTask.destroy().catch(() => {});
else if (entry.document) entry.document.destroy().catch(() => {});
}
function trimPDFDocumentCache() {
const limit = STATE.isMobile ? PDF_DOCUMENT_MOBILE_CACHE_LIMIT : PDF_DOCUMENT_CACHE_LIMIT;
while (pdfDocumentCache.size > limit) {
const candidate = [...pdfDocumentCache.keys()].find((url) => !activePDFViewer || activePDFViewer.url !== url);
if (!candidate) break;
destroyPDFCacheEntry(candidate, pdfDocumentCache.get(candidate));
}
}
function cleanupPDFDocumentCache(force = false) {
clearTimeout(pdfCacheCleanupTimer);
pdfCacheCleanupTimer = 0;
const now = Date.now();
for (const [url, entry] of [...pdfDocumentCache]) {
const active = activePDFViewer && activePDFViewer.url === url;
if (force || (!active && now - entry.lastUsed >= PDF_DOCUMENT_CACHE_IDLE_MS)) destroyPDFCacheEntry(url, entry);
}
if (!force && pdfDocumentCache.size) pdfCacheCleanupTimer = setTimeout(cleanupPDFDocumentCache, 60 * 1000);
}
function schedulePDFCacheCleanup() {
if (!pdfCacheCleanupTimer) pdfCacheCleanupTimer = setTimeout(cleanupPDFDocumentCache, 60 * 1000);
}
async function getCachedPDFDocument(url) {
const cached = pdfDocumentCache.get(url);
if (cached) {
pdfDocumentCache.delete(url);
cached.lastUsed = Date.now();
pdfDocumentCache.set(url, cached);
schedulePDFCacheCleanup();
return cached.promise;
}
const entry = { promise: null, initialController: new AbortController(), range: null, loadingTask: null, document: null, destroyed: false, lastUsed: Date.now() };
const pdfjsLoad = loadPDFJS();
const initialLoad = fetchInitialPDFRange(url, entry.initialController.signal);
entry.promise = Promise.all([pdfjsLoad, initialLoad]).then(async ([pdfjs, initial]) => {
const range = await createPDFRangeTransport(pdfjs, url, entry.initialController.signal, Promise.resolve(initial));
entry.range = range;
entry.loadingTask = pdfjs.getDocument({
range,
disableStream: true,
disableAutoFetch: true,
disableRange: false,
rangeChunkSize: 512 * 1024,
});
range.failurePromise.catch(() => {
destroyPDFCacheEntry(url, entry);
});
try {
entry.document = await Promise.race([entry.loadingTask.promise, range.failurePromise]);
return entry.document;
} catch (error) {
destroyPDFCacheEntry(url, entry);
throw error;
}
}).catch((error) => {
if (pdfDocumentCache.get(url) === entry) pdfDocumentCache.delete(url);
throw error;
});
pdfDocumentCache.set(url, entry);
trimPDFDocumentCache();
schedulePDFCacheCleanup();
return entry.promise;
}
function clearActiveMediaViewer() {
if (cancelPageImagePrefetch) cancelPageImagePrefetch();
cancelPageImagePrefetch = null;
if (pageImagePrefetchController) pageImagePrefetchController.abort();
pageImagePrefetchController = null;
DOM.previewPanel.querySelectorAll("[data-page-image-viewer]").forEach((viewer) => {
if (viewer.pageImageAbortController) {
viewer.pageImageAbortController.abort();
viewer.pageImageAbortController = null;
}
});
if (activeImageResizeObserver) activeImageResizeObserver.disconnect();
cancelAnimationFrame(activeImageFitFrame);
activeImageFitFrame = 0;
activeImageResizeObserver = null;
if (!activePDFViewer) return;
activePDFViewer.generation += 1;
if (activePDFViewer.renderTask) activePDFViewer.renderTask.cancel();
if (activePDFViewer.resizeObserver) activePDFViewer.resizeObserver.disconnect();
cancelAnimationFrame(activePDFViewer.resizeFrame || 0);
const entry = pdfDocumentCache.get(activePDFViewer.url);
if (entry && !entry.document) destroyPDFCacheEntry(activePDFViewer.url, entry);
activePDFViewer = null;
}
async function renderPDFViewer(viewer) {
const url = viewer.dataset.pdfUrl;
const download = viewer.dataset.pdfDownload;
const canvas = viewer.querySelector("[data-pdf-canvas]");
const stage = viewer.querySelector(".proofread-pdf-stage");
const status = viewer.querySelector("[data-pdf-status]");
const pageInput = viewer.querySelector("[data-pdf-page]");
if (!url || !canvas) return;
if (activePDFViewer) clearActiveMediaViewer();
const lifecycle = { viewer, url, generation: 0, renderTask: null, resizeObserver: null, resizeFrame: 0 };
activePDFViewer = lifecycle;
let pdf;
let pageNumber = Math.max(1, Number(viewer.dataset.pdfPage) || 1);
let zoomOffset = 0;
const setStatus = (text) => { if (status) status.textContent = text; };
const showError = (error) => {
if (activePDFViewer !== lifecycle || error && error.name === "RenderingCancelledException") return;
if (stage) {
stage.classList.remove("is-loading");
stage.classList.add("is-error");
}
setStatus("PDF 无法在线预览");
if (!viewer.querySelector("[data-pdf-download-link], [data-pdf-fallback]")) {
const fallback = document.createElement("a");
fallback.className = "text-btn-sm";
fallback.dataset.pdfFallback = "true";
fallback.href = download || url;
fallback.textContent = "下载原文件";
viewer.append(fallback);
}
};
const renderPage = async () => {
const generation = lifecycle.generation + 1;
lifecycle.generation = generation;
if (lifecycle.renderTask) {
lifecycle.renderTask.cancel();
try { await lifecycle.renderTask.promise; } catch (_error) { /* a newer render superseded it */ }
lifecycle.renderTask = null;
}
pageNumber = Math.min(pdf.numPages, Math.max(1, pageNumber));
const page = await pdf.getPage(pageNumber);
if (activePDFViewer !== lifecycle || generation !== lifecycle.generation || !viewer.isConnected) return;
const unitViewport = page.getViewport({ scale: 1 });
const availableWidth = Math.max(1, (stage ? stage.clientWidth : unitViewport.width) - 32);
const fitScale = Math.min(1.5, Math.max(0.5, availableWidth / unitViewport.width));
const scale = Math.min(3, Math.max(0.5, fitScale + zoomOffset));
const viewport = page.getViewport({ scale });
const context = canvas.getContext("2d");
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
canvas.style.width = `${Math.ceil(viewport.width)}px`;
canvas.style.height = `${Math.ceil(viewport.height)}px`;
lifecycle.renderTask = page.render({ canvasContext: context, viewport });
try {
await lifecycle.renderTask.promise;
} catch (error) {
if (error && error.name === "RenderingCancelledException") return false;
throw error;
} finally {
if (generation === lifecycle.generation) lifecycle.renderTask = null;
}
if (activePDFViewer !== lifecycle || generation !== lifecycle.generation || !viewer.isConnected) return false;
if (pageInput) {
pageInput.value = String(pageNumber);
pageInput.max = String(pdf.numPages);
}
viewer.dataset.pdfPage = String(pageNumber);
STATE.proofreadPage = String(pageNumber);
syncUrl(true);
setStatus(`${pageNumber} / ${pdf.numPages}`);
return true;
};
const requestRender = () => { renderPage().catch(showError); };
try {
setStatus("正在加载 PDF...");
pdf = await getCachedPDFDocument(url);
if (activePDFViewer !== lifecycle || !viewer.isConnected) return;
const rendered = await renderPage();
if (!rendered) return;
if (stage) stage.classList.remove("is-loading");
viewer.querySelectorAll("[data-pdf-action]").forEach((button) => {
button.addEventListener("click", async () => {
const action = button.dataset.pdfAction;
if (action === "prev") pageNumber -= 1;
else if (action === "next") pageNumber += 1;
else if (action === "zoom-in") zoomOffset = Math.min(2.5, zoomOffset + 0.2);
else if (action === "zoom-out") zoomOffset = Math.max(-1, zoomOffset - 0.2);
else if (action === "reset") zoomOffset = 0;
requestRender();
});
});
if (pageInput) pageInput.addEventListener("change", () => { pageNumber = Number(pageInput.value) || 1; requestRender(); });
if (stage && typeof ResizeObserver !== "undefined") {
let lastWidth = stage.clientWidth;
lifecycle.resizeObserver = new ResizeObserver(() => {
const width = stage.clientWidth;
if (Math.abs(width - lastWidth) < 2) return;
lastWidth = width;
cancelAnimationFrame(lifecycle.resizeFrame);
lifecycle.resizeFrame = requestAnimationFrame(requestRender);
});
lifecycle.resizeObserver.observe(stage);
}
} catch (error) {
showError(error);
}
}
async function openRandomPreview() {
const requestSeq = randomRequestSeq + 1;
randomRequestSeq = requestSeq;
if (randomAbortController) randomAbortController.abort();
randomAbortController = new AbortController();
try {
const item = await apiWithTimeout("/api/random", { signal: randomAbortController.signal });
if (requestSeq !== randomRequestSeq) return;
randomAbortController = null;
openPreview(item.doc_id);
} catch (error) {
if (error.name === "AbortError" || requestSeq !== randomRequestSeq) return;
showToast("随机文章加载失败");
}
}
function diffHTML(leftText, rightText) {
const left = splitDiffLines(leftText);
const right = splitDiffLines(rightText);
const maxLines = 1200;
if (left.length > maxLines || right.length > maxLines) {
return { left: escapeHTML(leftText), right: escapeHTML(rightText) };
}
const dp = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
dp[i][j] = left[i] === right[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const outLeft = [];
const outRight = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
const line = `${escapeHTML(left[i])}\n`;
outLeft.push(line);
outRight.push(line);
i += 1;
j += 1;
} else if (j >= right.length || (i < left.length && dp[i + 1][j] >= dp[i][j + 1])) {
outLeft.push(`<span class="diff-line diff-del">${escapeHTML(left[i])}</span>\n`);
outRight.push(`<span class="diff-line diff-gap"></span>\n`);
i += 1;
} else {
outLeft.push(`<span class="diff-line diff-gap"></span>\n`);
outRight.push(`<span class="diff-line diff-add">${escapeHTML(right[j])}</span>\n`);
j += 1;
}
}
return { left: outLeft.join(""), right: outRight.join("") };
}
function splitDiffLines(text) {
return String(text).split(/\n+/).map((line) => line.trim()).filter(Boolean);
}
function previewTitleHTML(item) {
return `<div class="preview-title">${escapeHTML(item.title)}</div>`;
}
function previewMetaHTML(item) {
const authors = item.authors && item.authors.length ? ` / ${escapeHTML(item.authors.join(", "))}` : "";
const tagNames = item.tag_names || [];
const pageSize = 8;
const totalPages = Math.ceil(tagNames.length / pageSize);
const tagItems = tagNames.map((tag, index) => {
const page = Math.floor(index / pageSize) + 1;
const hasNextOnPage = index + 1 < tagNames.length && Math.floor((index + 1) / pageSize) + 1 === page;
return `<span data-preview-tag-page="${page}" ${page > 1 ? "hidden" : ""}>${escapeHTML(tag)}${hasNextOnPage ? '<span class="path-sep"> / </span>' : ""}</span>`;
}).join("");
const pager = totalPages > 1 ? `<div class="preview-tag-pager"><button type="button" class="text-btn-sm" data-preview-tag-page-change="prev" disabled>上一页</button><span><span data-preview-tag-current>1</span> / ${totalPages}</span><button type="button" class="text-btn-sm" data-preview-tag-page-change="next">下一页</button></div>` : "";
const tags = tagNames.length ? `<div class="preview-tags" data-preview-tags data-total-pages="${totalPages}"><div class="preview-path preview-tag-list">${tagItems}</div>${pager}</div>` : "";
return `<div class="preview-meta-row"><div class="preview-path preview-source-author">${escapeHTML(item.publication_name)}${authors}</div>${tags}</div>`;
}
function changePreviewTagPage(button) {
const container = button.closest("[data-preview-tags]");
if (!container) return;
const totalPages = parseInt(container.dataset.totalPages || "1", 10);
const currentNode = container.querySelector("[data-preview-tag-current]");
const current = parseInt((currentNode && currentNode.textContent) || "1", 10);
const next = Math.min(totalPages, Math.max(1, current + (button.dataset.previewTagPageChange === "next" ? 1 : -1)));
container.querySelectorAll("[data-preview-tag-page]").forEach((tag) => { tag.hidden = parseInt(tag.dataset.previewTagPage, 10) !== next; });
if (currentNode) currentNode.textContent = String(next);
const prev = container.querySelector('[data-preview-tag-page-change="prev"]');
const nextButton = container.querySelector('[data-preview-tag-page-change="next"]');
if (prev) prev.disabled = next <= 1;
if (nextButton) nextButton.disabled = next >= totalPages;
}
function compareSelectHTML(item) {
const proofread = item.article && Array.isArray(item.article.parts)
? `<option value="__proofread__">${item.source_files && item.source_files.length ? "对比原文件并校对" : "校对"}</option>`
: "";
if ((!item.variants || !item.variants.length) && !proofread) return "";
const options = item.variants.map((variant) => `<option value="${escapeHTML(variant.doc_id)}">${escapeHTML(variant.publication_name)}</option>`).join("");
return `<label class="preview-compare-control" title="选择对比版本"><span>对比版本</span><select data-compare-select aria-label="选择对比版本"><option value="">对比版本</option>${proofread}${options}</select></label>`;
}
function textDelta(original, edited) {
if (original === edited) return "";
if (typeof diff_match_patch !== "undefined") {
const differ = new diff_match_patch();
differ.Diff_Timeout = 1;
return differ.diff_main(original, edited, true).map(([operation, text]) => {
if (operation === 0) return `=${text.length}`;
if (operation === -1) return `-${text.length}`;
return `+${text.replace(/%/g, "%25").replace(/\t/g, "%09")}`;
}).join("\t");
}
let prefix = 0;
while (prefix < original.length && prefix < edited.length && original[prefix] === edited[prefix]) prefix += 1;
let suffix = 0;
while (suffix < original.length - prefix && suffix < edited.length - prefix && original[original.length - 1 - suffix] === edited[edited.length - 1 - suffix]) suffix += 1;
const removedLength = original.length - prefix - suffix;
const inserted = edited.slice(prefix, edited.length - suffix);
return [
prefix ? `=${prefix}` : "",
removedLength ? `-${removedLength}` : "",
inserted ? `+${inserted.replace(/%/g, "%25").replace(/\t/g, "%09")}` : "",
suffix ? `=${suffix}` : "",
].filter(Boolean).join("\t");
}
function loadDiffMatchPatch() {
if (typeof diff_match_patch !== "undefined") return Promise.resolve();
if (diffMatchPatchPromise) return diffMatchPatchPromise;
diffMatchPatchPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = DIFF_MATCH_PATCH_URL;
script.integrity = DIFF_MATCH_PATCH_INTEGRITY;
script.crossOrigin = "anonymous";
script.referrerPolicy = "no-referrer";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}).catch((error) => {
diffMatchPatchPromise = null;
console.warn("校订差异库加载失败", error);
});
return diffMatchPatchPromise;
}
function applyTextDelta(original, delta) {
if (!delta) return String(original);
const source = String(original);
let cursor = 0;
let output = "";
for (const token of String(delta).split("\t")) {
if (!token) continue;
const operation = token[0];
const value = token.slice(1);
if (operation === "=") {
const size = parseInt(value, 10) || 0;
output += source.slice(cursor, cursor + size);
cursor += size;
} else if (operation === "-") {
cursor += parseInt(value, 10) || 0;
} else if (operation === "+") {
try { output += decodeURIComponent(value.replace(/\+/g, "%2B")); } catch (_error) { /* validated by the server */ }
}
}
if (cursor !== source.length) throw new Error("delta length mismatch");
return output;
}
function partTextWithPivots(article, partIndex) {
const chars = Array.from(String(article.parts[partIndex].text || ""));
(article.comment_pivots || [])
.filter((pivot) => Number(pivot.part_idx) === partIndex)
.sort((a, b) => Number(b.offset) - Number(a.offset))
.forEach((pivot) => chars.splice(Number(pivot.offset), 0, `〔${pivot.index}〕`));
return chars.join("");
}
function listEditorHTML(kind, values, fields) {
const rows = values.map((value) => {
const inputs = fields.map((field) => {
const current = typeof value === "object" ? (value[field.key] == null ? "" : value[field.key]) : value;
if (field.options) return `<select data-list-field="${field.key}" aria-label="${field.label}"><option value="">${field.label}</option>${field.options.map((option) => `<option value="${escapeHTML(option)}" ${current === option ? "selected" : ""}>${escapeHTML(option)}</option>`).join("")}</select>`;
return `<input data-list-field="${field.key}" type="${field.type || "text"}" placeholder="${field.label}" value="${escapeHTML(current)}">`;
}).join("");
return `<div class="proofread-list-row" data-list-row>${inputs}<button type="button" class="icon-btn-sm" data-remove-list aria-label="删除条目">×</button></div>`;
}).join("");
return `<div class="proofread-list" data-list="${kind}">${rows}<button type="button" class="text-btn-sm" data-add-list="${kind}">添加</button></div>`;
}
function proofreadHTML(item) {
const article = item.article || {};
const sourceFiles = item.source_files || [];
const requestedPage = Math.max(1, Number(article.page_start) || 1);
const defaultFile = STATE.proofreadFile !== "" ? Math.max(0, Math.min(sourceFiles.length - 1, Number(STATE.proofreadFile) || 0)) : 0;
const selectedUrl = sourceFiles[defaultFile] && sourceFiles[defaultFile].url || "";
const page = Math.max(1, Number(STATE.proofreadPage) || requestedPage);
const sourcePicker = sourceFiles.length > 1 ? `<select data-proofread-source-file aria-label="选择原文件">${sourceFiles.map((file) => `<option value="${file.index}" ${Number(file.index) === defaultFile ? "selected" : ""}>${escapeHTML(file.name)}</option>`).join("")}</select>` : "";
const selectedFile = sourceFiles[defaultFile] || {};
const sourceURL = sourceDownloadURL(item, defaultFile, true);
const sourceDownload = sourceDownloadURL(item, defaultFile);
const sourceViewer = sourceFiles.length ? (isImageSource(selectedUrl)
? `<div class="proofread-image-viewer" data-image-viewer><div class="proofread-image-tools"><button type="button" class="text-btn-sm" data-image-zoom="out">缩小</button><button type="button" class="text-btn-sm" data-image-zoom="reset">原始大小</button><button type="button" class="text-btn-sm" data-image-zoom="in">放大</button><a class="text-btn-sm proofread-image-download" href="${sourceDownload}">下载原文件</a></div><div class="proofread-image-stage"><img data-proofread-source-image src="${sourceURL}" alt="原文件" loading="eager" decoding="async" fetchpriority="high" data-image-scale="1"></div></div>`
: (isPDFSource(selectedUrl) && Array.isArray(selectedFile.pages) && selectedFile.pages.length
? pageImageViewerHTML(selectedFile, page, sourceDownload)
: (isPDFSource(selectedUrl)
? `<div class="proofread-document-viewer" data-pdf-viewer data-pdf-url="${escapeHTML(sourceURL)}" data-pdf-download="${escapeHTML(sourceDownload)}" data-pdf-page="${page}"><div class="proofread-pdf-tools"><button type="button" class="text-btn-sm" data-pdf-action="prev">上一页</button><button type="button" class="text-btn-sm" data-pdf-action="next">下一页</button><button type="button" class="text-btn-sm" data-pdf-action="zoom-out">缩小</button><button type="button" class="text-btn-sm" data-pdf-action="reset">原始大小</button><button type="button" class="text-btn-sm" data-pdf-action="zoom-in">放大</button><label>页码 <input data-pdf-page type="number" min="1" value="${page}"></label><span data-pdf-status>正在加载 PDF...</span><a class="text-btn-sm proofread-document-download" data-pdf-download-link href="${sourceDownload}">下载原文件</a></div><div class="proofread-pdf-stage is-loading"><canvas data-pdf-canvas></canvas></div></div>`
: `<div class="proofread-no-source"><strong>此原文件暂不支持在线预览</strong><a class="text-btn-sm" href="${sourceDownload}">下载原文件</a></div>`)))
: '<div class="proofread-no-source"><strong>没有原文件</strong><span>仍可编辑正文并提交校对。</span></div>';
const partTypeOptions = (current) => PART_TYPES.map((type) => `<option value="${type}" ${type === current ? "selected" : ""}>${type}</option>`).join("");
const parts = (article.parts || []).map((part, index) => `<div class="proofread-part-group" data-proofread-part-group="${index}"><div data-part-insert-before></div><label class="proofread-field proofread-part" data-proofread-part="${index}"><span>段落 ${index + 1} / <select data-proofread-part-type="${index}">${partTypeOptions(part.type || "paragraph")}</select></span><span class="proofread-part-actions"><button type="button" class="text-btn-sm" data-add-part-before="${index}">在上新建段落</button><button type="button" class="text-btn-sm" data-add-part-after="${index}">在下新建段落</button><button type="button" class="text-btn-sm" data-delete-part="${index}">删除当前段落</button></span><textarea data-proofread-part-input="${index}">${escapeHTML(partTextWithPivots(article, index))}</textarea></label><div data-part-insert-after></div></div>`).join("");
const comments = (article.comments || []).map((comment, index) => `<label class="proofread-field"><span>注释 ${index + 1}</span><textarea data-proofread-comment="${index}">${escapeHTML(comment)}</textarea></label>`).join("");
const tags = Array.isArray(article.tags) ? article.tags : [];
const metadata = `<fieldset><legend>文章元数据</legend><label class="proofread-field"><span>标题</span><input data-meta-title value="${escapeHTML(article.title || item.title || "")}"></label><label class="proofread-field"><span>作者</span>${listEditorHTML("authors", article.authors || item.authors || [], [{ key: "value", label: "作者" }])}</label><label class="proofread-field"><span>日期</span>${listEditorHTML("dates", article.dates || [], [{ key: "year", label: "年", type: "number" }, { key: "month", label: "月", type: "number" }, { key: "day", label: "日", type: "number" }])}</label><label class="proofread-field"><span>标签</span>${listEditorHTML("tags", tags, [{ key: "name", label: "名称" }, { key: "type", label: "类型", options: TAG_TYPES }])}</label></fieldset>`;
const sourceMetadata = `<fieldset><legend>来源元数据</legend><label class="proofread-field"><span>来源名称</span><input data-source-name value="${escapeHTML(item.publication_name || "")}"></label><label class="proofread-field"><span>来源作者</span><input data-source-author value="${escapeHTML(item.publication_author || "")}"></label><label class="proofread-field"><span>来源类型</span><input data-source-type value="${escapeHTML(item.publication_type || "")}"></label><label class="proofread-field"><span>来源文件</span>${listEditorHTML("files", sourceFiles.map((file) => file.url), [{ key: "value", label: "URL" }])}</label></fieldset>`;
const mobileModeLabel = STATE.isMobile ? "切换到电脑模式" : "切换到手机模式";
const actions = `<div class="preview-tools proofread-tools"><button type="button" class="icon-btn-sm" data-close-preview aria-label="关闭预览" title="关闭预览">×</button><button type="button" class="icon-btn-sm proofread-mode-btn" data-preview-mobile aria-label="${mobileModeLabel}" title="${mobileModeLabel}"><span class="ui-icon ${STATE.isMobile ? "ui-icon-phone" : "ui-icon-desktop"}" aria-hidden="true"></span></button><button type="button" class="icon-btn-sm preview-theme-btn" data-preview-theme aria-label="切换到白天模式" title="白天模式">${THEME_ICON[STATE.isDark ? "dark" : "light"]}</button></div>`;
const fulltextToggle = `<button type="button" class="text-btn-sm" data-fulltext-toggle>收起全文编辑</button>`;
const partToggle = `<button type="button" class="text-btn-sm" data-part-editor-toggle>段落编辑</button>`;
const fulltextBox = `<div data-fulltext-box><textarea data-fulltext-input>${escapeHTML((article.parts || []).map((_part, index) => partTextWithPivots(article, index)).join("\n"))}</textarea><button type="button" class="text-btn-sm" data-fulltext-apply>应用到段落</button></div>`;
const fulltextSection = `<div class="proofread-fulltext-section" data-fulltext-section><div class="proofread-form-actions">${fulltextToggle}</div>${fulltextBox}</div>`;
const bodyFieldset = `<fieldset><legend>正文</legend><div class="proofread-form-actions">${STATE.isMobile ? partToggle : fulltextToggle + partToggle}</div>${STATE.isMobile ? "" : fulltextBox}<div data-part-editor hidden>${parts}${comments}</div><label class="proofread-field"><span>正文说明</span><textarea data-proofread-description>${escapeHTML(article.description || "")}</textarea></label></fieldset>`;
const noteField = `<label class="proofread-field"><span>校订备注</span><textarea data-proofread-note></textarea></label>`;
return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}<div class="preview-compare-note"><span>对比原文件并校对</span><button type="button" class="text-btn-sm proofread-back-btn" data-proofread-back>返回预览</button></div></div><div class="preview-actions">${actions}</div></div><div class="proofread-grid"><section class="proofread-source"><div class="proofread-pane-header"><span>原文件</span>${sourcePicker}</div>${sourceViewer}</section><form class="proofread-editor" data-proofread-form><div class="proofread-pane-header"><span>校订内容</span><button type="submit" class="result-action-btn primary">提交校订</button></div>${STATE.isMobile ? fulltextSection : ""}${metadata}${sourceMetadata}${bodyFieldset}${noteField}</form></div>`;
}
function openProofread(item, focusPart = null, restoring = false) {
if (!item) return;
stopPreviewAnimation();
loadDiffMatchPatch();
flushProofreadDraft();
STATE.proofread = true;
STATE.proofreadItem = item;
STATE.compare = "";
if (!restoring) syncUrl(true);
clearActiveMediaViewer();
DOM.previewPanel.classList.add("proofread-mode");
DOM.previewPanel.innerHTML = proofreadHTML(item);
if (!(item.source_files || []).length) {
const sourcePane = DOM.previewPanel.querySelector(".proofread-source");
if (sourcePane) sourcePane.remove();
const grid = DOM.previewPanel.querySelector(".proofread-grid");
if (grid) grid.classList.add("proofread-grid-no-source");
}
const sourceImage = DOM.previewPanel.querySelector("[data-proofread-source-image]");
if (sourceImage) {
const fitImage = () => {
const stage = sourceImage.closest(".proofread-image-stage");
const currentSource = sourceImage.currentSrc || sourceImage.src;
if (!stage || !sourceImage.naturalWidth || sourceImage.dataset.imageLoadedSrc !== currentSource) return;
sourceImage.hidden = false;
sourceImage.style.removeProperty("display");
const error = stage.querySelector("[data-image-error]");
if (error) error.remove();
const availableWidth = Math.max(1, stage.clientWidth - 32);
const fitScale = Math.min(availableWidth / sourceImage.naturalWidth, 1);
const baseWidth = Math.max(1, Math.round(sourceImage.naturalWidth * fitScale));
sourceImage.dataset.imageBaseWidth = String(baseWidth);
sourceImage.style.maxWidth = "none";
sourceImage.style.width = `${baseWidth}px`;
sourceImage.style.height = "auto";
};
const imageLoaded = () => {
sourceImage.dataset.imageLoadedSrc = sourceImage.currentSrc || sourceImage.src;
fitImage();
const pageViewer = sourceImage.closest("[data-page-image-viewer]");
if (pageViewer) prefetchAdjacentPageImage(pageViewer);
};
sourceImage.addEventListener("load", imageLoaded);
if (sourceImage.complete && sourceImage.naturalWidth) imageLoaded();
if (typeof ResizeObserver !== "undefined") {
let lastStageWidth = 0;
let lastStageHeight = 0;
activeImageResizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(activeImageFitFrame);
activeImageFitFrame = requestAnimationFrame(() => {
const stage = sourceImage.closest(".proofread-image-stage");
const stageWidth = stage ? stage.clientWidth : 0;
const stageHeight = stage ? stage.clientHeight : 0;
if (Math.abs(stageWidth - lastStageWidth) > 2 || Math.abs(stageHeight - lastStageHeight) > 2) {
lastStageWidth = stageWidth;
lastStageHeight = stageHeight;
fitImage();
}
const scale = Number(sourceImage.dataset.imageScale || 1);
const baseWidth = Number(sourceImage.dataset.imageBaseWidth) || sourceImage.naturalWidth;
sourceImage.style.width = `${Math.max(1, baseWidth * scale)}px`;
});
});
const stage = sourceImage.closest(".proofread-image-stage");
if (stage) activeImageResizeObserver.observe(stage);
}
sourceImage.addEventListener("error", () => {
const stage = sourceImage.closest("[data-image-stage], .proofread-image-stage");
delete sourceImage.dataset.imageLoadedSrc;
sourceImage.hidden = true;
sourceImage.style.display = "none";
if (stage && !stage.querySelector("[data-image-error]")) {
stage.insertAdjacentHTML("beforeend", `<div class="proofread-no-source" data-image-error><strong>图片无法在线显示</strong><a class="text-btn-sm" href="${sourceDownloadURL(item, STATE.proofreadFile || 0)}">下载原文件</a></div>`);
}
});
}
const form = DOM.previewPanel.querySelector("[data-proofread-form]");
const pdfViewer = DOM.previewPanel.querySelector("[data-pdf-viewer]");
if (pdfViewer) renderPDFViewer(pdfViewer);
if (form) {
form.classList.add("proofread-structure-closed");
const partEditor = form.querySelector("[data-part-editor]");
if (partEditor) partEditor.hidden = false;
form.querySelectorAll("[data-proofread-part-type]").forEach((select) => {
select.insertAdjacentHTML("afterend", `<span class="proofread-part-type-label">${escapeHTML(select.value)}</span>`);
});
const structureActions = form.querySelector("fieldset .proofread-form-actions");
const descriptionInput = form.querySelector("[data-proofread-description]");
const descriptionField = descriptionInput ? descriptionInput.closest(".proofread-field") : null;
const descriptionLabel = descriptionField && descriptionField.querySelector("span");
if (structureActions && descriptionLabel) {
descriptionLabel.classList.add("proofread-description-heading");
descriptionLabel.append(structureActions);
const bodyFieldset = descriptionField.closest("fieldset");
const bodyLegend = bodyFieldset && bodyFieldset.querySelector("legend");
if (bodyFieldset) bodyFieldset.insertBefore(descriptionField, bodyLegend ? bodyLegend.nextSibling : bodyFieldset.firstChild);
}
}
const sourceHeader = DOM.previewPanel.querySelector(".proofread-source .proofread-pane-header");
if (sourceHeader && !sourceHeader.querySelector("[data-proofread-page]") && !DOM.previewPanel.querySelector("[data-page-image-viewer], [data-pdf-viewer]")) {
sourceHeader.insertAdjacentHTML("beforeend", `<label class="proofread-page-control">页码 <input data-proofread-page type="number" min="1" value="${Math.max(1, Number(STATE.proofreadPage) || Number((item.article || {}).page_start) || 1)}"></label>`);
}
if (form && !form.querySelector("[data-fulltext-box]")) {
const text = (item.article && item.article.parts || []).map((_part, index) => partTextWithPivots(item.article, index)).join("\n");
form.insertAdjacentHTML("afterbegin", `<div data-fulltext-box><textarea data-fulltext-input>${escapeHTML(text)}</textarea><button type="button" class="text-btn-sm" data-fulltext-apply>应用到段落</button></div>`);
const header = form.querySelector(".proofread-pane-header");
if (header) header.insertAdjacentHTML("afterbegin", `<button type="button" class="text-btn-sm" data-fulltext-toggle>收起全文编辑</button>`);
}
restoreProofreadDraft(item, form);
syncFulltextFromParts(form);
if (!STATE.proofreadFile) { const sourceInput = DOM.previewPanel.querySelector("[data-proofread-source-file]"); STATE.proofreadFile = sourceInput ? sourceInput.value : "0"; }
if (!STATE.proofreadPage) { const pageInput = form && form.querySelector("[data-proofread-page]"); STATE.proofreadPage = pageInput ? pageInput.value : String(Math.max(1, Number(item.article && item.article.page_start) || 1)); }
if (!restoring) syncUrl(true);
if (focusPart != null) requestAnimationFrame(() => {
const input = DOM.previewPanel.querySelector(`[data-proofread-part-input="${focusPart}"]`);
if (input) input.focus();
});
}
function readList(form, kind) {
return [...form.querySelectorAll(`[data-list="${kind}"] [data-list-row]`)].map((row) => {
const values = {};
row.querySelectorAll("[data-list-field]").forEach((input) => {
if (input.value !== "") values[input.dataset.listField] = input.type === "number" ? Number(input.value) : input.value.trim();
});
return kind === "authors" || kind === "files" ? (values.value || "") : values;
}).filter((value) => typeof value === "string" ? Boolean(value) : Object.keys(value).length);
}
function proofreadDraftKey(docId) {
return `bha-proofread-draft:${docId}`;
}
function safeSessionGet(key) {
try { return sessionStorage.getItem(key); } catch (_error) { return null; }
}
function safeSessionSet(key, value) {
try { sessionStorage.setItem(key, value); } catch (_error) { /* private browsing may disable storage */ }
}
function collectProofreadDraft(form) {
if (!form) return null;
const lists = {};
for (const kind of ["authors", "dates", "tags", "files"]) lists[kind] = readList(form, kind);
return {
parts: Object.fromEntries([...form.querySelectorAll("[data-proofread-part-input]")].map((input) => [input.dataset.proofreadPartInput, input.value])),
comments: Object.fromEntries([...form.querySelectorAll("[data-proofread-comment]")].map((input) => [input.dataset.proofreadComment, input.value])),
title: (form.querySelector("[data-meta-title]") || {}).value || "",
source: Object.fromEntries(["name", "author", "type"].map((key) => { const input = form.querySelector(`[data-source-${key}]`); return [key, input ? input.value : ""]; })),
lists,
note: (form.querySelector("[data-proofread-note]") || {}).value || "",
description: (form.querySelector("[data-proofread-description]") || {}).value || "",
page: ((DOM.previewPanel && DOM.previewPanel.querySelector("[data-proofread-page], [data-pdf-page], [data-page-image-input]") || {}).value) || "",
newParts: [...form.querySelectorAll("[data-proofread-new-part]")].map((row) => ({
text: (row.querySelector("textarea") || {}).value || "",
type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph",
group: ((row.closest("[data-proofread-part-group]") || {}).dataset || {}).proofreadPartGroup || "",
before: Boolean(row.parentElement && row.parentElement.hasAttribute("data-part-insert-before")),
})),
deleted: Object.fromEntries([...form.querySelectorAll("[data-delete-part]")].filter((button) => button.dataset.proofreadDeleted === "true").map((button) => [button.dataset.deletePart, true])),
types: Object.fromEntries([...form.querySelectorAll("[data-proofread-part-type]")].map((input) => [input.dataset.proofreadPartType, input.value])),
};
}
function flushProofreadDraft() {
clearTimeout(proofreadDraftTimer);
proofreadDraftTimer = 0;
if (!STATE.preview) return;
const draft = collectProofreadDraft(DOM.previewPanel.querySelector("[data-proofread-form]"));
if (draft) safeSessionSet(proofreadDraftKey(STATE.preview), JSON.stringify(draft));
}
function saveProofreadDraft() {
clearTimeout(proofreadDraftTimer);
proofreadDraftTimer = setTimeout(flushProofreadDraft, 400);
}
function restoreProofreadDraft(item, form) {
if (!form) return;
let draft;
try { draft = JSON.parse(safeSessionGet(proofreadDraftKey(item.doc_id)) || "null"); } catch (_error) { draft = null; }
if (!draft) return;
for (const [index, value] of Object.entries(draft.parts || {})) {
const input = form.querySelector(`[data-proofread-part-input="${CSS.escape(index)}"]`);
if (input) input.value = value;
}
for (const [index, value] of Object.entries(draft.comments || {})) {
const input = form.querySelector(`[data-proofread-comment="${CSS.escape(index)}"]`);
if (input) input.value = value;
}
if (form.querySelector("[data-meta-title]")) form.querySelector("[data-meta-title]").value = draft.title || "";
for (const key of ["name", "author", "type"]) {
const input = form.querySelector(`[data-source-${key}]`);
if (input && draft.source) input.value = draft.source[key] || "";
}
for (const kind of ["authors", "dates", "tags", "files"]) {
const values = draft.lists && draft.lists[kind];
const holder = form.querySelector(`[data-list="${kind}"]`);
if (!holder || !Array.isArray(values)) continue;
while (holder.querySelectorAll("[data-list-row]").length < values.length) {
const add = holder.querySelector(`[data-add-list="${kind}"]`);
if (add) add.click(); else break;
}
while (holder.querySelectorAll("[data-list-row]").length > values.length) {
const rows = holder.querySelectorAll("[data-list-row]");
const remove = rows[rows.length - 1].querySelector("[data-remove-list]");
if (remove) remove.click(); else break;
}
[...holder.querySelectorAll("[data-list-row]")].forEach((row, index) => {
const value = values[index];
row.querySelectorAll("[data-list-field]").forEach((input) => {
const next = typeof value === "string" ? value : value && value[input.dataset.listField];
input.value = next == null ? "" : String(next);
});
});
}
if (form.querySelector("[data-proofread-note]")) form.querySelector("[data-proofread-note]").value = draft.note || "";
if (form.querySelector("[data-proofread-description]")) form.querySelector("[data-proofread-description]").value = draft.description || "";
const draftPageInput = DOM.previewPanel.querySelector("[data-proofread-page], [data-pdf-page], [data-page-image-input]");
if (draft.page && draftPageInput) draftPageInput.value = draft.page;
for (const [index, type] of Object.entries(draft.types || {})) {
const input = form.querySelector(`[data-proofread-part-type="${CSS.escape(index)}"]`);
if (input) input.value = type;
}
for (const index of Object.keys(draft.deleted || {})) {
const button = form.querySelector(`[data-delete-part="${CSS.escape(index)}"]`);
if (button) { button.dataset.proofreadDeleted = "true"; button.textContent = "恢复"; const group = button.closest("[data-proofread-part-group]"); if (group) group.classList.add("proofread-deleted"); }
}
for (const part of draft.newParts || []) {
const target = form.querySelector(`[data-proofread-part-group="${CSS.escape(part.group)}"] [${part.before ? "data-part-insert-before" : "data-part-insert-after"}]`);
if (target) {
target.insertAdjacentHTML("beforeend", newPartHTML());
const row = target.lastElementChild;
row.querySelector("textarea").value = part.text || "";
row.querySelector("[data-new-part-type]").value = part.type || "paragraph";
}
}
}
function newPartHTML() {
return `<label class="proofread-field proofread-new-part" data-proofread-new-part><span>新增段落</span><select data-new-part-type>${PART_TYPES.map((type) => `<option value="${type}" ${type === "paragraph" ? "selected" : ""}>${type}</option>`).join("")}</select><textarea></textarea><button type="button" class="text-btn-sm" data-remove-new-part>删除</button></label>`;
}
function fullTextFromParts(form) {
const lines = [];
form.querySelectorAll("[data-proofread-part-group]").forEach((group) => {
if (group.classList.contains("proofread-deleted")) return;
group.querySelectorAll("[data-part-insert-before] > [data-proofread-new-part]").forEach((row) => {
const textarea = row.querySelector("textarea");
lines.push(textarea ? textarea.value : "");
});
const partInput = group.querySelector("[data-proofread-part-input]");
if (partInput) lines.push(partInput.value);
group.querySelectorAll("[data-part-insert-after] > [data-proofread-new-part]").forEach((row) => {
const textarea = row.querySelector("textarea");
lines.push(textarea ? textarea.value : "");
});
});
return lines.join("\n");
}
function syncFulltextFromParts(form) {
const input = form && form.querySelector("[data-fulltext-input]");
if (!input || input.dataset.fulltextDirty === "1") return;
input.value = fullTextFromParts(form);
}
function editablePartRows(form) {
const rows = [];
form.querySelectorAll("[data-proofread-part-group]").forEach((group) => {
if (group.classList.contains("proofread-deleted")) return;
group.querySelectorAll("[data-part-insert-before] > [data-proofread-new-part]").forEach((row) => rows.push(row));
const input = group.querySelector("[data-proofread-part-input]");
if (input) rows.push(input);
group.querySelectorAll("[data-part-insert-after] > [data-proofread-new-part]").forEach((row) => rows.push(row));
});
return rows;
}
function editablePartRowText(row) {
const input = row.matches("[data-proofread-new-part]") ? row.querySelector("textarea") : row;
return input ? input.value : "";
}
function editablePartRowType(row) {
const input = row.matches("[data-proofread-new-part]")
? row.querySelector("[data-new-part-type]")
: row.closest("[data-proofread-part-group]").querySelector("[data-proofread-part-type]");
return input ? input.value : "paragraph";
}
function removeEditablePartRow(row) {
if (row.matches("[data-proofread-new-part]")) {
row.remove();
return;
}
const group = row.closest("[data-proofread-part-group]");
const button = group && group.querySelector("[data-delete-part]");
if (button) {
button.dataset.proofreadDeleted = "true";
button.textContent = "恢复";
group.classList.add("proofread-deleted");
}
}
function insertEditablePartRow(form, reference, previous, text, type) {
let row;
if (previous && previous.matches("[data-proofread-new-part]")) {
previous.insertAdjacentHTML("afterend", newPartHTML());
row = previous.nextElementSibling;
} else if (previous) {
const target = previous.closest("[data-proofread-part-group]").querySelector("[data-part-insert-after]");
target.insertAdjacentHTML("beforeend", newPartHTML());
row = target.lastElementChild;
} else if (reference && reference.matches("[data-proofread-new-part]")) {
reference.insertAdjacentHTML("beforebegin", newPartHTML());
row = reference.previousElementSibling;
} else if (reference) {
const target = reference.closest("[data-proofread-part-group]").querySelector("[data-part-insert-before]");
target.insertAdjacentHTML("beforeend", newPartHTML());
row = target.lastElementChild;
} else {
const active = editablePartRows(form);
const tail = active[active.length - 1];
if (!tail) return null;
if (tail.matches("[data-proofread-new-part]")) {
tail.insertAdjacentHTML("afterend", newPartHTML());
row = tail.nextElementSibling;
} else {
const target = tail.closest("[data-proofread-part-group]").querySelector("[data-part-insert-after]");
target.insertAdjacentHTML("beforeend", newPartHTML());
row = target.lastElementChild;
}
}
row.querySelector("textarea").value = text;
row.querySelector("[data-new-part-type]").value = type || "paragraph";
return row;
}
function matchingLineAnchors(current, desired) {
const table = Array.from({ length: current.length + 1 }, () => new Uint32Array(desired.length + 1));
for (let left = current.length - 1; left >= 0; left -= 1) {
for (let right = desired.length - 1; right >= 0; right -= 1) {
table[left][right] = current[left] === desired[right]
? table[left + 1][right + 1] + 1
: Math.max(table[left + 1][right], table[left][right + 1]);
}
}
const anchors = [];
let left = 0;
let right = 0;
while (left < current.length && right < desired.length) {
if (current[left] === desired[right]) {
anchors.push([left, right]);
left += 1;
right += 1;
} else if (table[left + 1][right] >= table[left][right + 1]) {
left += 1;
} else {
right += 1;
}
}
return anchors;
}
function applyFullText(form) {
const input = form.querySelector("[data-fulltext-input]");
if (!input) return;
const lines = input.value.split("\n");
const rows = editablePartRows(form);
if (!rows.length) return;
const current = rows.map(editablePartRowText);
const anchors = [...matchingLineAnchors(current, lines), [rows.length, lines.length]];
let oldStart = 0;
let newStart = 0;
for (const [oldEnd, newEnd] of anchors) {
const paired = Math.min(oldEnd - oldStart, newEnd - newStart);
for (let offset = 0; offset < paired; offset += 1) {
const row = rows[oldStart + offset];
const target = row.matches("[data-proofread-new-part]") ? row.querySelector("textarea") : row;
target.value = lines[newStart + offset];
}
for (let index = oldStart + paired; index < oldEnd; index += 1) removeEditablePartRow(rows[index]);
const reference = oldEnd < rows.length ? rows[oldEnd] : null;
const inheritedType = paired
? editablePartRowType(rows[oldStart + paired - 1])
: (oldStart > 0 ? editablePartRowType(rows[oldStart - 1]) : (reference ? editablePartRowType(reference) : "paragraph"));
let previous = paired ? rows[oldStart + paired - 1] : (oldStart > 0 ? rows[oldStart - 1] : null);
for (let index = newStart + paired; index < newEnd; index += 1) {
previous = insertEditablePartRow(form, reference, previous, lines[index], inheritedType);
}
oldStart = oldEnd + 1;
newStart = newEnd + 1;
}
saveProofreadDraft();
delete input.dataset.fulltextDirty;
}
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
const out = {};
for (const key of Object.keys(value).sort()) {
const next = canonicalize(value[key]);
if (next === "" || next == null) continue;
out[key] = next;
}
return out;
}
return value;
}
function deepSame(left, right) {
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
}
function proofreadPayload(item, form) {
const article = item.article || {};
const patch = { version: 2, parts: {}, comments: {}, description: "" };
form.querySelectorAll("[data-proofread-part-input]").forEach((input) => {
const index = Number(input.dataset.proofreadPartInput);
const part = article.parts[index] || {};
const change = {};
const group = input.closest("[data-proofread-part-group]");
const before = group ? [...group.querySelector("[data-part-insert-before]").querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph" })) : [];
const after = group ? [...group.querySelector("[data-part-insert-after]").querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph" })) : [];
const deleted = group && group.querySelector("[data-proofread-deleted]");
if (deleted && deleted.dataset.proofreadDeleted === "true") {
const deletion = { delete: true };
if (before.length) deletion.insertBefore = before;
if (after.length) deletion.insertAfter = after;
patch.parts[index] = deletion;
return;
}
const delta = textDelta(partTextWithPivots(article, index), input.value);
if (delta) change.diff = delta;
const typeInput = form.querySelector(`[data-proofread-part-type="${index}"]`);
const type = typeInput ? typeInput.value : "";
if (type && type !== (part.type || "paragraph")) change.type = type;
if (before.length) change.insertBefore = before;
if (after.length) change.insertAfter = after;
if (Object.keys(change).length) patch.parts[index] = change;
});
form.querySelectorAll("[data-proofread-comment]").forEach((input) => {
const index = Number(input.dataset.proofreadComment);
const delta = textDelta(String((article.comments || [])[index] || ""), input.value);
if (delta) patch.comments[index + 1] = { diff: delta };
});
patch.description = textDelta(String(article.description || ""), form.querySelector("[data-proofread-description]").value);
const articleMeta = {
title: form.querySelector("[data-meta-title]").value.trim(),
authors: readList(form, "authors"), dates: readList(form, "dates"), tags: readList(form, "tags"),
};
const sourceMeta = {
name: form.querySelector("[data-source-name]").value.trim(),
author: form.querySelector("[data-source-author]").value.trim(),
type: form.querySelector("[data-source-type]").value.trim(), files: readList(form, "files"),
};
const changedPatch = Object.keys(patch.parts).length || Object.keys(patch.comments).length || patch.description;
const changedArticle = !deepSame(articleMeta, { title: article.title || item.title || "", authors: article.authors || item.authors || [], dates: article.dates || [], tags: article.tags || [] });
const changedSource = !deepSame(sourceMeta, { name: item.publication_name || "", author: item.publication_author || "", type: item.publication_type || "", files: (item.source_files || []).map((file) => file.url) });
return {
doc_id: item.doc_id,
base_content_hash: item.content_hash || null,
patch: changedPatch ? patch : null,
metadata: changedArticle || changedSource ? { ...(changedArticle ? { article: articleMeta } : {}), ...(changedSource ? { source: sourceMeta } : {}) } : null,
description: form.querySelector("[data-proofread-note]").value.trim(),
};
}
async function submitProofreadForm(form) {
let item = STATE.proofreadItem || getCachedPreview(STATE.preview);
if (!item) {
try {
item = await apiWithTimeout(`/api/preview/${encodeURIComponent(STATE.preview)}`, {}, 30000);
} catch (_error) {
item = null;
}
}
if (!item) {
showToast("校订内容已过期,请重新打开校对页");
return;
}
const payload = proofreadPayload(item, form);
flushProofreadDraft();
if (!payload.patch && !payload.metadata) {
showToast("没有需要提交的修改");
return;
}
showProofreadConfirm(item, payload);
}
function removeProofreadConfirm() {
const panel = document.querySelector("[data-proofread-confirm]");
if (panel) panel.remove();
}
function showProofreadConfirm(item, payload) {
removeProofreadConfirm();
const holder = document.createElement("div");
holder.className = "proofread-confirm";
holder.dataset.proofreadConfirm = "true";
holder.innerHTML = `<div class="proofread-confirm-panel" role="dialog" aria-modal="true" aria-label="校订提交预览"><div class="proofread-confirm-header"><span>校订提交预览</span><button type="button" class="icon-btn-sm" data-cancel-proofread aria-label="关闭预览">×</button></div><div class="proofread-confirm-body">${proofreadReviewHTML(item, payload)}</div><div class="proofread-confirm-actions"><button type="button" class="text-btn-sm" data-cancel-proofread>返回继续编辑</button><button type="button" class="result-action-btn primary" data-confirm-proofread>确认提交</button></div></div>`;
document.body.append(holder);
holder.querySelectorAll("[data-cancel-proofread]").forEach((button) => {
button.addEventListener("click", removeProofreadConfirm);
});
holder.querySelector("[data-confirm-proofread]").addEventListener("click", async () => {
const confirmButton = holder.querySelector("[data-confirm-proofread]");
confirmButton.disabled = true;
confirmButton.textContent = "提交中...";
await postProofread(payload);
confirmButton.disabled = false;
confirmButton.textContent = "确认提交";
removeProofreadConfirm();
});
}
function metadataItemText(field, value) {
if (value == null) return "";
if (typeof value === "string") return value;
if (field === "dates") {
const parts = [];
for (const [key, suffix] of [["year", "年"], ["month", "月"], ["day", "日"]]) {
if (value[key] != null) parts.push(`${value[key]}${suffix}`);
}
return parts.join("") || JSON.stringify(value);
}
if (field === "tags") {
const name = value.name || "";
return value.type ? `${name}${value.type})` : name;
}
return JSON.stringify(value);
}
function metadataCellHTML(field, value) {
const listFields = { authors: 1, dates: 1, tags: 1, files: 1 };
if (listFields[field]) {
const items = Array.isArray(value) ? value : [];
if (!items.length) return '<span class="proofread-metadata-empty">(空)</span>';
return items.map((item) => `<div>${escapeHTML(metadataItemText(field, item))}</div>`).join("");
}
const text = value == null || value === "" ? "" : String(value);
return text ? escapeHTML(text) : '<span class="proofread-metadata-empty">(空)</span>';
}
function proofreadReviewHTML(item, payload) {
const article = item.article || {};
const rows = [];
const patch = payload.patch || {};
for (const [rawIndex, change] of Object.entries(patch.parts || {})) {
const index = Number(rawIndex);
const original = partTextWithPivots(article, index);
const pieces = [];
if (change.delete) pieces.push(`<div class="proofread-confirm-field"><span class="diff-line diff-del">${escapeHTML(original)}</span></div>`);
if (change.diff) {
const edited = applyTextDelta(original, change.diff);
const diff = diffHTML(original, edited);
pieces.push(`<div class="proofread-confirm-diff"><div class="diff-pane">${diff.left}</div><div class="diff-pane">${diff.right}</div></div>`);
}
for (const key of ["insertBefore", "insertAfter"]) {
for (const part of Array.isArray(change[key]) ? change[key] : []) {
pieces.push(`<div class="proofread-confirm-newpart"><span class="proofread-confirm-parttype">${escapeHTML(part.type)}</span><span>${escapeHTML(part.text)}</span></div>`);
}
}
if (change.type) {
const previous = article.parts[index] && article.parts[index].type || "paragraph";
pieces.push(`<div class="proofread-confirm-field">段落类型:${escapeHTML(previous)}${escapeHTML(change.type)}</div>`);
}
if (pieces.length) rows.push(`<div class="proofread-confirm-item"><strong>段落 ${index + 1}</strong>${pieces.join("")}</div>`);
}
for (const [rawIndex, change] of Object.entries(patch.comments || {})) {
const index = Number(rawIndex) - 1;
const original = String((article.comments || [])[index] || "");
const pieces = [];
if (change.delete) pieces.push(`<div class="proofread-confirm-field"><span class="diff-line diff-del">${escapeHTML(original)}</span></div>`);
if (change.diff) {
const edited = applyTextDelta(original, change.diff);
const diff = diffHTML(original, edited);
pieces.push(`<div class="proofread-confirm-diff"><div class="diff-pane">${diff.left}</div><div class="diff-pane">${diff.right}</div></div>`);
}
for (const key of ["insertBefore", "insertAfter"]) {
for (const comment of Array.isArray(change[key]) ? change[key] : []) {
pieces.push(`<div class="proofread-confirm-newpart"><span class="proofread-confirm-parttype">注释</span><span>${escapeHTML(comment.text || "")}</span></div>`);
}
}
if (pieces.length) rows.push(`<div class="proofread-confirm-item"><strong>注释 ${index + 1}</strong>${pieces.join("")}</div>`);
}
if (patch.description) {
const original = String(article.description || "");
const edited = applyTextDelta(original, patch.description);
const diff = diffHTML(original, edited);
rows.push(`<div class="proofread-confirm-item"><strong>正文说明</strong><div class="proofread-confirm-diff"><div class="diff-pane">${diff.left}</div><div class="diff-pane">${diff.right}</div></div></div>`);
}
const fieldLabels = {
title: "标题", authors: "作者", dates: "日期", tags: "标签",
name: "来源名称", author: "来源作者", type: "来源类型", files: "来源文件",
};
const metadata = payload.metadata || {};
const articleOld = { title: article.title || item.title || "", authors: article.authors || item.authors || [], dates: article.dates || [], tags: article.tags || [] };
const sourceOld = { name: item.publication_name || "", author: item.publication_author || "", type: item.publication_type || "", files: (item.source_files || []).map((file) => file.url) };
const metadataRows = [];
for (const [group, values] of [["文章", metadata.article], ["来源", metadata.source]]) {
if (!values) continue;
const oldValues = group === "文章" ? articleOld : sourceOld;
for (const [key, value] of Object.entries(values)) {
if (deepSame(value, oldValues[key])) continue;
metadataRows.push(`<tr><td>${escapeHTML(group)}${escapeHTML(fieldLabels[key] || key)}</td><td>${metadataCellHTML(key, oldValues[key])}</td><td>${metadataCellHTML(key, value)}</td></tr>`);
}
}
if (metadataRows.length) {
rows.push(`<div class="proofread-confirm-item"><strong>元数据对照</strong><table class="proofread-metadata-table"><thead><tr><th>字段</th><th>原元数据</th><th>修改后元数据</th></tr></thead><tbody>${metadataRows.join("")}</tbody></table></div>`);
}
if (payload.description) {
rows.push(`<div class="proofread-confirm-item"><strong>校订备注</strong><div class="proofread-confirm-field">${escapeHTML(payload.description)}</div></div>`);
}
if (!rows.length) return '<div class="proofread-confirm-empty">没有可显示的变更</div>';
return rows.join("");
}
async function postProofread(payload) {
try {
const result = await apiWithTimeout("/api/proofread", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, 30000);
try { sessionStorage.removeItem(proofreadDraftKey(payload.doc_id)); } catch (_error) { /* storage is optional */ }
showToast(result.auto_merge ? "校订已提交,通过检查后自动合并" : "校订已提交,等待人工审核(合并对应 PR 后自动完成)");
} catch (error) {
showToast(`校订提交失败:${error && error.message ? error.message : "未知错误"}`);
}
}
async function comparePreview(docId, restoring = false) {
if (!STATE.preview || !docId) return;
stopPreviewAnimation();
const baseDocId = STATE.preview;
STATE.compare = docId;
STATE.proofread = false;
if (!restoring) syncUrl(true);
const requestSeq = compareRequestSeq + 1;
compareRequestSeq = requestSeq;
try {
const currentCached = getCachedPreview(baseDocId);
const targetCached = getCachedPreview(docId);
if (!currentCached || !targetCached) {
if (compareAbortController) compareAbortController.abort();
compareAbortController = new AbortController();
}
const [current, target] = await Promise.all([
currentCached || apiWithTimeout(`/api/preview/${encodeURIComponent(baseDocId)}`, { signal: compareAbortController.signal }),
targetCached || apiWithTimeout(`/api/preview/${encodeURIComponent(docId)}`, { signal: compareAbortController.signal }),
]);
if (requestSeq !== compareRequestSeq || STATE.preview !== baseDocId) return;
if (!currentCached) setCachedPreview(baseDocId, current);
if (!targetCached) setCachedPreview(docId, target);
compareAbortController = null;
clearActiveMediaViewer();
DOM.previewPanel.classList.remove("proofread-mode");
DOM.previewPanel.innerHTML = previewHTML(current, target);
} catch (error) {
if (error.name === "AbortError" || requestSeq !== compareRequestSeq) return;
showToast("对比加载失败");
}
}
function resetFilters() {
STATE.sources = [];
STATE.authors = [];
STATE.tags = [];
STATE.excludeSources = [];
STATE.excludeAuthors = [];
STATE.excludeTags = [];
STATE.archiveId = "";
STATE.publicationType = "";
clearDateFilter();
STATE.page = 1;
renderFilterOptions({ preserveScroll: true });
applyStateToDom();
doSearch();
}
function attachEvents() {
DOM.searchForm.addEventListener("submit", (event) => {
event.preventDefault();
STATE.parseMode = false;
STATE.q = DOM.searchInput.value.trim();
STATE.page = 1;
doSearch();
});
DOM.parseBtn.addEventListener("click", () => {
flushParseDraft();
STATE.parseMode = !STATE.parseMode;
if (STATE.parseMode) {
STATE.leftOpen = !STATE.isMobile;
STATE.sidebarExpanded = false;
}
STATE.parsePage = 1;
STATE.parseItem = null;
STATE.preview = "";
clearPreviewDom();
applyStateToDom();
syncUrl(false);
if (STATE.parseMode) loadParseQueue(); else doSearch();
});
DOM.searchInput.addEventListener("input", scheduleSearch);
DOM.searchInput.addEventListener("compositionstart", () => {
searchComposing = true;
});
DOM.searchInput.addEventListener("compositionend", () => {
searchComposing = false;
scheduleSearch();
});
DOM.leftToggle.addEventListener("click", () => {
STATE.leftOpen = !STATE.leftOpen;
if (!STATE.leftOpen) STATE.sidebarExpanded = false;
applyStateToDom();
syncUrl();
});
DOM.overlay.addEventListener("click", () => {
STATE.leftOpen = false;
STATE.sidebarExpanded = false;
applyStateToDom();
syncUrl();
});
DOM.themeBtn.addEventListener("click", toggleTheme);
DOM.mobileToggleBtn.addEventListener("click", toggleMobileMode);
DOM.sidebarExpand.addEventListener("click", () => {
STATE.sidebarExpanded = !STATE.sidebarExpanded;
applyStateToDom();
syncUrl();
});
DOM.exactToggle.addEventListener("change", () => {
STATE.exact = DOM.exactToggle.checked;
STATE.page = 1;
doSearch();
});
DOM.previewHighlightToggle.addEventListener("change", () => {
STATE.previewHighlight = DOM.previewHighlightToggle.checked;
syncUrl();
if (STATE.preview) restorePreview(false);
});
DOM.fulltextToggle.addEventListener("change", () => {
STATE.fulltext = DOM.fulltextToggle.checked;
STATE.page = 1;
doSearch();
});
DOM.pageSize.addEventListener("change", () => {
if (STATE.parseMode) {
STATE.parsePageSize = parseInt(DOM.pageSize.value, 10);
STATE.parsePage = 1;
syncUrl();
loadParseQueue();
return;
}
STATE.pageSize = parseInt(DOM.pageSize.value, 10);
STATE.page = 1;
doSearch();
});
DOM.sortFilter.addEventListener("change", () => { STATE.sort = DOM.sortFilter.value; STATE.page = 1; doSearch(); });
DOM.sourceSearch.addEventListener("input", () => { STATE.filterSearch.source = DOM.sourceSearch.value; STATE.filterPage.source = 1; scheduleFacetSearch("source"); });
DOM.authorSearch.addEventListener("input", () => { STATE.filterSearch.author = DOM.authorSearch.value; STATE.filterPage.author = 1; scheduleFacetSearch("author"); });
DOM.tagSearch.addEventListener("input", () => { STATE.filterSearch.tag = DOM.tagSearch.value; STATE.filterPage.tag = 1; scheduleFacetSearch("tag"); });
DOM.sourceFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-source-page]"); if (pageButton) { handleFilterPageButton("source", DOM.sourceFilter, pageButton); return; } if (event.target.closest("[data-clear-source]")) { clearFilterKind("source"); return; } if (event.target.closest("[data-cycle-source-page]")) { cycleFilterPage("source"); return; } const item = event.target.closest("[data-source]"); if (!item || event.target.closest("input")) return; cycleFilterValue("source", item.dataset.source); });
DOM.authorFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-author-page]"); if (pageButton) { handleFilterPageButton("author", DOM.authorFilter, pageButton); return; } if (event.target.closest("[data-clear-author]")) { clearFilterKind("author"); return; } if (event.target.closest("[data-cycle-author-page]")) { cycleFilterPage("author"); return; } const item = event.target.closest("[data-author]"); if (!item || event.target.closest("input")) return; cycleFilterValue("author", item.dataset.author); });
DOM.tagFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-tag-page]"); if (pageButton) { handleFilterPageButton("tag", DOM.tagFilter, pageButton); return; } if (event.target.closest("[data-clear-tag]")) { clearFilterKind("tag"); return; } if (event.target.closest("[data-cycle-tag-page]")) { cycleFilterPage("tag"); return; } const item = event.target.closest("[data-tag]"); if (!item || event.target.closest("input")) return; cycleFilterValue("tag", item.dataset.tag); });
for (const filter of [DOM.sourceFilter, DOM.authorFilter, DOM.tagFilter]) {
filter.addEventListener("keydown", (event) => {
const item = event.target.closest(".filter-choice-item");
if (!item || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
item.click();
});
}
for (const [kind, element] of Object.entries({ source: DOM.sourceFilter, author: DOM.authorFilter, tag: DOM.tagFilter })) {
element.addEventListener("keydown", (event) => {
const input = event.target.closest(`[data-${kind}-page-input]`);
if (!input || event.key !== "Enter") return;
event.preventDefault();
commitFilterPage(kind, input);
input.blur();
});
element.addEventListener("focusout", (event) => {
const input = event.target.closest(`[data-${kind}-page-input]`);
if (input) restoreFilterPageInput(kind, input);
});
}
DOM.archiveFilter.addEventListener("change", () => { STATE.archiveId = DOM.archiveFilter.value; STATE.page = 1; doSearch(); });
DOM.typeFilter.addEventListener("change", () => { STATE.publicationType = DOM.typeFilter.value; STATE.page = 1; doSearch(); });
DOM.periodPresets.addEventListener("click", (event) => {
const button = event.target.closest("[data-period-from]");
if (!button) return;
const isActive = dateKey("from") === (button.dataset.periodFrom || "") && dateKey("to") === (button.dataset.periodTo || "");
if (isActive) {
setDateParts("from", "");
setDateParts("to", "");
STATE.page = 1;
renderDateControls();
doSearch();
return;
}
setDateParts("from", button.dataset.periodFrom || "");
setDateParts("to", button.dataset.periodTo || "");
STATE.page = 1;
renderDateControls();
doSearch();
});
DOM.dateClear.addEventListener("click", () => {
clearDateFilter();
STATE.page = 1;
renderDateControls();
doSearch();
});
DOM.dateTermAdd.addEventListener("click", () => {
STATE.dateTerms.push({ year: "", month: "", day: "" });
renderDateControls();
syncUrl();
});
DOM.dateTerms.addEventListener("click", (event) => {
const remove = event.target.closest("[data-date-term-remove]");
if (!remove) return;
STATE.dateTerms.splice(parseInt(remove.dataset.dateTermRemove, 10), 1);
if (!STATE.dateTerms.length) STATE.dateTerms.push({ year: "", month: "", day: "" });
STATE.page = 1;
renderDateControls();
doSearch();
});
const updateDateTerm = (event) => {
const select = event.target.closest("[data-date-term-field]");
if (!select) return;
const row = select.closest("[data-date-term-index]");
const index = parseInt((row && row.dataset.dateTermIndex) || "-1", 10);
if (!STATE.dateTerms[index]) return;
STATE.dateTerms[index][select.dataset.dateTermField] = select.value;
if (select.dataset.dateTermField === "year") STATE.dateTerms[index].year = cleanYear(select.value);
STATE.page = 1;
renderDateControls();
doSearch();
};
DOM.dateTerms.addEventListener("change", updateDateTerm);
document.addEventListener("focusin", (event) => {
const input = yearInputFromTarget(event.target);
if (input) showYearPicker(input);
});
document.addEventListener("input", (event) => {
const input = yearInputFromTarget(event.target);
if (input) showYearPicker(input);
});
DOM.yearPicker.addEventListener("pointerdown", (event) => {
const option = event.target.closest ? event.target.closest("[data-year-value]") : null;
if (!option || !activeYearInput) return;
yearPickerPointer = { option, x: event.clientX, y: event.clientY };
event.stopPropagation();
});
DOM.yearPicker.addEventListener("pointerup", (event) => {
const option = event.target.closest ? event.target.closest("[data-year-value]") : null;
if (!option || !activeYearInput || !yearPickerPointer || yearPickerPointer.option !== option) return;
const moved = Math.abs(event.clientX - yearPickerPointer.x) > 8 || Math.abs(event.clientY - yearPickerPointer.y) > 8;
yearPickerPointer = null;
if (!moved) chooseYear(activeYearInput, option.dataset.yearValue || "");
});
DOM.yearPicker.addEventListener("pointercancel", () => {
yearPickerPointer = null;
});
document.addEventListener("click", (event) => {
if ((!event.target.closest || !event.target.closest(".year-input")) && (!event.target.closest || !event.target.closest("#year-picker"))) hideYearPicker();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !DOM.yearPicker.hidden) hideYearPicker();
});
window.addEventListener("scroll", handleYearPickerScroll, true);
window.addEventListener("resize", repositionYearPicker);
window.addEventListener("resize", () => schedulePageRecovery(120));
DOM.searchInput.addEventListener("focus", () => warmConnection());
window.addEventListener("focus", () => {
schedulePageRecovery(0);
warmConnection();
});
window.addEventListener("pageshow", () => schedulePageRecovery(0));
window.addEventListener("pagehide", (event) => {
flushParseDraft();
flushProofreadDraft();
if (event.persisted) return;
clearActiveMediaViewer();
cleanupPDFDocumentCache(true);
});
window.addEventListener("online", () => warmConnection(true));
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
flushParseDraft();
flushProofreadDraft();
} else {
schedulePageRecovery(0);
warmConnection(true);
}
});
for (const [element, stateKey] of [
[DOM.dateFromYear, "dateFromYear"],
[DOM.dateFromMonth, "dateFromMonth"],
[DOM.dateFromDay, "dateFromDay"],
[DOM.dateToYear, "dateToYear"],
[DOM.dateToMonth, "dateToMonth"],
[DOM.dateToDay, "dateToDay"],
]) {
element.addEventListener("change", () => {
STATE[stateKey] = element.value;
if (stateKey.endsWith("Year")) STATE[stateKey] = cleanYear(element.value);
if (stateKey.endsWith("Year") && !STATE[stateKey]) {
const prefix = stateKey.startsWith("dateFrom") ? "dateFrom" : "dateTo";
STATE[`${prefix}Month`] = "";
STATE[`${prefix}Day`] = "";
}
if (stateKey.endsWith("Month")) {
const prefix = stateKey.startsWith("dateFrom") ? "dateFrom" : "dateTo";
const day = STATE[`${prefix}Day`];
const year = STATE[`${prefix}Year`];
if (!element.value) STATE[`${prefix}Day`] = "";
if (day && year && element.value && Number(day) > daysInMonth(year, element.value)) STATE[`${prefix}Day`] = "";
}
STATE.page = 1;
renderDateControls();
doSearch();
});
}
DOM.clearFilters.addEventListener("click", resetFilters);
DOM.randomBookBtn.addEventListener("click", openRandomPreview);
DOM.emptyRandomBtn.addEventListener("click", openRandomPreview);
document.querySelectorAll("[data-filter-toggle]").forEach((toggle) => {
toggle.addEventListener("click", () => {
const key = toggle.dataset.filterToggle;
STATE.filterCollapsed[key] = !STATE.filterCollapsed[key];
applyStateToDom();
});
});
DOM.results.addEventListener("click", (event) => {
const parse = event.target.closest("[data-parse-open]");
if (parse) {
openParseEditor(parse.dataset.parseOpen);
return;
}
const preview = event.target.closest("[data-preview]");
if (preview) {
openPreview(preview.dataset.preview);
return;
}
const source = event.target.closest(".path-folder[data-source]");
if (source) {
toggleResultFilter("source", source.dataset.source);
return;
}
const author = event.target.closest(".path-folder[data-author]");
if (author) {
toggleResultFilter("author", author.dataset.author);
return;
}
const tag = event.target.closest(".path-folder[data-tag]");
if (tag) {
toggleResultFilter("tag", tag.dataset.tag);
return;
}
const dateTermKey = event.target.closest(".path-folder[data-date-term-key]");
if (dateTermKey) {
toggleResultDateTerm(dateTermKey.dataset.dateTermLabel || dateTermKey.textContent || "");
return;
}
});
DOM.previewPanel.addEventListener("click", (event) => {
if (event.target.closest("[data-parse-back], [data-parse-close]")) {
flushParseDraft();
STATE.parseItem = null;
STATE.preview = "";
clearPreviewDom();
applyStateToDom();
renderParseQueue();
return;
}
const cropOpen = event.target.closest("[data-parse-crop-open]");
if (cropOpen) {
openParseCrop(cropOpen.closest("[data-parse-article]"));
return;
}
if (event.target.closest("[data-parse-crop-toggle]")) {
const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]");
if (toolbar && toolbar.hidden) openParseCrop();
else closeParseCrop();
return;
}
const cropScope = event.target.closest("[data-parse-crop-scope]");
if (cropScope) {
setParseCropScope(cropScope.dataset.parseCropScope);
return;
}
if (event.target.closest("[data-parse-crop-reset]")) {
const row = parseCropTargetRow();
setParseCropThresholds(row, parseCropScope(), STATE.parseCurrentPage, [0, 0, 0, 0], true);
updateParseCropUI();
updateParsePageOCR();
finishParseCropChange(row);
return;
}
if (event.target.closest("[data-parse-crop-close]")) {
closeParseCrop();
return;
}
if (event.target.closest("[data-parse-page-prev]")) {
loadParsePage(STATE.parseCurrentPage - 1);
saveParseDraft();
return;
}
if (event.target.closest("[data-parse-page-next]")) {
loadParsePage(STATE.parseCurrentPage + 1);
saveParseDraft();
return;
}
const addArticle = event.target.closest("[data-parse-add]");
if (addArticle) {
const form = addArticle.closest("[data-parse-form]");
const holder = form.querySelector("[data-parse-articles]");
const index = holder.querySelectorAll("[data-parse-article]").length;
const current = Math.min(Number(form.dataset.maxPage) || 1, Math.max(1, STATE.parseCurrentPage));
holder.insertAdjacentHTML("beforeend", parseArticleRowHTML({ title: "", authors: [], dates: (STATE.parseItem.article || {}).dates || [], page_start: current, page_end: current }, index, Number(form.dataset.maxPage) || 1));
refreshParseArticleBody(holder.lastElementChild, false, true);
saveParseDraft();
return;
}
const refreshBody = event.target.closest("[data-parse-refresh-body]");
if (refreshBody) {
const row = refreshBody.closest("[data-parse-article]");
const textarea = row && row.querySelector("[data-parse-content]");
if (textarea && textarea.dataset.edited === "1" && !window.confirm("确认用当前页码范围的 OCR 覆盖已编辑正文?")) return;
refreshParseArticleBody(row, true, true);
return;
}
const removeArticle = event.target.closest("[data-parse-remove]");
if (removeArticle && !removeArticle.disabled) {
const row = removeArticle.closest("[data-parse-article]");
const wasCropTarget = row && row.classList.contains("is-crop-target");
if (row) row.remove();
DOM.previewPanel.querySelectorAll("[data-parse-article] legend").forEach((legend, index) => { legend.textContent = `文章 ${index + 1}`; });
if (wasCropTarget) setParseCropTarget(parseCropTargetRow());
saveParseDraft();
return;
}
const partInsert = event.target.closest("[data-add-part-before], [data-add-part-after]");
if (partInsert) {
const group = partInsert.closest("[data-proofread-part-group]");
const target = group && group.querySelector(partInsert.dataset.addPartBefore != null ? "[data-part-insert-before]" : "[data-part-insert-after]");
if (target) target.insertAdjacentHTML("beforeend", newPartHTML());
syncFulltextFromParts(partInsert.closest("[data-proofread-form]"));
saveProofreadDraft();
return;
}
const deletePart = event.target.closest("[data-delete-part]");
if (deletePart) {
deletePart.dataset.proofreadDeleted = deletePart.dataset.proofreadDeleted === "true" ? "false" : "true";
deletePart.textContent = deletePart.dataset.proofreadDeleted === "true" ? "恢复" : "删除";
const group = deletePart.closest("[data-proofread-part-group]"); if (group) group.classList.toggle("proofread-deleted", deletePart.dataset.proofreadDeleted === "true");
syncFulltextFromParts(deletePart.closest("[data-proofread-form]"));
saveProofreadDraft();
return;
}
if (event.target.closest("[data-remove-new-part]")) {
const newPart = event.target.closest("[data-proofread-new-part]"); if (newPart) newPart.remove();
syncFulltextFromParts(event.target.closest("[data-proofread-form]"));
saveProofreadDraft();
return;
}
if (event.target.closest("[data-fulltext-toggle]")) {
const box = DOM.previewPanel.querySelector("[data-fulltext-box]");
const button = event.target.closest("[data-fulltext-toggle]");
if (box) {
const opening = box.hidden;
if (opening) {
const form = DOM.previewPanel.querySelector("[data-proofread-form]");
const input = box.querySelector("[data-fulltext-input]");
if (form && input && input.dataset.fulltextDirty !== "1") input.value = fullTextFromParts(form);
}
box.hidden = !box.hidden;
button.textContent = box.hidden ? "全文编辑" : "收起全文编辑";
}
return;
}
const partEditorToggle = event.target.closest("[data-part-editor-toggle]");
if (partEditorToggle) {
const form = DOM.previewPanel.querySelector("[data-proofread-form]");
if (form) {
const open = form.classList.toggle("proofread-structure-open");
form.classList.toggle("proofread-structure-closed", !open);
partEditorToggle.textContent = open ? "收起段落编辑" : "段落编辑";
form.querySelectorAll("[data-proofread-part-type]").forEach((select) => {
const label = select.nextElementSibling;
if (label && label.classList.contains("proofread-part-type-label")) label.textContent = select.value;
});
}
return;
}
const zoomButton = event.target.closest("[data-image-zoom]");
if (zoomButton) {
const image = DOM.previewPanel.querySelector("[data-proofread-source-image]");
if (!image) return;
const current = Number(image.dataset.imageScale || 1);
const next = zoomButton.dataset.imageZoom === "reset"
? 1
: Math.min(4, Math.max(0.25, current + (zoomButton.dataset.imageZoom === "in" ? 0.25 : -0.25)));
image.dataset.imageScale = String(next);
const baseWidth = Number(image.dataset.imageBaseWidth) || image.naturalWidth;
image.style.maxWidth = "none";
image.style.width = `${Math.max(1, baseWidth * next)}px`;
image.style.height = "auto";
image.style.transform = "none";
return;
}
const pageButton = event.target.closest("[data-page-image-prev], [data-page-image-next]");
if (pageButton) {
const viewer = pageButton.closest("[data-page-image-viewer]");
if (viewer) {
const current = Number(viewer.dataset.pageIndex) || 0;
setPageImage(viewer, current + (pageButton.dataset.pageImagePrev != null ? 0 : 2));
}
return;
}
if (event.target.closest("[data-fulltext-apply]")) {
applyFullText(event.target.closest("[data-proofread-form]"));
return;
}
const addList = event.target.closest("[data-add-list]");
if (addList) {
const kind = addList.dataset.addList;
const fields = kind === "dates"
? [{ key: "year", label: "年", type: "number" }, { key: "month", label: "月", type: "number" }, { key: "day", label: "日", type: "number" }]
: kind === "tags" ? [{ key: "name", label: "名称" }, { key: "type", label: "类型", options: TAG_TYPES }]
: [{ key: "value", label: kind === "authors" ? "作者" : "URL" }];
const holder = document.createElement("div");
holder.innerHTML = listEditorHTML(kind, [kind === "dates" || kind === "tags" ? {} : ""], fields);
const row = holder.querySelector("[data-list-row]");
addList.before(row);
const firstInput = row.querySelector("input");
if (firstInput) firstInput.focus();
saveProofreadDraft();
return;
}
const removeList = event.target.closest("[data-remove-list]");
if (removeList) {
const row = removeList.closest("[data-list-row]");
if (row) row.remove();
saveProofreadDraft();
return;
}
const tagPageButton = event.target.closest("[data-preview-tag-page-change]");
if (tagPageButton) {
changePreviewTagPage(tagPageButton);
return;
}
if (event.target.closest("[data-close-preview]")) {
closePreview(true);
return;
}
if (event.target.closest("[data-proofread-back]")) {
const item = getCachedPreview(STATE.preview);
if (item) openPreview(item.doc_id, false);
return;
}
if (event.target.closest("[data-preview-theme]")) toggleTheme();
if (event.target.closest("[data-preview-mobile]")) toggleMobileMode();
});
DOM.previewPanel.addEventListener("pointerdown", (event) => {
if (event.target.closest("[data-parse-crop-overlay]")) beginParseCropDrag(event);
});
window.addEventListener("pointermove", moveParseCrop, { passive: false });
window.addEventListener("pointerup", endParseCrop);
window.addEventListener("pointercancel", endParseCrop);
DOM.previewPanel.addEventListener("change", (event) => {
const parsePage = event.target.closest("[data-parse-page]");
if (parsePage) {
loadParsePage(parsePage.value);
saveParseDraft();
return;
}
const parseRange = event.target.closest("[data-parse-start], [data-parse-end]");
if (parseRange) {
const row = parseRange.closest("[data-parse-article]");
refreshParseArticleBody(row, false, true);
if (row && row.classList.contains("is-crop-target")) updateParseCropUI();
saveParseDraft();
return;
}
const select = event.target.closest("[data-compare-select]");
if (select && select.value) {
if (select.value === "__proofread__") openProofread(getCachedPreview(STATE.preview));
else comparePreview(select.value);
return;
}
const sourceFile = event.target.closest("[data-proofread-source-file]");
if (sourceFile) {
STATE.proofreadFile = sourceFile.value;
const item = getCachedPreview(STATE.preview);
openProofread(item, null, true);
syncUrl(true);
saveProofreadDraft();
return;
}
const pageInput = event.target.closest("[data-proofread-page]");
if (pageInput) {
STATE.proofreadPage = String(Math.max(1, Number(pageInput.value) || 1));
syncUrl(true);
saveProofreadDraft();
return;
}
const pageImageInput = event.target.closest("[data-page-image-input]");
if (pageImageInput) {
const viewer = pageImageInput.closest("[data-page-image-viewer]");
if (viewer) setPageImage(viewer, Number(pageImageInput.value) || 1);
return;
}
const form = event.target.closest("[data-proofread-form]");
if (form) {
if (!event.target.matches("[data-fulltext-input]")) syncFulltextFromParts(form);
saveProofreadDraft();
}
});
DOM.previewPanel.addEventListener("input", (event) => {
if (event.target.matches("[data-parse-content]")) event.target.dataset.edited = "1";
if (event.target.closest("[data-parse-form]")) saveParseDraft();
if (event.target.matches("[data-fulltext-input]")) event.target.dataset.fulltextDirty = "1";
const form = event.target.closest("[data-proofread-form]");
if (form) {
if (!event.target.matches("[data-fulltext-input]")) syncFulltextFromParts(form);
saveProofreadDraft();
}
});
DOM.previewPanel.addEventListener("submit", (event) => {
const parseForm = event.target.closest("[data-parse-form]");
if (parseForm) {
event.preventDefault();
submitParseForm(parseForm);
return;
}
const form = event.target.closest("[data-proofread-form]");
if (!form) return;
event.preventDefault();
submitProofreadForm(form);
});
DOM.previewPanel.addEventListener("mouseup", (event) => {
if (!event.target.closest("[data-proofread-select]")) return;
const selection = window.getSelection();
const selected = String(selection ? selection.toString() : "").trim();
const item = getCachedPreview(STATE.preview);
if (!selected || !item || !item.article || !item.article.parts) return;
const partIndex = item.article.parts.findIndex((_part, index) => partTextWithPivots(item.article, index).includes(selected));
openProofread(item, partIndex >= 0 ? partIndex : null);
});
DOM.prevPage.addEventListener("click", () => {
if (STATE.parseMode) {
if (STATE.parsePage > 1) { STATE.parsePage -= 1; syncUrl(); loadParseQueue(); }
return;
}
DOM.pageInput.value = String(STATE.page);
if (STATE.page > 1) {
STATE.page -= 1;
doSearch();
}
});
DOM.nextPage.addEventListener("click", () => {
if (STATE.parseMode) {
if (STATE.parsePage < Math.ceil(STATE.parseTotal / STATE.parsePageSize)) { STATE.parsePage += 1; syncUrl(); loadParseQueue(); }
return;
}
DOM.pageInput.value = String(STATE.page);
if (STATE.page < Math.ceil(STATE.total / STATE.pageSize)) {
STATE.page += 1;
doSearch();
}
});
DOM.pageInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
if (STATE.parseMode) {
const totalPages = Math.max(1, Math.ceil(STATE.parseTotal / STATE.parsePageSize));
STATE.parsePage = Math.min(totalPages, Math.max(1, Number(DOM.pageInput.value) || 1));
syncUrl();
loadParseQueue();
} else commitResultPage();
DOM.pageInput.blur();
});
DOM.pageInput.addEventListener("blur", () => {
DOM.pageInput.value = String(STATE.parseMode ? STATE.parsePage : STATE.page);
});
window.addEventListener("popstate", () => {
cancelPreviewRequests();
const wasPreviewOpen = Boolean(STATE.preview);
parseUrl();
if (wasPreviewOpen && !STATE.preview) {
history.replaceState({ previewEntry: false }, "", previewCloseUrl);
parseUrl();
}
applyStateToDom();
if (STATE.parseMode) {
loadParseQueue();
return;
}
restorePreview(false);
if (wasPreviewOpen && !STATE.preview) {
restoreResultsScroll(previewResultsScrollTop);
} else {
doSearch();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "/" && document.activeElement !== DOM.searchInput) {
event.preventDefault();
DOM.searchInput.focus();
DOM.searchInput.select();
}
});
}
function cacheDom() {
Object.assign(DOM, {
leftSidebar: $("#left-sidebar"),
sidebarTitle: $("#sidebar-title"),
overlay: $("#overlay"),
sourceSearch: $("#source-search"),
sourceFilter: $("#source-filter"),
searchForm: $("#search-form"),
searchInput: $("#search-input"),
leftToggle: $("#left-toggle"),
mobileToggleBtn: $("#mobile-toggle-btn"),
mobileToggleIcon: $("#mobile-toggle-icon"),
themeBtn: $("#theme-btn"),
parseBtn: $("#parse-btn"),
sidebarExpand: $("#sidebar-expand"),
exactToggle: $("#exact-toggle"),
previewHighlightToggle: $("#preview-highlight-toggle"),
fulltextToggle: $("#fulltext-toggle"),
pageSize: $("#page-size"),
parsePageSizeOption: $("#parse-page-size-option"),
sortFilter: $("#sort-filter"),
authorSearch: $("#author-search"),
authorFilter: $("#author-filter"),
tagSearch: $("#tag-search"),
tagFilter: $("#tag-filter"),
archiveFilter: $("#archive-filter"),
typeFilter: $("#type-filter"),
periodPresets: $("#period-presets"),
dateFromYear: $("#date-from-year"),
dateFromMonth: $("#date-from-month"),
dateFromDay: $("#date-from-day"),
dateToYear: $("#date-to-year"),
dateToMonth: $("#date-to-month"),
dateToDay: $("#date-to-day"),
dateClear: $("#date-clear"),
dateError: $("#date-error"),
yearPicker: $("#year-picker"),
dateTermAdd: $("#date-term-add"),
dateTerms: $("#date-terms"),
clearFilters: $("#clear-filters"),
resultCount: $("#result-count"),
statusText: $("#status-text"),
indexProgress: $("#index-progress"),
results: $("#results"),
emptyState: $("#empty-state"),
randomBookBtn: $("#random-book-btn"),
emptyRandomBtn: $("#empty-random-btn"),
previewPanel: $("#preview-panel"),
prevPage: $("#prev-page"),
nextPage: $("#next-page"),
pageInput: $("#page-input"),
pageTotal: $("#page-total"),
toast: $("#toast"),
});
}
function init() {
document.body.classList.toggle("firefox", /Firefox\//.test(navigator.userAgent));
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/static/sw.js", { scope: "/" }).catch(() => {});
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
cacheDom();
STATE.isDark = localStorage.getItem("theme") !== "light";
const savedMobileMode = localStorage.getItem("mobileMode");
STATE.isMobile = savedMobileMode === "mobile" || (!savedMobileMode && window.innerWidth <= 768);
parseUrl();
if (STATE.preview && !(history.state && history.state.previewEntry === true)) {
const previewDocId = STATE.preview;
STATE.preview = "";
syncUrl();
STATE.preview = previewDocId;
syncUrl(false);
}
applyStateToDom();
attachEvents();
const initialSearch = STATE.parseMode
? loadParseQueue()
: canUseInitialBootstrap() ? loadInitialBootstrap() : Promise.all([doSearch(), loadFacets()]);
if (!STATE.parseMode) restorePreview(false);
initialSearch.finally(() => {
lastKeepaliveAt = Date.now();
window.setInterval(() => warmConnection(), KEEPALIVE_INTERVAL_MS);
if (STATE.parseMode) {
updateProgress();
loadFacets();
}
});
}
document.addEventListener("DOMContentLoaded", init);