Spaces:
Running
Running
| 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) => ({ | |
| "&": "&", | |
| "<": "<", | |
| ">": ">", | |
| '"': """, | |
| "'": "'", | |
| })[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); | |