index.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>云笔记</title>
  7. <!-- Highlight.js 代码高亮 -->
  8. <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
  9. <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
  10. <style>
  11. * { margin: 0; padding: 0; box-sizing: border-box; }
  12. body {
  13. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  14. background: #fafafa;
  15. color: #333;
  16. }
  17. header {
  18. background: #fff;
  19. padding: 16px 24px;
  20. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  21. display: flex;
  22. justify-content: space-between;
  23. align-items: center;
  24. position: fixed;
  25. top: 0;
  26. left: 0;
  27. right: 0;
  28. z-index: 100;
  29. }
  30. header h1 {
  31. font-size: 20px;
  32. font-weight: 600;
  33. color: #1a73e8;
  34. }
  35. .header-right {
  36. display: flex;
  37. align-items: center;
  38. gap: 16px;
  39. }
  40. .search-box {
  41. display: flex;
  42. align-items: center;
  43. background: #f1f3f4;
  44. border-radius: 24px;
  45. padding: 8px 16px;
  46. }
  47. .search-box input {
  48. border: none;
  49. background: transparent;
  50. outline: none;
  51. width: 200px;
  52. font-size: 14px;
  53. }
  54. .search-box svg {
  55. width: 20px;
  56. height: 20px;
  57. fill: #666;
  58. }
  59. .main {
  60. display: flex;
  61. margin-top: 65px;
  62. height: calc(100vh - 65px);
  63. }
  64. /* 左侧边栏 */
  65. .sidebar {
  66. width: 280px;
  67. background: #fff;
  68. border-right: 1px solid #e0e0e0;
  69. overflow-y: auto;
  70. flex-shrink: 0;
  71. display: flex;
  72. flex-direction: column;
  73. }
  74. .sidebar-header {
  75. padding: 16px;
  76. border-bottom: 1px solid #e0e0e0;
  77. display: flex;
  78. align-items: center;
  79. gap: 8px;
  80. }
  81. .sidebar-header h3 {
  82. font-size: 14px;
  83. font-weight: 600;
  84. color: #333;
  85. }
  86. .sidebar-section {
  87. padding: 12px;
  88. }
  89. .sidebar-section h4 {
  90. font-size: 11px;
  91. text-transform: uppercase;
  92. color: #888;
  93. margin-bottom: 8px;
  94. font-weight: 500;
  95. padding: 0 8px;
  96. }
  97. /* 树形目录 */
  98. .tree-view {
  99. flex: 1;
  100. overflow-y: auto;
  101. padding: 8px;
  102. }
  103. .tree-item {
  104. user-select: none;
  105. }
  106. .tree-node {
  107. display: flex;
  108. align-items: center;
  109. padding: 8px 10px;
  110. border-radius: 6px;
  111. cursor: pointer;
  112. transition: background 0.15s;
  113. gap: 6px;
  114. }
  115. .tree-node:hover {
  116. background: #f5f5f5;
  117. }
  118. .tree-node.active {
  119. background: #e3f2fd;
  120. color: #1976d2;
  121. }
  122. .tree-toggle {
  123. width: 18px;
  124. height: 18px;
  125. display: flex;
  126. align-items: center;
  127. justify-content: center;
  128. flex-shrink: 0;
  129. transition: transform 0.2s;
  130. }
  131. .tree-toggle svg {
  132. width: 14px;
  133. height: 14px;
  134. fill: #888;
  135. }
  136. .tree-toggle.expanded {
  137. transform: rotate(90deg);
  138. }
  139. .tree-icon {
  140. width: 18px;
  141. height: 18px;
  142. flex-shrink: 0;
  143. }
  144. .tree-icon svg {
  145. width: 16px;
  146. height: 16px;
  147. }
  148. .tree-icon.folder svg { fill: #f9a825; }
  149. .tree-icon.note svg { fill: #42a5f5; }
  150. .tree-label {
  151. flex: 1;
  152. font-size: 13px;
  153. white-space: nowrap;
  154. overflow: hidden;
  155. text-overflow: ellipsis;
  156. }
  157. .tree-children {
  158. padding-left: 20px;
  159. }
  160. .tree-children.collapsed {
  161. display: none;
  162. }
  163. .tree-item.pinned .tree-icon svg { fill: #f57c00; }
  164. /* 筛选区域 */
  165. .filter-area {
  166. padding: 12px;
  167. border-top: 1px solid #e0e0e0;
  168. background: #fafafa;
  169. }
  170. .filter-item {
  171. padding: 10px 12px;
  172. border-radius: 8px;
  173. cursor: pointer;
  174. font-size: 13px;
  175. display: flex;
  176. align-items: center;
  177. gap: 10px;
  178. color: #555;
  179. transition: background 0.2s;
  180. }
  181. .filter-item:hover { background: #f1f3f4; }
  182. .filter-item.active { background: #e3f2fd; color: #1976d2; }
  183. .filter-item svg { width: 16px; height: 16px; }
  184. .note-list {
  185. border-top: 1px solid #e0e0e0;
  186. padding: 8px;
  187. }
  188. .note-item {
  189. padding: 14px;
  190. border-radius: 8px;
  191. cursor: pointer;
  192. margin-bottom: 4px;
  193. transition: background 0.2s;
  194. }
  195. .note-item:hover { background: #f1f3f4; }
  196. .note-item.active { background: #e8f0fe; }
  197. .note-item h4 {
  198. font-size: 14px;
  199. font-weight: 500;
  200. margin-bottom: 4px;
  201. color: #202124;
  202. }
  203. .note-item .preview {
  204. font-size: 12px;
  205. color: #666;
  206. line-height: 1.4;
  207. overflow: hidden;
  208. text-overflow: ellipsis;
  209. white-space: nowrap;
  210. }
  211. .note-item .meta {
  212. font-size: 11px;
  213. color: #999;
  214. margin-top: 6px;
  215. }
  216. .note-item .tags {
  217. display: flex;
  218. gap: 4px;
  219. margin-top: 6px;
  220. flex-wrap: wrap;
  221. }
  222. .note-item .tag {
  223. padding: 2px 8px;
  224. background: #e8f0fe;
  225. color: #1a73e8;
  226. border-radius: 12px;
  227. font-size: 11px;
  228. }
  229. /* 右侧内容区 */
  230. .content {
  231. flex: 1;
  232. overflow-y: auto;
  233. background: #fff;
  234. display: flex;
  235. }
  236. /* 笔记目录(正文内) */
  237. .toc-sidebar {
  238. width: 220px;
  239. border-right: 1px solid #e8e8e8;
  240. padding: 24px 16px;
  241. background: #fafafa;
  242. flex-shrink: 0;
  243. overflow-y: auto;
  244. }
  245. .toc-title {
  246. font-size: 12px;
  247. font-weight: 600;
  248. color: #888;
  249. text-transform: uppercase;
  250. margin-bottom: 12px;
  251. padding-left: 8px;
  252. }
  253. .toc-list {
  254. list-style: none;
  255. }
  256. .toc-item {
  257. margin-bottom: 4px;
  258. }
  259. .toc-link {
  260. display: block;
  261. padding: 6px 8px;
  262. font-size: 13px;
  263. color: #555;
  264. text-decoration: none;
  265. border-radius: 4px;
  266. transition: all 0.15s;
  267. border-left: 2px solid transparent;
  268. }
  269. .toc-link:hover {
  270. background: #e8e8e8;
  271. color: #333;
  272. }
  273. .toc-link.active {
  274. background: #e3f2fd;
  275. color: #1976d2;
  276. border-left-color: #1976d2;
  277. }
  278. .toc-link.level-2 { padding-left: 16px; font-size: 12px; }
  279. .toc-link.level-3 { padding-left: 24px; font-size: 12px; color: #777; }
  280. /* 正文区域 */
  281. .note-area {
  282. flex: 1;
  283. overflow-y: auto;
  284. padding: 40px;
  285. }
  286. .note-detail {
  287. max-width: 800px;
  288. margin: 0 auto;
  289. }
  290. .note-detail h1 {
  291. font-size: 32px;
  292. font-weight: 600;
  293. margin-bottom: 16px;
  294. color: #202124;
  295. }
  296. .note-detail .meta {
  297. font-size: 14px;
  298. color: #666;
  299. margin-bottom: 24px;
  300. padding-bottom: 24px;
  301. border-bottom: 1px solid #e0e0e0;
  302. }
  303. .note-detail .meta span {
  304. margin-right: 16px;
  305. }
  306. .note-detail .tags {
  307. display: inline-flex;
  308. gap: 8px;
  309. }
  310. .note-detail .tag {
  311. padding: 4px 12px;
  312. background: #e8f0fe;
  313. color: #1a73e8;
  314. border-radius: 16px;
  315. font-size: 13px;
  316. }
  317. .note-content {
  318. font-size: 16px;
  319. line-height: 1.8;
  320. color: #333;
  321. }
  322. .note-content h1, .note-content h2, .note-content h3 {
  323. margin: 24px 0 12px 0;
  324. color: #202124;
  325. }
  326. .note-content h1 { font-size: 28px; }
  327. .note-content h2 { font-size: 24px; }
  328. .note-content h3 { font-size: 20px; }
  329. .note-content p { margin: 12px 0; }
  330. .note-content code {
  331. background: #f1f3f4;
  332. padding: 2px 6px;
  333. border-radius: 4px;
  334. font-family: 'Monaco', 'Menlo', monospace;
  335. font-size: 14px;
  336. }
  337. .note-content pre {
  338. background: #f8f9fa;
  339. padding: 16px;
  340. border-radius: 8px;
  341. overflow-x: auto;
  342. margin: 16px 0;
  343. }
  344. .note-content pre code {
  345. background: none;
  346. padding: 0;
  347. }
  348. .note-content blockquote {
  349. border-left: 4px solid #1a73e8;
  350. padding-left: 16px;
  351. margin: 16px 0;
  352. color: #555;
  353. }
  354. .note-content ul, .note-content ol {
  355. margin: 12px 0;
  356. padding-left: 24px;
  357. }
  358. .note-content li { margin: 4px 0; }
  359. .note-content a { color: #1a73e8; }
  360. /* 空状态 */
  361. .empty-state {
  362. text-align: center;
  363. padding: 80px 40px;
  364. color: #666;
  365. }
  366. .empty-state svg {
  367. width: 80px;
  368. height: 80px;
  369. fill: #ddd;
  370. margin-bottom: 20px;
  371. }
  372. .empty-state h2 {
  373. font-size: 20px;
  374. margin-bottom: 8px;
  375. color: #333;
  376. }
  377. .empty-state p { font-size: 14px; }
  378. /* 底部 */
  379. footer {
  380. text-align: center;
  381. padding: 20px;
  382. color: #999;
  383. font-size: 12px;
  384. }
  385. footer a { color: #1a73e8; text-decoration: none; }
  386. /* Toast */
  387. .toast {
  388. position: fixed;
  389. bottom: 20px;
  390. right: 20px;
  391. padding: 12px 24px;
  392. background: #333;
  393. color: #fff;
  394. border-radius: 8px;
  395. display: none;
  396. z-index: 1001;
  397. }
  398. .toast.show { display: block; }
  399. .toast.success { background: #28a745; }
  400. .toast.error { background: #dc3545; }
  401. /* 响应式 */
  402. @media (max-width: 768px) {
  403. .sidebar { width: 100%; display: none; }
  404. .sidebar.show { display: block; }
  405. .content { padding: 20px; }
  406. .note-detail h1 { font-size: 24px; }
  407. }
  408. </style>
  409. </head>
  410. <body>
  411. <header>
  412. <h1>云笔记</h1>
  413. <div class="header-right">
  414. <div class="search-box">
  415. <svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
  416. <input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
  417. </div>
  418. <a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
  419. </div>
  420. </header>
  421. <div class="main">
  422. <div class="sidebar">
  423. <div class="sidebar-header">
  424. <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#1976d2;"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>
  425. <h3>笔记目录</h3>
  426. </div>
  427. <div class="tree-view" id="treeView">
  428. <div style="text-align: center; padding: 40px; color: #999;">加载中...</div>
  429. </div>
  430. <div class="filter-area">
  431. <h4>快速筛选</h4>
  432. <div class="filter-item active" onclick="filterAll()">
  433. <svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
  434. 全部笔记
  435. </div>
  436. <div class="filter-item" onclick="filterFavorites()">
  437. <svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
  438. 收藏笔记
  439. </div>
  440. </div>
  441. </div>
  442. <div class="content" id="contentArea">
  443. <div class="empty-state" id="emptyState">
  444. <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
  445. <h2>选择一篇笔记</h2>
  446. <p>从左侧列表选择笔记查看内容</p>
  447. </div>
  448. <div class="toc-sidebar" id="tocSidebar" style="display: none;">
  449. <div class="toc-title">目录</div>
  450. <ul class="toc-list" id="tocList"></ul>
  451. </div>
  452. <div class="note-area">
  453. <div class="note-detail" id="noteDetail" style="display: none;">
  454. <h1 id="noteTitle"></h1>
  455. <div class="meta">
  456. <span id="noteCategory"></span>
  457. <span id="noteDate"></span>
  458. <div class="tags" id="noteTags"></div>
  459. </div>
  460. <div class="note-content" id="noteContent"></div>
  461. </div>
  462. </div>
  463. </div>
  464. </div>
  465. <footer>
  466. <a href="/admin/">管理后台</a>
  467. </footer>
  468. <div class="toast" id="toast"></div>
  469. <script>
  470. const API = '/api';
  471. let treeData = [];
  472. let flatNotes = []; // 扁平化的笔记列表(用于筛选)
  473. let currentNote = null;
  474. let currentFilter = { type: 'all' };
  475. let expandedNodes = new Set();
  476. async function init() {
  477. await loadTree();
  478. }
  479. async function loadTree() {
  480. try {
  481. const res = await fetch(`${API}/tree`);
  482. const data = await res.json();
  483. if (data.code === 0) {
  484. treeData = data.data;
  485. renderTree();
  486. }
  487. } catch (err) {
  488. document.getElementById('treeView').innerHTML =
  489. '<div style="text-align: center; padding: 40px; color: #999;">加载失败</div>';
  490. }
  491. }
  492. function renderTree() {
  493. const container = document.getElementById('treeView');
  494. if (treeData.length === 0) {
  495. container.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">暂无笔记</div>';
  496. return;
  497. }
  498. // 构建树形结构
  499. const tree = buildTree(treeData);
  500. container.innerHTML = tree.map(node => renderTreeNode(node, 0)).join('');
  501. }
  502. // 构建树形结构
  503. function buildTree(items) {
  504. const map = {};
  505. const roots = [];
  506. // 先把所有节点转成有 children 的对象
  507. items.forEach(item => {
  508. map[item.id] = { ...item, children: [] };
  509. });
  510. // 再构建父子关系
  511. items.forEach(item => {
  512. if (item.parent_id && map[item.parent_id]) {
  513. map[item.parent_id].children.push(map[item.id]);
  514. } else {
  515. roots.push(map[item.id]);
  516. }
  517. });
  518. return roots;
  519. }
  520. function renderTreeNode(node, level) {
  521. const hasChildren = node.children && node.children.length > 0;
  522. const isExpanded = expandedNodes.has(node.id);
  523. const isActive = currentNote && currentNote.id === node.id;
  524. const pinnedClass = node.is_pinned ? 'pinned' : '';
  525. let html = `
  526. <div class="tree-item">
  527. <div class="tree-node ${isActive ? 'active' : ''} ${pinnedClass}"
  528. onclick="${node.is_folder ? `toggleNode(${node.id})` : `viewNote(${node.id})`}">
  529. ${hasChildren ? `
  530. <span class="tree-toggle ${isExpanded ? 'expanded' : ''}" id="toggle-${node.id}">
  531. <svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
  532. </span>
  533. ` : `
  534. <span class="tree-toggle"></span>
  535. `}
  536. <span class="tree-icon ${node.is_folder ? 'folder' : 'note'}">
  537. ${node.is_folder ? `
  538. <svg viewBox="0 0 24 24"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
  539. ` : `
  540. <svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
  541. `}
  542. </span>
  543. <span class="tree-label">${escapeHtml(node.title)}</span>
  544. </div>
  545. ${hasChildren ? `
  546. <div class="tree-children ${isExpanded ? '' : 'collapsed'}" id="children-${node.id}">
  547. ${node.children.map(child => renderTreeNode(child, level + 1)).join('')}
  548. </div>
  549. ` : ''}
  550. </div>
  551. `;
  552. return html;
  553. }
  554. function toggleNode(id) {
  555. if (expandedNodes.has(id)) {
  556. expandedNodes.delete(id);
  557. } else {
  558. expandedNodes.add(id);
  559. }
  560. const toggle = document.getElementById(`toggle-${id}`);
  561. const children = document.getElementById(`children-${id}`);
  562. if (toggle) toggle.classList.toggle('expanded');
  563. if (children) children.classList.toggle('collapsed');
  564. }
  565. async function viewNote(id) {
  566. try {
  567. // 先获取笔记信息检查是否有密码
  568. const basicRes = await fetch(`${API}/notes/${id}`);
  569. const basicData = await basicRes.json();
  570. if (basicData.code !== 0) {
  571. showToast(basicData.message || '加载失败', 'error');
  572. return;
  573. }
  574. const basicNote = basicData.data;
  575. // 如果有密码,需要验证
  576. if (basicNote.has_password) {
  577. const password = prompt('此笔记需要密码访问,请输入密码:');
  578. if (!password) {
  579. return; // 用户取消
  580. }
  581. const accessRes = await fetch(`${API}/notes/${id}/access`, {
  582. method: 'POST',
  583. headers: { 'Content-Type': 'application/json' },
  584. body: JSON.stringify({ password: password })
  585. });
  586. const accessData = await accessRes.json();
  587. if (accessData.code !== 0) {
  588. showToast(accessData.message || '密码错误', 'error');
  589. return;
  590. }
  591. currentNote = accessData.data;
  592. } else {
  593. currentNote = basicNote;
  594. }
  595. renderTree(); // 重新渲染以更新 active 状态
  596. renderNoteDetail();
  597. } catch (err) {
  598. showToast('加载笔记失败', 'error');
  599. }
  600. }
  601. function renderNoteDetail() {
  602. if (!currentNote) {
  603. document.getElementById('emptyState').style.display = 'block';
  604. document.getElementById('noteDetail').style.display = 'none';
  605. document.getElementById('tocSidebar').style.display = 'none';
  606. return;
  607. }
  608. document.getElementById('emptyState').style.display = 'none';
  609. document.getElementById('noteDetail').style.display = 'block';
  610. document.getElementById('noteTitle').textContent = currentNote.title;
  611. document.getElementById('noteCategory').textContent = currentNote.category || '未分类';
  612. document.getElementById('noteDate').textContent = formatDate(currentNote.updated_at);
  613. const tags = parseTags(currentNote.tags);
  614. document.getElementById('noteTags').innerHTML = tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('');
  615. const renderedContent = renderMarkdown(currentNote.content || '');
  616. document.getElementById('noteContent').innerHTML = renderedContent;
  617. // 生成目录
  618. generateTOC(currentNote.content || '');
  619. }
  620. function generateTOC(content) {
  621. const tocList = document.getElementById('tocList');
  622. const tocSidebar = document.getElementById('tocSidebar');
  623. // 提取标题
  624. const headingRegex = /^(#{1,3})\s+(.+)$/gm;
  625. const headings = [];
  626. let match;
  627. while ((match = headingRegex.exec(content)) !== null) {
  628. const level = match[1].length;
  629. const text = match[2].trim();
  630. const id = text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
  631. headings.push({ level, text, id });
  632. }
  633. if (headings.length === 0) {
  634. tocSidebar.style.display = 'none';
  635. return;
  636. }
  637. tocSidebar.style.display = 'block';
  638. tocList.innerHTML = headings.map(h =>
  639. `<li class="toc-item">
  640. <a href="#${h.id}" class="toc-link level-${h.level}" onclick="scrollToHeading('${h.id}')">${escapeHtml(h.text)}</a>
  641. </li>`
  642. ).join('');
  643. }
  644. function scrollToHeading(id) {
  645. const el = document.getElementById(id);
  646. if (el) {
  647. el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  648. }
  649. }
  650. async function filterAll() {
  651. currentFilter = { type: 'all' };
  652. updateFilterUI();
  653. await loadTree();
  654. }
  655. async function filterFavorites() {
  656. currentFilter = { type: 'favorites' };
  657. updateFilterUI();
  658. try {
  659. const res = await fetch(`${API}/notes?favorite=true&page_size=100`);
  660. const data = await res.json();
  661. if (data.code === 0) {
  662. flatNotes = data.data || [];
  663. renderFlatNotes();
  664. }
  665. } catch (err) {
  666. showToast('加载失败', 'error');
  667. }
  668. }
  669. function renderFlatNotes() {
  670. const container = document.getElementById('treeView');
  671. if (flatNotes.length === 0) {
  672. container.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">暂无收藏笔记</div>';
  673. return;
  674. }
  675. flatNotes.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
  676. container.innerHTML = flatNotes.map(note => {
  677. const tags = parseTags(note.tags);
  678. const isActive = currentNote && currentNote.id === note.id;
  679. return `
  680. <div class="tree-item">
  681. <div class="tree-node ${isActive ? 'active' : ''} ${note.is_pinned ? 'pinned' : ''}"
  682. onclick="viewNote(${note.id})">
  683. <span class="tree-toggle"></span>
  684. <span class="tree-icon note">
  685. <svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
  686. </span>
  687. <span class="tree-label">${escapeHtml(note.title)}</span>
  688. ${note.is_pinned ? '<span style="color:#f57c00;font-size:11px;">📌</span>' : ''}
  689. </div>
  690. </div>
  691. `;
  692. }).join('');
  693. }
  694. function updateFilterUI() {
  695. document.querySelectorAll('.filter-item').forEach(el => el.classList.remove('active'));
  696. const items = document.querySelectorAll('.filter-item');
  697. if (currentFilter.type === 'all') {
  698. items[0].classList.add('active');
  699. } else if (currentFilter.type === 'favorites') {
  700. items[1].classList.add('active');
  701. }
  702. }
  703. async function handleSearch(event) {
  704. if (event.key !== 'Enter') return;
  705. const keyword = event.target.value.trim();
  706. if (!keyword) {
  707. await loadTree();
  708. return;
  709. }
  710. try {
  711. const res = await fetch(`${API}/notes/search?q=${encodeURIComponent(keyword)}`);
  712. const data = await res.json();
  713. if (data.code === 0) {
  714. flatNotes = data.data || [];
  715. renderFlatNotes();
  716. }
  717. } catch (err) {
  718. showToast('搜索失败', 'error');
  719. }
  720. }
  721. function parseTags(tagsJson) {
  722. if (!tagsJson) return [];
  723. try { return JSON.parse(tagsJson); } catch { return []; }
  724. }
  725. function escapeHtml(text) {
  726. if (!text) return '';
  727. const div = document.createElement('div');
  728. div.textContent = text;
  729. return div.innerHTML;
  730. }
  731. function formatDate(dateStr) {
  732. return new Date(dateStr).toLocaleDateString('zh-CN', {
  733. year: 'numeric', month: 'long', day: 'numeric'
  734. });
  735. }
  736. function showToast(message, type = 'info') {
  737. const toast = document.getElementById('toast');
  738. toast.textContent = message;
  739. toast.className = `toast show ${type}`;
  740. setTimeout(() => toast.classList.remove('show'), 3000);
  741. }
  742. // 简单的 Markdown 渲染
  743. function renderMarkdown(text) {
  744. if (!text) return '<p style="color: #999;">无内容</p>';
  745. // 先处理代码块,保留语言标识
  746. const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g;
  747. const codeBlocks = [];
  748. let index = 0;
  749. text = text.replace(codeBlockRegex, (match, lang, code) => {
  750. const placeholder = `__CODE_BLOCK_${index}__`;
  751. codeBlocks.push({ lang: lang || 'plaintext', code: code.trim() });
  752. index++;
  753. return placeholder;
  754. });
  755. // 用占位符保护图片,避免被后续处理影响
  756. const imgPlaceholders = [];
  757. text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
  758. const placeholder = '__IMG_' + imgPlaceholders.length + '__';
  759. imgPlaceholders.push('<img src="' + src + '" alt="' + alt + '" style="max-width: 100%; border-radius: 4px;">');
  760. return placeholder;
  761. });
  762. // 用占位符保护链接
  763. const linkPlaceholders = [];
  764. text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text_, url) {
  765. const placeholder = '__LINK_' + linkPlaceholders.length + '__';
  766. linkPlaceholders.push('<a href="' + url + '" target="_blank">' + text_ + '</a>');
  767. return placeholder;
  768. });
  769. // 转义 HTML(处理代码块内的内容和其他文本)
  770. text = escapeHtml(text);
  771. // 恢复链接(在转义之后)
  772. linkPlaceholders.forEach(function(link, i) {
  773. text = text.replace('__LINK_' + i + '__', link);
  774. });
  775. // 恢复图片(在转义之后)
  776. imgPlaceholders.forEach(function(img, i) {
  777. text = text.replace('__IMG_' + i + '__', img);
  778. });
  779. // 处理行内代码
  780. text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
  781. // 恢复代码块并应用高亮
  782. codeBlocks.forEach((block, i) => {
  783. const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
  784. text = text.replace(
  785. `__CODE_BLOCK_${i}__`,
  786. `<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`
  787. );
  788. });
  789. // 标题(带 id 用于目录导航)
  790. text = text.replace(/^### (.+)$/gm, (match, content) => {
  791. const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
  792. return `<h3 id="${id}">${content}</h3>`;
  793. });
  794. text = text.replace(/^## (.+)$/gm, (match, content) => {
  795. const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
  796. return `<h2 id="${id}">${content}</h2>`;
  797. });
  798. text = text.replace(/^# (.+)$/gm, (match, content) => {
  799. const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
  800. return `<h1 id="${id}">${content}</h1>`;
  801. });
  802. // 粗体斜体
  803. text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
  804. text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  805. text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
  806. // 删除线
  807. text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
  808. // 引用
  809. text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
  810. // 无序列表
  811. text = text.replace(/^[\-\*] (.*$)/gm, '<li>$1</li>');
  812. text = text.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
  813. // 有序列表
  814. text = text.replace(/^\d+\. (.*$)/gm, '<li>$1</li>');
  815. // 换行
  816. text = text.replace(/\n\n/g, '</p><p>');
  817. text = text.replace(/\n/g, '<br>');
  818. return `<p>${text}</p>`;
  819. }
  820. init();
  821. </script>
  822. </body>
  823. </html>