reader.js 44 KB

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