|
@@ -0,0 +1,704 @@
|
|
|
|
|
+let currentCategory = '';
|
|
|
|
|
+let currentDoc = '';
|
|
|
|
|
+let docList = [];
|
|
|
|
|
+
|
|
|
|
|
+// DOM 元素缓存
|
|
|
|
|
+const DOM = {
|
|
|
|
|
+ loading: null,
|
|
|
|
|
+ content: null,
|
|
|
|
|
+ docNav: null,
|
|
|
|
|
+ toc: null,
|
|
|
|
|
+ leftSidebar: null,
|
|
|
|
|
+ rightSidebar: null
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 配置 marked
|
|
|
|
|
+marked.setOptions({
|
|
|
|
|
+ highlight: function(code, lang) {
|
|
|
|
|
+ if (lang && hljs.getLanguage(lang)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return hljs.highlight(code, { language: lang }).value;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Highlight error:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return hljs.highlightAuto(code).value;
|
|
|
|
|
+ },
|
|
|
|
|
+ breaks: true,
|
|
|
|
|
+ gfm: true
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 从 URL 获取参数
|
|
|
|
|
+function getQueryParams() {
|
|
|
|
|
+ const params = new URLSearchParams(window.location.search);
|
|
|
|
|
+ return {
|
|
|
|
|
+ category: params.get('category'),
|
|
|
|
|
+ doc: params.get('doc')
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 加载文档列表
|
|
|
|
|
+async function loadDocList() {
|
|
|
|
|
+ const { category } = getQueryParams();
|
|
|
|
|
+ if (!category) {
|
|
|
|
|
+ window.location.href = '/';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ currentCategory = category;
|
|
|
|
|
+ const categoryNameSpan = document.querySelector('#category-title .category-name');
|
|
|
|
|
+ if (categoryNameSpan) {
|
|
|
|
|
+ categoryNameSpan.textContent = category;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await fetch(`/api/category/${encodeURIComponent(category)}`);
|
|
|
|
|
+ if (!response.ok) throw new Error('获取文档列表失败');
|
|
|
|
|
+
|
|
|
|
|
+ const categoryData = await response.json();
|
|
|
|
|
+ docList = categoryData.docs;
|
|
|
|
|
+
|
|
|
|
|
+ renderDocNav(docList);
|
|
|
|
|
+
|
|
|
|
|
+ // 加载第一篇文档(或 URL 指定的文档)
|
|
|
|
|
+ const { doc } = getQueryParams();
|
|
|
|
|
+ const firstDoc = doc || docList[0]?.name;
|
|
|
|
|
+
|
|
|
|
|
+ if (firstDoc) {
|
|
|
|
|
+ loadDocument(firstDoc);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Error loading doc list:', err);
|
|
|
|
|
+ showError('加载文档列表失败');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 渲染文档导航
|
|
|
|
|
+function renderDocNav(docs) {
|
|
|
|
|
+ const nav = DOM.docNav || document.getElementById('doc-nav');
|
|
|
|
|
+ if (!DOM.docNav) DOM.docNav = nav;
|
|
|
|
|
+
|
|
|
|
|
+ nav.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ docs.forEach(doc => {
|
|
|
|
|
+ const item = document.createElement('div');
|
|
|
|
|
+ item.className = `nav-item level-${doc.level}`;
|
|
|
|
|
+ item.dataset.docName = doc.name;
|
|
|
|
|
+
|
|
|
|
|
+ const link = document.createElement('a');
|
|
|
|
|
+ link.href = '#';
|
|
|
|
|
+ link.className = 'nav-link';
|
|
|
|
|
+ link.textContent = doc.name;
|
|
|
|
|
+
|
|
|
|
|
+ item.appendChild(link);
|
|
|
|
|
+ nav.appendChild(item);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 使用事件委托 - 只添加一个监听器
|
|
|
|
|
+ nav.removeEventListener('click', handleNavClick);
|
|
|
|
|
+ nav.addEventListener('click', handleNavClick);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 导航点击处理函数
|
|
|
|
|
+function handleNavClick(e) {
|
|
|
|
|
+ if (e.target.classList.contains('nav-link')) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const item = e.target.parentElement;
|
|
|
|
|
+ const docName = item.dataset.docName;
|
|
|
|
|
+
|
|
|
|
|
+ if (docName) {
|
|
|
|
|
+ loadDocument(docName);
|
|
|
|
|
+ updateURL(currentCategory, docName);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新活动状态 - 使用缓存的查询结果
|
|
|
|
|
+ const navItems = DOM.docNav.querySelectorAll('.nav-item');
|
|
|
|
|
+ navItems.forEach(el => el.classList.toggle('active', el === item));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 加载文档内容
|
|
|
|
|
+async function loadDocument(docName, scrollToText = null) {
|
|
|
|
|
+ // 使用 DOM 缓存
|
|
|
|
|
+ if (!DOM.loading) DOM.loading = document.getElementById('loading');
|
|
|
|
|
+ if (!DOM.content) DOM.content = document.getElementById('markdown-content');
|
|
|
|
|
+
|
|
|
|
|
+ DOM.loading.style.display = 'flex';
|
|
|
|
|
+ DOM.content.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(docName)}`);
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const errorData = await response.json().catch(() => ({}));
|
|
|
|
|
+ throw new Error(errorData.error || `加载失败 (${response.status})`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+
|
|
|
|
|
+ // 验证数据有效性
|
|
|
|
|
+ if (!data.content || typeof data.content !== 'string') {
|
|
|
|
|
+ throw new Error('文档内容格式错误');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ currentDoc = docName;
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染 Markdown
|
|
|
|
|
+ const html = marked.parse(data.content);
|
|
|
|
|
+ DOM.content.innerHTML = html;
|
|
|
|
|
+
|
|
|
|
|
+ // 生成 TOC
|
|
|
|
|
+ generateTOC();
|
|
|
|
|
+
|
|
|
|
|
+ // 更新活动文档 - 使用 toggle 优化
|
|
|
|
|
+ if (DOM.docNav) {
|
|
|
|
|
+ const navItems = DOM.docNav.querySelectorAll('.nav-item');
|
|
|
|
|
+ navItems.forEach(el => el.classList.toggle('active', el.dataset.docName === docName));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DOM.loading.style.display = 'none';
|
|
|
|
|
+
|
|
|
|
|
+ // 如果指定了要滚动到的文本,等待渲染完成后滚动
|
|
|
|
|
+ if (scrollToText) {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ scrollToSearchMatch(scrollToText);
|
|
|
|
|
+ }, 100);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Error loading document:', err);
|
|
|
|
|
+ DOM.loading.style.display = 'none';
|
|
|
|
|
+ showError(`加载文档失败:${err.message || '请稍后重试'}`);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 生成 TOC
|
|
|
|
|
+function generateTOC() {
|
|
|
|
|
+ // 使用 DOM 缓存
|
|
|
|
|
+ if (!DOM.toc) DOM.toc = document.getElementById('toc');
|
|
|
|
|
+
|
|
|
|
|
+ const headings = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
|
|
|
+ DOM.toc.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ if (headings.length === 0) {
|
|
|
|
|
+ DOM.toc.innerHTML = '<p class="toc-empty">本文档没有标题</p>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 创建标题 ID 映射
|
|
|
|
|
+ const headingMap = new Map();
|
|
|
|
|
+
|
|
|
|
|
+ headings.forEach((heading, index) => {
|
|
|
|
|
+ // 给标题添加 ID
|
|
|
|
|
+ if (!heading.id) {
|
|
|
|
|
+ heading.id = `heading-${index}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const level = parseInt(heading.tagName.substring(1));
|
|
|
|
|
+ const link = document.createElement('a');
|
|
|
|
|
+ link.href = `#${heading.id}`;
|
|
|
|
|
+ link.className = `toc-link toc-level-${level}`;
|
|
|
|
|
+ link.textContent = heading.textContent;
|
|
|
|
|
+
|
|
|
|
|
+ headingMap.set(heading.id, heading);
|
|
|
|
|
+ DOM.toc.appendChild(link);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 使用事件委托 - 只添加一个监听器
|
|
|
|
|
+ DOM.toc.removeEventListener('click', handleTocClick);
|
|
|
|
|
+ DOM.toc.addEventListener('click', (e) => handleTocClick(e, headingMap));
|
|
|
|
|
+
|
|
|
|
|
+ // 监听滚动,高亮当前标题
|
|
|
|
|
+ setupScrollSpy(headings);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TOC 点击处理函数
|
|
|
|
|
+function handleTocClick(e, headingMap) {
|
|
|
|
|
+ if (e.target.classList.contains('toc-link')) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const headingId = e.target.getAttribute('href').substring(1);
|
|
|
|
|
+ const heading = headingMap.get(headingId);
|
|
|
|
|
+
|
|
|
|
|
+ if (heading) {
|
|
|
|
|
+ heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
+
|
|
|
|
|
+ // 更新活动状态
|
|
|
|
|
+ const tocLinks = DOM.toc.querySelectorAll('.toc-link');
|
|
|
|
|
+ tocLinks.forEach(el => el.classList.toggle('active', el === e.target));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 滚动监听
|
|
|
|
|
+function setupScrollSpy(headings) {
|
|
|
|
|
+ let activeLink = null;
|
|
|
|
|
+
|
|
|
|
|
+ const observer = new IntersectionObserver((entries) => {
|
|
|
|
|
+ entries.forEach(entry => {
|
|
|
|
|
+ if (entry.isIntersecting) {
|
|
|
|
|
+ const id = entry.target.id;
|
|
|
|
|
+ const link = DOM.toc.querySelector(`.toc-link[href="#${id}"]`);
|
|
|
|
|
+
|
|
|
|
|
+ if (link && link !== activeLink) {
|
|
|
|
|
+ // 只在需要时更新,减少 DOM 操作
|
|
|
|
|
+ if (activeLink) activeLink.classList.remove('active');
|
|
|
|
|
+ link.classList.add('active');
|
|
|
|
|
+ activeLink = link;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }, {
|
|
|
|
|
+ rootMargin: '-100px 0px -80% 0px',
|
|
|
|
|
+ threshold: 0
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ headings.forEach(heading => observer.observe(heading));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新 URL
|
|
|
|
|
+function updateURL(category, doc) {
|
|
|
|
|
+ const url = new URL(window.location);
|
|
|
|
|
+ url.searchParams.set('category', category);
|
|
|
|
|
+ url.searchParams.set('doc', doc);
|
|
|
|
|
+ window.history.pushState({}, '', url);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 显示错误
|
|
|
|
|
+function showError(message) {
|
|
|
|
|
+ const content = document.getElementById('markdown-content');
|
|
|
|
|
+ content.innerHTML = `<div class="error-message">${message}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 切换侧边栏(移动端)
|
|
|
|
|
+function setupSidebarToggles() {
|
|
|
|
|
+ const toggleLeft = document.getElementById('toggle-left');
|
|
|
|
|
+ const toggleRight = document.getElementById('toggle-right');
|
|
|
|
|
+
|
|
|
|
|
+ // 使用 DOM 缓存
|
|
|
|
|
+ if (!DOM.leftSidebar) DOM.leftSidebar = document.getElementById('left-sidebar');
|
|
|
|
|
+ if (!DOM.rightSidebar) DOM.rightSidebar = document.getElementById('right-sidebar');
|
|
|
|
|
+
|
|
|
|
|
+ // 移动端/平板初始化:默认折叠侧边栏
|
|
|
|
|
+ if (window.innerWidth <= 768) {
|
|
|
|
|
+ DOM.leftSidebar.classList.add('collapsed');
|
|
|
|
|
+ DOM.rightSidebar.classList.add('collapsed');
|
|
|
|
|
+ } else if (window.innerWidth <= 1024) {
|
|
|
|
|
+ // 平板:只折叠右侧栏
|
|
|
|
|
+ DOM.rightSidebar.classList.add('collapsed');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 监听窗口大小变化
|
|
|
|
|
+ window.addEventListener('resize', () => {
|
|
|
|
|
+ if (window.innerWidth <= 768) {
|
|
|
|
|
+ // 移动端:确保左右侧边栏都折叠
|
|
|
|
|
+ if (!DOM.leftSidebar.classList.contains('collapsed')) {
|
|
|
|
|
+ DOM.leftSidebar.classList.add('collapsed');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!DOM.rightSidebar.classList.contains('collapsed')) {
|
|
|
|
|
+ DOM.rightSidebar.classList.add('collapsed');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (window.innerWidth <= 1024) {
|
|
|
|
|
+ // 平板:只折叠右侧栏,展开左侧栏
|
|
|
|
|
+ DOM.leftSidebar.classList.remove('collapsed');
|
|
|
|
|
+ if (!DOM.rightSidebar.classList.contains('collapsed')) {
|
|
|
|
|
+ DOM.rightSidebar.classList.add('collapsed');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 桌面端:展开所有侧边栏
|
|
|
|
|
+ DOM.leftSidebar.classList.remove('collapsed');
|
|
|
|
|
+ DOM.rightSidebar.classList.remove('collapsed');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ toggleLeft.onclick = () => {
|
|
|
|
|
+ DOM.leftSidebar.classList.toggle('collapsed');
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ toggleRight.onclick = () => {
|
|
|
|
|
+ DOM.rightSidebar.classList.toggle('collapsed');
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 搜索功能
|
|
|
|
|
+function initSearch() {
|
|
|
|
|
+ const searchContainer = document.getElementById('search-container');
|
|
|
|
|
+ const searchToggleBtn = document.getElementById('search-toggle-btn');
|
|
|
|
|
+ const searchInput = document.getElementById('search-input');
|
|
|
|
|
+ const searchBtn = document.getElementById('search-btn');
|
|
|
|
|
+ const searchResults = document.getElementById('search-results');
|
|
|
|
|
+ const closeSearchBoxBtn = document.getElementById('close-search-box');
|
|
|
|
|
+
|
|
|
|
|
+ // 检查元素是否存在
|
|
|
|
|
+ if (!searchContainer || !searchToggleBtn || !searchInput || !searchBtn || !searchResults || !closeSearchBoxBtn) {
|
|
|
|
|
+ console.error('Search elements not found:', {
|
|
|
|
|
+ searchContainer: !!searchContainer,
|
|
|
|
|
+ searchToggleBtn: !!searchToggleBtn,
|
|
|
|
|
+ searchInput: !!searchInput,
|
|
|
|
|
+ searchBtn: !!searchBtn,
|
|
|
|
|
+ searchResults: !!searchResults,
|
|
|
|
|
+ closeSearchBoxBtn: !!closeSearchBoxBtn
|
|
|
|
|
+ });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let searchTimeout;
|
|
|
|
|
+ const SEARCH_HISTORY_KEY = 'cjydocs_search_history';
|
|
|
|
|
+
|
|
|
|
|
+ // 保存搜索历史到localStorage
|
|
|
|
|
+ const saveSearchHistory = (query) => {
|
|
|
|
|
+ if (!query || query.trim().length < 2) return;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
|
|
|
|
|
+ history[currentCategory] = query;
|
|
|
|
|
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to save search history:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 搜索函数
|
|
|
|
|
+ const performSearch = async () => {
|
|
|
|
|
+ const query = searchInput.value.trim();
|
|
|
|
|
+
|
|
|
|
|
+ if (query.length < 2) {
|
|
|
|
|
+ searchResults.style.display = 'none';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保存搜索历史
|
|
|
|
|
+ saveSearchHistory(query);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await fetch(
|
|
|
|
|
+ `/api/search/${encodeURIComponent(currentCategory)}?q=${encodeURIComponent(query)}¤tDoc=${encodeURIComponent(currentDoc)}`
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const errorData = await response.json().catch(() => ({}));
|
|
|
|
|
+ throw new Error(errorData.error || `搜索失败 (${response.status})`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ displaySearchResults(data);
|
|
|
|
|
+ searchResults.style.display = 'block';
|
|
|
|
|
+
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Search error:', err);
|
|
|
|
|
+ // 向用户显示错误信息
|
|
|
|
|
+ displaySearchError(err.message || '搜索失败,请稍后重试');
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 加载搜索历史
|
|
|
|
|
+ const loadSearchHistory = () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
|
|
|
|
|
+ const lastQuery = history[currentCategory];
|
|
|
|
|
+
|
|
|
|
|
+ if (lastQuery) {
|
|
|
|
|
+ searchInput.value = lastQuery;
|
|
|
|
|
+ // 自动搜索上次的内容
|
|
|
|
|
+ performSearch();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to load search history:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 清除其他分类的搜索历史
|
|
|
|
|
+ const clearOtherCategoryHistory = () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
|
|
|
|
|
+ // 只保留当前分类
|
|
|
|
|
+ history = { [currentCategory]: history[currentCategory] || '' };
|
|
|
|
|
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to clear search history:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 切换搜索框显示/隐藏
|
|
|
|
|
+ searchToggleBtn.addEventListener('click', (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ console.log('Search button clicked');
|
|
|
|
|
+ const isActive = searchContainer.classList.toggle('active');
|
|
|
|
|
+ console.log('Search container active:', isActive);
|
|
|
|
|
+
|
|
|
|
|
+ if (isActive) {
|
|
|
|
|
+ // 加载搜索历史
|
|
|
|
|
+ loadSearchHistory();
|
|
|
|
|
+ // 聚焦到搜索框
|
|
|
|
|
+ setTimeout(() => searchInput.focus(), 100);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 隐藏搜索结果
|
|
|
|
|
+ searchResults.style.display = 'none';
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 输入时实时搜索(防抖) - 500ms 减少 API 调用
|
|
|
|
|
+ searchInput.addEventListener('input', () => {
|
|
|
|
|
+ clearTimeout(searchTimeout);
|
|
|
|
|
+ searchTimeout = setTimeout(performSearch, 500);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 点击搜索按钮
|
|
|
|
|
+ searchBtn.addEventListener('click', performSearch);
|
|
|
|
|
+
|
|
|
|
|
+ // 按回车搜索
|
|
|
|
|
+ searchInput.addEventListener('keypress', (e) => {
|
|
|
|
|
+ if (e.key === 'Enter') {
|
|
|
|
|
+ performSearch();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭搜索框按钮
|
|
|
|
|
+ closeSearchBoxBtn.addEventListener('click', (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ searchContainer.classList.remove('active');
|
|
|
|
|
+ searchResults.style.display = 'none';
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 点击搜索结果外部关闭
|
|
|
|
|
+ searchResults.addEventListener('click', (e) => {
|
|
|
|
|
+ // 如果点击的是搜索结果容器本身(即遮罩背景),关闭搜索
|
|
|
|
|
+ if (e.target === searchResults) {
|
|
|
|
|
+ searchResults.style.display = 'none';
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 点击外部关闭
|
|
|
|
|
+ document.addEventListener('click', (e) => {
|
|
|
|
|
+ const searchToggle = document.getElementById('search-toggle-btn');
|
|
|
|
|
+
|
|
|
|
|
+ if (!searchResults.contains(e.target) &&
|
|
|
|
|
+ !searchInput.contains(e.target) &&
|
|
|
|
|
+ !searchBtn.contains(e.target) &&
|
|
|
|
|
+ !searchContainer.contains(e.target) &&
|
|
|
|
|
+ !searchToggle.contains(e.target)) {
|
|
|
|
|
+ searchResults.style.display = 'none';
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化时清除其他分类的历史
|
|
|
|
|
+ clearOtherCategoryHistory();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 显示搜索错误
|
|
|
|
|
+function displaySearchError(message) {
|
|
|
|
|
+ const currentDocSection = document.querySelector('#current-doc-results .results-list');
|
|
|
|
|
+ const otherDocsSection = document.querySelector('#other-docs-results .results-list');
|
|
|
|
|
+ const searchResults = document.getElementById('search-results');
|
|
|
|
|
+
|
|
|
|
|
+ // 清空之前的结果
|
|
|
|
|
+ currentDocSection.innerHTML = '';
|
|
|
|
|
+ otherDocsSection.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ // 隐藏分组标题
|
|
|
|
|
+ document.getElementById('current-doc-results').style.display = 'none';
|
|
|
|
|
+ document.getElementById('other-docs-results').style.display = 'none';
|
|
|
|
|
+
|
|
|
|
|
+ // 显示错误信息
|
|
|
|
|
+ currentDocSection.innerHTML = `<p class="search-error" style="color: #d73a49; padding: 20px; text-align: center;">${message}</p>`;
|
|
|
|
|
+ document.getElementById('current-doc-results').style.display = 'block';
|
|
|
|
|
+
|
|
|
|
|
+ // 显示搜索结果面板
|
|
|
|
|
+ searchResults.style.display = 'block';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 显示搜索结果
|
|
|
|
|
+function displaySearchResults(data) {
|
|
|
|
|
+ const currentDocSection = document.querySelector('#current-doc-results .results-list');
|
|
|
|
|
+ const otherDocsSection = document.querySelector('#other-docs-results .results-list');
|
|
|
|
|
+
|
|
|
|
|
+ // 清空之前的结果
|
|
|
|
|
+ currentDocSection.innerHTML = '';
|
|
|
|
|
+ otherDocsSection.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ // 隐藏/显示分组标题
|
|
|
|
|
+ document.getElementById('current-doc-results').style.display =
|
|
|
|
|
+ data.currentDoc.length > 0 ? 'block' : 'none';
|
|
|
|
|
+ document.getElementById('other-docs-results').style.display =
|
|
|
|
|
+ data.otherDocs.length > 0 ? 'block' : 'none';
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染当前文档结果
|
|
|
|
|
+ if (data.currentDoc.length > 0) {
|
|
|
|
|
+ data.currentDoc.forEach(doc => {
|
|
|
|
|
+ const docResults = createDocResultElement(doc, true);
|
|
|
|
|
+ currentDocSection.appendChild(docResults);
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ currentDocSection.innerHTML = '<p class="no-results">当前文档无匹配结果</p>';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染其他文档结果
|
|
|
|
|
+ if (data.otherDocs.length > 0) {
|
|
|
|
|
+ data.otherDocs.forEach(doc => {
|
|
|
|
|
+ const docResults = createDocResultElement(doc, false);
|
|
|
|
|
+ otherDocsSection.appendChild(docResults);
|
|
|
|
|
+ });
|
|
|
|
|
+ } else if (data.currentDoc.length === 0) {
|
|
|
|
|
+ otherDocsSection.innerHTML = '<p class="no-results">未找到匹配结果</p>';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 创建搜索结果元素
|
|
|
|
|
+function createDocResultElement(doc, isCurrent) {
|
|
|
|
|
+ const container = document.createElement('div');
|
|
|
|
|
+ container.className = 'search-result-item';
|
|
|
|
|
+
|
|
|
|
|
+ const header = document.createElement('div');
|
|
|
|
|
+ header.className = 'result-header';
|
|
|
|
|
+ header.innerHTML = `
|
|
|
|
|
+ <span class="result-doc-name">${doc.docName}</span>
|
|
|
|
|
+ <span class="result-count">${doc.matchCount} 个匹配</span>
|
|
|
|
|
+ `;
|
|
|
|
|
+ container.appendChild(header);
|
|
|
|
|
+
|
|
|
|
|
+ // 添加匹配片段
|
|
|
|
|
+ doc.matches.forEach(match => {
|
|
|
|
|
+ const matchItem = document.createElement('div');
|
|
|
|
|
+ matchItem.className = 'result-match';
|
|
|
|
|
+
|
|
|
|
|
+ // 高亮搜索词
|
|
|
|
|
+ const highlightedSnippet = highlightSearchTerm(match.snippet, document.getElementById('search-input').value);
|
|
|
|
|
+
|
|
|
|
|
+ matchItem.innerHTML = `
|
|
|
|
|
+ <div class="match-line-number">行 ${match.line}</div>
|
|
|
|
|
+ <div class="match-snippet">${highlightedSnippet}</div>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ // 点击跳转
|
|
|
|
|
+ matchItem.onclick = () => {
|
|
|
|
|
+ // 隐藏搜索框和搜索结果
|
|
|
|
|
+ const searchContainer = document.querySelector('.search-container');
|
|
|
|
|
+ const searchResults = document.getElementById('search-results');
|
|
|
|
|
+ searchContainer.classList.remove('active');
|
|
|
|
|
+ searchResults.style.display = 'none';
|
|
|
|
|
+
|
|
|
|
|
+ if (isCurrent) {
|
|
|
|
|
+ // 当前文档,滚动到对应位置
|
|
|
|
|
+ scrollToSearchMatch(match.fullLine);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 其他文档,加载该文档并跳转到具体位置
|
|
|
|
|
+ loadDocument(doc.docName, match.fullLine);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ container.appendChild(matchItem);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return container;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 高亮搜索词
|
|
|
|
|
+function highlightSearchTerm(text, searchTerm) {
|
|
|
|
|
+ const regex = new RegExp(`(${searchTerm})`, 'gi');
|
|
|
|
|
+ return text.replace(regex, '<mark class="search-highlight">$1</mark>');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 滚动到搜索匹配位置
|
|
|
|
|
+function scrollToSearchMatch(fullLine) {
|
|
|
|
|
+ const content = document.getElementById('markdown-content');
|
|
|
|
|
+ const searchText = fullLine.trim();
|
|
|
|
|
+
|
|
|
|
|
+ if (!searchText) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 使用TreeWalker遍历所有文本节点
|
|
|
|
|
+ const walker = document.createTreeWalker(
|
|
|
|
|
+ content,
|
|
|
|
|
+ NodeFilter.SHOW_TEXT,
|
|
|
|
|
+ null
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ let found = false;
|
|
|
|
|
+ let node;
|
|
|
|
|
+
|
|
|
|
|
+ while (node = walker.nextNode()) {
|
|
|
|
|
+ if (node.textContent.includes(searchText)) {
|
|
|
|
|
+ const parentElement = node.parentElement;
|
|
|
|
|
+
|
|
|
|
|
+ // 滚动到元素
|
|
|
|
|
+ parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
+
|
|
|
|
|
+ // 临时高亮父元素
|
|
|
|
|
+ parentElement.classList.add('temp-highlight');
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ parentElement.classList.remove('temp-highlight');
|
|
|
|
|
+ }, 2000);
|
|
|
|
|
+
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有找到精确匹配,尝试部分匹配
|
|
|
|
|
+ if (!found) {
|
|
|
|
|
+ const elements = content.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre, code');
|
|
|
|
|
+
|
|
|
|
|
+ for (const element of elements) {
|
|
|
|
|
+ const text = element.textContent.trim();
|
|
|
|
|
+ // 尝试匹配至少50%的内容
|
|
|
|
|
+ if (text.length > 10 && searchText.includes(text.substring(0, Math.min(text.length, 50)))) {
|
|
|
|
|
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
+
|
|
|
|
|
+ // 临时高亮
|
|
|
|
|
+ element.classList.add('temp-highlight');
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ element.classList.remove('temp-highlight');
|
|
|
|
|
+ }, 2000);
|
|
|
|
|
+
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置回到顶部按钮
|
|
|
|
|
+function setupBackToTop() {
|
|
|
|
|
+ const backToTopBtn = document.getElementById('back-to-top');
|
|
|
|
|
+ const contentArea = document.getElementById('content-area');
|
|
|
|
|
+
|
|
|
|
|
+ if (!backToTopBtn || !contentArea) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 初始状态:隐藏按钮
|
|
|
|
|
+ backToTopBtn.classList.add('hidden');
|
|
|
|
|
+
|
|
|
|
|
+ // 点击按钮滚动到顶部
|
|
|
|
|
+ backToTopBtn.addEventListener('click', () => {
|
|
|
|
|
+ contentArea.scrollTo({
|
|
|
|
|
+ top: 0,
|
|
|
|
|
+ behavior: 'smooth'
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 监听滚动事件,控制按钮显示/隐藏
|
|
|
|
|
+ let scrollTimeout;
|
|
|
|
|
+ contentArea.addEventListener('scroll', () => {
|
|
|
|
|
+ clearTimeout(scrollTimeout);
|
|
|
|
|
+ scrollTimeout = setTimeout(() => {
|
|
|
|
|
+ if (contentArea.scrollTop > 300) {
|
|
|
|
|
+ backToTopBtn.classList.remove('hidden');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ backToTopBtn.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 100);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 初始化
|
|
|
|
|
+document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
+ loadDocList();
|
|
|
|
|
+ setupSidebarToggles();
|
|
|
|
|
+ initSearch();
|
|
|
|
|
+ setupBackToTop();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 处理浏览器后退/前进
|
|
|
|
|
+window.addEventListener('popstate', () => {
|
|
|
|
|
+ const { doc } = getQueryParams();
|
|
|
|
|
+ if (doc) {
|
|
|
|
|
+ loadDocument(doc);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|