Upload 3 files
Browse files- static/app.js +60 -13
- static/index.html +1 -1
- static/style.css +3 -0
static/app.js
CHANGED
|
@@ -61,6 +61,7 @@ let snippetObserver = null;
|
|
| 61 |
let snippetActive = 0;
|
| 62 |
const snippetQueue = [];
|
| 63 |
const snippetCache = new Map();
|
|
|
|
| 64 |
|
| 65 |
function themeIcon(isDark) {
|
| 66 |
const shape = isDark
|
|
@@ -187,6 +188,7 @@ function buildPath(folder, file) {
|
|
| 187 |
const API = {
|
| 188 |
async getSources() {
|
| 189 |
const resp = await fetch("/api/sources");
|
|
|
|
| 190 |
return resp.json();
|
| 191 |
},
|
| 192 |
async search(body, sourceSlug, signal) {
|
|
@@ -197,6 +199,7 @@ const API = {
|
|
| 197 |
body: JSON.stringify(body),
|
| 198 |
signal,
|
| 199 |
});
|
|
|
|
| 200 |
return resp.json();
|
| 201 |
},
|
| 202 |
async getFolders(sourceSlug) {
|
|
@@ -236,15 +239,18 @@ const API = {
|
|
| 236 |
},
|
| 237 |
async preview(docId) {
|
| 238 |
const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
|
|
|
|
| 239 |
return resp.json();
|
| 240 |
},
|
| 241 |
-
async snippet(docId, query, exact) {
|
| 242 |
-
const resp = await fetch(`/api/snippet/${encodeURIComponent(docId)}?q=${encodeURIComponent(query || "")}&exact=${exact ? "true" : "false"}`);
|
|
|
|
| 243 |
return resp.json();
|
| 244 |
},
|
| 245 |
async random(sourceSlug) {
|
| 246 |
const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random";
|
| 247 |
const resp = await fetch(url);
|
|
|
|
| 248 |
return resp.json();
|
| 249 |
},
|
| 250 |
};
|
|
@@ -717,7 +723,7 @@ async function loadInitialPayload() {
|
|
| 717 |
return data;
|
| 718 |
}
|
| 719 |
|
| 720 |
-
async function applyInitialSearch() {
|
| 721 |
if (!currentStateCanUseInitial()) return false;
|
| 722 |
let data;
|
| 723 |
try {
|
|
@@ -725,7 +731,7 @@ async function applyInitialSearch() {
|
|
| 725 |
} catch (_error) {
|
| 726 |
return false;
|
| 727 |
}
|
| 728 |
-
if (!initialMatchesCurrentState(data)) return false;
|
| 729 |
const body = buildSearchBody(1);
|
| 730 |
const cacheKey = searchCacheKey(body);
|
| 731 |
setCachedSearch(cacheKey, data);
|
|
@@ -782,7 +788,11 @@ async function doSearch() {
|
|
| 782 |
if (STATE.searchController) STATE.searchController.abort();
|
| 783 |
abortSearchPrefetch();
|
| 784 |
const sequence = ++STATE.searchSequence;
|
| 785 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
const controller = new AbortController();
|
| 787 |
STATE.searchController = controller;
|
| 788 |
STATE.isLoading = true;
|
|
@@ -881,6 +891,8 @@ function resetSnippetObserver() {
|
|
| 881 |
if (snippetObserver) snippetObserver.disconnect();
|
| 882 |
snippetObserver = null;
|
| 883 |
snippetQueue.length = 0;
|
|
|
|
|
|
|
| 884 |
}
|
| 885 |
|
| 886 |
function observeResultSnippets() {
|
|
@@ -929,13 +941,23 @@ async function loadExactSnippet(task) {
|
|
| 929 |
let data = snippetCache.get(cacheKey);
|
| 930 |
try {
|
| 931 |
if (!data) {
|
| 932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
if (data && !data.error) {
|
| 934 |
snippetCache.set(cacheKey, data);
|
| 935 |
while (snippetCache.size > 300) snippetCache.delete(snippetCache.keys().next().value);
|
| 936 |
}
|
| 937 |
}
|
| 938 |
} catch (_error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
return;
|
| 940 |
}
|
| 941 |
if (!data || data.error || task.sequence !== STATE.searchSequence || task.query !== STATE.query || task.exact !== STATE.exact || !task.node.isConnected) return;
|
|
@@ -1518,12 +1540,17 @@ function clearFilters() {
|
|
| 1518 |
}
|
| 1519 |
|
| 1520 |
async function randomDoc() {
|
| 1521 |
-
|
| 1522 |
-
|
| 1523 |
-
|
| 1524 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1525 |
}
|
| 1526 |
-
openPreview(data.doc_id, "");
|
| 1527 |
}
|
| 1528 |
|
| 1529 |
function updateMultiUi() {
|
|
@@ -1745,7 +1772,14 @@ function attachEvents() {
|
|
| 1745 |
showToast("未选中任何文件");
|
| 1746 |
return;
|
| 1747 |
}
|
| 1748 |
-
ids.forEach((id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1749 |
});
|
| 1750 |
DOM.multiZipDownload.addEventListener("click", () => {
|
| 1751 |
const ids = Array.from(STATE.selectedIds);
|
|
@@ -1753,11 +1787,15 @@ function attachEvents() {
|
|
| 1753 |
showToast("未选中任何文件");
|
| 1754 |
return;
|
| 1755 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1756 |
const form = document.createElement("form");
|
| 1757 |
form.method = "POST";
|
| 1758 |
form.action = "/api/zip-download";
|
| 1759 |
form.style.display = "none";
|
| 1760 |
-
for (const id of ids
|
| 1761 |
const input = document.createElement("input");
|
| 1762 |
input.type = "hidden";
|
| 1763 |
input.name = "doc_id";
|
|
@@ -1849,6 +1887,14 @@ function attachEvents() {
|
|
| 1849 |
if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
|
| 1850 |
});
|
| 1851 |
document.addEventListener("keydown", (event) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1852 |
if (event.key === "/" && document.activeElement !== DOM.searchInput) {
|
| 1853 |
event.preventDefault();
|
| 1854 |
DOM.searchInput.focus();
|
|
@@ -1909,6 +1955,7 @@ async function init() {
|
|
| 1909 |
if (DOM.searchPathsToggle) {
|
| 1910 |
DOM.searchPathsToggle.checked = STATE.searchPaths;
|
| 1911 |
}
|
|
|
|
| 1912 |
updateSidebarVisibility();
|
| 1913 |
syncUrl();
|
| 1914 |
restorePreviewFromUrl({ scroll: false });
|
|
|
|
| 61 |
let snippetActive = 0;
|
| 62 |
const snippetQueue = [];
|
| 63 |
const snippetCache = new Map();
|
| 64 |
+
const snippetControllers = new Set();
|
| 65 |
|
| 66 |
function themeIcon(isDark) {
|
| 67 |
const shape = isDark
|
|
|
|
| 188 |
const API = {
|
| 189 |
async getSources() {
|
| 190 |
const resp = await fetch("/api/sources");
|
| 191 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 192 |
return resp.json();
|
| 193 |
},
|
| 194 |
async search(body, sourceSlug, signal) {
|
|
|
|
| 199 |
body: JSON.stringify(body),
|
| 200 |
signal,
|
| 201 |
});
|
| 202 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 203 |
return resp.json();
|
| 204 |
},
|
| 205 |
async getFolders(sourceSlug) {
|
|
|
|
| 239 |
},
|
| 240 |
async preview(docId) {
|
| 241 |
const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
|
| 242 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 243 |
return resp.json();
|
| 244 |
},
|
| 245 |
+
async snippet(docId, query, exact, signal) {
|
| 246 |
+
const resp = await fetch(`/api/snippet/${encodeURIComponent(docId)}?q=${encodeURIComponent(query || "")}&exact=${exact ? "true" : "false"}`, { signal });
|
| 247 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 248 |
return resp.json();
|
| 249 |
},
|
| 250 |
async random(sourceSlug) {
|
| 251 |
const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random";
|
| 252 |
const resp = await fetch(url);
|
| 253 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 254 |
return resp.json();
|
| 255 |
},
|
| 256 |
};
|
|
|
|
| 723 |
return data;
|
| 724 |
}
|
| 725 |
|
| 726 |
+
async function applyInitialSearch(sequence) {
|
| 727 |
if (!currentStateCanUseInitial()) return false;
|
| 728 |
let data;
|
| 729 |
try {
|
|
|
|
| 731 |
} catch (_error) {
|
| 732 |
return false;
|
| 733 |
}
|
| 734 |
+
if (sequence !== STATE.searchSequence || !initialMatchesCurrentState(data)) return false;
|
| 735 |
const body = buildSearchBody(1);
|
| 736 |
const cacheKey = searchCacheKey(body);
|
| 737 |
setCachedSearch(cacheKey, data);
|
|
|
|
| 788 |
if (STATE.searchController) STATE.searchController.abort();
|
| 789 |
abortSearchPrefetch();
|
| 790 |
const sequence = ++STATE.searchSequence;
|
| 791 |
+
if (STATE.page === 1 && STATE.selectedIds.size) {
|
| 792 |
+
STATE.selectedIds.clear();
|
| 793 |
+
DOM.multiSelectedCount.textContent = "";
|
| 794 |
+
}
|
| 795 |
+
if (await applyInitialSearch(sequence)) return;
|
| 796 |
const controller = new AbortController();
|
| 797 |
STATE.searchController = controller;
|
| 798 |
STATE.isLoading = true;
|
|
|
|
| 891 |
if (snippetObserver) snippetObserver.disconnect();
|
| 892 |
snippetObserver = null;
|
| 893 |
snippetQueue.length = 0;
|
| 894 |
+
for (const controller of snippetControllers) controller.abort();
|
| 895 |
+
snippetControllers.clear();
|
| 896 |
}
|
| 897 |
|
| 898 |
function observeResultSnippets() {
|
|
|
|
| 941 |
let data = snippetCache.get(cacheKey);
|
| 942 |
try {
|
| 943 |
if (!data) {
|
| 944 |
+
const controller = new AbortController();
|
| 945 |
+
snippetControllers.add(controller);
|
| 946 |
+
try {
|
| 947 |
+
data = await API.snippet(task.docId, task.query, task.exact, controller.signal);
|
| 948 |
+
} finally {
|
| 949 |
+
snippetControllers.delete(controller);
|
| 950 |
+
}
|
| 951 |
if (data && !data.error) {
|
| 952 |
snippetCache.set(cacheKey, data);
|
| 953 |
while (snippetCache.size > 300) snippetCache.delete(snippetCache.keys().next().value);
|
| 954 |
}
|
| 955 |
}
|
| 956 |
} catch (_error) {
|
| 957 |
+
if (task.node.isConnected && task.sequence === STATE.searchSequence) {
|
| 958 |
+
const loading = task.node.querySelector(".snippet-loading");
|
| 959 |
+
if (loading) loading.remove();
|
| 960 |
+
}
|
| 961 |
return;
|
| 962 |
}
|
| 963 |
if (!data || data.error || task.sequence !== STATE.searchSequence || task.query !== STATE.query || task.exact !== STATE.exact || !task.node.isConnected) return;
|
|
|
|
| 1540 |
}
|
| 1541 |
|
| 1542 |
async function randomDoc() {
|
| 1543 |
+
try {
|
| 1544 |
+
const data = await API.random(STATE.source);
|
| 1545 |
+
if (!data.doc_id) {
|
| 1546 |
+
showToast("暂无可用文档");
|
| 1547 |
+
return;
|
| 1548 |
+
}
|
| 1549 |
+
openPreview(data.doc_id, "");
|
| 1550 |
+
} catch (error) {
|
| 1551 |
+
console.error(error);
|
| 1552 |
+
showToast("随机文章加载失败");
|
| 1553 |
}
|
|
|
|
| 1554 |
}
|
| 1555 |
|
| 1556 |
function updateMultiUi() {
|
|
|
|
| 1772 |
showToast("未选中任何文件");
|
| 1773 |
return;
|
| 1774 |
}
|
| 1775 |
+
ids.forEach((id) => {
|
| 1776 |
+
const frame = document.createElement("iframe");
|
| 1777 |
+
frame.hidden = true;
|
| 1778 |
+
frame.src = `/api/download/${encodeURIComponent(id)}`;
|
| 1779 |
+
document.body.appendChild(frame);
|
| 1780 |
+
setTimeout(() => frame.remove(), 60000);
|
| 1781 |
+
});
|
| 1782 |
+
showToast(`已请求下载 ${ids.length} 个文件`);
|
| 1783 |
});
|
| 1784 |
DOM.multiZipDownload.addEventListener("click", () => {
|
| 1785 |
const ids = Array.from(STATE.selectedIds);
|
|
|
|
| 1787 |
showToast("未选中任何文件");
|
| 1788 |
return;
|
| 1789 |
}
|
| 1790 |
+
if (ids.length > 500) {
|
| 1791 |
+
showToast("合并下载最多支持 500 个文件,请减少选择", 3500);
|
| 1792 |
+
return;
|
| 1793 |
+
}
|
| 1794 |
const form = document.createElement("form");
|
| 1795 |
form.method = "POST";
|
| 1796 |
form.action = "/api/zip-download";
|
| 1797 |
form.style.display = "none";
|
| 1798 |
+
for (const id of ids) {
|
| 1799 |
const input = document.createElement("input");
|
| 1800 |
input.type = "hidden";
|
| 1801 |
input.name = "doc_id";
|
|
|
|
| 1887 |
if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
|
| 1888 |
});
|
| 1889 |
document.addEventListener("keydown", (event) => {
|
| 1890 |
+
if (event.key === "Escape") {
|
| 1891 |
+
setHistoryDropdownOpen(false);
|
| 1892 |
+
if (STATE.previewDocId) closePreview();
|
| 1893 |
+
STATE.leftSidebarOpen = false;
|
| 1894 |
+
STATE.rightSidebarOpen = false;
|
| 1895 |
+
updateSidebarVisibility();
|
| 1896 |
+
return;
|
| 1897 |
+
}
|
| 1898 |
if (event.key === "/" && document.activeElement !== DOM.searchInput) {
|
| 1899 |
event.preventDefault();
|
| 1900 |
DOM.searchInput.focus();
|
|
|
|
| 1955 |
if (DOM.searchPathsToggle) {
|
| 1956 |
DOM.searchPathsToggle.checked = STATE.searchPaths;
|
| 1957 |
}
|
| 1958 |
+
DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径...";
|
| 1959 |
updateSidebarVisibility();
|
| 1960 |
syncUrl();
|
| 1961 |
restorePreviewFromUrl({ scroll: false });
|
static/index.html
CHANGED
|
@@ -62,7 +62,7 @@
|
|
| 62 |
</div>
|
| 63 |
</div>
|
| 64 |
<div id="multi-action-bar" class="multi-action-bar" style="display:none">
|
| 65 |
-
<button id="multi-select-all" class="multi-action-btn">全选</button>
|
| 66 |
<button id="multi-deselect" class="multi-action-btn">取消选择</button>
|
| 67 |
<button id="multi-batch-download" class="multi-action-btn primary">批量下载</button>
|
| 68 |
<button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
|
|
|
|
| 62 |
</div>
|
| 63 |
</div>
|
| 64 |
<div id="multi-action-bar" class="multi-action-bar" style="display:none">
|
| 65 |
+
<button id="multi-select-all" class="multi-action-btn">全选已加载</button>
|
| 66 |
<button id="multi-deselect" class="multi-action-btn">取消选择</button>
|
| 67 |
<button id="multi-batch-download" class="multi-action-btn primary">批量下载</button>
|
| 68 |
<button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
|
static/style.css
CHANGED
|
@@ -557,6 +557,9 @@ ul, li {
|
|
| 557 |
-webkit-line-clamp: 3;
|
| 558 |
-webkit-box-orient: vertical;
|
| 559 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
| 560 |
}
|
| 561 |
|
| 562 |
.result-snippet mark {
|
|
|
|
| 557 |
-webkit-line-clamp: 3;
|
| 558 |
-webkit-box-orient: vertical;
|
| 559 |
overflow: hidden;
|
| 560 |
+
line-height: 1.5;
|
| 561 |
+
height: 4.5em;
|
| 562 |
+
min-height: 4.5em;
|
| 563 |
}
|
| 564 |
|
| 565 |
.result-snippet mark {
|