| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738 |
- 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 = (e) => {
- e.stopPropagation();
- // 如果右侧栏展开,先隐藏它
- if (!DOM.rightSidebar.classList.contains('collapsed')) {
- DOM.rightSidebar.classList.add('collapsed');
- }
- DOM.leftSidebar.classList.toggle('collapsed');
- };
- toggleRight.onclick = (e) => {
- e.stopPropagation();
- // 如果左侧栏展开,先隐藏它
- if (!DOM.leftSidebar.classList.contains('collapsed')) {
- DOM.leftSidebar.classList.add('collapsed');
- }
- DOM.rightSidebar.classList.toggle('collapsed');
- };
- // 点击侧边栏外部时隐藏所有已展开的侧边栏(仅在移动端和平板)
- document.addEventListener('click', (e) => {
- // 仅在移动端和平板上启用此功能
- if (window.innerWidth > 1024) return;
- const isClickInsideLeftSidebar = DOM.leftSidebar.contains(e.target);
- const isClickInsideRightSidebar = DOM.rightSidebar.contains(e.target);
- const isToggleButton = e.target.closest('#toggle-left') || e.target.closest('#toggle-right');
- // 如果点击的不是侧边栏内部,也不是切换按钮,则隐藏所有展开的侧边栏
- if (!isClickInsideLeftSidebar && !isClickInsideRightSidebar && !isToggleButton) {
- if (!DOM.leftSidebar.classList.contains('collapsed')) {
- DOM.leftSidebar.classList.add('collapsed');
- }
- if (!DOM.rightSidebar.classList.contains('collapsed')) {
- DOM.rightSidebar.classList.add('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);
- }
- });
|