reader.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. let currentCategory = '';
  2. let currentDoc = '';
  3. let docList = [];
  4. // DOM 元素缓存
  5. const DOM = {
  6. loading: null,
  7. content: null,
  8. docNav: null,
  9. toc: null,
  10. leftSidebar: null,
  11. rightSidebar: null
  12. };
  13. // 配置 marked
  14. marked.setOptions({
  15. highlight: function(code, lang) {
  16. if (lang && hljs.getLanguage(lang)) {
  17. try {
  18. return hljs.highlight(code, { language: lang }).value;
  19. } catch (err) {
  20. console.error('Highlight error:', err);
  21. }
  22. }
  23. return hljs.highlightAuto(code).value;
  24. },
  25. breaks: true,
  26. gfm: true
  27. });
  28. // 从 URL 获取参数
  29. function getQueryParams() {
  30. const params = new URLSearchParams(window.location.search);
  31. return {
  32. category: params.get('category'),
  33. doc: params.get('doc')
  34. };
  35. }
  36. // 加载文档列表
  37. async function loadDocList() {
  38. const { category } = getQueryParams();
  39. if (!category) {
  40. window.location.href = '/';
  41. return;
  42. }
  43. currentCategory = category;
  44. const categoryNameSpan = document.querySelector('#category-title .category-name');
  45. if (categoryNameSpan) {
  46. categoryNameSpan.textContent = category;
  47. }
  48. try {
  49. const response = await fetch(`/api/category/${encodeURIComponent(category)}`);
  50. if (!response.ok) throw new Error('获取文档列表失败');
  51. const categoryData = await response.json();
  52. docList = categoryData.docs;
  53. renderDocNav(docList);
  54. // 加载第一篇文档(或 URL 指定的文档)
  55. const { doc } = getQueryParams();
  56. const firstDoc = doc || docList[0]?.name;
  57. if (firstDoc) {
  58. loadDocument(firstDoc);
  59. }
  60. } catch (err) {
  61. console.error('Error loading doc list:', err);
  62. showError('加载文档列表失败');
  63. }
  64. }
  65. // 渲染文档导航
  66. function renderDocNav(docs) {
  67. const nav = DOM.docNav || document.getElementById('doc-nav');
  68. if (!DOM.docNav) DOM.docNav = nav;
  69. nav.innerHTML = '';
  70. docs.forEach(doc => {
  71. const item = document.createElement('div');
  72. item.className = `nav-item level-${doc.level}`;
  73. item.dataset.docName = doc.name;
  74. const link = document.createElement('a');
  75. link.href = '#';
  76. link.className = 'nav-link';
  77. link.textContent = doc.name;
  78. item.appendChild(link);
  79. nav.appendChild(item);
  80. });
  81. // 使用事件委托 - 只添加一个监听器
  82. nav.removeEventListener('click', handleNavClick);
  83. nav.addEventListener('click', handleNavClick);
  84. }
  85. // 导航点击处理函数
  86. function handleNavClick(e) {
  87. if (e.target.classList.contains('nav-link')) {
  88. e.preventDefault();
  89. const item = e.target.parentElement;
  90. const docName = item.dataset.docName;
  91. if (docName) {
  92. loadDocument(docName);
  93. updateURL(currentCategory, docName);
  94. // 更新活动状态 - 使用缓存的查询结果
  95. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  96. navItems.forEach(el => el.classList.toggle('active', el === item));
  97. }
  98. }
  99. }
  100. // 加载文档内容
  101. async function loadDocument(docName, scrollToText = null) {
  102. // 使用 DOM 缓存
  103. if (!DOM.loading) DOM.loading = document.getElementById('loading');
  104. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  105. DOM.loading.style.display = 'flex';
  106. DOM.content.innerHTML = '';
  107. try {
  108. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(docName)}`);
  109. if (!response.ok) {
  110. const errorData = await response.json().catch(() => ({}));
  111. throw new Error(errorData.error || `加载失败 (${response.status})`);
  112. }
  113. const data = await response.json();
  114. // 验证数据有效性
  115. if (!data.content || typeof data.content !== 'string') {
  116. throw new Error('文档内容格式错误');
  117. }
  118. currentDoc = docName;
  119. // 渲染 Markdown
  120. const html = marked.parse(data.content);
  121. DOM.content.innerHTML = html;
  122. // 生成 TOC
  123. generateTOC();
  124. // 更新活动文档 - 使用 toggle 优化
  125. if (DOM.docNav) {
  126. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  127. navItems.forEach(el => el.classList.toggle('active', el.dataset.docName === docName));
  128. }
  129. DOM.loading.style.display = 'none';
  130. // 如果指定了要滚动到的文本,等待渲染完成后滚动
  131. if (scrollToText) {
  132. setTimeout(() => {
  133. scrollToSearchMatch(scrollToText);
  134. }, 100);
  135. }
  136. } catch (err) {
  137. console.error('Error loading document:', err);
  138. DOM.loading.style.display = 'none';
  139. showError(`加载文档失败:${err.message || '请稍后重试'}`);
  140. }
  141. }
  142. // 生成 TOC
  143. function generateTOC() {
  144. // 使用 DOM 缓存
  145. if (!DOM.toc) DOM.toc = document.getElementById('toc');
  146. const headings = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6');
  147. DOM.toc.innerHTML = '';
  148. if (headings.length === 0) {
  149. DOM.toc.innerHTML = '<p class="toc-empty">本文档没有标题</p>';
  150. return;
  151. }
  152. // 创建标题 ID 映射
  153. const headingMap = new Map();
  154. headings.forEach((heading, index) => {
  155. // 给标题添加 ID
  156. if (!heading.id) {
  157. heading.id = `heading-${index}`;
  158. }
  159. const level = parseInt(heading.tagName.substring(1));
  160. const link = document.createElement('a');
  161. link.href = `#${heading.id}`;
  162. link.className = `toc-link toc-level-${level}`;
  163. link.textContent = heading.textContent;
  164. headingMap.set(heading.id, heading);
  165. DOM.toc.appendChild(link);
  166. });
  167. // 使用事件委托 - 只添加一个监听器
  168. DOM.toc.removeEventListener('click', handleTocClick);
  169. DOM.toc.addEventListener('click', (e) => handleTocClick(e, headingMap));
  170. // 监听滚动,高亮当前标题
  171. setupScrollSpy(headings);
  172. }
  173. // TOC 点击处理函数
  174. function handleTocClick(e, headingMap) {
  175. if (e.target.classList.contains('toc-link')) {
  176. e.preventDefault();
  177. const headingId = e.target.getAttribute('href').substring(1);
  178. const heading = headingMap.get(headingId);
  179. if (heading) {
  180. heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
  181. // 更新活动状态
  182. const tocLinks = DOM.toc.querySelectorAll('.toc-link');
  183. tocLinks.forEach(el => el.classList.toggle('active', el === e.target));
  184. }
  185. }
  186. }
  187. // 滚动监听
  188. function setupScrollSpy(headings) {
  189. let activeLink = null;
  190. const observer = new IntersectionObserver((entries) => {
  191. entries.forEach(entry => {
  192. if (entry.isIntersecting) {
  193. const id = entry.target.id;
  194. const link = DOM.toc.querySelector(`.toc-link[href="#${id}"]`);
  195. if (link && link !== activeLink) {
  196. // 只在需要时更新,减少 DOM 操作
  197. if (activeLink) activeLink.classList.remove('active');
  198. link.classList.add('active');
  199. activeLink = link;
  200. }
  201. }
  202. });
  203. }, {
  204. rootMargin: '-100px 0px -80% 0px',
  205. threshold: 0
  206. });
  207. headings.forEach(heading => observer.observe(heading));
  208. }
  209. // 更新 URL
  210. function updateURL(category, doc) {
  211. const url = new URL(window.location);
  212. url.searchParams.set('category', category);
  213. url.searchParams.set('doc', doc);
  214. window.history.pushState({}, '', url);
  215. }
  216. // 显示错误
  217. function showError(message) {
  218. const content = document.getElementById('markdown-content');
  219. content.innerHTML = `<div class="error-message">${message}</div>`;
  220. }
  221. // 切换侧边栏(移动端)
  222. function setupSidebarToggles() {
  223. const toggleLeft = document.getElementById('toggle-left');
  224. const toggleRight = document.getElementById('toggle-right');
  225. // 使用 DOM 缓存
  226. if (!DOM.leftSidebar) DOM.leftSidebar = document.getElementById('left-sidebar');
  227. if (!DOM.rightSidebar) DOM.rightSidebar = document.getElementById('right-sidebar');
  228. // 移动端/平板初始化:默认折叠侧边栏
  229. if (window.innerWidth <= 768) {
  230. DOM.leftSidebar.classList.add('collapsed');
  231. DOM.rightSidebar.classList.add('collapsed');
  232. } else if (window.innerWidth <= 1024) {
  233. // 平板:只折叠右侧栏
  234. DOM.rightSidebar.classList.add('collapsed');
  235. }
  236. // 监听窗口大小变化
  237. window.addEventListener('resize', () => {
  238. if (window.innerWidth <= 768) {
  239. // 移动端:确保左右侧边栏都折叠
  240. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  241. DOM.leftSidebar.classList.add('collapsed');
  242. }
  243. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  244. DOM.rightSidebar.classList.add('collapsed');
  245. }
  246. } else if (window.innerWidth <= 1024) {
  247. // 平板:只折叠右侧栏,展开左侧栏
  248. DOM.leftSidebar.classList.remove('collapsed');
  249. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  250. DOM.rightSidebar.classList.add('collapsed');
  251. }
  252. } else {
  253. // 桌面端:展开所有侧边栏
  254. DOM.leftSidebar.classList.remove('collapsed');
  255. DOM.rightSidebar.classList.remove('collapsed');
  256. }
  257. });
  258. toggleLeft.onclick = (e) => {
  259. e.stopPropagation();
  260. // 如果右侧栏展开,先隐藏它
  261. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  262. DOM.rightSidebar.classList.add('collapsed');
  263. }
  264. DOM.leftSidebar.classList.toggle('collapsed');
  265. };
  266. toggleRight.onclick = (e) => {
  267. e.stopPropagation();
  268. // 如果左侧栏展开,先隐藏它
  269. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  270. DOM.leftSidebar.classList.add('collapsed');
  271. }
  272. DOM.rightSidebar.classList.toggle('collapsed');
  273. };
  274. // 点击侧边栏外部时隐藏所有已展开的侧边栏(仅在移动端和平板)
  275. document.addEventListener('click', (e) => {
  276. // 仅在移动端和平板上启用此功能
  277. if (window.innerWidth > 1024) return;
  278. const isClickInsideLeftSidebar = DOM.leftSidebar.contains(e.target);
  279. const isClickInsideRightSidebar = DOM.rightSidebar.contains(e.target);
  280. const isToggleButton = e.target.closest('#toggle-left') || e.target.closest('#toggle-right');
  281. // 如果点击的不是侧边栏内部,也不是切换按钮,则隐藏所有展开的侧边栏
  282. if (!isClickInsideLeftSidebar && !isClickInsideRightSidebar && !isToggleButton) {
  283. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  284. DOM.leftSidebar.classList.add('collapsed');
  285. }
  286. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  287. DOM.rightSidebar.classList.add('collapsed');
  288. }
  289. }
  290. });
  291. }
  292. // 搜索功能
  293. function initSearch() {
  294. const searchContainer = document.getElementById('search-container');
  295. const searchToggleBtn = document.getElementById('search-toggle-btn');
  296. const searchInput = document.getElementById('search-input');
  297. const searchBtn = document.getElementById('search-btn');
  298. const searchResults = document.getElementById('search-results');
  299. const closeSearchBoxBtn = document.getElementById('close-search-box');
  300. // 检查元素是否存在
  301. if (!searchContainer || !searchToggleBtn || !searchInput || !searchBtn || !searchResults || !closeSearchBoxBtn) {
  302. console.error('Search elements not found:', {
  303. searchContainer: !!searchContainer,
  304. searchToggleBtn: !!searchToggleBtn,
  305. searchInput: !!searchInput,
  306. searchBtn: !!searchBtn,
  307. searchResults: !!searchResults,
  308. closeSearchBoxBtn: !!closeSearchBoxBtn
  309. });
  310. return;
  311. }
  312. let searchTimeout;
  313. const SEARCH_HISTORY_KEY = 'cjydocs_search_history';
  314. // 保存搜索历史到localStorage
  315. const saveSearchHistory = (query) => {
  316. if (!query || query.trim().length < 2) return;
  317. try {
  318. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  319. history[currentCategory] = query;
  320. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  321. } catch (err) {
  322. console.error('Failed to save search history:', err);
  323. }
  324. };
  325. // 搜索函数
  326. const performSearch = async () => {
  327. const query = searchInput.value.trim();
  328. if (query.length < 2) {
  329. searchResults.style.display = 'none';
  330. return;
  331. }
  332. // 保存搜索历史
  333. saveSearchHistory(query);
  334. try {
  335. const response = await fetch(
  336. `/api/search/${encodeURIComponent(currentCategory)}?q=${encodeURIComponent(query)}&currentDoc=${encodeURIComponent(currentDoc)}`
  337. );
  338. if (!response.ok) {
  339. const errorData = await response.json().catch(() => ({}));
  340. throw new Error(errorData.error || `搜索失败 (${response.status})`);
  341. }
  342. const data = await response.json();
  343. displaySearchResults(data);
  344. searchResults.style.display = 'block';
  345. } catch (err) {
  346. console.error('Search error:', err);
  347. // 向用户显示错误信息
  348. displaySearchError(err.message || '搜索失败,请稍后重试');
  349. }
  350. };
  351. // 加载搜索历史
  352. const loadSearchHistory = () => {
  353. try {
  354. const history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  355. const lastQuery = history[currentCategory];
  356. if (lastQuery) {
  357. searchInput.value = lastQuery;
  358. // 自动搜索上次的内容
  359. performSearch();
  360. }
  361. } catch (err) {
  362. console.error('Failed to load search history:', err);
  363. }
  364. };
  365. // 清除其他分类的搜索历史
  366. const clearOtherCategoryHistory = () => {
  367. try {
  368. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  369. // 只保留当前分类
  370. history = { [currentCategory]: history[currentCategory] || '' };
  371. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  372. } catch (err) {
  373. console.error('Failed to clear search history:', err);
  374. }
  375. };
  376. // 切换搜索框显示/隐藏
  377. searchToggleBtn.addEventListener('click', (e) => {
  378. e.stopPropagation();
  379. console.log('Search button clicked');
  380. const isActive = searchContainer.classList.toggle('active');
  381. console.log('Search container active:', isActive);
  382. if (isActive) {
  383. // 加载搜索历史
  384. loadSearchHistory();
  385. // 聚焦到搜索框
  386. setTimeout(() => searchInput.focus(), 100);
  387. } else {
  388. // 隐藏搜索结果
  389. searchResults.style.display = 'none';
  390. }
  391. });
  392. // 输入时实时搜索(防抖) - 500ms 减少 API 调用
  393. searchInput.addEventListener('input', () => {
  394. clearTimeout(searchTimeout);
  395. searchTimeout = setTimeout(performSearch, 500);
  396. });
  397. // 点击搜索按钮
  398. searchBtn.addEventListener('click', performSearch);
  399. // 按回车搜索
  400. searchInput.addEventListener('keypress', (e) => {
  401. if (e.key === 'Enter') {
  402. performSearch();
  403. }
  404. });
  405. // 关闭搜索框按钮
  406. closeSearchBoxBtn.addEventListener('click', (e) => {
  407. e.stopPropagation();
  408. searchContainer.classList.remove('active');
  409. searchResults.style.display = 'none';
  410. });
  411. // 点击搜索结果外部关闭
  412. searchResults.addEventListener('click', (e) => {
  413. // 如果点击的是搜索结果容器本身(即遮罩背景),关闭搜索
  414. if (e.target === searchResults) {
  415. searchResults.style.display = 'none';
  416. }
  417. });
  418. // 点击外部关闭
  419. document.addEventListener('click', (e) => {
  420. const searchToggle = document.getElementById('search-toggle-btn');
  421. if (!searchResults.contains(e.target) &&
  422. !searchInput.contains(e.target) &&
  423. !searchBtn.contains(e.target) &&
  424. !searchContainer.contains(e.target) &&
  425. !searchToggle.contains(e.target)) {
  426. searchResults.style.display = 'none';
  427. }
  428. });
  429. // 初始化时清除其他分类的历史
  430. clearOtherCategoryHistory();
  431. }
  432. // 显示搜索错误
  433. function displaySearchError(message) {
  434. const currentDocSection = document.querySelector('#current-doc-results .results-list');
  435. const otherDocsSection = document.querySelector('#other-docs-results .results-list');
  436. const searchResults = document.getElementById('search-results');
  437. // 清空之前的结果
  438. currentDocSection.innerHTML = '';
  439. otherDocsSection.innerHTML = '';
  440. // 隐藏分组标题
  441. document.getElementById('current-doc-results').style.display = 'none';
  442. document.getElementById('other-docs-results').style.display = 'none';
  443. // 显示错误信息
  444. currentDocSection.innerHTML = `<p class="search-error" style="color: #d73a49; padding: 20px; text-align: center;">${message}</p>`;
  445. document.getElementById('current-doc-results').style.display = 'block';
  446. // 显示搜索结果面板
  447. searchResults.style.display = 'block';
  448. }
  449. // 显示搜索结果
  450. function displaySearchResults(data) {
  451. const currentDocSection = document.querySelector('#current-doc-results .results-list');
  452. const otherDocsSection = document.querySelector('#other-docs-results .results-list');
  453. // 清空之前的结果
  454. currentDocSection.innerHTML = '';
  455. otherDocsSection.innerHTML = '';
  456. // 隐藏/显示分组标题
  457. document.getElementById('current-doc-results').style.display =
  458. data.currentDoc.length > 0 ? 'block' : 'none';
  459. document.getElementById('other-docs-results').style.display =
  460. data.otherDocs.length > 0 ? 'block' : 'none';
  461. // 渲染当前文档结果
  462. if (data.currentDoc.length > 0) {
  463. data.currentDoc.forEach(doc => {
  464. const docResults = createDocResultElement(doc, true);
  465. currentDocSection.appendChild(docResults);
  466. });
  467. } else {
  468. currentDocSection.innerHTML = '<p class="no-results">当前文档无匹配结果</p>';
  469. }
  470. // 渲染其他文档结果
  471. if (data.otherDocs.length > 0) {
  472. data.otherDocs.forEach(doc => {
  473. const docResults = createDocResultElement(doc, false);
  474. otherDocsSection.appendChild(docResults);
  475. });
  476. } else if (data.currentDoc.length === 0) {
  477. otherDocsSection.innerHTML = '<p class="no-results">未找到匹配结果</p>';
  478. }
  479. }
  480. // 创建搜索结果元素
  481. function createDocResultElement(doc, isCurrent) {
  482. const container = document.createElement('div');
  483. container.className = 'search-result-item';
  484. const header = document.createElement('div');
  485. header.className = 'result-header';
  486. header.innerHTML = `
  487. <span class="result-doc-name">${doc.docName}</span>
  488. <span class="result-count">${doc.matchCount} 个匹配</span>
  489. `;
  490. container.appendChild(header);
  491. // 添加匹配片段
  492. doc.matches.forEach(match => {
  493. const matchItem = document.createElement('div');
  494. matchItem.className = 'result-match';
  495. // 高亮搜索词
  496. const highlightedSnippet = highlightSearchTerm(match.snippet, document.getElementById('search-input').value);
  497. matchItem.innerHTML = `
  498. <div class="match-line-number">行 ${match.line}</div>
  499. <div class="match-snippet">${highlightedSnippet}</div>
  500. `;
  501. // 点击跳转
  502. matchItem.onclick = () => {
  503. // 隐藏搜索框和搜索结果
  504. const searchContainer = document.querySelector('.search-container');
  505. const searchResults = document.getElementById('search-results');
  506. searchContainer.classList.remove('active');
  507. searchResults.style.display = 'none';
  508. if (isCurrent) {
  509. // 当前文档,滚动到对应位置
  510. scrollToSearchMatch(match.fullLine);
  511. } else {
  512. // 其他文档,加载该文档并跳转到具体位置
  513. loadDocument(doc.docName, match.fullLine);
  514. }
  515. };
  516. container.appendChild(matchItem);
  517. });
  518. return container;
  519. }
  520. // 高亮搜索词
  521. function highlightSearchTerm(text, searchTerm) {
  522. const regex = new RegExp(`(${searchTerm})`, 'gi');
  523. return text.replace(regex, '<mark class="search-highlight">$1</mark>');
  524. }
  525. // 滚动到搜索匹配位置
  526. function scrollToSearchMatch(fullLine) {
  527. const content = document.getElementById('markdown-content');
  528. const searchText = fullLine.trim();
  529. if (!searchText) return;
  530. // 使用TreeWalker遍历所有文本节点
  531. const walker = document.createTreeWalker(
  532. content,
  533. NodeFilter.SHOW_TEXT,
  534. null
  535. );
  536. let found = false;
  537. let node;
  538. while (node = walker.nextNode()) {
  539. if (node.textContent.includes(searchText)) {
  540. const parentElement = node.parentElement;
  541. // 滚动到元素
  542. parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  543. // 临时高亮父元素
  544. parentElement.classList.add('temp-highlight');
  545. setTimeout(() => {
  546. parentElement.classList.remove('temp-highlight');
  547. }, 2000);
  548. found = true;
  549. break;
  550. }
  551. }
  552. // 如果没有找到精确匹配,尝试部分匹配
  553. if (!found) {
  554. const elements = content.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre, code');
  555. for (const element of elements) {
  556. const text = element.textContent.trim();
  557. // 尝试匹配至少50%的内容
  558. if (text.length > 10 && searchText.includes(text.substring(0, Math.min(text.length, 50)))) {
  559. element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  560. // 临时高亮
  561. element.classList.add('temp-highlight');
  562. setTimeout(() => {
  563. element.classList.remove('temp-highlight');
  564. }, 2000);
  565. break;
  566. }
  567. }
  568. }
  569. }
  570. // 设置回到顶部按钮
  571. function setupBackToTop() {
  572. const backToTopBtn = document.getElementById('back-to-top');
  573. const contentArea = document.getElementById('content-area');
  574. if (!backToTopBtn || !contentArea) return;
  575. // 初始状态:隐藏按钮
  576. backToTopBtn.classList.add('hidden');
  577. // 点击按钮滚动到顶部
  578. backToTopBtn.addEventListener('click', () => {
  579. contentArea.scrollTo({
  580. top: 0,
  581. behavior: 'smooth'
  582. });
  583. });
  584. // 监听滚动事件,控制按钮显示/隐藏
  585. let scrollTimeout;
  586. contentArea.addEventListener('scroll', () => {
  587. clearTimeout(scrollTimeout);
  588. scrollTimeout = setTimeout(() => {
  589. if (contentArea.scrollTop > 300) {
  590. backToTopBtn.classList.remove('hidden');
  591. } else {
  592. backToTopBtn.classList.add('hidden');
  593. }
  594. }, 100);
  595. });
  596. }
  597. // 初始化
  598. document.addEventListener('DOMContentLoaded', () => {
  599. loadDocList();
  600. setupSidebarToggles();
  601. initSearch();
  602. setupBackToTop();
  603. });
  604. // 处理浏览器后退/前进
  605. window.addEventListener('popstate', () => {
  606. const { doc } = getQueryParams();
  607. if (doc) {
  608. loadDocument(doc);
  609. }
  610. });