| const STATE = { |
| mode: "global", |
| source: null, |
| query: "", |
| page: 1, |
| pageSize: 100, |
| total: 0, |
| results: [], |
| sources: [], |
| sourceMap: {}, |
| folderTree: [], |
| folderTreeSource: null, |
| folderNodeMap: {}, |
| folderCollapsed: {}, |
| folderSelections: [], |
| selectedSources: [], |
| minSize: null, |
| maxSize: null, |
| exact: false, |
| fulltext: false, |
| searchPaths: true, |
| fulltextManifest: null, |
| fulltextLoaded: {}, |
| historyEnabled: true, |
| leftSidebarOpen: true, |
| rightSidebarOpen: false, |
| isMobile: false, |
| isDark: true, |
| isLoading: false, |
| multiSelect: false, |
| selectedIds: new Set(), |
| }; |
|
|
| const DOM = {}; |
| const HISTORY_KEY = "vomebook_search_history"; |
| const ICON_HTML = { |
| source: '<span class="ui-icon ui-icon-stack" aria-hidden="true"></span>', |
| folder: '<span class="ui-icon ui-icon-folder" aria-hidden="true"></span>', |
| file: '<span class="ui-icon ui-icon-file" aria-hidden="true"></span>', |
| }; |
|
|
| function $(selector) { |
| return document.querySelector(selector); |
| } |
|
|
| function escapeHTML(value) { |
| return String(value).replace(/[&<>"']/g, function(ch) { |
| return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]; |
| }); |
| } |
|
|
| function tokenize(text) { |
| return Array.from(new Set(String(text || "").toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+/g) || [])); |
| } |
|
|
| function formatSize(bytes) { |
| if (!bytes) return ""; |
| if (bytes < 1024) return bytes + " B"; |
| if (bytes < 1048576) return (bytes / 1024).toFixed(1).replace(/\.0$/, "") + " KB"; |
| if (bytes < 1073741824) return (bytes / 1048576).toFixed(1).replace(/\.0$/, "") + " MB"; |
| return (bytes / 1073741824).toFixed(2).replace(/\.00$/, "") + " GB"; |
| } |
|
|
| function highlightText(text, query) { |
| const safeText = escapeHTML(text || ""); |
| const tokens = tokenize(query).filter(Boolean); |
| if (!safeText || !tokens.length) return safeText; |
| let output = safeText; |
| for (const token of tokens) { |
| const pattern = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| output = output.replace(new RegExp(`(${pattern})`, "gi"), "<mark>$1</mark>"); |
| } |
| return output; |
| } |
|
|
| function bytesFromInput(value, unit) { |
| const parsed = parseFloat(value); |
| if (Number.isNaN(parsed) || parsed < 0) return null; |
| if (unit === "KB") return Math.round(parsed * 1024); |
| if (unit === "MB") return Math.round(parsed * 1048576); |
| if (unit === "GB") return Math.round(parsed * 1073741824); |
| return Math.round(parsed); |
| } |
|
|
| function buildPath(folder, file) { |
| return folder.length ? folder.join("/") + "/" + file : file; |
| } |
|
|
| const API = { |
| async getSources() { |
| const resp = await fetch("/api/sources"); |
| return resp.json(); |
| }, |
| async search(body, sourceSlug) { |
| const url = sourceSlug ? `/api/search/${sourceSlug}` : "/api/search"; |
| const resp = await fetch(url, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(body), |
| }); |
| return resp.json(); |
| }, |
| async getFolders(sourceSlug) { |
| const resp = await fetch(`/api/folders/${sourceSlug}`); |
| return resp.json(); |
| }, |
| async getContents(sourceSlug, path) { |
| const qs = path ? `?path=${encodeURIComponent(path)}` : ""; |
| const resp = await fetch(`/api/folders/${sourceSlug}/contents${qs}`); |
| return resp.json(); |
| }, |
| async preview(docId) { |
| const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`); |
| return resp.json(); |
| }, |
| async snippet(docId, query) { |
| const resp = await fetch(`/api/snippet/${encodeURIComponent(docId)}?q=${encodeURIComponent(query || "")}`); |
| return resp.json(); |
| }, |
| async random(sourceSlug) { |
| const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random"; |
| const resp = await fetch(url); |
| return resp.json(); |
| }, |
| async fulltextManifest() { |
| const resp = await fetch("/api/fulltext-manifest"); |
| return resp.json(); |
| }, |
| async fulltextIndex(path) { |
| const resp = await fetch(`/data/${path}`); |
| const blob = await resp.blob(); |
| const ds = new DecompressionStream("gzip"); |
| const stream = blob.stream().pipeThrough(ds); |
| const text = await new Response(stream).text(); |
| return JSON.parse(text); |
| }, |
| }; |
|
|
| function cacheDom() { |
| DOM.headerTitle = $("#header-title"); |
| DOM.headerLogo = $("#header-logo"); |
| DOM.searchInput = $("#search-input"); |
| DOM.searchHistoryDropdown = $("#search-history-dropdown"); |
| DOM.hamburgerBtn = $("#hamburger-btn"); |
| DOM.settingsBtn = $("#settings-btn"); |
| DOM.mobileToggleBtn = $("#mobile-toggle-btn"); |
| DOM.mobileToggleIcon = $("#mobile-toggle-icon"); |
| DOM.themeBtn = $("#theme-btn"); |
| DOM.leftSidebar = $("#left-sidebar"); |
| DOM.rightSidebar = $("#right-sidebar"); |
| DOM.sidebarExpandBtn = $("#sidebar-expand-btn"); |
| DOM.sidebarTitle = $("#sidebar-title"); |
| DOM.sidebarContent = $("#sidebar-content"); |
| DOM.resultCount = $("#result-count"); |
| DOM.resultsContainer = $("#results-container"); |
| DOM.resultsList = $("#results-list"); |
| DOM.resultsLoading = $("#results-loading"); |
| DOM.emptyState = $("#empty-state"); |
| DOM.emptyDesc = $("#empty-desc"); |
| DOM.emptyRandomBtn = $("#empty-random-btn"); |
| DOM.loadInfo = $("#load-info"); |
| DOM.loadedCount = $("#loaded-count"); |
| DOM.totalCount = $("#total-count"); |
| DOM.didYouMean = $("#did-you-mean"); |
| DOM.clearFiltersBtn = $("#clear-filters-btn"); |
| DOM.sortSelect = $("#sort-select"); |
| DOM.previewPanel = $("#preview-panel"); |
| DOM.closeFiltersBtn = $("#close-filters-btn"); |
| DOM.fulltextToggle = $("#fulltext-toggle"); |
| DOM.searchPathsToggle = $("#search-paths-toggle"); |
| DOM.historyToggle = $("#history-toggle"); |
| DOM.exactToggle = $("#exact-search-toggle"); |
| DOM.filterSourceList = $("#filter-source-list"); |
| DOM.filterFolderTree = $("#filter-folder-tree"); |
| DOM.filterMinSize = $("#filter-min-size"); |
| DOM.filterMaxSize = $("#filter-max-size"); |
| DOM.filterMinUnit = $("#filter-min-unit"); |
| DOM.filterMaxUnit = $("#filter-max-unit"); |
| DOM.folderSelectAll = $("#folder-select-all"); |
| DOM.folderDeselectAll = $("#folder-deselect-all"); |
| DOM.multiToggleLabel = $("#multi-toggle-label"); |
| DOM.multiSelectToggle = $("#multi-select-toggle"); |
| DOM.multiActionBar = $("#multi-action-bar"); |
| DOM.multiSelectAll = $("#multi-select-all"); |
| DOM.multiDeselect = $("#multi-deselect"); |
| DOM.multiBatchDownload = $("#multi-batch-download"); |
| DOM.multiZipDownload = $("#multi-zip-download"); |
| DOM.multiSelectedCount = $("#multi-selected-count"); |
| DOM.randomBookBtn = $("#random-book-btn"); |
| DOM.overlay = $("#overlay"); |
| DOM.toast = $("#toast"); |
| } |
|
|
| function parseRoute() { |
| const path = window.location.pathname.replace(/^\/+|\/+$/g, ""); |
| if (!path) return { mode: "global", source: null }; |
| return { mode: "source", source: decodeURIComponent(path) }; |
| } |
|
|
| function syncRoute() { |
| const route = parseRoute(); |
| STATE.mode = route.mode; |
| STATE.source = route.source; |
| if (STATE.source) { |
| DOM.sidebarTitle.textContent = STATE.source; |
| if (DOM.sidebarExpandBtn && !STATE.isMobile) DOM.sidebarExpandBtn.style.display = ""; |
| } else { |
| DOM.sidebarTitle.textContent = "数据包"; |
| if (DOM.sidebarExpandBtn) { |
| DOM.sidebarExpandBtn.style.display = "none"; |
| DOM.leftSidebar.classList.remove("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = "↔"; |
| } |
| } |
| } |
|
|
| function navigateToSource(sourceSlug) { |
| history.pushState(null, "", `/${encodeURIComponent(sourceSlug)}`); |
| STATE.page = 1; |
| STATE.folderSelections = []; |
| syncRoute(); |
| renderSidebar(); |
| renderFolderTree(); |
| doSearch(); |
| } |
|
|
| function navigateHome() { |
| history.pushState(null, "", "/"); |
| STATE.page = 1; |
| STATE.folderSelections = []; |
| syncRoute(); |
| renderSidebar(); |
| renderFolderTree(); |
| doSearch(); |
| } |
|
|
| function getSelectedSourcesForSearch() { |
| if (STATE.source) return [STATE.source]; |
| if (STATE.selectedSources.length) return STATE.selectedSources.slice(); |
| return []; |
| } |
|
|
| function updateStatus() { |
| DOM.resultCount.textContent = STATE.total ? `共 ${STATE.total.toLocaleString()} 条结果` : ""; |
| DOM.loadedCount.textContent = STATE.results.length.toLocaleString(); |
| DOM.totalCount.textContent = STATE.total.toLocaleString(); |
| DOM.loadInfo.style.display = STATE.total ? "" : "none"; |
| DOM.multiToggleLabel.style.display = STATE.total ? "" : "none"; |
| const hasFilter = STATE.folderSelections.length || STATE.selectedSources.length || STATE.minSize !== null || STATE.maxSize !== null; |
| DOM.clearFiltersBtn.style.display = hasFilter ? "" : "none"; |
| } |
|
|
| function updateFilterVisibility() { |
| const sourceSection = $("#filter-source-section"); |
| const folderSection = $("#filter-folder-section"); |
| if (sourceSection) sourceSection.style.display = STATE.source ? "none" : ""; |
| if (folderSection) folderSection.style.display = STATE.source ? "" : "none"; |
| } |
|
|
| function showToast(message, duration = 2000) { |
| DOM.toast.textContent = message; |
| DOM.toast.style.display = ""; |
| clearTimeout(showToast._timer); |
| showToast._timer = setTimeout(() => { |
| DOM.toast.style.display = "none"; |
| }, duration); |
| } |
|
|
| function setHistoryDropdownOpen(open) { |
| DOM.searchHistoryDropdown.style.display = open ? "" : "none"; |
| } |
|
|
| function getHistory() { |
| try { |
| return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; |
| } catch (_error) { |
| return []; |
| } |
| } |
|
|
| function saveHistory(items) { |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, 20))); |
| } |
|
|
| function removeHistory(query) { |
| const items = getHistory().filter((item) => item !== query); |
| saveHistory(items); |
| renderHistoryDropdown(); |
| } |
|
|
| function clearHistory() { |
| saveHistory([]); |
| renderHistoryDropdown(); |
| } |
|
|
| function addHistory(query) { |
| if (!STATE.historyEnabled || !query) return; |
| const items = getHistory().filter((item) => item !== query); |
| items.unshift(query); |
| saveHistory(items); |
| } |
|
|
| function renderHistoryDropdown() { |
| const items = getHistory(); |
| if (!items.length) { |
| setHistoryDropdownOpen(false); |
| return; |
| } |
| DOM.searchHistoryDropdown.innerHTML = items.map((item) => ` |
| <div class="history-item" data-query="${escapeHTML(item)}"> |
| <span class="history-text">${escapeHTML(item)}</span> |
| <button type="button" class="history-del" data-del="${escapeHTML(item)}" aria-label="删除历史">×</button> |
| </div>`).join("") + '<div class="history-footer"><button type="button" class="history-clear-all">清空历史</button></div>'; |
| setHistoryDropdownOpen(true); |
| } |
|
|
| function applyTheme() { |
| document.body.classList.toggle("light", !STATE.isDark); |
| } |
|
|
| function applyMobileMode() { |
| document.body.classList.toggle("mobile", STATE.isMobile); |
| document.body.classList.toggle("force-desktop", !STATE.isMobile); |
| if (DOM.mobileToggleIcon) { |
| DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-desktop" : "ui-icon ui-icon-phone"; |
| } |
| if (DOM.sidebarExpandBtn) { |
| DOM.sidebarExpandBtn.style.display = (!STATE.isMobile && STATE.source) ? "" : "none"; |
| if (STATE.isMobile) { |
| DOM.leftSidebar.classList.remove("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = "↔"; |
| } |
| } |
| if (STATE.isMobile) { |
| STATE.leftSidebarOpen = false; |
| STATE.rightSidebarOpen = false; |
| } else { |
| STATE.leftSidebarOpen = true; |
| STATE.rightSidebarOpen = false; |
| } |
| updateSidebarVisibility(); |
| } |
|
|
| function syncUrl(replace = true) { |
| const params = new URLSearchParams(); |
| if (STATE.query) params.set("q", STATE.query); |
| if (STATE.selectedSources.length && !STATE.source) { |
| for (const slug of STATE.selectedSources) params.append("source", slug); |
| } |
| if (STATE.folderSelections.length && STATE.source) { |
| for (const path of STATE.folderSelections) params.append("folder", path); |
| } |
| if (STATE.minSize !== null) params.set("min_size", String(STATE.minSize)); |
| if (STATE.maxSize !== null) params.set("max_size", String(STATE.maxSize)); |
| if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value); |
| if (STATE.exact) params.set("exact", "1"); |
| if (STATE.fulltext) params.set("fulltext", "1"); |
| if (!STATE.searchPaths) params.set("search_paths", "0"); |
| if (!STATE.historyEnabled) params.set("history", "0"); |
| const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/"; |
| const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath; |
| if (replace) history.replaceState(null, "", nextUrl); |
| else history.pushState(null, "", nextUrl); |
| } |
|
|
| function loadUrlState() { |
| const params = new URLSearchParams(window.location.search); |
| STATE.query = params.get("q") || ""; |
| STATE.selectedSources = params.getAll("source"); |
| STATE.folderSelections = params.getAll("folder"); |
| STATE.minSize = params.get("min_size") ? parseInt(params.get("min_size"), 10) : null; |
| STATE.maxSize = params.get("max_size") ? parseInt(params.get("max_size"), 10) : null; |
| STATE.exact = params.get("exact") === "1"; |
| STATE.fulltext = params.get("fulltext") === "1"; |
| STATE.searchPaths = params.get("search_paths") !== "0"; |
| STATE.historyEnabled = params.get("history") !== "0"; |
| const sort = params.get("sort") || "relevance"; |
| DOM.searchInput.value = STATE.query; |
| DOM.exactToggle.checked = STATE.exact; |
| DOM.fulltextToggle.checked = STATE.fulltext; |
| DOM.searchPathsToggle.checked = STATE.searchPaths; |
| DOM.historyToggle.checked = STATE.historyEnabled; |
| DOM.sortSelect.value = sort; |
| if (STATE.minSize !== null) DOM.filterMinSize.value = STATE.minSize; |
| if (STATE.maxSize !== null) DOM.filterMaxSize.value = STATE.maxSize; |
| } |
|
|
| async function ensureFulltextLoaded() { |
| if (!STATE.fulltextManifest) { |
| STATE.fulltextManifest = await API.fulltextManifest(); |
| } |
| const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug)); |
| for (const slug of sourceSlugs) { |
| if (STATE.fulltextLoaded[slug]) continue; |
| const item = (STATE.fulltextManifest.sources || []).find((source) => source.slug === slug); |
| if (!item) continue; |
| showToast(`加载全文索引: ${slug}`); |
| STATE.fulltextLoaded[slug] = await API.fulltextIndex(item.path); |
| } |
| } |
|
|
| function scoreFulltextDoc(docId, payload, queryTokens, exactQuery) { |
| if (exactQuery) { |
| const snippet = (payload.snippets[docId] || "").toLowerCase(); |
| return snippet.includes(exactQuery.toLowerCase()) ? 100 : 1; |
| } |
| let score = 0; |
| for (const token of queryTokens) { |
| if ((payload.snippets[docId] || "").toLowerCase().includes(token)) score += 4; |
| if ((payload.docs[docId]?.display_name || "").toLowerCase().includes(token)) score += 1; |
| } |
| return score; |
| } |
|
|
| async function fulltextSearch() { |
| await ensureFulltextLoaded(); |
| const query = STATE.query.trim(); |
| const queryTokens = tokenize(query); |
| const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug)); |
| let results = []; |
| for (const slug of sourceSlugs) { |
| const payload = STATE.fulltextLoaded[slug]; |
| if (!payload) continue; |
| let matchedIds = null; |
| if (STATE.exact) { |
| matchedIds = Object.keys(payload.docs).filter((docId) => { |
| const snippet = (payload.snippets[docId] || "") + " " + (payload.docs[docId]?.display_name || ""); |
| return snippet.toLowerCase().includes(query.toLowerCase()); |
| }); |
| } else { |
| for (const token of queryTokens) { |
| const docIds = payload.index[token] || []; |
| const set = new Set(docIds); |
| if (matchedIds === null) matchedIds = set; |
| else matchedIds = new Set(Array.from(matchedIds).filter((docId) => set.has(docId))); |
| } |
| matchedIds = Array.from(matchedIds || []); |
| } |
| for (const docId of matchedIds) { |
| const meta = payload.docs[docId]; |
| const folderParts = meta.display_rel_path.split("/"); |
| const fileName = folderParts.pop(); |
| const displayName = fileName.replace(/\.txt$/i, ""); |
| results.push({ |
| doc_id: docId, |
| Source: slug, |
| SourceName: STATE.sourceMap[slug]?.name || slug, |
| File: displayName, |
| Extension: "txt", |
| Folder: folderParts, |
| DisplayPath: meta.display_rel_path, |
| Size: meta.size, |
| HasTxt: true, |
| snippet: payload.snippets[docId] || "", |
| _score: scoreFulltextDoc(docId, payload, queryTokens, query), |
| }); |
| } |
| } |
| if (STATE.folderSelections.length) { |
| results = results.filter((item) => { |
| const folder = item.Folder.join("/"); |
| return STATE.folderSelections.some((selected) => folder === selected || folder.startsWith(selected + "/")); |
| }); |
| } |
| if (STATE.minSize !== null) results = results.filter((item) => item.Size >= STATE.minSize); |
| if (STATE.maxSize !== null) results = results.filter((item) => item.Size <= STATE.maxSize); |
| if (DOM.sortSelect.value === "name") results.sort((a, b) => a.DisplayPath.localeCompare(b.DisplayPath, "zh")); |
| else if (DOM.sortSelect.value === "size") results.sort((a, b) => (b.Size - a.Size) || a.DisplayPath.localeCompare(b.DisplayPath, "zh")); |
| else results.sort((a, b) => (b._score - a._score) || a.DisplayPath.localeCompare(b.DisplayPath, "zh")); |
| const snippetTargets = results.slice(0, STATE.page * STATE.pageSize); |
| await Promise.all(snippetTargets.map(async (item) => { |
| const data = await API.snippet(item.doc_id, query); |
| item.snippet = data && data.snippet ? data.snippet : ""; |
| })); |
| STATE.total = results.length; |
| STATE.results = results.slice(0, STATE.page * STATE.pageSize); |
| } |
|
|
| async function pathSearch() { |
| const body = { |
| q: STATE.query, |
| sources: getSelectedSourcesForSearch(), |
| folders: STATE.folderSelections, |
| min_size: STATE.minSize, |
| max_size: STATE.maxSize, |
| page: 1, |
| page_size: STATE.page * STATE.pageSize, |
| sort: DOM.sortSelect.value, |
| exact: STATE.exact, |
| search_paths: STATE.searchPaths, |
| }; |
| const data = await API.search(body, STATE.source); |
| STATE.total = data.total || 0; |
| STATE.results = data.results || []; |
| } |
|
|
| async function doSearch() { |
| STATE.isLoading = true; |
| DOM.resultsLoading.style.display = "flex"; |
| DOM.previewPanel.style.display = "none"; |
| try { |
| if (STATE.fulltext && STATE.query.trim()) { |
| await fulltextSearch(); |
| } else { |
| await pathSearch(); |
| } |
| renderResults(); |
| updateStatus(); |
| } catch (error) { |
| console.error(error); |
| showToast("搜索失败"); |
| } finally { |
| STATE.isLoading = false; |
| DOM.resultsLoading.style.display = "none"; |
| } |
| } |
|
|
| function resultPathHtml(item) { |
| const sourcePrefix = `<span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="">${highlightText(item.SourceName, STATE.fulltext ? "" : STATE.query)}</span>`; |
| const rest = (item.Folder || []).map((part, index) => { |
| const path = item.Folder.slice(0, index + 1).join("/"); |
| return `<span class="path-sep">/</span><span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="${escapeHTML(path)}">${highlightText(part, STATE.fulltext ? "" : STATE.query)}</span>`; |
| }).join(""); |
| return sourcePrefix + rest; |
| } |
|
|
| function renderResults() { |
| if (!STATE.results.length) { |
| DOM.resultsList.innerHTML = ""; |
| DOM.emptyState.style.display = "flex"; |
| DOM.emptyDesc.textContent = STATE.query ? `没有找到与“${STATE.query}”相关的结果` : "暂无数据"; |
| DOM.multiActionBar.style.display = "none"; |
| return; |
| } |
| DOM.emptyState.style.display = "none"; |
| DOM.resultsList.innerHTML = STATE.results.map((item) => { |
| const titleHtml = `${highlightText(item.File, STATE.fulltext ? "" : STATE.query)}<span style="opacity:0.5;font-size:12px">.txt</span>`; |
| const snippetHtml = item.snippet ? `<div class="result-snippet">${highlightText(item.snippet, STATE.query)}</div>` : ""; |
| return ` |
| <div class="result-item" data-doc-id="${escapeHTML(item.doc_id)}"> |
| <input type="checkbox" class="result-checkbox" data-doc-id="${escapeHTML(item.doc_id)}" ${STATE.selectedIds.has(item.doc_id) ? "checked" : ""}> |
| <div class="result-file-icon">${ICON_HTML.file}</div> |
| <div class="result-info"> |
| <div class="result-title">${titleHtml}</div> |
| <div class="result-path">${resultPathHtml(item)}</div> |
| <div class="result-meta"><span class="result-size">${escapeHTML(formatSize(item.Size))}</span></div> |
| ${snippetHtml} |
| </div> |
| <div class="result-actions"> |
| <button class="result-action-btn" data-action="preview" data-doc-id="${escapeHTML(item.doc_id)}">在线预览</button> |
| <a class="result-action-btn primary" href="/api/download/${encodeURIComponent(item.doc_id)}">下载</a> |
| </div> |
| </div>`; |
| }).join(""); |
| DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none"; |
| } |
|
|
| async function openPreview(docId, keyword) { |
| try { |
| const data = await API.preview(docId); |
| if (data.error) { |
| showToast("预览失败"); |
| return; |
| } |
| let text = escapeHTML(data.text || ""); |
| if (keyword) { |
| const tokens = tokenize(keyword).filter(Boolean); |
| for (const token of tokens) { |
| text = text.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `<mark>${m}</mark>`); |
| } |
| } |
| DOM.previewPanel.innerHTML = ` |
| <div class="preview-header"> |
| <div class="preview-title">${escapeHTML(data.title)}</div> |
| <div class="preview-path">${escapeHTML(data.source)} / ${escapeHTML(data.path)}</div> |
| </div> |
| <pre class="preview-body">${text}</pre>`; |
| DOM.previewPanel.style.display = "block"; |
| DOM.previewPanel.scrollIntoView({ behavior: "smooth", block: "start" }); |
| } catch (error) { |
| console.error(error); |
| showToast("预览失败"); |
| } |
| } |
|
|
| function renderCheckboxList(container, items, selected, onChange) { |
| container.innerHTML = items.map((item) => ` |
| <label class="filter-checkbox-item"> |
| <input type="checkbox" value="${escapeHTML(item.key)}" ${selected.includes(item.key) ? "checked" : ""}> |
| <span>${escapeHTML(item.label)}</span> |
| <span class="checkbox-count">${(item.count || 0).toLocaleString()}</span> |
| </label>`).join(""); |
| container.querySelectorAll("input").forEach((input) => { |
| input.addEventListener("change", () => { |
| const values = Array.from(container.querySelectorAll("input:checked")).map((item) => item.value); |
| onChange(values); |
| }); |
| }); |
| } |
|
|
| async function renderSourcesFilter() { |
| renderCheckboxList(DOM.filterSourceList, STATE.sources.map((source) => ({ key: source.slug, label: source.name, count: source.count })), STATE.selectedSources, (values) => { |
| STATE.selectedSources = values; |
| STATE.page = 1; |
| syncUrl(); |
| doSearch(); |
| }); |
| } |
|
|
| function flattenNodes(nodes, into) { |
| for (const node of nodes) { |
| if (node.path) into.push(node.path); |
| flattenNodes(node.children || [], into); |
| } |
| } |
|
|
| function buildFolderNodeMap(nodes, map = {}) { |
| for (const node of nodes) { |
| if (node.path) map[node.path] = node; |
| buildFolderNodeMap(node.children || [], map); |
| } |
| return map; |
| } |
|
|
| function getVisibleFolderNodes(nodes, depth = 0, into = []) { |
| for (const node of nodes) { |
| into.push({ node, depth }); |
| if (node.children && node.children.length && !STATE.folderCollapsed[node.path]) { |
| getVisibleFolderNodes(node.children, depth + 1, into); |
| } |
| } |
| return into; |
| } |
|
|
| function collectVisibleDescendantPaths(node, into = []) { |
| for (const child of node.children || []) { |
| if (!child.path) continue; |
| into.push(child.path); |
| if (child.children && child.children.length && !STATE.folderCollapsed[child.path]) { |
| collectVisibleDescendantPaths(child, into); |
| } |
| } |
| return into; |
| } |
|
|
| function normalizeFolderTree(nodes) { |
| const roots = Array.isArray(nodes) ? nodes : []; |
| if (roots.length === 1) { |
| const only = roots[0]; |
| const hasChildren = Array.isArray(only.children) && only.children.length; |
| if ((only.isRoot || !only.path || only.path === STATE.source || only.name === STATE.source) && hasChildren) { |
| return only.children; |
| } |
| } |
| return roots; |
| } |
|
|
| function initializeFolderCollapsedState(nodes) { |
| const next = {}; |
| const visit = (items, depth = 0) => { |
| for (const node of items) { |
| if (node.path && !(node.path in STATE.folderCollapsed)) next[node.path] = depth > 0; |
| visit(node.children || [], depth + 1); |
| } |
| }; |
| visit(nodes, 0); |
| STATE.folderCollapsed = { ...next, ...STATE.folderCollapsed }; |
| } |
|
|
| function isPathCovered(path, selectionSet) { |
| if (!path) return false; |
| if (selectionSet.has(path)) return true; |
| const parts = path.split("/"); |
| while (parts.length > 1) { |
| parts.pop(); |
| if (selectionSet.has(parts.join("/"))) return true; |
| } |
| return false; |
| } |
|
|
| function getFolderSelectionState(node, selectionSet, cache = new Map()) { |
| const cacheKey = node.path || `__virtual__:${node.name}:${(node.children || []).length}`; |
| if (cache.has(cacheKey)) return cache.get(cacheKey); |
| let state = "unchecked"; |
| if (node.path && isPathCovered(node.path, selectionSet)) { |
| state = "checked"; |
| } else { |
| const children = node.children || []; |
| if (children.length) { |
| const childStates = children.map((child) => getFolderSelectionState(child, selectionSet, cache)); |
| if (childStates.every((item) => item === "checked")) state = "checked"; |
| else if (childStates.some((item) => item !== "unchecked")) state = "indeterminate"; |
| } |
| } |
| cache.set(cacheKey, state); |
| return state; |
| } |
|
|
| function normalizeFolderSelections(selections) { |
| const unique = Array.from(new Set((selections || []).filter(Boolean))); |
| return unique.filter((path) => !unique.some((other) => other !== path && path.startsWith(other + "/"))); |
| } |
|
|
| function selectFolderPath(path) { |
| const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); |
| if (isPathCovered(path, selectionSet)) return Array.from(selectionSet); |
| const next = Array.from(selectionSet).filter((item) => !(item === path || item.startsWith(path + "/"))); |
| next.push(path); |
| return normalizeFolderSelections(next); |
| } |
|
|
| function collectSelectionsExcept(node, excludedPath, into) { |
| if (!node.path) { |
| for (const child of node.children || []) collectSelectionsExcept(child, excludedPath, into); |
| return; |
| } |
| if (node.path === excludedPath || excludedPath.startsWith(node.path + "/")) { |
| for (const child of node.children || []) collectSelectionsExcept(child, excludedPath, into); |
| return; |
| } |
| into.push(node.path); |
| } |
|
|
| function deselectFolderPath(path) { |
| let next = normalizeFolderSelections(STATE.folderSelections).filter((item) => !(item === path || item.startsWith(path + "/"))); |
| const parts = path.split("/"); |
| while (parts.length > 1) { |
| parts.pop(); |
| const ancestorPath = parts.join("/"); |
| const ancestorIndex = next.indexOf(ancestorPath); |
| if (ancestorIndex === -1) continue; |
| next.splice(ancestorIndex, 1); |
| const ancestorNode = STATE.folderNodeMap[ancestorPath]; |
| if (ancestorNode) { |
| const expanded = []; |
| collectSelectionsExcept(ancestorNode, path, expanded); |
| next.push(...expanded); |
| } |
| break; |
| } |
| return normalizeFolderSelections(next); |
| } |
|
|
| function invertFolderSelections() { |
| const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); |
| const stateCache = new Map(); |
| const next = []; |
| const invertNode = (node) => { |
| const state = getFolderSelectionState(node, selectionSet, stateCache); |
| if (state === "checked") return; |
| if (state === "unchecked") { |
| if (node.path) next.push(node.path); |
| else for (const child of node.children || []) invertNode(child); |
| return; |
| } |
| for (const child of node.children || []) invertNode(child); |
| }; |
| for (const node of STATE.folderTree) invertNode(node); |
| return normalizeFolderSelections(next); |
| } |
|
|
| function refreshFolderTreeSelectionState() { |
| if (!DOM.filterFolderTree) return; |
| const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); |
| const stateCache = new Map(); |
| DOM.filterFolderTree.querySelectorAll("input[data-folder-path]").forEach((checkbox) => { |
| const node = STATE.folderNodeMap[checkbox.dataset.folderPath]; |
| if (!node) return; |
| const state = getFolderSelectionState(node, selectionSet, stateCache); |
| checkbox.checked = state === "checked"; |
| checkbox.indeterminate = state === "indeterminate"; |
| }); |
| DOM.filterFolderTree.querySelectorAll(".tree-toggle[data-folder-path]").forEach((toggle) => { |
| const path = toggle.dataset.folderPath; |
| const collapsed = !!STATE.folderCollapsed[path]; |
| toggle.classList.toggle("expanded", !collapsed); |
| toggle.setAttribute("aria-label", collapsed ? "展开子文件夹" : "收起子文件夹"); |
| toggle.setAttribute("title", collapsed ? "展开" : "收起"); |
| }); |
| } |
|
|
| function applyFolderSelections(nextSelections) { |
| STATE.folderSelections = normalizeFolderSelections(nextSelections); |
| STATE.page = 1; |
| refreshFolderTreeSelectionState(); |
| syncUrl(); |
| doSearch(); |
| } |
|
|
| function renderFolderTreeRows(animation = {}) { |
| const container = DOM.filterFolderTree; |
| if (!container) return; |
| const firstRects = new Map(); |
| container.querySelectorAll(".filter-folder-item[data-folder-path]").forEach((row) => { |
| firstRects.set(row.dataset.folderPath, row.getBoundingClientRect()); |
| }); |
| container.innerHTML = ""; |
| renderFolderNodes(container, STATE.folderTree, 0); |
| refreshFolderTreeSelectionState(); |
| const newRows = container.querySelectorAll(".filter-folder-item[data-folder-path]"); |
| newRows.forEach((row) => { |
| const path = row.dataset.folderPath; |
| const lastRect = row.getBoundingClientRect(); |
| const firstRect = firstRects.get(path); |
| if (firstRect) { |
| const deltaY = firstRect.top - lastRect.top; |
| if (Math.abs(deltaY) > 0.5) { |
| row.animate([ |
| { transform: `translateY(${deltaY}px)` }, |
| { transform: "translateY(0)" }, |
| ], { |
| duration: 220, |
| easing: "ease", |
| }); |
| } |
| } else { |
| row.animate([ |
| { opacity: 0, transform: "translateY(-8px)" }, |
| { opacity: 1, transform: "translateY(0)" }, |
| ], { |
| duration: 220, |
| easing: "ease", |
| }); |
| } |
| if (animation.expanding && animation.toggledPath && row.dataset.folderPath === animation.toggledPath) { |
| const glyph = row.querySelector(".tree-toggle-glyph"); |
| if (glyph) { |
| const fromTransform = "rotate(-45deg)"; |
| const toTransform = "rotate(45deg)"; |
| glyph.animate([ |
| { transform: fromTransform }, |
| { transform: toTransform }, |
| ], { |
| duration: 220, |
| easing: "ease", |
| }); |
| } |
| } |
| }); |
| } |
|
|
| function animateFolderCollapse(nodePath, exitingPaths) { |
| const container = DOM.filterFolderTree; |
| if (!container) return 200; |
| const duration = 200; |
| const glyph = container.querySelector(`.filter-folder-item[data-folder-path="${CSS.escape(nodePath)}"] .tree-toggle-glyph`); |
| if (glyph) { |
| glyph.animate([ |
| { transform: "rotate(45deg)" }, |
| { transform: "rotate(-45deg)" }, |
| ], { |
| duration: 220, |
| easing: "ease", |
| fill: "forwards", |
| }); |
| } |
| for (const path of exitingPaths) { |
| const row = container.querySelector(`.filter-folder-item[data-folder-path="${CSS.escape(path)}"]`); |
| if (!row) continue; |
| row.animate([ |
| { opacity: 1, transform: "translateY(0) scaleY(1)" }, |
| { opacity: 0, transform: "translateY(-10px) scaleY(0.96)" }, |
| ], { |
| duration, |
| easing: "ease", |
| fill: "forwards", |
| }); |
| } |
| return duration; |
| } |
|
|
| function renderFolderNodes(container, nodes, depth) { |
| const visibleNodes = getVisibleFolderNodes(nodes, depth); |
| for (const { node, depth: itemDepth } of visibleNodes) { |
| const hasChildren = !!(node.children && node.children.length); |
| const row = document.createElement("div"); |
| row.className = "filter-folder-item"; |
| row.dataset.folderPath = node.path; |
| row.style.setProperty("--fdepth", itemDepth); |
| row.innerHTML = `${hasChildren ? `<button type="button" class="tree-toggle" data-folder-path="${escapeHTML(node.path)}" aria-label="展开子文件夹" title="展开"><span class="tree-toggle-glyph" aria-hidden="true"></span></button>` : '<span class="tree-toggle-placeholder"></span>'}<input type="checkbox" data-folder-path="${escapeHTML(node.path)}" value="${escapeHTML(node.path)}"><span class="folder-name">${escapeHTML(node.name)}</span><span class="folder-count">${(node.count || 0).toLocaleString()}</span>`; |
| const toggle = row.querySelector(".tree-toggle"); |
| if (toggle) { |
| toggle.addEventListener("click", (event) => { |
| event.stopPropagation(); |
| const expanding = !!STATE.folderCollapsed[node.path]; |
| if (expanding) { |
| STATE.folderCollapsed[node.path] = false; |
| renderFolderTreeRows({ toggledPath: node.path, expanding: true }); |
| return; |
| } |
| const exitingPaths = collectVisibleDescendantPaths(node); |
| const duration = animateFolderCollapse(node.path, exitingPaths); |
| STATE.folderCollapsed[node.path] = true; |
| window.setTimeout(() => { |
| renderFolderTreeRows({ toggledPath: node.path, expanding: false }); |
| }, duration); |
| }); |
| } |
| const checkbox = row.querySelector("input"); |
| checkbox.addEventListener("change", () => { |
| const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); |
| const state = getFolderSelectionState(node, selectionSet); |
| if (state === "checked") applyFolderSelections(deselectFolderPath(node.path)); |
| else applyFolderSelections(selectFolderPath(node.path)); |
| }); |
| container.appendChild(row); |
| } |
| } |
|
|
| async function renderFolderTree(forceReload = false) { |
| updateFilterVisibility(); |
| if (!STATE.source) { |
| DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;opacity:0.6">进入单仓库后可按路径过滤</div>'; |
| STATE.folderTree = []; |
| STATE.folderTreeSource = null; |
| STATE.folderNodeMap = {}; |
| return; |
| } |
| if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) { |
| STATE.folderTree = normalizeFolderTree(await API.getFolders(STATE.source)); |
| STATE.folderTreeSource = STATE.source; |
| STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree); |
| STATE.folderCollapsed = {}; |
| initializeFolderCollapsedState(STATE.folderTree); |
| renderFolderTreeRows(); |
| } |
| refreshFolderTreeSelectionState(); |
| } |
|
|
| async function renderSidebar() { |
| if (!STATE.source) { |
| DOM.sidebarTitle.textContent = "数据包"; |
| DOM.sidebarContent.innerHTML = STATE.sources.map((source) => `<div class="repo-list-item" data-source="${escapeHTML(source.slug)}">${ICON_HTML.source}<span class="repo-name">${escapeHTML(source.name)}</span><span class="repo-count">${(source.count || 0).toLocaleString()}</span></div>`).join(""); |
| return; |
| } |
| DOM.sidebarTitle.textContent = STATE.sourceMap[STATE.source]?.name || STATE.source; |
| const data = await API.getContents(STATE.source, ""); |
| let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>'; |
| html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">${ICON_HTML.folder}<span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join(""); |
| html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">${ICON_HTML.file}<span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join(""); |
| DOM.sidebarContent.innerHTML = html; |
| } |
|
|
| async function openFolder(path) { |
| const data = await API.getContents(STATE.source, path); |
| const parts = path ? path.split("/") : []; |
| let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>'; |
| html += `<div class="sidebar-breadcrumb"><span class="crumb-item" data-folder-nav="">根目录</span>${parts.map((part, index) => `<span class="crumb-sep">/</span><span class="crumb-item" data-folder-nav="${escapeHTML(parts.slice(0, index + 1).join("/"))}">${escapeHTML(part)}</span>`).join("")}</div>`; |
| html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">${ICON_HTML.folder}<span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join(""); |
| html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">${ICON_HTML.file}<span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join(""); |
| DOM.sidebarContent.innerHTML = html; |
| } |
|
|
| function updateSidebarVisibility() { |
| DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen); |
| DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen); |
| DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen); |
| DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen); |
| DOM.overlay.style.display = (STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen)) ? "" : "none"; |
| } |
|
|
| function clearFilters() { |
| STATE.folderSelections = []; |
| STATE.selectedSources = []; |
| STATE.minSize = null; |
| STATE.maxSize = null; |
| DOM.filterMinSize.value = ""; |
| DOM.filterMaxSize.value = ""; |
| DOM.filterMinUnit.value = "MB"; |
| DOM.filterMaxUnit.value = "MB"; |
| renderSourcesFilter(); |
| refreshFolderTreeSelectionState(); |
| syncUrl(); |
| doSearch(); |
| } |
|
|
| async function randomDoc() { |
| const data = await API.random(STATE.source); |
| if (!data.doc_id) { |
| showToast("暂无可用文档"); |
| return; |
| } |
| openPreview(data.doc_id, ""); |
| } |
|
|
| function updateMultiUi() { |
| STATE.multiSelect = DOM.multiSelectToggle.checked; |
| DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none"; |
| document.body.classList.toggle("multiselect", STATE.multiSelect); |
| if (!STATE.multiSelect) STATE.selectedIds.clear(); |
| DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : ""; |
| renderResults(); |
| } |
|
|
| function attachEvents() { |
| DOM.headerLogo.addEventListener("click", (event) => { |
| if (window.location.pathname !== "/") return; |
| event.preventDefault(); |
| window.location.href = DOM.headerLogo.href; |
| }); |
| DOM.hamburgerBtn.addEventListener("click", () => { |
| STATE.leftSidebarOpen = !STATE.leftSidebarOpen; |
| if (STATE.isMobile && STATE.leftSidebarOpen) STATE.rightSidebarOpen = false; |
| updateSidebarVisibility(); |
| }); |
| if (DOM.sidebarExpandBtn) { |
| DOM.sidebarExpandBtn.addEventListener("click", () => { |
| DOM.leftSidebar.classList.toggle("expanded-wide"); |
| DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔"; |
| }); |
| } |
| DOM.settingsBtn.addEventListener("click", () => { |
| STATE.rightSidebarOpen = !STATE.rightSidebarOpen; |
| if (STATE.isMobile && STATE.rightSidebarOpen) STATE.leftSidebarOpen = false; |
| updateSidebarVisibility(); |
| }); |
| DOM.closeFiltersBtn.addEventListener("click", () => { |
| STATE.rightSidebarOpen = false; |
| updateSidebarVisibility(); |
| }); |
| DOM.overlay.addEventListener("click", () => { |
| STATE.leftSidebarOpen = false; |
| STATE.rightSidebarOpen = false; |
| updateSidebarVisibility(); |
| }); |
| DOM.themeBtn.addEventListener("click", () => { |
| STATE.isDark = !STATE.isDark; |
| applyTheme(); |
| localStorage.setItem("theme", STATE.isDark ? "dark" : "light"); |
| }); |
| DOM.mobileToggleBtn.addEventListener("click", () => { |
| STATE.isMobile = !STATE.isMobile; |
| applyMobileMode(); |
| }); |
| let historyDropdownActive = false; |
| DOM.searchInput.addEventListener("focus", renderHistoryDropdown); |
| DOM.searchInput.addEventListener("blur", () => setTimeout(() => { if (!historyDropdownActive) setHistoryDropdownOpen(false); }, 120)); |
| DOM.searchHistoryDropdown.addEventListener("mouseenter", () => { historyDropdownActive = true; }); |
| DOM.searchHistoryDropdown.addEventListener("mouseleave", () => { historyDropdownActive = false; }); |
| DOM.searchHistoryDropdown.addEventListener("click", (event) => { |
| const delBtn = event.target.closest(".history-del"); |
| if (delBtn) { |
| event.stopPropagation(); |
| removeHistory(delBtn.dataset.del); |
| historyDropdownActive = true; |
| DOM.searchInput.focus(); |
| return; |
| } |
| const clearBtn = event.target.closest(".history-clear-all"); |
| if (clearBtn) { |
| clearHistory(); |
| historyDropdownActive = true; |
| DOM.searchInput.focus(); |
| return; |
| } |
| const item = event.target.closest("[data-query]"); |
| if (!item) return; |
| DOM.searchInput.value = item.dataset.query; |
| STATE.query = item.dataset.query; |
| syncUrl(); |
| doSearch(); |
| }); |
| let historyLongPressTimer = null; |
| const cancelHistoryLongPress = () => { |
| if (historyLongPressTimer) { |
| clearTimeout(historyLongPressTimer); |
| historyLongPressTimer = null; |
| } |
| }; |
| DOM.searchHistoryDropdown.addEventListener("mousedown", (event) => { |
| const item = event.target.closest("[data-query]"); |
| if (!item || event.target.closest(".history-del") || event.target.closest(".history-clear-all")) return; |
| cancelHistoryLongPress(); |
| historyLongPressTimer = setTimeout(() => { |
| removeHistory(item.dataset.query); |
| historyLongPressTimer = null; |
| }, 600); |
| }); |
| DOM.searchHistoryDropdown.addEventListener("mouseup", cancelHistoryLongPress); |
| DOM.searchHistoryDropdown.addEventListener("mouseleave", cancelHistoryLongPress); |
| DOM.searchHistoryDropdown.addEventListener("touchstart", (event) => { |
| const item = event.target.closest("[data-query]"); |
| if (!item || event.target.closest(".history-del") || event.target.closest(".history-clear-all")) return; |
| cancelHistoryLongPress(); |
| historyLongPressTimer = setTimeout(() => { |
| removeHistory(item.dataset.query); |
| historyLongPressTimer = null; |
| }, 650); |
| }, { passive: true }); |
| DOM.searchHistoryDropdown.addEventListener("touchend", cancelHistoryLongPress, { passive: true }); |
| DOM.searchHistoryDropdown.addEventListener("touchmove", cancelHistoryLongPress, { passive: true }); |
| let timer = null; |
| DOM.searchInput.addEventListener("input", () => { |
| clearTimeout(timer); |
| timer = setTimeout(() => { |
| STATE.query = DOM.searchInput.value.trim(); |
| STATE.page = 1; |
| addHistory(STATE.query); |
| renderHistoryDropdown(); |
| syncUrl(); |
| doSearch(); |
| }, 250); |
| }); |
| DOM.sortSelect.addEventListener("change", () => { |
| STATE.page = 1; |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.exactToggle.addEventListener("change", () => { |
| STATE.exact = DOM.exactToggle.checked; |
| STATE.page = 1; |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.historyToggle.addEventListener("change", () => { |
| STATE.historyEnabled = DOM.historyToggle.checked; |
| if (!STATE.historyEnabled) saveHistory([]); |
| syncUrl(); |
| }); |
| DOM.fulltextToggle.addEventListener("change", async () => { |
| STATE.fulltext = DOM.fulltextToggle.checked; |
| if (DOM.searchPathsToggle) { |
| DOM.searchPathsToggle.disabled = STATE.fulltext; |
| if (STATE.fulltext) DOM.searchPathsToggle.closest(".toggle-row").style.opacity = "0.5"; |
| else DOM.searchPathsToggle.closest(".toggle-row").style.opacity = ""; |
| } |
| DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径..."; |
| STATE.page = 1; |
| if (STATE.fulltext) await ensureFulltextLoaded(); |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.searchPathsToggle.addEventListener("change", () => { |
| STATE.searchPaths = DOM.searchPathsToggle.checked; |
| STATE.page = 1; |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.clearFiltersBtn.addEventListener("click", clearFilters); |
| DOM.emptyRandomBtn.addEventListener("click", randomDoc); |
| if (DOM.randomBookBtn) DOM.randomBookBtn.addEventListener("click", randomDoc); |
| DOM.filterMinSize.addEventListener("input", () => { |
| STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value); |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.filterMaxSize.addEventListener("input", () => { |
| STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value); |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.filterMinUnit.addEventListener("change", () => { |
| STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value); |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.filterMaxUnit.addEventListener("change", () => { |
| STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value); |
| syncUrl(); |
| doSearch(); |
| }); |
| DOM.folderSelectAll.addEventListener("click", () => { |
| const paths = []; |
| flattenNodes(STATE.folderTree, paths); |
| applyFolderSelections(paths); |
| }); |
| DOM.folderDeselectAll.addEventListener("click", () => { |
| applyFolderSelections(invertFolderSelections()); |
| }); |
| DOM.multiSelectToggle.addEventListener("change", updateMultiUi); |
| DOM.multiSelectAll.addEventListener("click", () => { |
| STATE.selectedIds = new Set(STATE.results.map((item) => item.doc_id)); |
| updateMultiUi(); |
| }); |
| DOM.multiDeselect.addEventListener("click", () => { |
| STATE.selectedIds.clear(); |
| updateMultiUi(); |
| }); |
| DOM.multiBatchDownload.addEventListener("click", () => { |
| const ids = Array.from(STATE.selectedIds); |
| if (!ids.length) { |
| showToast("未选中任何文件"); |
| return; |
| } |
| ids.forEach((id, index) => setTimeout(() => { window.open(`/api/download/${encodeURIComponent(id)}`, "_blank"); }, index * 250)); |
| }); |
| DOM.multiZipDownload.addEventListener("click", async () => { |
| const ids = Array.from(STATE.selectedIds); |
| if (!ids.length) { |
| showToast("未选中任何文件"); |
| return; |
| } |
| showToast(`正在打包 ${ids.length} 个文件...`, 2500); |
| try { |
| const resp = await fetch("/api/zip", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ doc_ids: ids }), |
| }); |
| if (!resp.ok) { |
| let message = `HTTP ${resp.status}`; |
| try { |
| const data = await resp.json(); |
| if (data && data.error) message = data.error; |
| } catch (_error) {} |
| throw new Error(message); |
| } |
| const blob = await resp.blob(); |
| const link = document.createElement("a"); |
| const objectUrl = URL.createObjectURL(blob); |
| link.href = objectUrl; |
| link.download = `vomebook_batch_${Date.now()}.zip`; |
| link.style.display = "none"; |
| document.body.appendChild(link); |
| link.click(); |
| link.remove(); |
| setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); |
| showToast(`已开始下载 ${ids.length} 个文件的压缩包`); |
| } catch (error) { |
| console.error(error); |
| showToast(`合并下载失败: ${error.message || '未知错误'}`, 3500); |
| } |
| }); |
| DOM.resultsList.addEventListener("click", async (event) => { |
| const checkbox = event.target.closest(".result-checkbox"); |
| if (checkbox) { |
| const docId = checkbox.dataset.docId; |
| if (checkbox.checked) STATE.selectedIds.add(docId); |
| else STATE.selectedIds.delete(docId); |
| DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : ""; |
| return; |
| } |
| const action = event.target.closest("[data-action='preview']"); |
| if (action) { |
| event.preventDefault(); |
| openPreview(action.dataset.docId, STATE.query); |
| return; |
| } |
| const folder = event.target.closest(".path-folder[data-folder]"); |
| if (folder) { |
| const source = folder.dataset.source; |
| const path = folder.dataset.folder; |
| if (STATE.mode === "global" && source && !STATE.source) { |
| navigateToSource(source); |
| if (path) STATE.folderSelections = [path]; |
| } else { |
| STATE.folderSelections = path ? [path] : []; |
| } |
| refreshFolderTreeSelectionState(); |
| syncUrl(); |
| doSearch(); |
| } |
| }); |
| DOM.sidebarContent.addEventListener("click", async (event) => { |
| const sourceItem = event.target.closest("[data-source]"); |
| if (sourceItem) { |
| navigateToSource(sourceItem.dataset.source); |
| return; |
| } |
| const backHome = event.target.closest("#back-home"); |
| if (backHome) { |
| navigateHome(); |
| return; |
| } |
| const folderOpen = event.target.closest("[data-folder-open]"); |
| if (folderOpen) { |
| openFolder(folderOpen.dataset.folderOpen); |
| return; |
| } |
| const folderNav = event.target.closest("[data-folder-nav]"); |
| if (folderNav) { |
| openFolder(folderNav.dataset.folderNav); |
| return; |
| } |
| const docItem = event.target.closest("[data-doc-id]"); |
| if (docItem) { |
| openPreview(docItem.dataset.docId, STATE.query); |
| } |
| }); |
| DOM.resultsContainer.addEventListener("scroll", () => { |
| const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 200; |
| if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) { |
| STATE.page += 1; |
| doSearch(); |
| } |
| }); |
| document.addEventListener("keydown", (event) => { |
| if (event.key === "/" && document.activeElement !== DOM.searchInput) { |
| event.preventDefault(); |
| DOM.searchInput.focus(); |
| DOM.searchInput.select(); |
| } |
| }); |
| window.addEventListener("popstate", async () => { |
| syncRoute(); |
| await renderSidebar(); |
| await renderFolderTree(); |
| doSearch(); |
| }); |
| } |
|
|
| async function init() { |
| cacheDom(); |
| STATE.isDark = localStorage.getItem("theme") !== "light"; |
| applyTheme(); |
| STATE.isMobile = window.innerWidth <= 768; |
| syncRoute(); |
| loadUrlState(); |
| applyMobileMode(); |
| STATE.sources = await API.getSources(); |
| STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source])); |
| await renderSourcesFilter(); |
| await renderSidebar(); |
| await renderFolderTree(); |
| updateFilterVisibility(); |
| attachEvents(); |
| if (DOM.searchPathsToggle) { |
| DOM.searchPathsToggle.checked = STATE.searchPaths; |
| DOM.searchPathsToggle.disabled = STATE.fulltext; |
| DOM.searchPathsToggle.closest(".toggle-row").style.opacity = STATE.fulltext ? "0.5" : ""; |
| } |
| updateSidebarVisibility(); |
| syncUrl(); |
| doSearch(); |
| } |
|
|
| document.addEventListener("DOMContentLoaded", init); |
|
|