Greasy Fork is available in English.

CSDN 专栏优化脚本 📚

通过在 CSDN 专栏页面添加一个侧边栏菜单,列出当前专栏的所有文章,提升阅读体验 🌟

  1. // ==UserScript==
  2. // @name CSDN 专栏优化脚本 📚
  3. // @description 通过在 CSDN 专栏页面添加一个侧边栏菜单,列出当前专栏的所有文章,提升阅读体验 🌟
  4. // @version 1.4.1
  5. // @author Silence
  6. // @match *://blog.csdn.net/*/article/*
  7. // @match *://*.blog.csdn.net/article/*
  8. // @grant GM_addStyle
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @run-at document-start
  12. // @license MIT
  13. // @namespace https://greasyfork.org/users/1394594
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. 'use strict';
  18.  
  19. const CONFIG = {
  20. cleanParams: GM_getValue('cleanParams', true)
  21. };
  22.  
  23. // 在脚本开始时立即执行清理
  24. cleanAnalyticsParams();
  25.  
  26. // 监听URL变化,处理动态加载的情况
  27. window.addEventListener('popstate', cleanAnalyticsParams);
  28. window.addEventListener('pushState', cleanAnalyticsParams);
  29. window.addEventListener('replaceState', cleanAnalyticsParams);
  30.  
  31. const $ = (Selector, el) => (el || document).querySelector(Selector);
  32. const $$ = (Selector, el) => (el || document).querySelectorAll(Selector);
  33.  
  34. window.onload = function () {
  35. console.log('CSDN 专栏优化脚本开始加载');
  36. initSidebar();
  37. };
  38.  
  39. // 添加缓存相关的常量
  40. const CACHE_KEY_PREFIX = 'csdn_column_';
  41. const CACHE_EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时过期
  42.  
  43. // 缓存操作工具函数
  44. const CacheUtil = {
  45. /**
  46. * 获取缓存数据
  47. * @param {string} key - 缓存键名
  48. * @returns {any|null} - 缓存数据或null
  49. */
  50. get(key) {
  51. const data = localStorage.getItem(CACHE_KEY_PREFIX + key);
  52. if (!data) return null;
  53. try {
  54. const { value, timestamp } = JSON.parse(data);
  55. // 检查是否过期
  56. if (Date.now() - timestamp > CACHE_EXPIRE_TIME) {
  57. this.remove(key);
  58. return null;
  59. }
  60. return value;
  61. } catch (e) {
  62. return null;
  63. }
  64. },
  65.  
  66. /**
  67. * 设置缓存数据
  68. * @param {string} key - 缓存键名
  69. * @param {any} value - 缓存数据
  70. */
  71. set(key, value) {
  72. const data = {
  73. value,
  74. timestamp: Date.now()
  75. };
  76. localStorage.setItem(CACHE_KEY_PREFIX + key, JSON.stringify(data));
  77. },
  78.  
  79. /**
  80. * 删除缓存数据
  81. * @param {string} key - 缓存键名
  82. */
  83. remove(key) {
  84. localStorage.removeItem(CACHE_KEY_PREFIX + key);
  85. }
  86. };
  87.  
  88. /**
  89. * 获取专栏文章列表(带缓存和懒加载)
  90. * @param {string} columnId - 专栏ID
  91. * @param {string} blogUsername - 博客用户名
  92. * @param {number} articleCount - 文章总数
  93. * @returns {Promise<Array>} - 文章列表
  94. */
  95. async function getColumnArticles(columnId, blogUsername, articleCount) {
  96. // 尝试从缓存获取
  97. const cacheKey = `${columnId}_${blogUsername}`;
  98. const cachedData = CacheUtil.get(cacheKey);
  99. if (cachedData) {
  100. console.log('从缓存获取专栏文章');
  101. // 确保缓存的数据也是排序的
  102. return sortArticles(cachedData);
  103. }
  104. const pageSize = 100; // 每页最大100条
  105. const totalPages = Math.ceil(articleCount / pageSize);
  106. let allArticles = [];
  107. try {
  108. // 懒加载:先只加载第一页
  109. const firstPageData = await fetchArticlePage(columnId, blogUsername, 1, pageSize);
  110. allArticles = firstPageData;
  111. // 如果有更多页,异步加载其余页面
  112. if (totalPages > 1) {
  113. loadRemainingPages(columnId, blogUsername, totalPages, pageSize).then(articles => {
  114. allArticles = allArticles.concat(articles);
  115. // 排序后再缓存
  116. const sortedArticles = sortArticles(allArticles);
  117. CacheUtil.set(cacheKey, sortedArticles);
  118. // 触发更新UI
  119. updateArticleList(sortedArticles);
  120. });
  121. } else {
  122. // 只有一页时直接排序并缓存
  123. const sortedArticles = sortArticles(allArticles);
  124. CacheUtil.set(cacheKey, sortedArticles);
  125. allArticles = sortedArticles;
  126. }
  127. } catch (error) {
  128. console.error('获取专栏文章失败:', error);
  129. }
  130. // 返回排序后的结果
  131. return sortArticles(allArticles);
  132. }
  133. /**
  134. * 按文章ID排序
  135. * @param {Array} articles - 文章列表
  136. * @returns {Array} - 排序后的文章列表
  137. */
  138. function sortArticles(articles) {
  139. return articles.sort((a, b) => {
  140. const aId = parseInt(a.url.split('/').pop());
  141. const bId = parseInt(b.url.split('/').pop());
  142. return aId - bId;
  143. });
  144. }
  145.  
  146. /**
  147. * 获取单页文章数据
  148. * @param {string} columnId 专栏ID
  149. * @param {string} blogUsername 博客用户名
  150. * @param {number} page 页码
  151. * @param {number} pageSize 每页文章数量
  152. * @return {Promise<Array<{url: string, title: string}>>} 文章列表
  153. */
  154. async function fetchArticlePage(columnId, blogUsername, page, pageSize) {
  155. try {
  156. const response = await fetch(
  157. `https://blog.csdn.net/phoenix/web/v1/column/article/list?columnId=${columnId}&blogUsername=${blogUsername}&page=${page}&pageSize=${pageSize}`
  158. );
  159. const data = await response.json();
  160. if (data.code === 200) {
  161. return data.data.map(article => ({
  162. url: article.url,
  163. title: article.title
  164. }));
  165. }
  166. throw new Error(`获取专栏文章失败: ${data.message}`);
  167. } catch (error) {
  168. console.error(`获取第${page}页文章失败:`, error);
  169. return [];
  170. }
  171. }
  172.  
  173. /**
  174. * 异步加载剩余页面
  175. * @param {string} columnId 专栏ID
  176. * @param {string} blogUsername 博客用户名
  177. * @param {number} totalPages 总页数
  178. * @param {number} pageSize 每页文章数量
  179. * @return {Promise<Array<{url: string, title: string}>>} 文章列表
  180. */
  181. async function loadRemainingPages(columnId, blogUsername, totalPages, pageSize) {
  182. const remainingPages = Array.from(
  183. { length: totalPages - 1 },
  184. (_, i) => fetchArticlePage(columnId, blogUsername, i + 2, pageSize)
  185. );
  186. try {
  187. const results = await Promise.all(remainingPages);
  188. return results.flat();
  189. } catch (error) {
  190. console.error('加载剩余页面失败:', error);
  191. return [];
  192. }
  193. }
  194.  
  195. /**
  196. * 更新文章列表UI
  197. * @param {Object} articles 文章信息
  198. */
  199. function updateArticleList(articles) {
  200. const menu = document.querySelector('.column-menu');
  201. if (!menu) return;
  202. const currentColumnIndex = menu.querySelector('.column-selector').value;
  203. showColumnArticles({
  204. columnTitle: menu.querySelector('option:checked').textContent,
  205. articles
  206. }, menu);
  207. }
  208.  
  209. /**
  210. * 获取专栏信息
  211. * @returns {Promise<{blogUsername: string, columnId: string}>} 专栏信息
  212. */
  213. function getColumnInfo() {
  214. const columnInfoListDom = $$('#blogColumnPayAdvert .column-group-item');
  215. const promises = Array.from(columnInfoListDom).map(async element => {
  216. const columnUrl = element.querySelector('.item-target').href;
  217. const columnTitle = element.querySelector('.item-target').title;
  218. const columnInfo = element.querySelector('.item-m').querySelectorAll('span');
  219. let articleCount = 0;
  220. columnInfo.forEach(info => {
  221. if (info.innerText.includes('篇文章')) {
  222. articleCount = info.innerText.replace(' 篇文章', '');
  223. }
  224. })
  225. console.log('文章数量: ', articleCount);
  226. // 从columnUrl获取blogUserName和columnId
  227. const urlInfo = parseColumnUrl(columnUrl);
  228. if (!urlInfo) {
  229. console.error('无法解析专栏 URL:', columnUrl);
  230. return null;
  231. }
  232.  
  233. const { blogUsername, columnId } = urlInfo;
  234. console.log('解析结果:', { blogUsername, columnId });
  235. // 访问专栏地址,获取专栏所有文章列表
  236. try {
  237. const articles = await getColumnArticles(columnId, blogUsername, articleCount);
  238.  
  239. return { columnTitle, articles };
  240. } catch (error) {
  241. console.error('Error fetching column articles:', error);
  242. return null;
  243. }
  244. });
  245. return Promise.all(promises).then(results => {
  246. return results.filter(column => column !== null); // 过滤掉null值
  247. });
  248. }
  249.  
  250. /**
  251. * 解析专栏 URL 获取用户名和专栏 ID
  252. * @param {string} url 专栏地址
  253. * @returns {Promise<{blogUsername: string, columnId: string}>} 专栏信息
  254. */
  255. function parseColumnUrl(url) {
  256. // 使用正则表达式匹配 URL 中的用户名和专栏 ID
  257. const regex = /blog\.csdn\.net\/([^\/]+)\/category_(\d+)\.html/;
  258. const match = url.match(regex);
  259. if (match) {
  260. return {
  261. blogUsername: match[1], // 第一个捕获组是用户名
  262. columnId: match[2] // 第二个捕获组是专栏 ID
  263. };
  264. }
  265. return null;
  266. }
  267.  
  268. /**
  269. * 构建专栏目录菜单
  270. * @param {Object} columnInfo 专栏目录信息
  271. * @returns {Object} 菜单元素
  272. */
  273. function buildMenu(columnInfo) {
  274. const currentUrl = window.location.href;
  275. const menu = document.createElement('div');
  276. menu.classList.add('column-menu');
  277. // 添加专栏选择器
  278. const columnSelector = document.createElement('select');
  279. columnSelector.classList.add('column-selector');
  280. // 找到当前文章所在的专栏
  281. let currentColumnIndex = 0;
  282. columnInfo.forEach((column, index) => {
  283. const option = document.createElement('option');
  284. option.value = index;
  285. option.textContent = column.columnTitle;
  286. columnSelector.appendChild(option);
  287. // 检查当前文章是否在这个专栏中
  288. if (column.articles.some(article => article.url.split('/').pop().split('?')[0] === currentUrl.split('/').pop().split('?')[0])) {
  289. currentColumnIndex = index;
  290. }
  291. });
  292.  
  293. // 设置当前专栏为默认选中
  294. columnSelector.value = currentColumnIndex;
  295.  
  296. // 添加切换事件
  297. columnSelector.addEventListener('change', (e) => {
  298. const selectedIndex = e.target.value;
  299. showColumnArticles(columnInfo[selectedIndex], menu);
  300. });
  301.  
  302. menu.appendChild(columnSelector);
  303.  
  304. // 显示当前专栏的文章
  305. showColumnArticles(columnInfo[currentColumnIndex], menu);
  306. return menu;
  307. }
  308.  
  309. /**
  310. * 展示专栏文章列表
  311. * @param {Object} column 专栏信息
  312. * @param {Object} menu 菜单元素
  313. */
  314. function showColumnArticles(column, menu) {
  315. const currentUrl = window.location.href;
  316. // 移除现有的文章列表
  317. const existingList = menu.querySelector('.article-list');
  318. if (existingList) {
  319. existingList.remove();
  320. }
  321.  
  322. const articleList = document.createElement('ul');
  323. articleList.classList.add('article-list');
  324.  
  325. let activeArticleElement = null;
  326.  
  327. column.articles.forEach(article => {
  328. const articleItem = document.createElement('li');
  329. const articleLink = document.createElement('a');
  330. articleLink.href = article.url;
  331. articleLink.textContent = article.title;
  332. if (article.url.split('/').pop().split('?')[0] === currentUrl.split('/').pop().split('?')[0]) {
  333. articleItem.classList.add('column-active');
  334. activeArticleElement = articleItem;
  335. }
  336. articleItem.appendChild(articleLink);
  337. articleList.appendChild(articleItem);
  338. });
  339.  
  340. menu.appendChild(articleList);
  341.  
  342. // 滚动到当前文章
  343. if (activeArticleElement) {
  344. // 等待 DOM 更新完成后再滚动
  345. setTimeout(() => {
  346. activeArticleElement.scrollIntoView({
  347. behavior: 'smooth',
  348. block: 'center'
  349. });
  350. }, 100);
  351. }
  352. }
  353.  
  354. /**
  355. * 初始化侧边栏
  356. */
  357. async function initSidebar() {
  358. try {
  359. // 获取专栏信息
  360. const columnInfo = await getColumnInfo();
  361. const article = document.querySelector('.blog-content-box');
  362. const headers = article?.querySelectorAll('h1, h2, h3, h4, h5, h6');
  363. // 如果既没有专栏信息也没有文章目录,则不添加侧边栏
  364. if ((!columnInfo || columnInfo.length === 0) && (!headers || headers.length === 0)) {
  365. return;
  366. }
  367.  
  368. const sidebar = document.createElement('div');
  369. sidebar.id = 'custom-sidebar';
  370. sidebar.classList.add('column-menu-sidebar');
  371.  
  372. if (columnInfo && columnInfo.length > 0) {
  373. // 有专栏信息时显示专栏目录
  374. const menu = buildMenu(columnInfo);
  375. addMenuToSidebar(menu, false, true);
  376. } else {
  377. // 没有专栏信息但有文章目录时直接显示文章目录
  378. addMenuToSidebar(null, true, false);
  379. }
  380. } catch (error) {
  381. console.error('初始化侧边栏失败:', error);
  382. }
  383. }
  384.  
  385. /**
  386. * 添加侧边栏到页面
  387. * @param {HTMLElement} menu - 菜单元素
  388. * @param {boolean} showTocDirectly - 是否直接显示文章目录
  389. * @param {boolean} hasColumnMenu - 是否有专栏目录
  390. */
  391. function addMenuToSidebar(menu, showTocDirectly = false, hasColumnMenu = true) {
  392. const sidebar = document.createElement('div');
  393. sidebar.id = 'custom-sidebar';
  394. sidebar.classList.add('column-menu-sidebar');
  395. // 添加标题栏
  396. const titleBar = document.createElement('div');
  397. titleBar.classList.add('sidebar-title');
  398. // 添加标题文本容器
  399. const titleContent = document.createElement('div');
  400. titleContent.classList.add('title-content');
  401. titleContent.textContent = showTocDirectly ? '文章目录' : '专栏文章';
  402. // 添加按钮容器
  403. const buttonContainer = document.createElement('div');
  404. buttonContainer.classList.add('title-buttons');
  405.  
  406. // 添加目录切换按钮
  407. if (hasColumnMenu) {
  408. const toggleTocBtn = document.createElement('button');
  409. toggleTocBtn.classList.add('sidebar-btn', 'toggle-toc-btn');
  410. toggleTocBtn.innerHTML = showTocDirectly ? '&#x1F4DA;' : '&#x1F4D1;';
  411. toggleTocBtn.title = '切换文章目录';
  412. toggleTocBtn.onclick = () => toggleTocMode(showTocDirectly);
  413. buttonContainer.appendChild(toggleTocBtn);
  414. }
  415. // 添加定位按钮(仅在专栏模式下显示)
  416. if (!showTocDirectly && hasColumnMenu) {
  417. const locateBtn = document.createElement('button');
  418. locateBtn.classList.add('sidebar-btn', 'locate-btn');
  419. locateBtn.innerHTML = '&#x1F50D;';
  420. locateBtn.title = '定位当前文章';
  421. locateBtn.onclick = () => {
  422. const activeArticle = sidebar.querySelector('.column-active');
  423. if (activeArticle) {
  424. activeArticle.scrollIntoView({
  425. behavior: 'smooth',
  426. block: 'center'
  427. });
  428. }
  429. };
  430. buttonContainer.appendChild(locateBtn);
  431. }
  432. // 添加配置按钮
  433. const configBtn = document.createElement('button');
  434. configBtn.classList.add('sidebar-btn', 'config-btn');
  435. configBtn.innerHTML = '&#x2699';
  436. configBtn.title = '设置';
  437. configBtn.onclick = showConfig;
  438. buttonContainer.appendChild(configBtn);
  439.  
  440. // 添加收起按钮
  441. const collapseBtn = document.createElement('button');
  442. collapseBtn.classList.add('sidebar-btn', 'collapse-btn');
  443. collapseBtn.innerHTML = '&times;';
  444. collapseBtn.title = '收起侧边栏';
  445. collapseBtn.onclick = () => toggleSidebar(false);
  446. buttonContainer.appendChild(collapseBtn);
  447. // 组装标题栏
  448. titleBar.appendChild(titleContent);
  449. titleBar.appendChild(buttonContainer);
  450. sidebar.appendChild(titleBar);
  451.  
  452. // 添加返回顶部按钮
  453. const backToTopBtn = document.createElement('button');
  454. backToTopBtn.classList.add('back-to-top');
  455. backToTopBtn.title = '返回顶部';
  456. // 监听滚动事件
  457. sidebar.addEventListener('scroll', () => {
  458. if (sidebar.scrollTop > 300) {
  459. backToTopBtn.style.display = 'flex';
  460. } else {
  461. backToTopBtn.style.display = 'none';
  462. }
  463. });
  464. backToTopBtn.onclick = () => {
  465. sidebar.scrollTo({
  466. top: 0,
  467. behavior: 'smooth'
  468. });
  469. };
  470. sidebar.appendChild(backToTopBtn);
  471. if (menu && !showTocDirectly) {
  472. sidebar.appendChild(menu);
  473. }
  474. // 插入侧边栏到页面
  475. const blogContentBox = document.querySelector('.blog-content-box');
  476. if (blogContentBox) {
  477. blogContentBox.insertAdjacentElement('beforeBegin', sidebar);
  478. } else {
  479. document.body.insertBefore(sidebar, document.body.firstChild);
  480. }
  481. adjustMainContentStyle(true);
  482. // 如果需要直接显示文章目录
  483. if (showTocDirectly) {
  484. generateToc(sidebar);
  485. }
  486. }
  487.  
  488. /**
  489. * 切换侧边栏的显示状态
  490. * @param {boolean} show 是否显示
  491. */
  492. function toggleSidebar(show) {
  493. const sidebar = document.querySelector('#custom-sidebar');
  494. let expandBtn = document.querySelector('#sidebar-expand-btn');
  495. if (show) {
  496. sidebar.style.transform = 'translateX(0)';
  497. if (expandBtn) {
  498. expandBtn.style.display = 'none';
  499. }
  500. adjustMainContentStyle(true);
  501. } else {
  502. sidebar.style.transform = 'translateX(-250px)';
  503. // 如果展开按钮不存在,则创建
  504. if (!expandBtn) {
  505. expandBtn = document.createElement('div');
  506. expandBtn.id = 'sidebar-expand-btn';
  507. expandBtn.onclick = () => toggleSidebar(true);
  508. document.body.appendChild(expandBtn);
  509. }
  510. expandBtn.style.display = 'block';
  511. adjustMainContentStyle(false);
  512. }
  513. }
  514.  
  515. /**
  516. * 切换目录模式
  517. * @param {boolean} [isInTocMode=false] - 当前是否在目录模式
  518. */
  519. function toggleTocMode(isInTocMode = false) {
  520. const sidebar = document.querySelector('#custom-sidebar');
  521. const menu = sidebar.querySelector('.column-menu');
  522. const existingToc = sidebar.querySelector('.article-toc');
  523. const titleContent = sidebar.querySelector('.title-content');
  524. const toggleBtn = sidebar.querySelector('.toggle-toc-btn');
  525.  
  526. if (existingToc) {
  527. // 切换回专栏模式
  528. existingToc.remove(); // 移除而不是隐藏
  529. if (menu) menu.style.display = 'block';
  530. titleContent.textContent = '专栏文章';
  531. toggleBtn.innerHTML = '&#x1F4D1;';
  532. } else {
  533. // 切换到目录模式
  534. if (menu) menu.style.display = 'none';
  535. titleContent.textContent = '文章目录';
  536. toggleBtn.innerHTML = '&#x1F4DA;';
  537. generateToc(sidebar);
  538. }
  539. }
  540.  
  541. /**
  542. * 显示配置面板
  543. */
  544. function showConfig() {
  545. const configPanel = document.createElement('div');
  546. configPanel.classList.add('config-panel');
  547.  
  548. const cleanParamsOption = document.createElement('label');
  549. cleanParamsOption.innerHTML = `
  550. <input type="checkbox" ${CONFIG.cleanParams ? 'checked' : ''}>
  551. 去除链接中的分析参数
  552. `;
  553.  
  554. cleanParamsOption.querySelector('input').onchange = (e) => {
  555. CONFIG.cleanParams = e.target.checked;
  556. GM_setValue('cleanParams', CONFIG.cleanParams);
  557. };
  558.  
  559. configPanel.appendChild(cleanParamsOption);
  560.  
  561. // 添加关闭按钮
  562. const closeBtn = document.createElement('button');
  563. closeBtn.textContent = '关闭';
  564. closeBtn.onclick = () => {
  565. configPanel.remove();
  566. };
  567. configPanel.appendChild(closeBtn);
  568.  
  569. document.body.appendChild(configPanel);
  570. }
  571.  
  572. /**
  573. * 去除链接中的分析参数
  574. */
  575. function cleanAnalyticsParams() {
  576. if (!CONFIG.cleanParams) return ;
  577. const paramsToRemove = ['spm', 'utm_source', 'utm_medium', 'utm_campaign', 'depth_1-utm_source', 'depth_1-utm_medium', 'depth_1-utm_campaign'];
  578. const url = new URL(window.location.href);
  579. let changed = false;
  580. paramsToRemove.forEach(param => {
  581. if (url.searchParams.has(param)) {
  582. url.searchParams.delete(param);
  583. changed = true;
  584. }
  585. });
  586. if (changed) {
  587. window.history.replaceState({}, '', url.toString());
  588. }
  589. }
  590.  
  591. /**
  592. * 生成文章目录
  593. * @param {HTMLElement} sidebar - 侧边栏元素
  594. */
  595. function generateToc(sidebar) {
  596. const article = document.querySelector('.blog-content-box');
  597. if (!article) return;
  598.  
  599. const toc = document.createElement('div');
  600. toc.classList.add('article-toc');
  601.  
  602. // 获取所有标题
  603. const headers = article.querySelectorAll('h1, h2, h3, h4, h5, h6');
  604. const tocList = document.createElement('ul');
  605. tocList.classList.add('toc-list');
  606.  
  607. // 创建目录树结构
  608. const headerTree = buildHeaderTree(headers);
  609. renderHeaderTree(headerTree, tocList);
  610.  
  611. toc.appendChild(tocList);
  612. sidebar.appendChild(toc);
  613.  
  614. // 添加目录滚动监听
  615. addTocScrollSpy(headers, tocList);
  616. }
  617.  
  618. /**
  619. * 构建标题树结构
  620. * @param {NodeList} headers - 标题元素列表
  621. * @returns {Array} 标题树结构
  622. */
  623. function buildHeaderTree(headers) {
  624. const tree = [];
  625. const stack = [{ level: 0, children: tree }];
  626.  
  627. headers.forEach((header, index) => {
  628. const level = parseInt(header.tagName.charAt(1));
  629. const node = {
  630. id: header.id || `toc-heading-${index}`,
  631. title: header.textContent,
  632. level,
  633. children: []
  634. };
  635.  
  636. if (!header.id) {
  637. header.id = node.id;
  638. }
  639.  
  640. while (stack[stack.length - 1].level >= level) {
  641. stack.pop();
  642. }
  643.  
  644. stack[stack.length - 1].children.push(node);
  645. stack.push({ level, children: node.children });
  646. });
  647.  
  648. return tree;
  649. }
  650.  
  651. /**
  652. * 渲染标题树
  653. * @param {Array} tree - 标题树结构
  654. * @param {HTMLElement} parent - 父容器元素
  655. */
  656. function renderHeaderTree(tree, parent) {
  657. tree.forEach(node => {
  658. const item = document.createElement('li');
  659. item.classList.add(`toc-level-${node.level}`);
  660. const titleContainer = document.createElement('div');
  661. titleContainer.classList.add('toc-title-container');
  662. // 只有当有子节点时才添加展开/折叠按钮
  663. if (node.children.length > 0) {
  664. const toggleBtn = document.createElement('span');
  665. toggleBtn.classList.add('toc-toggle');
  666. toggleBtn.innerHTML = '▼';
  667. toggleBtn.onclick = (e) => {
  668. e.preventDefault();
  669. e.stopPropagation();
  670. const subList = item.querySelector('ul');
  671. if (subList) {
  672. const isExpanded = subList.style.display !== 'none';
  673. subList.style.display = isExpanded ? 'none' : 'block';
  674. toggleBtn.innerHTML = isExpanded ? '▶' : '▼';
  675. }
  676. };
  677. titleContainer.appendChild(toggleBtn);
  678. } else {
  679. // 添加一个空的占位符,保持对齐
  680. const spacer = document.createElement('span');
  681. spacer.classList.add('toc-toggle-spacer');
  682. titleContainer.appendChild(spacer);
  683. }
  684. const link = document.createElement('a');
  685. link.href = `#${node.id}`;
  686. link.textContent = node.title;
  687. link.onclick = (e) => {
  688. e.preventDefault();
  689. document.getElementById(node.id).scrollIntoView({ behavior: 'smooth' });
  690. };
  691. titleContainer.appendChild(link);
  692. item.appendChild(titleContainer);
  693. if (node.children.length > 0) {
  694. const subList = document.createElement('ul');
  695. renderHeaderTree(node.children, subList);
  696. item.appendChild(subList);
  697. }
  698. parent.appendChild(item);
  699. });
  700. }
  701.  
  702. /**
  703. * 添加目录滚动监听
  704. * @param {HTMLElement} headers
  705. * @param {HTMLElement} tocList
  706. */
  707. function addTocScrollSpy(headers, tocList) {
  708. const tocLinks = tocList.querySelectorAll('a');
  709. const observer = new IntersectionObserver((entries) => {
  710. entries.forEach(entry => {
  711. const id = entry.target.id;
  712. const tocLink = tocList.querySelector(`a[href="#${id}"]`);
  713. if (entry.isIntersecting) {
  714. tocLinks.forEach(link => link.classList.remove('toc-active'));
  715. tocLink?.classList.add('toc-active');
  716. }
  717. });
  718. }, {
  719. rootMargin: '-20% 0px -80% 0px'
  720. });
  721. headers.forEach(header => observer.observe(header));
  722. }
  723.  
  724. /**
  725. * 调整主内容区域的样式
  726. * @param {boolean} isExpanded 是否展开
  727. */
  728. function adjustMainContentStyle(isExpanded) {
  729. const mainContent = document.querySelector('.blog-content-box');
  730. if (mainContent) {
  731. const margin = isExpanded ? '250px' : '0';
  732. mainContent.style.marginLeft = margin;
  733. mainContent.style.marginRight = '0';
  734. // 调整顶部工具栏
  735. const topToolbarBox = document.querySelector('#toolbarBox');
  736. if (topToolbarBox) {
  737. topToolbarBox.style.marginLeft = margin;
  738. }
  739. // 调整底部工具栏
  740. const bottomToolbox = document.querySelector('#toolBarBox');
  741. if (bottomToolbox) {
  742. bottomToolbox.style.marginLeft = margin;
  743. }
  744. // 调整评论区
  745. const footer = document.querySelector('#pcCommentBox');
  746. if (footer) {
  747. footer.style.marginLeft = margin;
  748. }
  749. }
  750. }
  751.  
  752. /**
  753. * 添加点击事件到菜单
  754. * @param {Object} menu 菜单对象
  755. */
  756. function addClickEventToMenu(menu) {
  757. const articleLinks = menu.querySelectorAll('.article-list a');
  758. articleLinks.forEach(link => {
  759. link.addEventListener('click', event => {
  760. event.preventDefault();
  761. const targetUrl = link.getAttribute('href');
  762. // 直接在当前页面重新加载
  763. window.location.href = targetUrl;
  764. });
  765. });
  766. }
  767.  
  768. // 更新样式
  769. const customStyle = `
  770. /* 侧边栏基础样式 */
  771. #custom-sidebar {
  772. all: unset;
  773. position: fixed;
  774. left: 0;
  775. top: 0;
  776. width: 250px;
  777. height: 100vh;
  778. background-color: #fff;
  779. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  780. z-index: 999;
  781. overflow-x: auto;
  782. overflow-y: auto;
  783. padding: 0;
  784. border-right: 1px solid #eee;
  785. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
  786. transition: transform 0.3s ease;
  787. }
  788.  
  789. /* 标题栏样式组 */
  790. .sidebar-title {
  791. display: flex;
  792. justify-content: space-between;
  793. align-items: center;
  794. padding: 12px 15px;
  795. font-size: 16px;
  796. font-weight: bold;
  797. border-bottom: 1px solid #eee;
  798. background-color: #f8f9fa;
  799. }
  800.  
  801. .title-buttons {
  802. display: flex;
  803. gap: 8px;
  804. align-items: center;
  805. }
  806.  
  807. .title-content {
  808. flex: 1;
  809. }
  810.  
  811. /* 按钮通用样式 */
  812. .sidebar-btn {
  813. background: none;
  814. border: none;
  815. color: #666;
  816. font-size: 16px;
  817. cursor: pointer;
  818. padding: 4px;
  819. border-radius: 4px;
  820. transition: all 0.2s ease;
  821. display: flex;
  822. align-items: center;
  823. justify-content: center;
  824. width: 28px;
  825. height: 28px;
  826. }
  827.  
  828. .sidebar-btn:hover {
  829. background-color: rgba(0, 0, 0, 0.05);
  830. color: #1890ff;
  831. }
  832.  
  833. .sidebar-btn:active {
  834. transform: scale(0.95);
  835. }
  836.  
  837. /* 特定按钮样式 */
  838. .locate-btn { font-size: 14px; }
  839. .collapse-btn { font-size: 18px; }
  840. .toggle-toc-btn { font-size: 16px; }
  841.  
  842. /* 展开按钮样式 */
  843. #sidebar-expand-btn {
  844. position: fixed;
  845. left: 0;
  846. top: 50%;
  847. transform: translateY(-50%);
  848. width: 20px;
  849. height: 50px;
  850. background-color: #fff;
  851. box-shadow: 2px 0 4px rgba(0,0,0,0.1);
  852. cursor: pointer;
  853. z-index: 999;
  854. border-radius: 0 4px 4px 0;
  855. display: flex;
  856. align-items: center;
  857. justify-content: center;
  858. transition: background-color 0.2s;
  859. text-align: center;
  860. line-height: 50px;
  861. }
  862.  
  863. #sidebar-expand-btn:hover {
  864. background-color: #f0f0f0;
  865. }
  866.  
  867. #sidebar-expand-btn::after {
  868. content: '›';
  869. font-size: 20px;
  870. color: #666;
  871. position: absolute;
  872. left: 50%;
  873. top: 50%;
  874. transform: translate(-50%, -50%);
  875. line-height: 1;
  876. }
  877.  
  878. /* 专栏选择器样式 */
  879. .column-selector {
  880. width: 90%;
  881. margin: 10px auto;
  882. display: block;
  883. padding: 8px;
  884. border: 1px solid #ddd;
  885. border-radius: 4px;
  886. font-size: 14px;
  887. background-color: #fff;
  888. }
  889.  
  890. .column-selector:hover {
  891. border-color: #40a9ff;
  892. }
  893.  
  894. .column-selector:focus {
  895. outline: none;
  896. border-color: #1890ff;
  897. box-shadow: 0 0 0 2px rgba(24,144,255,0.2);
  898. }
  899.  
  900. /* 文章列表样式 */
  901. .article-list {
  902. list-style: none;
  903. padding: 0;
  904. margin: 0;
  905. background-color: #fff;
  906. }
  907.  
  908. .article-list li {
  909. padding: 0;
  910. border-bottom: 1px solid #f0f0f0;
  911. background-color: #fff;
  912. }
  913.  
  914. .article-list li a {
  915. display: block;
  916. padding: 12px 15px;
  917. color: #000;
  918. text-decoration: none;
  919. font-size: 14px;
  920. line-height: 1.5;
  921. transition: all 0.2s;
  922. background-color: #fff;
  923. white-space: normal;
  924. word-break: break-all;
  925. }
  926.  
  927. .article-list li:hover {
  928. background-color: #f8f9fa;
  929. }
  930.  
  931. .article-list li:hover a {
  932. color: #1890ff;
  933. }
  934.  
  935. .column-active {
  936. background-color: #e6f7ff;
  937. }
  938.  
  939. .column-active a {
  940. color: #1890ff !important;
  941. font-weight: 500;
  942. }
  943.  
  944. /* 目录树样式 */
  945. .article-toc {
  946. padding: 10px 0;
  947. overflow-y: auto;
  948. height: calc(100vh - 50px);
  949. }
  950.  
  951. .toc-list, .toc-list ul {
  952. list-style: none;
  953. padding: 0;
  954. margin: 0;
  955. }
  956.  
  957. .toc-list > li {
  958. padding-left: 0;
  959. }
  960.  
  961. .toc-list ul > li {
  962. padding-left: 20px;
  963. background-color: #fff !important;
  964. }
  965.  
  966. .toc-title-container {
  967. display: flex;
  968. align-items: center;
  969. padding: 8px 15px;
  970. cursor: pointer;
  971. transition: all 0.2s;
  972. background-color: #fff !important;
  973. }
  974.  
  975. .toc-title-container:hover {
  976. background-color: #f8f9fa;
  977. }
  978.  
  979. .toc-toggle, .toc-toggle-spacer {
  980. width: 20px;
  981. height: 20px;
  982. line-height: 20px;
  983. text-align: center;
  984. margin-right: 5px;
  985. }
  986.  
  987. .toc-toggle:hover {
  988. color: #1890ff;
  989. }
  990.  
  991. /* 目录链接样式 */
  992. .toc-list a {
  993. flex: 1;
  994. color: #333;
  995. text-decoration: none;
  996. font-size: 14px;
  997. line-height: 1.5;
  998. transition: all 0.2s;
  999. display: block;
  1000. overflow: hidden;
  1001. text-overflow: ellipsis;
  1002. white-space: normal;
  1003. word-break: break-all;
  1004. }
  1005.  
  1006. .toc-list a:hover {
  1007. color: #1890ff;
  1008. }
  1009.  
  1010. /* 目录级别样式 */
  1011. .toc-level-1 > .toc-title-container { font-size: 16px; font-weight: 500; }
  1012. .toc-level-2 > .toc-title-container { font-size: 15px; }
  1013. .toc-level-3 > .toc-title-container { font-size: 14px; }
  1014. .toc-level-4 > .toc-title-container,
  1015. .toc-level-5 > .toc-title-container,
  1016. .toc-level-6 > .toc-title-container { font-size: 13px; }
  1017.  
  1018. /* 激活状态样式 */
  1019. .toc-active {
  1020. color: #1890ff !important;
  1021. font-weight: 500;
  1022. }
  1023.  
  1024. .toc-active > .toc-title-container {
  1025. background-color: #e6f7ff;
  1026. }
  1027.  
  1028. /* 滚动条样式 */
  1029. #custom-sidebar::-webkit-scrollbar {
  1030. width: 6px;
  1031. }
  1032.  
  1033. #custom-sidebar::-webkit-scrollbar-track {
  1034. background: #f1f1f1;
  1035. }
  1036.  
  1037. #custom-sidebar::-webkit-scrollbar-thumb {
  1038. background: #888;
  1039. border-radius: 3px;
  1040. }
  1041.  
  1042. #custom-sidebar::-webkit-scrollbar-thumb:hover {
  1043. background: #555;
  1044. }
  1045.  
  1046. /* 响应式样式 */
  1047. @media (max-width: 1200px) {
  1048. #custom-sidebar {
  1049. width: 200px;
  1050. }
  1051. .blog-content-box {
  1052. margin-left: 200px !important;
  1053. }
  1054. #sidebar-expand-btn {
  1055. width: 16px;
  1056. }
  1057. .column-selector {
  1058. width: 85%;
  1059. }
  1060. }
  1061.  
  1062. /* 过渡动画 */
  1063. .blog-content-box,
  1064. #toolbarBox,
  1065. #toolBarBox,
  1066. #pcCommentBox {
  1067. transition: margin-left 0.3s ease;
  1068. }
  1069.  
  1070. /* 返回顶部按钮样式 */
  1071. .back-to-top {
  1072. position: fixed;
  1073. top: 10%;
  1074. left: 190px;
  1075. width: 40px;
  1076. height: 40px;
  1077. background-color: #fff;
  1078. border: 1px solid #eee;
  1079. border-radius: 50%;
  1080. cursor: pointer;
  1081. display: none;
  1082. align-items: center;
  1083. justify-content: center;
  1084. transition: all 0.3s ease;
  1085. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  1086. z-index: 1000;
  1087. }
  1088.  
  1089. .back-to-top:hover {
  1090. background-color: #f8f9fa;
  1091. box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  1092. transform: translateY(-2px);
  1093. }
  1094.  
  1095. .back-to-top::after {
  1096. content: '↑';
  1097. font-size: 20px;
  1098. color: #1890ff;
  1099. font-weight: bold;
  1100. }
  1101.  
  1102. .column-menu {
  1103. position: relative;
  1104. height: 100%;
  1105. }
  1106.  
  1107. .config-panel {
  1108. position: fixed;
  1109. top: 50%;
  1110. left: 50%;
  1111. transform: translate(-50%, -50%);
  1112. background: white;
  1113. padding: 20px;
  1114. border-radius: 8px;
  1115. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  1116. z-index: 1000;
  1117. }
  1118. .config-panel label {
  1119. display: block;
  1120. margin: 10px 0;
  1121. cursor: pointer;
  1122. }
  1123. .config-panel button {
  1124. margin-top: 15px;
  1125. padding: 5px 15px;
  1126. background: #1890ff;
  1127. color: white;
  1128. border: none;
  1129. border-radius: 4px;
  1130. cursor: pointer;
  1131. }
  1132. .config-btn {
  1133. font-size: 16px;
  1134. }
  1135. `;
  1136.  
  1137. // 如果支持GM_addStyle,则使用它来添加样式
  1138. if (typeof GM_addStyle !== 'undefined') {
  1139. GM_addStyle(customStyle);
  1140. } else {
  1141. // 否则,创建一个style元素并添加到head中
  1142. const styleEl = document.createElement('style');
  1143. styleEl.type = 'text/css';
  1144. styleEl.innerHTML = customStyle;
  1145. document.head.appendChild(styleEl);
  1146. }
  1147. })();