reader.js 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334
  1. let currentCategory = '';
  2. let currentDoc = '';
  3. let docList = [];
  4. let currentMarkdownContent = ''; // 保存当前文档的markdown源码
  5. let isEditMode = false; // 是否处于编辑模式
  6. let savedScrollPosition = 0; // 保存的滚动位置
  7. let currentScrollObserver = null; // 保存当前的滚动监听器
  8. // DOM 元素缓存
  9. const DOM = {
  10. loading: null,
  11. content: null,
  12. docNav: null,
  13. toc: null,
  14. leftSidebar: null,
  15. rightSidebar: null,
  16. editBtn: null,
  17. editorContainer: null,
  18. markdownEditor: null,
  19. editorDocName: null,
  20. navPrev: null,
  21. navNext: null,
  22. contentArea: null,
  23. searchContainer: null,
  24. searchToggleBtn: null,
  25. searchInput: null,
  26. searchBtn: null,
  27. searchResults: null,
  28. closeSearchBoxBtn: null,
  29. saveBtn: null,
  30. cancelBtn: null,
  31. backToTopBtn: null,
  32. docNavigation: null,
  33. toggleLeft: null,
  34. toggleRight: null,
  35. categoryNameSpan: null,
  36. currentDocResults: null,
  37. otherDocResults: null
  38. };
  39. // 配置 marked
  40. marked.setOptions({
  41. highlight: function(code, lang) {
  42. if (lang && hljs.getLanguage(lang)) {
  43. try {
  44. const result = hljs.highlight(code, { language: lang });
  45. return result.value;
  46. } catch (err) {
  47. console.error('Highlight error:', err);
  48. }
  49. }
  50. // 自动检测语言
  51. const autoResult = hljs.highlightAuto(code);
  52. return autoResult.value;
  53. },
  54. breaks: true,
  55. gfm: true
  56. });
  57. // 从 URL 获取参数
  58. function getQueryParams() {
  59. const params = new URLSearchParams(window.location.search);
  60. return {
  61. category: params.get('category'),
  62. doc: params.get('doc')
  63. };
  64. }
  65. // 加载文档列表
  66. async function loadDocList() {
  67. const { category } = getQueryParams();
  68. if (!category) {
  69. window.location.href = '/';
  70. return;
  71. }
  72. currentCategory = category;
  73. if (!DOM.categoryNameSpan) DOM.categoryNameSpan = document.querySelector('#category-title .category-name');
  74. if (DOM.categoryNameSpan) {
  75. DOM.categoryNameSpan.textContent = category;
  76. }
  77. try {
  78. const response = await fetch(`/api/category/${encodeURIComponent(category)}`);
  79. if (!response.ok) throw new Error('获取文档列表失败');
  80. const categoryData = await response.json();
  81. docList = categoryData.docs;
  82. renderDocNav(docList);
  83. // 加载第一篇文档(或 URL 指定的文档)
  84. const { doc } = getQueryParams();
  85. const firstDoc = doc || docList[0]?.name;
  86. if (firstDoc) {
  87. loadDocument(firstDoc);
  88. }
  89. } catch (err) {
  90. console.error('Error loading doc list:', err);
  91. showError('加载文档列表失败');
  92. }
  93. }
  94. // 渲染文档导航
  95. function renderDocNav(docs) {
  96. const nav = DOM.docNav || document.getElementById('doc-nav');
  97. if (!DOM.docNav) DOM.docNav = nav;
  98. nav.innerHTML = '';
  99. docs.forEach(doc => {
  100. const item = document.createElement('div');
  101. item.className = `nav-item level-${doc.level}`;
  102. item.dataset.docName = doc.name;
  103. const link = document.createElement('a');
  104. link.href = '#';
  105. link.className = 'nav-link';
  106. link.textContent = doc.name;
  107. item.appendChild(link);
  108. nav.appendChild(item);
  109. });
  110. // 使用事件委托 - 只添加一个监听器
  111. nav.removeEventListener('click', handleNavClick);
  112. nav.addEventListener('click', handleNavClick);
  113. }
  114. // 导航点击处理函数
  115. function handleNavClick(e) {
  116. if (e.target.classList.contains('nav-link')) {
  117. e.preventDefault();
  118. const item = e.target.parentElement;
  119. const docName = item.dataset.docName;
  120. if (docName) {
  121. loadDocument(docName);
  122. updateURL(currentCategory, docName);
  123. // 更新活动状态 - 使用缓存的查询结果
  124. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  125. navItems.forEach(el => el.classList.toggle('active', el === item));
  126. }
  127. }
  128. }
  129. // 加载文档内容
  130. async function loadDocument(docName, scrollToText = null) {
  131. // 使用 DOM 缓存
  132. if (!DOM.loading) DOM.loading = document.getElementById('loading');
  133. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  134. DOM.loading.style.display = 'flex';
  135. DOM.content.innerHTML = '';
  136. try {
  137. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(docName)}`);
  138. if (!response.ok) {
  139. const errorData = await response.json().catch(() => ({}));
  140. throw new Error(errorData.error || `加载失败 (${response.status})`);
  141. }
  142. const data = await response.json();
  143. // 验证数据有效性
  144. if (!data.content || typeof data.content !== 'string') {
  145. throw new Error('文档内容格式错误');
  146. }
  147. currentDoc = docName;
  148. currentMarkdownContent = data.content; // 保存markdown源码
  149. // 渲染 Markdown
  150. const html = marked.parse(data.content);
  151. DOM.content.innerHTML = html;
  152. // 处理图片路径,将相对路径转换为正确的URL
  153. DOM.content.querySelectorAll('img').forEach(img => {
  154. const src = img.getAttribute('src');
  155. // 如果是相对路径(不以 http/https 开头)
  156. if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('/')) {
  157. // 使用新的API路径,避免中文路径问题
  158. const newSrc = `/api/image/${encodeURIComponent(currentCategory)}/${src}`;
  159. img.setAttribute('src', newSrc);
  160. // 添加错误处理
  161. img.onerror = function() {
  162. console.error('Failed to load image:', newSrc);
  163. // 显示占位图片或错误提示
  164. this.alt = `图片加载失败: ${src}`;
  165. this.classList.add('img-error');
  166. };
  167. }
  168. });
  169. // 手动为所有代码块添加 hljs 类并确保高亮(修复 marked 高亮可能失败的问题)
  170. DOM.content.querySelectorAll('pre code').forEach((block) => {
  171. // 如果代码块没有 hljs 类,添加它
  172. if (!block.classList.contains('hljs')) {
  173. block.classList.add('hljs');
  174. }
  175. // 如果没有高亮的标记(没有 span 元素),尝试重新高亮
  176. if (!block.querySelector('span')) {
  177. const lang = block.className.match(/language-(\w+)/)?.[1];
  178. if (lang && hljs.getLanguage(lang)) {
  179. try {
  180. const highlighted = hljs.highlight(block.textContent, { language: lang });
  181. block.innerHTML = highlighted.value;
  182. block.classList.add('hljs', `language-${lang}`);
  183. } catch (err) {
  184. console.error('Error re-highlighting:', err);
  185. }
  186. } else {
  187. // 尝试自动检测
  188. try {
  189. const highlighted = hljs.highlightAuto(block.textContent);
  190. block.innerHTML = highlighted.value;
  191. block.classList.add('hljs');
  192. if (highlighted.language) {
  193. block.classList.add(`language-${highlighted.language}`);
  194. }
  195. } catch (err) {
  196. console.error('Error auto-highlighting:', err);
  197. }
  198. }
  199. }
  200. });
  201. // 为所有代码块添加复制按钮
  202. addCopyButtonsToCodeBlocks();
  203. // 生成 TOC
  204. generateTOC();
  205. // 更新文档导航(上一页/下一页)
  206. updateDocNavigation();
  207. // 更新活动文档 - 使用 toggle 优化
  208. if (DOM.docNav) {
  209. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  210. navItems.forEach(el => el.classList.toggle('active', el.dataset.docName === docName));
  211. }
  212. DOM.loading.style.display = 'none';
  213. // 如果指定了要滚动到的文本,等待渲染完成后滚动
  214. if (scrollToText) {
  215. setTimeout(() => {
  216. scrollToSearchMatch(scrollToText);
  217. }, 100);
  218. }
  219. } catch (err) {
  220. console.error('Error loading document:', err);
  221. DOM.loading.style.display = 'none';
  222. showError(`加载文档失败:${err.message || '请稍后重试'}`);
  223. }
  224. }
  225. // 生成 TOC
  226. function generateTOC() {
  227. // 使用 DOM 缓存
  228. if (!DOM.toc) DOM.toc = document.getElementById('toc');
  229. // 确保 DOM.content 也被正确缓存
  230. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  231. // 先清空旧的TOC内容和事件监听器
  232. DOM.toc.innerHTML = '';
  233. DOM.toc.removeEventListener('click', handleTocClick);
  234. const headings = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6');
  235. if (headings.length === 0) {
  236. DOM.toc.innerHTML = '<p class="toc-empty">本文档没有标题</p>';
  237. return;
  238. }
  239. // 创建标题 ID 映射
  240. const headingMap = new Map();
  241. headings.forEach((heading, index) => {
  242. // 给标题添加 ID
  243. if (!heading.id) {
  244. heading.id = `heading-${index}`;
  245. }
  246. const level = parseInt(heading.tagName.substring(1));
  247. const link = document.createElement('a');
  248. link.href = `#${heading.id}`;
  249. link.className = `toc-link toc-level-${level}`;
  250. link.textContent = heading.textContent;
  251. headingMap.set(heading.id, heading);
  252. DOM.toc.appendChild(link);
  253. });
  254. // 使用事件委托 - 只添加一个监听器
  255. DOM.toc.removeEventListener('click', handleTocClick);
  256. DOM.toc.addEventListener('click', (e) => handleTocClick(e, headingMap));
  257. // 监听滚动,高亮当前标题
  258. setupScrollSpy(headings);
  259. }
  260. // TOC 点击处理函数
  261. function handleTocClick(e, headingMap) {
  262. if (e.target.classList.contains('toc-link')) {
  263. e.preventDefault();
  264. const headingId = e.target.getAttribute('href').substring(1);
  265. const heading = headingMap.get(headingId);
  266. if (heading) {
  267. heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
  268. // 更新活动状态
  269. const tocLinks = DOM.toc.querySelectorAll('.toc-link');
  270. tocLinks.forEach(el => el.classList.toggle('active', el === e.target));
  271. }
  272. }
  273. }
  274. // 滚动监听
  275. function setupScrollSpy(headings) {
  276. // 清理之前的 observer
  277. if (currentScrollObserver) {
  278. currentScrollObserver.disconnect();
  279. currentScrollObserver = null;
  280. }
  281. // 如果没有标题,直接返回
  282. if (!headings || headings.length === 0) {
  283. return;
  284. }
  285. let activeLink = null;
  286. const observer = new IntersectionObserver((entries) => {
  287. entries.forEach(entry => {
  288. if (entry.isIntersecting) {
  289. const id = entry.target.id;
  290. const link = DOM.toc.querySelector(`.toc-link[href="#${id}"]`);
  291. if (link && link !== activeLink) {
  292. // 只在需要时更新,减少 DOM 操作
  293. if (activeLink) activeLink.classList.remove('active');
  294. link.classList.add('active');
  295. activeLink = link;
  296. }
  297. }
  298. });
  299. }, {
  300. rootMargin: '-100px 0px -80% 0px',
  301. threshold: 0
  302. });
  303. headings.forEach(heading => observer.observe(heading));
  304. // 保存当前的 observer 以便后续清理
  305. currentScrollObserver = observer;
  306. }
  307. // 更新 URL
  308. function updateURL(category, doc) {
  309. const url = new URL(window.location);
  310. url.searchParams.set('category', category);
  311. url.searchParams.set('doc', doc);
  312. window.history.pushState({}, '', url);
  313. }
  314. // 更新文档导航(上一页/下一页)
  315. function updateDocNavigation() {
  316. // 缓存 DOM 元素
  317. if (!DOM.navPrev) DOM.navPrev = document.getElementById('nav-prev');
  318. if (!DOM.navNext) DOM.navNext = document.getElementById('nav-next');
  319. if (!DOM.navPrev || !DOM.navNext || docList.length === 0) return;
  320. if (!DOM.docNavigation) DOM.docNavigation = document.getElementById('doc-navigation');
  321. if (!DOM.docNavigation) return;
  322. // 找到当前文档在列表中的索引
  323. const currentIndex = docList.findIndex(doc => doc.name === currentDoc);
  324. if (currentIndex === -1) {
  325. DOM.navPrev.style.display = 'none';
  326. DOM.navNext.style.display = 'none';
  327. DOM.docNavigation.className = 'doc-navigation';
  328. return;
  329. }
  330. const hasPrev = currentIndex > 0;
  331. const hasNext = currentIndex < docList.length - 1;
  332. // 辅助函数:设置导航按钮
  333. const setupNavButton = (button, doc) => {
  334. if (doc) {
  335. button.style.display = 'flex';
  336. button.querySelector('.nav-doc-name').textContent = doc.name;
  337. button.onclick = () => {
  338. loadDocument(doc.name);
  339. updateURL(currentCategory, doc.name);
  340. // 滚动到顶部
  341. if (!DOM.contentArea) DOM.contentArea = document.getElementById('content-area');
  342. if (DOM.contentArea) {
  343. DOM.contentArea.scrollTo({ top: 0, behavior: 'smooth' });
  344. }
  345. };
  346. } else {
  347. button.style.display = 'none';
  348. }
  349. };
  350. // 设置上一页和下一页按钮
  351. setupNavButton(DOM.navPrev, hasPrev ? docList[currentIndex - 1] : null);
  352. setupNavButton(DOM.navNext, hasNext ? docList[currentIndex + 1] : null);
  353. // 根据导航按钮状态添加类,用于CSS定位
  354. if (hasPrev && hasNext) {
  355. DOM.docNavigation.className = 'doc-navigation has-both';
  356. } else if (hasPrev) {
  357. DOM.docNavigation.className = 'doc-navigation has-prev-only';
  358. } else if (hasNext) {
  359. DOM.docNavigation.className = 'doc-navigation has-next-only';
  360. } else {
  361. DOM.docNavigation.className = 'doc-navigation';
  362. }
  363. }
  364. // 显示错误
  365. function showError(message) {
  366. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  367. if (DOM.content) {
  368. DOM.content.innerHTML = `<div class="error-message">${message}</div>`;
  369. }
  370. }
  371. // 切换侧边栏(移动端)
  372. function setupSidebarToggles() {
  373. if (!DOM.toggleLeft) DOM.toggleLeft = document.getElementById('toggle-left');
  374. if (!DOM.toggleRight) DOM.toggleRight = document.getElementById('toggle-right');
  375. const toggleLeft = DOM.toggleLeft;
  376. const toggleRight = DOM.toggleRight;
  377. // 使用 DOM 缓存
  378. if (!DOM.leftSidebar) DOM.leftSidebar = document.getElementById('left-sidebar');
  379. if (!DOM.rightSidebar) DOM.rightSidebar = document.getElementById('right-sidebar');
  380. // 移动端/平板初始化:默认折叠侧边栏
  381. if (window.innerWidth <= 768) {
  382. DOM.leftSidebar.classList.add('collapsed');
  383. DOM.rightSidebar.classList.add('collapsed');
  384. } else if (window.innerWidth <= 1024) {
  385. // 平板:只折叠右侧栏
  386. DOM.rightSidebar.classList.add('collapsed');
  387. }
  388. // 监听窗口大小变化
  389. window.addEventListener('resize', () => {
  390. if (window.innerWidth <= 768) {
  391. // 移动端:确保左右侧边栏都折叠
  392. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  393. DOM.leftSidebar.classList.add('collapsed');
  394. }
  395. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  396. DOM.rightSidebar.classList.add('collapsed');
  397. }
  398. } else if (window.innerWidth <= 1024) {
  399. // 平板:只折叠右侧栏,展开左侧栏
  400. DOM.leftSidebar.classList.remove('collapsed');
  401. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  402. DOM.rightSidebar.classList.add('collapsed');
  403. }
  404. } else {
  405. // 桌面端:展开所有侧边栏
  406. DOM.leftSidebar.classList.remove('collapsed');
  407. DOM.rightSidebar.classList.remove('collapsed');
  408. }
  409. });
  410. toggleLeft.onclick = (e) => {
  411. e.stopPropagation();
  412. // 如果右侧栏展开,先隐藏它
  413. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  414. DOM.rightSidebar.classList.add('collapsed');
  415. }
  416. DOM.leftSidebar.classList.toggle('collapsed');
  417. };
  418. toggleRight.onclick = (e) => {
  419. e.stopPropagation();
  420. // 如果左侧栏展开,先隐藏它
  421. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  422. DOM.leftSidebar.classList.add('collapsed');
  423. }
  424. DOM.rightSidebar.classList.toggle('collapsed');
  425. };
  426. // 点击侧边栏外部时隐藏所有已展开的侧边栏(仅在移动端和平板)
  427. document.addEventListener('click', (e) => {
  428. // 仅在移动端和平板上启用此功能
  429. if (window.innerWidth > 1024) return;
  430. const isClickInsideLeftSidebar = DOM.leftSidebar.contains(e.target);
  431. const isClickInsideRightSidebar = DOM.rightSidebar.contains(e.target);
  432. const isToggleButton = e.target.closest('#toggle-left') || e.target.closest('#toggle-right');
  433. // 如果点击的不是侧边栏内部,也不是切换按钮,则隐藏所有展开的侧边栏
  434. if (!isClickInsideLeftSidebar && !isClickInsideRightSidebar && !isToggleButton) {
  435. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  436. DOM.leftSidebar.classList.add('collapsed');
  437. }
  438. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  439. DOM.rightSidebar.classList.add('collapsed');
  440. }
  441. }
  442. });
  443. }
  444. // 搜索功能
  445. function initSearch() {
  446. // 初始化搜索相关的DOM缓存
  447. if (!DOM.searchContainer) DOM.searchContainer = document.getElementById('search-container');
  448. if (!DOM.searchToggleBtn) DOM.searchToggleBtn = document.getElementById('search-toggle-btn');
  449. if (!DOM.searchInput) DOM.searchInput = document.getElementById('search-input');
  450. if (!DOM.searchBtn) DOM.searchBtn = document.getElementById('search-btn');
  451. if (!DOM.searchResults) DOM.searchResults = document.getElementById('search-results');
  452. if (!DOM.closeSearchBoxBtn) DOM.closeSearchBoxBtn = document.getElementById('close-search-box');
  453. // 检查元素是否存在
  454. if (!DOM.searchContainer || !DOM.searchToggleBtn || !DOM.searchInput || !DOM.searchBtn || !DOM.searchResults || !DOM.closeSearchBoxBtn) {
  455. console.error('Search elements not found:', {
  456. searchContainer: !!DOM.searchContainer,
  457. searchToggleBtn: !!DOM.searchToggleBtn,
  458. searchInput: !!DOM.searchInput,
  459. searchBtn: !!DOM.searchBtn,
  460. searchResults: !!DOM.searchResults,
  461. closeSearchBoxBtn: !!DOM.closeSearchBoxBtn
  462. });
  463. return;
  464. }
  465. const searchContainer = DOM.searchContainer;
  466. const searchToggleBtn = DOM.searchToggleBtn;
  467. const searchInput = DOM.searchInput;
  468. const searchBtn = DOM.searchBtn;
  469. const searchResults = DOM.searchResults;
  470. const closeSearchBoxBtn = DOM.closeSearchBoxBtn;
  471. let searchTimeout;
  472. const SEARCH_HISTORY_KEY = 'cjydocs_search_history';
  473. // 保存搜索历史到localStorage
  474. const saveSearchHistory = (query) => {
  475. if (!query || query.trim().length < 2) return;
  476. try {
  477. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  478. history[currentCategory] = query;
  479. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  480. } catch (err) {
  481. console.error('Failed to save search history:', err);
  482. }
  483. };
  484. // 搜索函数
  485. const performSearch = async () => {
  486. const query = searchInput.value.trim();
  487. if (query.length < 2) {
  488. searchResults.style.display = 'none';
  489. return;
  490. }
  491. // 保存搜索历史
  492. saveSearchHistory(query);
  493. try {
  494. const response = await fetch(
  495. `/api/search/${encodeURIComponent(currentCategory)}?q=${encodeURIComponent(query)}&currentDoc=${encodeURIComponent(currentDoc)}`
  496. );
  497. if (!response.ok) {
  498. const errorData = await response.json().catch(() => ({}));
  499. throw new Error(errorData.error || `搜索失败 (${response.status})`);
  500. }
  501. const data = await response.json();
  502. displaySearchResults(data);
  503. searchResults.style.display = 'block';
  504. } catch (err) {
  505. console.error('Search error:', err);
  506. // 向用户显示错误信息
  507. displaySearchError(err.message || '搜索失败,请稍后重试');
  508. }
  509. };
  510. // 加载搜索历史
  511. const loadSearchHistory = () => {
  512. try {
  513. const history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  514. const lastQuery = history[currentCategory];
  515. if (lastQuery) {
  516. searchInput.value = lastQuery;
  517. // 自动搜索上次的内容
  518. performSearch();
  519. }
  520. } catch (err) {
  521. console.error('Failed to load search history:', err);
  522. }
  523. };
  524. // 清除其他分类的搜索历史
  525. const clearOtherCategoryHistory = () => {
  526. try {
  527. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  528. // 只保留当前分类
  529. history = { [currentCategory]: history[currentCategory] || '' };
  530. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  531. } catch (err) {
  532. console.error('Failed to clear search history:', err);
  533. }
  534. };
  535. // 切换搜索框显示/隐藏
  536. searchToggleBtn.addEventListener('click', (e) => {
  537. e.stopPropagation();
  538. const isActive = searchContainer.classList.toggle('active');
  539. if (isActive) {
  540. // 加载搜索历史
  541. loadSearchHistory();
  542. // 聚焦到搜索框
  543. setTimeout(() => searchInput.focus(), 100);
  544. } else {
  545. // 隐藏搜索结果
  546. searchResults.style.display = 'none';
  547. }
  548. });
  549. // 输入时实时搜索(防抖) - 500ms 减少 API 调用
  550. searchInput.addEventListener('input', () => {
  551. clearTimeout(searchTimeout);
  552. searchTimeout = setTimeout(performSearch, 500);
  553. });
  554. // 点击搜索按钮
  555. searchBtn.addEventListener('click', performSearch);
  556. // 按回车搜索
  557. searchInput.addEventListener('keypress', (e) => {
  558. if (e.key === 'Enter') {
  559. performSearch();
  560. }
  561. });
  562. // 关闭搜索框按钮
  563. closeSearchBoxBtn.addEventListener('click', (e) => {
  564. e.stopPropagation();
  565. searchContainer.classList.remove('active');
  566. searchResults.style.display = 'none';
  567. });
  568. // 点击搜索结果外部关闭
  569. searchResults.addEventListener('click', (e) => {
  570. // 如果点击的是搜索结果容器本身(即遮罩背景),关闭搜索
  571. if (e.target === searchResults) {
  572. searchResults.style.display = 'none';
  573. }
  574. });
  575. // 点击外部关闭
  576. document.addEventListener('click', (e) => {
  577. if (!searchResults.contains(e.target) &&
  578. !searchInput.contains(e.target) &&
  579. !searchBtn.contains(e.target) &&
  580. !searchContainer.contains(e.target) &&
  581. !searchToggleBtn.contains(e.target)) {
  582. searchResults.style.display = 'none';
  583. }
  584. });
  585. // 初始化时清除其他分类的历史
  586. clearOtherCategoryHistory();
  587. }
  588. // 显示搜索错误
  589. function displaySearchError(message) {
  590. if (!DOM.currentDocResults) DOM.currentDocResults = document.getElementById('current-doc-results');
  591. if (!DOM.otherDocResults) DOM.otherDocResults = document.getElementById('other-docs-results');
  592. const currentDocSection = DOM.currentDocResults ? DOM.currentDocResults.querySelector('.results-list') : null;
  593. const otherDocsSection = DOM.otherDocResults ? DOM.otherDocResults.querySelector('.results-list') : null;
  594. // 清空之前的结果
  595. currentDocSection.innerHTML = '';
  596. otherDocsSection.innerHTML = '';
  597. // 隐藏分组标题
  598. DOM.currentDocResults.style.display = 'none';
  599. DOM.otherDocResults.style.display = 'none';
  600. // 显示错误信息
  601. if (currentDocSection) {
  602. currentDocSection.innerHTML = `<p class="search-error" style="color: #d73a49; padding: 20px; text-align: center;">${message}</p>`;
  603. }
  604. DOM.currentDocResults.style.display = 'block';
  605. // 显示搜索结果面板
  606. if (DOM.searchResults) {
  607. DOM.searchResults.style.display = 'block';
  608. }
  609. }
  610. // 显示搜索结果
  611. function displaySearchResults(data) {
  612. if (!DOM.currentDocResults) DOM.currentDocResults = document.getElementById('current-doc-results');
  613. if (!DOM.otherDocResults) DOM.otherDocResults = document.getElementById('other-docs-results');
  614. const currentDocSection = DOM.currentDocResults ? DOM.currentDocResults.querySelector('.results-list') : null;
  615. const otherDocsSection = DOM.otherDocResults ? DOM.otherDocResults.querySelector('.results-list') : null;
  616. // 清空之前的结果
  617. currentDocSection.innerHTML = '';
  618. otherDocsSection.innerHTML = '';
  619. // 隐藏/显示分组标题
  620. DOM.currentDocResults.style.display =
  621. data.currentDoc.length > 0 ? 'block' : 'none';
  622. DOM.otherDocResults.style.display =
  623. data.otherDocs.length > 0 ? 'block' : 'none';
  624. // 渲染当前文档结果
  625. if (data.currentDoc.length > 0) {
  626. data.currentDoc.forEach(doc => {
  627. const docResults = createDocResultElement(doc, true);
  628. currentDocSection.appendChild(docResults);
  629. });
  630. } else {
  631. currentDocSection.innerHTML = '<p class="no-results">当前文档无匹配结果</p>';
  632. }
  633. // 渲染其他文档结果
  634. if (data.otherDocs.length > 0) {
  635. data.otherDocs.forEach(doc => {
  636. const docResults = createDocResultElement(doc, false);
  637. otherDocsSection.appendChild(docResults);
  638. });
  639. } else if (data.currentDoc.length === 0) {
  640. otherDocsSection.innerHTML = '<p class="no-results">未找到匹配结果</p>';
  641. }
  642. }
  643. // 创建搜索结果元素
  644. function createDocResultElement(doc, isCurrent) {
  645. const container = document.createElement('div');
  646. container.className = 'search-result-item';
  647. const header = document.createElement('div');
  648. header.className = 'result-header';
  649. header.innerHTML = `
  650. <span class="result-doc-name">${doc.docName}</span>
  651. <span class="result-count">${doc.matchCount} 个匹配</span>
  652. `;
  653. container.appendChild(header);
  654. // 添加匹配片段
  655. doc.matches.forEach(match => {
  656. const matchItem = document.createElement('div');
  657. matchItem.className = 'result-match';
  658. // 高亮搜索词
  659. const highlightedSnippet = highlightSearchTerm(match.snippet, DOM.searchInput ? DOM.searchInput.value : '');
  660. matchItem.innerHTML = `
  661. <div class="match-line-number">行 ${match.line}</div>
  662. <div class="match-snippet">${highlightedSnippet}</div>
  663. `;
  664. // 点击跳转
  665. matchItem.onclick = () => {
  666. // 隐藏搜索框和搜索结果
  667. if (DOM.searchContainer) {
  668. DOM.searchContainer.classList.remove('active');
  669. }
  670. if (DOM.searchResults) {
  671. DOM.searchResults.style.display = 'none';
  672. }
  673. // 在移动端和平板上关闭所有侧边栏,确保用户能看到跳转后的内容
  674. if (window.innerWidth <= 1024) {
  675. if (DOM.leftSidebar && !DOM.leftSidebar.classList.contains('collapsed')) {
  676. DOM.leftSidebar.classList.add('collapsed');
  677. }
  678. if (DOM.rightSidebar && !DOM.rightSidebar.classList.contains('collapsed')) {
  679. DOM.rightSidebar.classList.add('collapsed');
  680. }
  681. }
  682. if (isCurrent) {
  683. // 当前文档,滚动到对应位置
  684. scrollToSearchMatch(match.fullLine);
  685. } else {
  686. // 其他文档,加载该文档并跳转到具体位置
  687. loadDocument(doc.docName, match.fullLine);
  688. }
  689. };
  690. container.appendChild(matchItem);
  691. });
  692. return container;
  693. }
  694. // 高亮搜索词
  695. function highlightSearchTerm(text, searchTerm) {
  696. const regex = new RegExp(`(${searchTerm})`, 'gi');
  697. return text.replace(regex, '<mark class="search-highlight">$1</mark>');
  698. }
  699. // 滚动到搜索匹配位置
  700. function scrollToSearchMatch(fullLine) {
  701. const content = document.getElementById('markdown-content');
  702. const searchText = fullLine.trim();
  703. if (!searchText) return;
  704. // 使用TreeWalker遍历所有文本节点
  705. const walker = document.createTreeWalker(
  706. content,
  707. NodeFilter.SHOW_TEXT,
  708. null
  709. );
  710. let found = false;
  711. let node;
  712. while (node = walker.nextNode()) {
  713. if (node.textContent.includes(searchText)) {
  714. const parentElement = node.parentElement;
  715. // 滚动到元素
  716. parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  717. // 临时高亮父元素
  718. parentElement.classList.add('temp-highlight');
  719. setTimeout(() => {
  720. parentElement.classList.remove('temp-highlight');
  721. }, 2000);
  722. found = true;
  723. break;
  724. }
  725. }
  726. // 如果没有找到精确匹配,尝试部分匹配
  727. if (!found) {
  728. const elements = content.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre, code');
  729. for (const element of elements) {
  730. const text = element.textContent.trim();
  731. // 尝试匹配至少50%的内容
  732. if (text.length > 10 && searchText.includes(text.substring(0, Math.min(text.length, 50)))) {
  733. element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  734. // 临时高亮
  735. element.classList.add('temp-highlight');
  736. setTimeout(() => {
  737. element.classList.remove('temp-highlight');
  738. }, 2000);
  739. break;
  740. }
  741. }
  742. }
  743. }
  744. // 设置回到顶部按钮
  745. function setupBackToTop() {
  746. if (!DOM.backToTopBtn) DOM.backToTopBtn = document.getElementById('back-to-top');
  747. const backToTopBtn = DOM.backToTopBtn;
  748. if (!backToTopBtn) return;
  749. // 初始状态:隐藏按钮
  750. backToTopBtn.classList.add('hidden');
  751. // 点击按钮滚动到顶部
  752. backToTopBtn.addEventListener('click', (e) => {
  753. e.preventDefault();
  754. e.stopPropagation();
  755. // 滚动到顶部
  756. window.scrollTo({
  757. top: 0,
  758. behavior: 'smooth'
  759. });
  760. });
  761. // 监听窗口滚动事件,根据滚动位置控制按钮显示/隐藏
  762. let scrollTimeout;
  763. const handleScroll = () => {
  764. clearTimeout(scrollTimeout);
  765. scrollTimeout = setTimeout(() => {
  766. const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
  767. // 滚动超过 300px 时显示按钮
  768. if (scrollTop > 300) {
  769. backToTopBtn.classList.remove('hidden');
  770. } else {
  771. backToTopBtn.classList.add('hidden');
  772. }
  773. }, 100);
  774. };
  775. window.addEventListener('scroll', handleScroll);
  776. }
  777. // 编辑功能
  778. function setupEditFeature() {
  779. // 缓存DOM元素
  780. if (!DOM.editBtn) DOM.editBtn = document.getElementById('edit-btn');
  781. if (!DOM.editorContainer) DOM.editorContainer = document.getElementById('editor-container');
  782. if (!DOM.markdownEditor) DOM.markdownEditor = document.getElementById('markdown-editor');
  783. if (!DOM.editorDocName) DOM.editorDocName = document.getElementById('editor-doc-name');
  784. if (!DOM.saveBtn) DOM.saveBtn = document.getElementById('save-btn');
  785. if (!DOM.cancelBtn) DOM.cancelBtn = document.getElementById('cancel-edit-btn');
  786. if (!DOM.contentArea) DOM.contentArea = document.getElementById('content-area');
  787. const saveBtn = DOM.saveBtn;
  788. const cancelBtn = DOM.cancelBtn;
  789. const contentArea = DOM.contentArea;
  790. // 编辑按钮点击事件 - 切换编辑/查看模式
  791. DOM.editBtn.addEventListener('click', () => {
  792. if (isEditMode) {
  793. exitEditMode();
  794. } else {
  795. enterEditMode();
  796. }
  797. });
  798. // 取消编辑
  799. cancelBtn.addEventListener('click', () => {
  800. exitEditMode();
  801. });
  802. // 保存按钮
  803. saveBtn.addEventListener('click', async () => {
  804. await saveDocument();
  805. });
  806. }
  807. // 进入编辑模式
  808. function enterEditMode() {
  809. if (!currentDoc || !currentMarkdownContent) {
  810. alert('请先加载一个文档');
  811. return;
  812. }
  813. isEditMode = true;
  814. // 更新编辑按钮状态
  815. DOM.editBtn.classList.add('active');
  816. DOM.editBtn.title = '退出编辑';
  817. // 保存当前滚动位置
  818. if (!DOM.contentArea) DOM.contentArea = document.getElementById('content-area');
  819. savedScrollPosition = DOM.contentArea.scrollTop;
  820. // 找到当前视口中心附近的元素
  821. const visibleElement = findVisibleElement();
  822. // 隐藏渲染的内容
  823. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  824. DOM.content.style.display = 'none';
  825. // 显示编辑器
  826. DOM.editorContainer.style.display = 'flex';
  827. DOM.editorDocName.textContent = `${currentDoc}.md`;
  828. DOM.markdownEditor.value = currentMarkdownContent;
  829. // 定位光标到对应位置
  830. setTimeout(() => {
  831. if (visibleElement) {
  832. positionCursorByElement(visibleElement);
  833. } else {
  834. // 如果找不到元素,使用滚动比例
  835. const scrollRatio = savedScrollPosition / DOM.contentArea.scrollHeight;
  836. DOM.markdownEditor.scrollTop = DOM.markdownEditor.scrollHeight * scrollRatio;
  837. }
  838. DOM.markdownEditor.focus();
  839. }, 50);
  840. }
  841. // 找到当前视口中可见的元素
  842. function findVisibleElement() {
  843. if (!DOM.contentArea) DOM.contentArea = document.getElementById('content-area');
  844. const viewportTop = DOM.contentArea.scrollTop;
  845. const viewportMiddle = viewportTop + DOM.contentArea.clientHeight / 3; // 视口上方1/3处
  846. // 查找所有重要元素(标题、段落等)
  847. const elements = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, blockquote, pre');
  848. let closestElement = null;
  849. let minDistance = Infinity;
  850. elements.forEach(el => {
  851. const rect = el.getBoundingClientRect();
  852. const elementTop = el.offsetTop;
  853. const distance = Math.abs(elementTop - viewportMiddle);
  854. if (distance < minDistance && elementTop <= viewportMiddle + 200) {
  855. minDistance = distance;
  856. closestElement = el;
  857. }
  858. });
  859. return closestElement;
  860. }
  861. // 根据元素定位光标
  862. function positionCursorByElement(element) {
  863. const elementText = element.textContent.trim();
  864. if (!elementText) {
  865. return;
  866. }
  867. // 获取元素标签类型
  868. const tagName = element.tagName.toLowerCase();
  869. let searchText = elementText;
  870. // 如果是标题,添加markdown标记进行搜索
  871. if (tagName.match(/^h[1-6]$/)) {
  872. const level = parseInt(tagName[1]);
  873. const hashes = '#'.repeat(level);
  874. // 尝试匹配标题行
  875. const lines = currentMarkdownContent.split('\n');
  876. let targetLine = -1;
  877. for (let i = 0; i < lines.length; i++) {
  878. const line = lines[i].trim();
  879. // 匹配 "# 标题" 格式
  880. if (line.startsWith(hashes + ' ') && line.substring(level + 1).trim() === elementText) {
  881. targetLine = i;
  882. break;
  883. }
  884. }
  885. if (targetLine !== -1) {
  886. setEditorCursorToLine(targetLine);
  887. return;
  888. }
  889. }
  890. // 对于其他元素,搜索文本内容
  891. const lines = currentMarkdownContent.split('\n');
  892. for (let i = 0; i < lines.length; i++) {
  893. const line = lines[i].trim();
  894. // 移除markdown标记进行比较
  895. const cleanLine = line.replace(/^[#\-*>]+\s*/, '').trim();
  896. if (cleanLine.includes(elementText.substring(0, Math.min(50, elementText.length)))) {
  897. setEditorCursorToLine(i);
  898. return;
  899. }
  900. }
  901. }
  902. // 设置编辑器光标到指定行
  903. function setEditorCursorToLine(lineNumber) {
  904. const lines = currentMarkdownContent.split('\n');
  905. // 计算目标位置的字符索引
  906. let charPosition = 0;
  907. for (let i = 0; i < lineNumber && i < lines.length; i++) {
  908. charPosition += lines[i].length + 1; // +1 for newline
  909. }
  910. // 设置光标位置
  911. DOM.markdownEditor.setSelectionRange(charPosition, charPosition);
  912. // 滚动到光标位置
  913. const linesBeforeCursor = lineNumber;
  914. const lineHeight = 22; // 大约的行高(px)
  915. const targetScrollTop = linesBeforeCursor * lineHeight - DOM.markdownEditor.clientHeight / 3;
  916. DOM.markdownEditor.scrollTop = Math.max(0, targetScrollTop);
  917. }
  918. // 退出编辑模式
  919. function exitEditMode() {
  920. isEditMode = false;
  921. // 更新编辑按钮状态
  922. DOM.editBtn.classList.remove('active');
  923. DOM.editBtn.title = '编辑文档';
  924. // 隐藏编辑器
  925. DOM.editorContainer.style.display = 'none';
  926. // 显示渲染的内容
  927. DOM.content.style.display = 'block';
  928. // 恢复滚动位置
  929. if (!DOM.contentArea) DOM.contentArea = document.getElementById('content-area');
  930. setTimeout(() => {
  931. DOM.contentArea.scrollTop = savedScrollPosition;
  932. }, 0);
  933. }
  934. // 保存文档
  935. async function saveDocument() {
  936. const newContent = DOM.markdownEditor.value;
  937. if (!currentCategory || !currentDoc) {
  938. alert('无法保存:缺少文档信息');
  939. return;
  940. }
  941. // 禁用保存按钮,防止重复点击
  942. if (!DOM.saveBtn) DOM.saveBtn = document.getElementById('save-btn');
  943. DOM.saveBtn.disabled = true;
  944. DOM.saveBtn.textContent = '保存中...';
  945. try {
  946. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(currentDoc)}`, {
  947. method: 'PUT',
  948. headers: {
  949. 'Content-Type': 'application/json'
  950. },
  951. body: JSON.stringify({
  952. content: newContent
  953. })
  954. });
  955. if (!response.ok) {
  956. const errorData = await response.json().catch(() => ({}));
  957. throw new Error(errorData.error || `保存失败 (${response.status})`);
  958. }
  959. const result = await response.json();
  960. // 更新本地的markdown内容
  961. currentMarkdownContent = newContent;
  962. // 重新渲染内容
  963. const html = marked.parse(newContent);
  964. DOM.content.innerHTML = html;
  965. // 重新生成TOC
  966. generateTOC();
  967. // 退出编辑模式
  968. exitEditMode();
  969. // 显示成功提示
  970. showSuccessMessage('保存成功!');
  971. } catch (error) {
  972. console.error('Save error:', error);
  973. alert(`保存失败:${error.message || '请稍后重试'}`);
  974. } finally {
  975. // 恢复保存按钮
  976. DOM.saveBtn.disabled = false;
  977. DOM.saveBtn.textContent = '保存';
  978. }
  979. }
  980. // 显示成功提示
  981. function showSuccessMessage(message) {
  982. const toast = document.createElement('div');
  983. toast.className = 'success-toast';
  984. toast.textContent = message;
  985. toast.style.cssText = `
  986. position: fixed;
  987. top: 20px;
  988. right: 20px;
  989. background: #28a745;
  990. color: white;
  991. padding: 12px 24px;
  992. border-radius: 6px;
  993. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  994. z-index: 10000;
  995. animation: slideInRight 0.3s ease-out;
  996. `;
  997. document.body.appendChild(toast);
  998. setTimeout(() => {
  999. toast.style.animation = 'slideOutRight 0.3s ease-out';
  1000. setTimeout(() => {
  1001. document.body.removeChild(toast);
  1002. }, 300);
  1003. }, 2000);
  1004. }
  1005. // 为代码块添加复制按钮
  1006. function addCopyButtonsToCodeBlocks() {
  1007. const codeBlocks = document.querySelectorAll('pre');
  1008. codeBlocks.forEach(pre => {
  1009. // 如果已经有复制按钮,跳过
  1010. if (pre.querySelector('.code-copy-btn')) {
  1011. return;
  1012. }
  1013. // 创建包装容器
  1014. const wrapper = document.createElement('div');
  1015. wrapper.className = 'code-block-wrapper';
  1016. // 将pre元素包装起来
  1017. pre.parentNode.insertBefore(wrapper, pre);
  1018. wrapper.appendChild(pre);
  1019. // 创建复制按钮
  1020. const copyBtn = document.createElement('button');
  1021. copyBtn.className = 'code-copy-btn';
  1022. copyBtn.innerHTML = `
  1023. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1024. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  1025. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  1026. </svg>
  1027. <span>复制</span>
  1028. `;
  1029. // 添加点击事件
  1030. copyBtn.addEventListener('click', () => {
  1031. copyCodeToClipboard(pre, copyBtn);
  1032. });
  1033. wrapper.appendChild(copyBtn);
  1034. });
  1035. }
  1036. // 复制代码到剪贴板
  1037. async function copyCodeToClipboard(preElement, button) {
  1038. const codeElement = preElement.querySelector('code');
  1039. if (!codeElement) return;
  1040. // 获取纯文本内容(去除HTML标签)
  1041. const text = codeElement.textContent || codeElement.innerText;
  1042. try {
  1043. // 使用现代API复制
  1044. await navigator.clipboard.writeText(text);
  1045. // 显示成功状态
  1046. showCopySuccess(button);
  1047. } catch (err) {
  1048. // 降级方案:使用传统方法
  1049. const textArea = document.createElement('textarea');
  1050. textArea.value = text;
  1051. textArea.style.position = 'fixed';
  1052. textArea.style.left = '-9999px';
  1053. document.body.appendChild(textArea);
  1054. textArea.select();
  1055. try {
  1056. document.execCommand('copy');
  1057. showCopySuccess(button);
  1058. } catch (err) {
  1059. console.error('复制失败:', err);
  1060. showCopyError(button);
  1061. } finally {
  1062. document.body.removeChild(textArea);
  1063. }
  1064. }
  1065. }
  1066. // 显示复制成功
  1067. function showCopySuccess(button) {
  1068. // 更新按钮状态
  1069. const originalHTML = button.innerHTML;
  1070. button.classList.add('copied');
  1071. button.innerHTML = `
  1072. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1073. <polyline points="20 6 9 17 4 12"></polyline>
  1074. </svg>
  1075. <span>已复制</span>
  1076. `;
  1077. // 2秒后恢复原状
  1078. setTimeout(() => {
  1079. button.classList.remove('copied');
  1080. button.innerHTML = originalHTML;
  1081. }, 2000);
  1082. }
  1083. // 显示复制失败
  1084. function showCopyError(button) {
  1085. const originalHTML = button.innerHTML;
  1086. button.innerHTML = `
  1087. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1088. <circle cx="12" cy="12" r="10"></circle>
  1089. <line x1="12" y1="8" x2="12" y2="12"></line>
  1090. <line x1="12" y1="16" x2="12.01" y2="16"></line>
  1091. </svg>
  1092. <span>复制失败</span>
  1093. `;
  1094. setTimeout(() => {
  1095. button.innerHTML = originalHTML;
  1096. }, 2000);
  1097. }
  1098. // 初始化
  1099. document.addEventListener('DOMContentLoaded', () => {
  1100. loadDocList();
  1101. setupSidebarToggles();
  1102. initSearch();
  1103. setupBackToTop();
  1104. setupEditFeature();
  1105. });
  1106. // 处理浏览器后退/前进
  1107. window.addEventListener('popstate', () => {
  1108. const { doc } = getQueryParams();
  1109. if (doc) {
  1110. loadDocument(doc);
  1111. }
  1112. });