vomebook commited on
Commit
f0e4f4e
·
1 Parent(s): a366e50

Upload 3 files

Browse files
Files changed (3) hide show
  1. static/app.js +207 -39
  2. static/index.html +8 -6
  3. static/style.css +0 -10
static/app.js CHANGED
@@ -41,6 +41,15 @@ const STATE = {
41
  const DOM = {};
42
  const HISTORY_KEY = "vomebook_search_history";
43
  const SEARCH_CACHE_LIMIT = 60;
 
 
 
 
 
 
 
 
 
44
 
45
  function themeIcon(isDark) {
46
  const shape = isDark
@@ -172,17 +181,39 @@ const API = {
172
  return resp.json();
173
  },
174
  async getFolders(sourceSlug) {
175
- const resp = await fetch(`/api/folders/${sourceSlug}`);
176
- return resp.json();
 
 
 
 
 
 
 
 
 
177
  },
178
  async getContents(sourceSlug, path) {
179
  const cacheKey = `${sourceSlug}\n${path || ""}`;
180
  if (STATE.folderContentsCache.has(cacheKey)) return STATE.folderContentsCache.get(cacheKey);
 
181
  const qs = path ? `?path=${encodeURIComponent(path)}` : "";
182
- const resp = await fetch(`/api/folders/${sourceSlug}/contents${qs}`);
183
- const data = await resp.json();
184
- STATE.folderContentsCache.set(cacheKey, data);
185
- return data;
 
 
 
 
 
 
 
 
 
 
 
 
186
  },
187
  async preview(docId) {
188
  const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
@@ -205,14 +236,18 @@ function stableStringify(value) {
205
 
206
  function getCachedSearch(key) {
207
  if (!STATE.searchCache.has(key)) return null;
208
- const value = STATE.searchCache.get(key);
 
 
 
 
209
  STATE.searchCache.delete(key);
210
- STATE.searchCache.set(key, value);
211
- return value;
212
  }
213
 
214
  function setCachedSearch(key, value) {
215
- STATE.searchCache.set(key, value);
216
  while (STATE.searchCache.size > SEARCH_CACHE_LIMIT) {
217
  STATE.searchCache.delete(STATE.searchCache.keys().next().value);
218
  }
@@ -243,7 +278,6 @@ function cacheDom() {
243
  DOM.loadInfo = $("#load-info");
244
  DOM.loadedCount = $("#loaded-count");
245
  DOM.totalCount = $("#total-count");
246
- DOM.didYouMean = $("#did-you-mean");
247
  DOM.clearFiltersBtn = $("#clear-filters-btn");
248
  DOM.sortSelect = $("#sort-select");
249
  DOM.previewPanel = $("#preview-panel");
@@ -301,8 +335,10 @@ function navigateToSource(sourceSlug) {
301
  STATE.page = 1;
302
  STATE.folderSelections = [];
303
  syncRoute();
304
- renderSidebar();
305
- renderFolderTree();
 
 
306
  doSearch();
307
  }
308
 
@@ -311,8 +347,10 @@ async function navigateToSourceFolder(sourceSlug, folderPath) {
311
  STATE.page = 1;
312
  STATE.folderSelections = folderPath ? [folderPath] : [];
313
  syncRoute();
314
- await renderSidebar();
315
- await renderFolderTree();
 
 
316
  refreshFolderTreeSelectionState();
317
  syncUrl();
318
  doSearch();
@@ -323,8 +361,10 @@ function navigateHome() {
323
  STATE.page = 1;
324
  STATE.folderSelections = [];
325
  syncRoute();
326
- renderSidebar();
327
- renderFolderTree();
 
 
328
  doSearch();
329
  }
330
 
@@ -413,7 +453,7 @@ function applyTheme() {
413
  DOM.themeBtn.innerHTML = themeIcon(STATE.isDark);
414
  DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
415
  DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
416
- const previewThemeBtn = DOM.previewPanel?.querySelector("[data-action='toggle-preview-theme']");
417
  if (previewThemeBtn) {
418
  previewThemeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
419
  previewThemeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
@@ -476,7 +516,7 @@ function syncUrl(replace = true) {
476
  const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/";
477
  const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath;
478
  STATE.searchUrlWithoutPreview = searchUrlKey(nextUrl);
479
- const previewEntry = STATE.previewDocId && (!replace || history.state?.previewEntry === true);
480
  const historyState = STATE.previewDocId ? { preview: true, previewEntry } : null;
481
  if (replace) history.replaceState(historyState, "", nextUrl);
482
  else history.pushState(historyState, "", nextUrl);
@@ -528,9 +568,8 @@ function loadUrlState() {
528
  restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize);
529
  }
530
 
531
- async function pathSearch(signal) {
532
- const page = STATE.page;
533
- const body = {
534
  q: STATE.query,
535
  sources: getSelectedSourcesForSearch(),
536
  folders: STATE.folderSelections,
@@ -543,7 +582,15 @@ async function pathSearch(signal) {
543
  search_paths: STATE.searchPaths,
544
  fulltext: STATE.fulltext,
545
  };
546
- const cacheKey = `${STATE.source || ""}\n${stableStringify(body)}`;
 
 
 
 
 
 
 
 
547
  const cached = getCachedSearch(cacheKey);
548
  if (cached) return { data: cached, page, cached: true };
549
  const data = await API.search(body, STATE.source, signal);
@@ -551,8 +598,36 @@ async function pathSearch(signal) {
551
  return { data, page };
552
  }
553
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  async function doSearch() {
555
  if (STATE.searchController) STATE.searchController.abort();
 
556
  const controller = new AbortController();
557
  const sequence = ++STATE.searchSequence;
558
  STATE.searchController = controller;
@@ -577,6 +652,7 @@ async function doSearch() {
577
  appendResults(newResults);
578
  }
579
  updateStatus();
 
580
  } catch (error) {
581
  if (error.name === "AbortError") return;
582
  console.error(error);
@@ -643,7 +719,7 @@ function renderResults() {
643
  async function openPreview(docId, keyword, options = {}) {
644
  if (options.sync !== false) {
645
  STATE.previewDocId = docId;
646
- syncUrl(history.state?.preview === true);
647
  }
648
  try {
649
  const data = await API.preview(docId);
@@ -690,7 +766,7 @@ async function openPreview(docId, keyword, options = {}) {
690
  }
691
 
692
  function closePreview() {
693
- if (history.state?.previewEntry === true) {
694
  history.back();
695
  return;
696
  }
@@ -1033,7 +1109,7 @@ function renderFolderNodes(container, nodes, depth) {
1033
  }
1034
  }
1035
 
1036
- async function renderFolderTree(forceReload = false) {
1037
  updateFilterVisibility();
1038
  if (!STATE.source) {
1039
  DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;opacity:0.6">进入单仓库后可按路径过滤</div>';
@@ -1043,7 +1119,18 @@ async function renderFolderTree(forceReload = false) {
1043
  return;
1044
  }
1045
  if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) {
1046
- STATE.folderTree = normalizeFolderTree(await API.getFolders(STATE.source));
 
 
 
 
 
 
 
 
 
 
 
1047
  STATE.folderTreeSource = STATE.source;
1048
  STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree);
1049
  STATE.folderCollapsed = {};
@@ -1053,22 +1140,60 @@ async function renderFolderTree(forceReload = false) {
1053
  refreshFolderTreeSelectionState();
1054
  }
1055
 
1056
- async function renderSidebar() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  if (!STATE.source) {
1058
  DOM.sidebarTitle.textContent = "数据包";
1059
  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("");
1060
  return;
1061
  }
1062
- DOM.sidebarTitle.textContent = STATE.sourceMap[STATE.source]?.name || STATE.source;
1063
- const data = await API.getContents(STATE.source, "");
 
 
 
 
 
 
 
 
 
 
 
1064
  let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
1065
  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("");
1066
  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("");
1067
  DOM.sidebarContent.innerHTML = html;
1068
  }
1069
 
1070
- async function openFolder(path) {
1071
- const data = await API.getContents(STATE.source, path);
 
 
 
 
 
 
 
 
 
 
 
1072
  const parts = path ? path.split("/") : [];
1073
  let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
1074
  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>`;
@@ -1086,6 +1211,40 @@ function updateSidebarVisibility() {
1086
  DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
1087
  }
1088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1089
  function clearFilters() {
1090
  STATE.page = 1;
1091
  STATE.folderSelections = [];
@@ -1405,12 +1564,12 @@ function attachEvents() {
1405
  }
1406
  const folderOpen = event.target.closest("[data-folder-open]");
1407
  if (folderOpen) {
1408
- openFolder(folderOpen.dataset.folderOpen);
1409
  return;
1410
  }
1411
  const folderNav = event.target.closest("[data-folder-nav]");
1412
  if (folderNav) {
1413
- openFolder(folderNav.dataset.folderNav);
1414
  return;
1415
  }
1416
  const docItem = event.target.closest("[data-doc-id]");
@@ -1426,6 +1585,12 @@ function attachEvents() {
1426
  doSearch();
1427
  }
1428
  });
 
 
 
 
 
 
1429
  DOM.previewPanel.addEventListener("click", (event) => {
1430
  const closeBtn = event.target.closest("[data-action='close-preview']");
1431
  if (closeBtn) {
@@ -1463,8 +1628,10 @@ function attachEvents() {
1463
  STATE.searchUrlWithoutPreview = nextSearchUrl;
1464
  syncRoute();
1465
  loadUrlState();
1466
- await renderSidebar();
1467
- await renderFolderTree();
 
 
1468
  updateSidebarVisibility();
1469
  restorePreviewFromUrl({ scroll: false });
1470
  doSearch();
@@ -1482,7 +1649,7 @@ async function init() {
1482
  syncRoute();
1483
  applyMobileMode();
1484
  loadUrlState();
1485
- if (STATE.previewDocId && history.state?.previewEntry !== true) {
1486
  const previewDocId = STATE.previewDocId;
1487
  STATE.previewDocId = null;
1488
  syncUrl();
@@ -1500,8 +1667,9 @@ async function init() {
1500
  STATE.sources = await API.getSources();
1501
  STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source]));
1502
  await renderSourcesFilter();
1503
- await renderSidebar();
1504
- await renderFolderTree();
 
1505
  updateFilterVisibility();
1506
  await initialSearch;
1507
  }
 
41
  const DOM = {};
42
  const HISTORY_KEY = "vomebook_search_history";
43
  const SEARCH_CACHE_LIMIT = 60;
44
+ const SEARCH_CACHE_TTL = 8 * 60 * 1000;
45
+ const REQUEST_TIMEOUT_MS = 12000;
46
+ const folderContentsPending = new Map();
47
+ const folderTreePending = new Map();
48
+ const sidebarRetryCounts = new Map();
49
+ let searchPrefetchController = null;
50
+ let routeRenderId = 0;
51
+ let sidebarCurrentPath = "";
52
+ let scrollRecoveryTimer = null;
53
 
54
  function themeIcon(isDark) {
55
  const shape = isDark
 
181
  return resp.json();
182
  },
183
  async getFolders(sourceSlug) {
184
+ if (folderTreePending.has(sourceSlug)) return folderTreePending.get(sourceSlug);
185
+ const controller = new AbortController();
186
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
187
+ const pending = fetch(`/api/folders/${sourceSlug}`, { signal: controller.signal })
188
+ .then((resp) => resp.json())
189
+ .finally(() => {
190
+ clearTimeout(timeoutId);
191
+ folderTreePending.delete(sourceSlug);
192
+ });
193
+ folderTreePending.set(sourceSlug, pending);
194
+ return pending;
195
  },
196
  async getContents(sourceSlug, path) {
197
  const cacheKey = `${sourceSlug}\n${path || ""}`;
198
  if (STATE.folderContentsCache.has(cacheKey)) return STATE.folderContentsCache.get(cacheKey);
199
+ if (folderContentsPending.has(cacheKey)) return folderContentsPending.get(cacheKey);
200
  const qs = path ? `?path=${encodeURIComponent(path)}` : "";
201
+ const controller = new AbortController();
202
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
203
+ const pending = fetch(`/api/folders/${sourceSlug}/contents${qs}`, { signal: controller.signal })
204
+ .then((resp) => resp.json())
205
+ .then((data) => {
206
+ if (data && (Array.isArray(data.folders) || Array.isArray(data.files))) {
207
+ STATE.folderContentsCache.set(cacheKey, data);
208
+ }
209
+ return data;
210
+ })
211
+ .finally(() => {
212
+ clearTimeout(timeoutId);
213
+ folderContentsPending.delete(cacheKey);
214
+ });
215
+ folderContentsPending.set(cacheKey, pending);
216
+ return pending;
217
  },
218
  async preview(docId) {
219
  const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
 
236
 
237
  function getCachedSearch(key) {
238
  if (!STATE.searchCache.has(key)) return null;
239
+ const entry = STATE.searchCache.get(key);
240
+ if (!entry || Date.now() - entry.time > SEARCH_CACHE_TTL) {
241
+ STATE.searchCache.delete(key);
242
+ return null;
243
+ }
244
  STATE.searchCache.delete(key);
245
+ STATE.searchCache.set(key, entry);
246
+ return entry.value;
247
  }
248
 
249
  function setCachedSearch(key, value) {
250
+ STATE.searchCache.set(key, { value, time: Date.now() });
251
  while (STATE.searchCache.size > SEARCH_CACHE_LIMIT) {
252
  STATE.searchCache.delete(STATE.searchCache.keys().next().value);
253
  }
 
278
  DOM.loadInfo = $("#load-info");
279
  DOM.loadedCount = $("#loaded-count");
280
  DOM.totalCount = $("#total-count");
 
281
  DOM.clearFiltersBtn = $("#clear-filters-btn");
282
  DOM.sortSelect = $("#sort-select");
283
  DOM.previewPanel = $("#preview-panel");
 
335
  STATE.page = 1;
336
  STATE.folderSelections = [];
337
  syncRoute();
338
+ const routeId = ++routeRenderId;
339
+ sidebarCurrentPath = "";
340
+ renderSidebar(routeId);
341
+ renderFolderTree(false, routeId);
342
  doSearch();
343
  }
344
 
 
347
  STATE.page = 1;
348
  STATE.folderSelections = folderPath ? [folderPath] : [];
349
  syncRoute();
350
+ const routeId = ++routeRenderId;
351
+ sidebarCurrentPath = "";
352
+ await renderSidebar(routeId);
353
+ await renderFolderTree(false, routeId);
354
  refreshFolderTreeSelectionState();
355
  syncUrl();
356
  doSearch();
 
361
  STATE.page = 1;
362
  STATE.folderSelections = [];
363
  syncRoute();
364
+ const routeId = ++routeRenderId;
365
+ sidebarCurrentPath = "";
366
+ renderSidebar(routeId);
367
+ renderFolderTree(false, routeId);
368
  doSearch();
369
  }
370
 
 
453
  DOM.themeBtn.innerHTML = themeIcon(STATE.isDark);
454
  DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
455
  DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
456
+ const previewThemeBtn = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-action='toggle-preview-theme']") : null;
457
  if (previewThemeBtn) {
458
  previewThemeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式");
459
  previewThemeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式";
 
516
  const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/";
517
  const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath;
518
  STATE.searchUrlWithoutPreview = searchUrlKey(nextUrl);
519
+ const previewEntry = STATE.previewDocId && (!replace || (history.state && history.state.previewEntry === true));
520
  const historyState = STATE.previewDocId ? { preview: true, previewEntry } : null;
521
  if (replace) history.replaceState(historyState, "", nextUrl);
522
  else history.pushState(historyState, "", nextUrl);
 
568
  restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize);
569
  }
570
 
571
+ function buildSearchBody(page = STATE.page) {
572
+ return {
 
573
  q: STATE.query,
574
  sources: getSelectedSourcesForSearch(),
575
  folders: STATE.folderSelections,
 
582
  search_paths: STATE.searchPaths,
583
  fulltext: STATE.fulltext,
584
  };
585
+ }
586
+
587
+ function searchCacheKey(body, sourceSlug = STATE.source) {
588
+ return `${sourceSlug || ""}\n${stableStringify(body)}`;
589
+ }
590
+
591
+ async function pathSearch(signal, page = STATE.page) {
592
+ const body = buildSearchBody(page);
593
+ const cacheKey = searchCacheKey(body);
594
  const cached = getCachedSearch(cacheKey);
595
  if (cached) return { data: cached, page, cached: true };
596
  const data = await API.search(body, STATE.source, signal);
 
598
  return { data, page };
599
  }
600
 
601
+ function abortSearchPrefetch() {
602
+ if (searchPrefetchController) searchPrefetchController.abort();
603
+ searchPrefetchController = null;
604
+ }
605
+
606
+ function prefetchNextPage() {
607
+ if (STATE.isLoading || STATE.results.length >= STATE.total) return;
608
+ abortSearchPrefetch();
609
+ const nextPage = Math.floor(STATE.results.length / STATE.pageSize) + 1;
610
+ if (nextPage <= STATE.page) return;
611
+ const body = buildSearchBody(nextPage);
612
+ const cacheKey = searchCacheKey(body);
613
+ if (getCachedSearch(cacheKey)) return;
614
+ const controller = new AbortController();
615
+ searchPrefetchController = controller;
616
+ API.search(body, STATE.source, controller.signal)
617
+ .then((data) => {
618
+ if (!controller.signal.aborted) setCachedSearch(cacheKey, data);
619
+ })
620
+ .catch((error) => {
621
+ if (error.name !== "AbortError") console.warn("搜索预取失败", error);
622
+ })
623
+ .finally(() => {
624
+ if (searchPrefetchController === controller) searchPrefetchController = null;
625
+ });
626
+ }
627
+
628
  async function doSearch() {
629
  if (STATE.searchController) STATE.searchController.abort();
630
+ abortSearchPrefetch();
631
  const controller = new AbortController();
632
  const sequence = ++STATE.searchSequence;
633
  STATE.searchController = controller;
 
652
  appendResults(newResults);
653
  }
654
  updateStatus();
655
+ prefetchNextPage();
656
  } catch (error) {
657
  if (error.name === "AbortError") return;
658
  console.error(error);
 
719
  async function openPreview(docId, keyword, options = {}) {
720
  if (options.sync !== false) {
721
  STATE.previewDocId = docId;
722
+ syncUrl(history.state && history.state.preview === true);
723
  }
724
  try {
725
  const data = await API.preview(docId);
 
766
  }
767
 
768
  function closePreview() {
769
+ if (history.state && history.state.previewEntry === true) {
770
  history.back();
771
  return;
772
  }
 
1109
  }
1110
  }
1111
 
1112
+ async function renderFolderTree(forceReload = false, routeId = routeRenderId) {
1113
  updateFilterVisibility();
1114
  if (!STATE.source) {
1115
  DOM.filterFolderTree.innerHTML = '<div style="font-size:12px;opacity:0.6">进入单仓库后可按路径过滤</div>';
 
1119
  return;
1120
  }
1121
  if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) {
1122
+ const source = STATE.source;
1123
+ let data;
1124
+ try {
1125
+ data = await API.getFolders(source);
1126
+ } catch (error) {
1127
+ if (routeId === routeRenderId && STATE.source === source) {
1128
+ DOM.filterFolderTree.innerHTML = '<div class="sidebar-loading">路径加载失败</div>';
1129
+ }
1130
+ return;
1131
+ }
1132
+ if (routeId !== routeRenderId || STATE.source !== source) return;
1133
+ STATE.folderTree = normalizeFolderTree(data);
1134
  STATE.folderTreeSource = STATE.source;
1135
  STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree);
1136
  STATE.folderCollapsed = {};
 
1140
  refreshFolderTreeSelectionState();
1141
  }
1142
 
1143
+ function retrySidebar(source, path, routeId) {
1144
+ const retryKey = `${source}\n${path || ""}`;
1145
+ const tries = sidebarRetryCounts.get(retryKey) || 0;
1146
+ if (tries >= 2) {
1147
+ DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载失败</div>';
1148
+ return;
1149
+ }
1150
+ sidebarRetryCounts.set(retryKey, tries + 1);
1151
+ DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载失败,正在重试...</div>';
1152
+ setTimeout(() => {
1153
+ if (routeId !== routeRenderId || STATE.source !== source) return;
1154
+ if (path) openFolder(path, routeId);
1155
+ else renderSidebar(routeId);
1156
+ }, 1200);
1157
+ }
1158
+
1159
+ async function renderSidebar(routeId = routeRenderId) {
1160
  if (!STATE.source) {
1161
  DOM.sidebarTitle.textContent = "数据包";
1162
  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("");
1163
  return;
1164
  }
1165
+ const source = STATE.source;
1166
+ sidebarCurrentPath = "";
1167
+ DOM.sidebarTitle.textContent = (STATE.sourceMap[source] && STATE.sourceMap[source].name) || source;
1168
+ DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载中...</div>';
1169
+ let data;
1170
+ try {
1171
+ data = await API.getContents(source, "");
1172
+ } catch (error) {
1173
+ if (routeId === routeRenderId && STATE.source === source) retrySidebar(source, "", routeId);
1174
+ return;
1175
+ }
1176
+ if (routeId !== routeRenderId || STATE.source !== source) return;
1177
+ sidebarRetryCounts.delete(`${source}\n`);
1178
  let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
1179
  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("");
1180
  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("");
1181
  DOM.sidebarContent.innerHTML = html;
1182
  }
1183
 
1184
+ async function openFolder(path, routeId = routeRenderId) {
1185
+ const source = STATE.source;
1186
+ sidebarCurrentPath = path || "";
1187
+ DOM.sidebarContent.innerHTML = '<div class="sidebar-loading">加载中...</div>';
1188
+ let data;
1189
+ try {
1190
+ data = await API.getContents(source, path);
1191
+ } catch (error) {
1192
+ if (routeId === routeRenderId && STATE.source === source && sidebarCurrentPath === (path || "")) retrySidebar(source, path, routeId);
1193
+ return;
1194
+ }
1195
+ if (routeId !== routeRenderId || STATE.source !== source || sidebarCurrentPath !== (path || "")) return;
1196
+ sidebarRetryCounts.delete(`${source}\n${path || ""}`);
1197
  const parts = path ? path.split("/") : [];
1198
  let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
1199
  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>`;
 
1211
  DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
1212
  }
1213
 
1214
+ function recoverSidebarState() {
1215
+ if (!DOM.sidebarContent || !DOM.sidebarContent.querySelector(".sidebar-loading")) return;
1216
+ folderContentsPending.clear();
1217
+ folderTreePending.clear();
1218
+ sidebarRetryCounts.clear();
1219
+ const routeId = ++routeRenderId;
1220
+ if (STATE.source && sidebarCurrentPath) openFolder(sidebarCurrentPath, routeId);
1221
+ else renderSidebar(routeId);
1222
+ renderFolderTree(false, routeId);
1223
+ }
1224
+
1225
+ function recoverScrollState() {
1226
+ if (!DOM.resultsContainer) return;
1227
+ if (STATE.isLoading && !STATE.searchController) {
1228
+ STATE.isLoading = false;
1229
+ DOM.resultsLoading.style.display = "none";
1230
+ }
1231
+ const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 240;
1232
+ if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) {
1233
+ STATE.page += 1;
1234
+ doSearch();
1235
+ } else {
1236
+ prefetchNextPage();
1237
+ }
1238
+ }
1239
+
1240
+ function schedulePageRecovery(delay = 0) {
1241
+ clearTimeout(scrollRecoveryTimer);
1242
+ scrollRecoveryTimer = setTimeout(() => {
1243
+ recoverSidebarState();
1244
+ recoverScrollState();
1245
+ }, delay);
1246
+ }
1247
+
1248
  function clearFilters() {
1249
  STATE.page = 1;
1250
  STATE.folderSelections = [];
 
1564
  }
1565
  const folderOpen = event.target.closest("[data-folder-open]");
1566
  if (folderOpen) {
1567
+ openFolder(folderOpen.dataset.folderOpen, routeRenderId);
1568
  return;
1569
  }
1570
  const folderNav = event.target.closest("[data-folder-nav]");
1571
  if (folderNav) {
1572
+ openFolder(folderNav.dataset.folderNav, routeRenderId);
1573
  return;
1574
  }
1575
  const docItem = event.target.closest("[data-doc-id]");
 
1585
  doSearch();
1586
  }
1587
  });
1588
+ window.addEventListener("resize", () => schedulePageRecovery(120));
1589
+ window.addEventListener("focus", () => schedulePageRecovery(0));
1590
+ window.addEventListener("pageshow", () => schedulePageRecovery(0));
1591
+ document.addEventListener("visibilitychange", () => {
1592
+ if (!document.hidden) schedulePageRecovery(0);
1593
+ });
1594
  DOM.previewPanel.addEventListener("click", (event) => {
1595
  const closeBtn = event.target.closest("[data-action='close-preview']");
1596
  if (closeBtn) {
 
1628
  STATE.searchUrlWithoutPreview = nextSearchUrl;
1629
  syncRoute();
1630
  loadUrlState();
1631
+ const routeId = ++routeRenderId;
1632
+ sidebarCurrentPath = "";
1633
+ await renderSidebar(routeId);
1634
+ await renderFolderTree(false, routeId);
1635
  updateSidebarVisibility();
1636
  restorePreviewFromUrl({ scroll: false });
1637
  doSearch();
 
1649
  syncRoute();
1650
  applyMobileMode();
1651
  loadUrlState();
1652
+ if (STATE.previewDocId && !(history.state && history.state.previewEntry === true)) {
1653
  const previewDocId = STATE.previewDocId;
1654
  STATE.previewDocId = null;
1655
  syncUrl();
 
1667
  STATE.sources = await API.getSources();
1668
  STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source]));
1669
  await renderSourcesFilter();
1670
+ const routeId = ++routeRenderId;
1671
+ await renderSidebar(routeId);
1672
+ await renderFolderTree(false, routeId);
1673
  updateFilterVisibility();
1674
  await initialSearch;
1675
  }
static/index.html CHANGED
@@ -12,6 +12,7 @@
12
  <link rel="stylesheet" href="/static/style.css">
13
  </head>
14
  <body class="dark">
 
15
  <header id="header">
16
  <div class="header-left">
17
  <button id="hamburger-btn" class="icon-btn" aria-label="菜单"><span class="ui-icon ui-icon-menu" aria-hidden="true"></span></button>
@@ -32,12 +33,14 @@
32
  <button id="mobile-toggle-btn" class="icon-btn" aria-label="切换手机或电脑模式" title="手机/电脑模式切换"><span id="mobile-toggle-icon" class="ui-icon ui-icon-devices" aria-hidden="true"></span></button>
33
  <button id="theme-btn" class="icon-btn" aria-label="切换到白天模式" title="白天模式"><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></button>
34
  </div>
35
- </header>
36
 
 
37
  <div id="app-body" class="app-body">
 
38
  <aside id="left-sidebar" class="sidebar left-sidebar">
39
  <div class="sidebar-header"><span id="sidebar-title">数据包</span><button id="sidebar-expand-btn" class="icon-btn-sm" title="展开/收起侧边栏" style="display:none">↔</button></div>
40
  <div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
 
41
  </aside>
42
 
43
  <main id="main-content" class="main-content">
@@ -45,7 +48,6 @@
45
  <div class="status-left">
46
  <span id="result-count" class="result-count"></span>
47
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
48
- <span id="did-you-mean" class="did-you-mean"></span>
49
  </div>
50
  <div class="status-right">
51
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
@@ -59,7 +61,6 @@
59
  </div>
60
  </div>
61
  </div>
62
-
63
  <div id="multi-action-bar" class="multi-action-bar" style="display:none">
64
  <button id="multi-select-all" class="multi-action-btn">全选</button>
65
  <button id="multi-deselect" class="multi-action-btn">取消选择</button>
@@ -67,7 +68,6 @@
67
  <button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
68
  <span id="multi-selected-count" class="multi-selected-count"></span>
69
  </div>
70
-
71
  <div id="results-container" class="results-container">
72
  <div id="results-list" class="results-list"></div>
73
  <div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
@@ -77,9 +77,9 @@
77
  <button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
78
  </div>
79
  </div>
80
-
81
  <div id="load-info" class="load-info" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
82
  <div id="preview-panel" class="preview-panel" style="display:none"></div>
 
83
  </main>
84
 
85
  <aside id="right-sidebar" class="sidebar right-sidebar">
@@ -132,6 +132,7 @@
132
  </div>
133
  </div>
134
  </div>
 
135
  </aside>
136
  </div>
137
 
@@ -141,10 +142,11 @@
141
  <span class="ui-icon ui-icon-book" aria-hidden="true"></span>
142
  <span>随机来一篇</span>
143
  </button>
144
- </footer>
145
 
 
146
  <div id="overlay" class="overlay" style="display:none"></div>
147
  <div id="toast" class="toast" style="display:none"></div>
 
148
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
149
  <script src="/static/app.js"></script>
150
  </body>
 
12
  <link rel="stylesheet" href="/static/style.css">
13
  </head>
14
  <body class="dark">
15
+
16
  <header id="header">
17
  <div class="header-left">
18
  <button id="hamburger-btn" class="icon-btn" aria-label="菜单"><span class="ui-icon ui-icon-menu" aria-hidden="true"></span></button>
 
33
  <button id="mobile-toggle-btn" class="icon-btn" aria-label="切换手机或电脑模式" title="手机/电脑模式切换"><span id="mobile-toggle-icon" class="ui-icon ui-icon-devices" aria-hidden="true"></span></button>
34
  <button id="theme-btn" class="icon-btn" aria-label="切换到白天模式" title="白天模式"><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></button>
35
  </div>
 
36
 
37
+ </header>
38
  <div id="app-body" class="app-body">
39
+
40
  <aside id="left-sidebar" class="sidebar left-sidebar">
41
  <div class="sidebar-header"><span id="sidebar-title">数据包</span><button id="sidebar-expand-btn" class="icon-btn-sm" title="展开/收起侧边栏" style="display:none">↔</button></div>
42
  <div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
43
+
44
  </aside>
45
 
46
  <main id="main-content" class="main-content">
 
48
  <div class="status-left">
49
  <span id="result-count" class="result-count"></span>
50
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
 
51
  </div>
52
  <div class="status-right">
53
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
 
61
  </div>
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>
 
68
  <button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
69
  <span id="multi-selected-count" class="multi-selected-count"></span>
70
  </div>
 
71
  <div id="results-container" class="results-container">
72
  <div id="results-list" class="results-list"></div>
73
  <div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
 
77
  <button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
78
  </div>
79
  </div>
 
80
  <div id="load-info" class="load-info" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
81
  <div id="preview-panel" class="preview-panel" style="display:none"></div>
82
+
83
  </main>
84
 
85
  <aside id="right-sidebar" class="sidebar right-sidebar">
 
132
  </div>
133
  </div>
134
  </div>
135
+
136
  </aside>
137
  </div>
138
 
 
142
  <span class="ui-icon ui-icon-book" aria-hidden="true"></span>
143
  <span>随机来一篇</span>
144
  </button>
 
145
 
146
+ </footer>
147
  <div id="overlay" class="overlay" style="display:none"></div>
148
  <div id="toast" class="toast" style="display:none"></div>
149
+
150
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
151
  <script src="/static/app.js"></script>
152
  </body>
static/style.css CHANGED
@@ -1128,16 +1128,6 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
1128
  cursor: pointer;
1129
  }
1130
 
1131
- .did-you-mean {
1132
- font-size: 12px;
1133
- color: var(--primary);
1134
- cursor: pointer;
1135
- }
1136
-
1137
- .did-you-mean:hover, .did-you-mean:focus-visible {
1138
- text-decoration: underline;
1139
- }
1140
-
1141
  .results-container {
1142
  flex: 1;
1143
  overflow-y: auto;
 
1128
  cursor: pointer;
1129
  }
1130
 
 
 
 
 
 
 
 
 
 
 
1131
  .results-container {
1132
  flex: 1;
1133
  overflow-y: auto;