905 rindas
32 KiB
HTML
905 rindas
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>云笔记</title>
|
|
<!-- Highlight.js 代码高亮 -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #fafafa;
|
|
color: #333;
|
|
}
|
|
header {
|
|
background: #fff;
|
|
padding: 16px 24px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 100;
|
|
}
|
|
header h1 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #1a73e8;
|
|
}
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
.search-box {
|
|
display: flex;
|
|
align-items: center;
|
|
background: #f1f3f4;
|
|
border-radius: 24px;
|
|
padding: 8px 16px;
|
|
}
|
|
.search-box input {
|
|
border: none;
|
|
background: transparent;
|
|
outline: none;
|
|
width: 200px;
|
|
font-size: 14px;
|
|
}
|
|
.search-box svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
fill: #666;
|
|
}
|
|
.main {
|
|
display: flex;
|
|
margin-top: 65px;
|
|
height: calc(100vh - 65px);
|
|
}
|
|
|
|
/* 左侧边栏 */
|
|
.sidebar {
|
|
width: 280px;
|
|
background: #fff;
|
|
border-right: 1px solid #e0e0e0;
|
|
overflow-y: auto;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.sidebar-header {
|
|
padding: 16px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.sidebar-header h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
.sidebar-section {
|
|
padding: 12px;
|
|
}
|
|
.sidebar-section h4 {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
color: #888;
|
|
margin-bottom: 8px;
|
|
font-weight: 500;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
/* 树形目录 */
|
|
.tree-view {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
}
|
|
.tree-item {
|
|
user-select: none;
|
|
}
|
|
.tree-node {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 10px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
gap: 6px;
|
|
}
|
|
.tree-node:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
.tree-node.active {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
.tree-toggle {
|
|
width: 18px;
|
|
height: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
transition: transform 0.2s;
|
|
}
|
|
.tree-toggle svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
fill: #888;
|
|
}
|
|
.tree-toggle.expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
.tree-icon {
|
|
width: 18px;
|
|
height: 18px;
|
|
flex-shrink: 0;
|
|
}
|
|
.tree-icon svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
.tree-icon.folder svg { fill: #f9a825; }
|
|
.tree-icon.note svg { fill: #42a5f5; }
|
|
.tree-label {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.tree-children {
|
|
padding-left: 20px;
|
|
}
|
|
.tree-children.collapsed {
|
|
display: none;
|
|
}
|
|
.tree-item.pinned .tree-icon svg { fill: #f57c00; }
|
|
|
|
/* 筛选区域 */
|
|
.filter-area {
|
|
padding: 12px;
|
|
border-top: 1px solid #e0e0e0;
|
|
background: #fafafa;
|
|
}
|
|
.filter-item {
|
|
padding: 10px 12px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: #555;
|
|
transition: background 0.2s;
|
|
}
|
|
.filter-item:hover { background: #f1f3f4; }
|
|
.filter-item.active { background: #e3f2fd; color: #1976d2; }
|
|
.filter-item svg { width: 16px; height: 16px; }
|
|
|
|
.note-list {
|
|
border-top: 1px solid #e0e0e0;
|
|
padding: 8px;
|
|
}
|
|
.note-item {
|
|
padding: 14px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
margin-bottom: 4px;
|
|
transition: background 0.2s;
|
|
}
|
|
.note-item:hover { background: #f1f3f4; }
|
|
.note-item.active { background: #e8f0fe; }
|
|
.note-item h4 {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
margin-bottom: 4px;
|
|
color: #202124;
|
|
}
|
|
.note-item .preview {
|
|
font-size: 12px;
|
|
color: #666;
|
|
line-height: 1.4;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.note-item .meta {
|
|
font-size: 11px;
|
|
color: #999;
|
|
margin-top: 6px;
|
|
}
|
|
.note-item .tags {
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-top: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.note-item .tag {
|
|
padding: 2px 8px;
|
|
background: #e8f0fe;
|
|
color: #1a73e8;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* 右侧内容区 */
|
|
.content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
background: #fff;
|
|
display: flex;
|
|
}
|
|
/* 笔记目录(正文内) */
|
|
.toc-sidebar {
|
|
width: 220px;
|
|
border-right: 1px solid #e8e8e8;
|
|
padding: 24px 16px;
|
|
background: #fafafa;
|
|
flex-shrink: 0;
|
|
overflow-y: auto;
|
|
}
|
|
.toc-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
margin-bottom: 12px;
|
|
padding-left: 8px;
|
|
}
|
|
.toc-list {
|
|
list-style: none;
|
|
}
|
|
.toc-item {
|
|
margin-bottom: 4px;
|
|
}
|
|
.toc-link {
|
|
display: block;
|
|
padding: 6px 8px;
|
|
font-size: 13px;
|
|
color: #555;
|
|
text-decoration: none;
|
|
border-radius: 4px;
|
|
transition: all 0.15s;
|
|
border-left: 2px solid transparent;
|
|
}
|
|
.toc-link:hover {
|
|
background: #e8e8e8;
|
|
color: #333;
|
|
}
|
|
.toc-link.active {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
border-left-color: #1976d2;
|
|
}
|
|
.toc-link.level-2 { padding-left: 16px; font-size: 12px; }
|
|
.toc-link.level-3 { padding-left: 24px; font-size: 12px; color: #777; }
|
|
|
|
/* 正文区域 */
|
|
.note-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 40px;
|
|
}
|
|
.note-detail {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}
|
|
.note-detail h1 {
|
|
font-size: 32px;
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
color: #202124;
|
|
}
|
|
.note-detail .meta {
|
|
font-size: 14px;
|
|
color: #666;
|
|
margin-bottom: 24px;
|
|
padding-bottom: 24px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
.note-detail .meta span {
|
|
margin-right: 16px;
|
|
}
|
|
.note-detail .tags {
|
|
display: inline-flex;
|
|
gap: 8px;
|
|
}
|
|
.note-detail .tag {
|
|
padding: 4px 12px;
|
|
background: #e8f0fe;
|
|
color: #1a73e8;
|
|
border-radius: 16px;
|
|
font-size: 13px;
|
|
}
|
|
.note-content {
|
|
font-size: 16px;
|
|
line-height: 1.8;
|
|
color: #333;
|
|
}
|
|
.note-content h1, .note-content h2, .note-content h3 {
|
|
margin: 24px 0 12px 0;
|
|
color: #202124;
|
|
}
|
|
.note-content h1 { font-size: 28px; }
|
|
.note-content h2 { font-size: 24px; }
|
|
.note-content h3 { font-size: 20px; }
|
|
.note-content p { margin: 12px 0; }
|
|
.note-content code {
|
|
background: #f1f3f4;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-family: 'Monaco', 'Menlo', monospace;
|
|
font-size: 14px;
|
|
}
|
|
.note-content pre {
|
|
background: #f8f9fa;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
overflow-x: auto;
|
|
margin: 16px 0;
|
|
}
|
|
.note-content pre code {
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
.note-content blockquote {
|
|
border-left: 4px solid #1a73e8;
|
|
padding-left: 16px;
|
|
margin: 16px 0;
|
|
color: #555;
|
|
}
|
|
.note-content ul, .note-content ol {
|
|
margin: 12px 0;
|
|
padding-left: 24px;
|
|
}
|
|
.note-content li { margin: 4px 0; }
|
|
.note-content a { color: #1a73e8; }
|
|
|
|
/* 空状态 */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 80px 40px;
|
|
color: #666;
|
|
}
|
|
.empty-state svg {
|
|
width: 80px;
|
|
height: 80px;
|
|
fill: #ddd;
|
|
margin-bottom: 20px;
|
|
}
|
|
.empty-state h2 {
|
|
font-size: 20px;
|
|
margin-bottom: 8px;
|
|
color: #333;
|
|
}
|
|
.empty-state p { font-size: 14px; }
|
|
|
|
/* 底部 */
|
|
footer {
|
|
text-align: center;
|
|
padding: 20px;
|
|
color: #999;
|
|
font-size: 12px;
|
|
}
|
|
footer a { color: #1a73e8; text-decoration: none; }
|
|
|
|
/* Toast */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
padding: 12px 24px;
|
|
background: #333;
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
display: none;
|
|
z-index: 1001;
|
|
}
|
|
.toast.show { display: block; }
|
|
.toast.success { background: #28a745; }
|
|
.toast.error { background: #dc3545; }
|
|
|
|
/* 响应式 */
|
|
@media (max-width: 768px) {
|
|
.sidebar { width: 100%; display: none; }
|
|
.sidebar.show { display: block; }
|
|
.content { padding: 20px; }
|
|
.note-detail h1 { font-size: 24px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>云笔记</h1>
|
|
<div class="header-right">
|
|
<div class="search-box">
|
|
<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>
|
|
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
|
|
</div>
|
|
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="main">
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">
|
|
<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>
|
|
<h3>笔记目录</h3>
|
|
</div>
|
|
|
|
<div class="tree-view" id="treeView">
|
|
<div style="text-align: center; padding: 40px; color: #999;">加载中...</div>
|
|
</div>
|
|
|
|
<div class="filter-area">
|
|
<h4>快速筛选</h4>
|
|
<div class="filter-item active" onclick="filterAll()">
|
|
<svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
|
|
全部笔记
|
|
</div>
|
|
<div class="filter-item" onclick="filterFavorites()">
|
|
<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>
|
|
收藏笔记
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content" id="contentArea">
|
|
<div class="empty-state" id="emptyState">
|
|
<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>
|
|
<h2>选择一篇笔记</h2>
|
|
<p>从左侧列表选择笔记查看内容</p>
|
|
</div>
|
|
|
|
<div class="toc-sidebar" id="tocSidebar" style="display: none;">
|
|
<div class="toc-title">目录</div>
|
|
<ul class="toc-list" id="tocList"></ul>
|
|
</div>
|
|
|
|
<div class="note-area">
|
|
<div class="note-detail" id="noteDetail" style="display: none;">
|
|
<h1 id="noteTitle"></h1>
|
|
<div class="meta">
|
|
<span id="noteCategory"></span>
|
|
<span id="noteDate"></span>
|
|
<div class="tags" id="noteTags"></div>
|
|
</div>
|
|
<div class="note-content" id="noteContent"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<a href="/admin/">管理后台</a>
|
|
</footer>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
const API = '/api';
|
|
let treeData = [];
|
|
let flatNotes = []; // 扁平化的笔记列表(用于筛选)
|
|
let currentNote = null;
|
|
let currentFilter = { type: 'all' };
|
|
let expandedNodes = new Set();
|
|
|
|
async function init() {
|
|
await loadTree();
|
|
}
|
|
|
|
async function loadTree() {
|
|
try {
|
|
const res = await fetch(`${API}/tree`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
treeData = data.data;
|
|
renderTree();
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('treeView').innerHTML =
|
|
'<div style="text-align: center; padding: 40px; color: #999;">加载失败</div>';
|
|
}
|
|
}
|
|
|
|
function renderTree() {
|
|
const container = document.getElementById('treeView');
|
|
if (treeData.length === 0) {
|
|
container.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">暂无笔记</div>';
|
|
return;
|
|
}
|
|
// 构建树形结构
|
|
const tree = buildTree(treeData);
|
|
container.innerHTML = tree.map(node => renderTreeNode(node, 0)).join('');
|
|
}
|
|
|
|
// 构建树形结构
|
|
function buildTree(items) {
|
|
const map = {};
|
|
const roots = [];
|
|
|
|
// 先把所有节点转成有 children 的对象
|
|
items.forEach(item => {
|
|
map[item.id] = { ...item, children: [] };
|
|
});
|
|
|
|
// 再构建父子关系
|
|
items.forEach(item => {
|
|
if (item.parent_id && map[item.parent_id]) {
|
|
map[item.parent_id].children.push(map[item.id]);
|
|
} else {
|
|
roots.push(map[item.id]);
|
|
}
|
|
});
|
|
|
|
return roots;
|
|
}
|
|
|
|
function renderTreeNode(node, level) {
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
const isExpanded = expandedNodes.has(node.id);
|
|
const isActive = currentNote && currentNote.id === node.id;
|
|
const pinnedClass = node.is_pinned ? 'pinned' : '';
|
|
|
|
let html = `
|
|
<div class="tree-item">
|
|
<div class="tree-node ${isActive ? 'active' : ''} ${pinnedClass}"
|
|
onclick="${node.is_folder ? `toggleNode(${node.id})` : `viewNote(${node.id})`}">
|
|
${hasChildren ? `
|
|
<span class="tree-toggle ${isExpanded ? 'expanded' : ''}" id="toggle-${node.id}">
|
|
<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
|
</span>
|
|
` : `
|
|
<span class="tree-toggle"></span>
|
|
`}
|
|
<span class="tree-icon ${node.is_folder ? 'folder' : 'note'}">
|
|
${node.is_folder ? `
|
|
<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>
|
|
` : `
|
|
<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>
|
|
`}
|
|
</span>
|
|
<span class="tree-label">${escapeHtml(node.title)}</span>
|
|
</div>
|
|
${hasChildren ? `
|
|
<div class="tree-children ${isExpanded ? '' : 'collapsed'}" id="children-${node.id}">
|
|
${node.children.map(child => renderTreeNode(child, level + 1)).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
return html;
|
|
}
|
|
|
|
function toggleNode(id) {
|
|
if (expandedNodes.has(id)) {
|
|
expandedNodes.delete(id);
|
|
} else {
|
|
expandedNodes.add(id);
|
|
}
|
|
|
|
const toggle = document.getElementById(`toggle-${id}`);
|
|
const children = document.getElementById(`children-${id}`);
|
|
|
|
if (toggle) toggle.classList.toggle('expanded');
|
|
if (children) children.classList.toggle('collapsed');
|
|
}
|
|
|
|
async function viewNote(id) {
|
|
try {
|
|
// 先获取笔记信息检查是否有密码
|
|
const basicRes = await fetch(`${API}/notes/${id}`);
|
|
const basicData = await basicRes.json();
|
|
|
|
if (basicData.code !== 0) {
|
|
showToast(basicData.message || '加载失败', 'error');
|
|
return;
|
|
}
|
|
|
|
const basicNote = basicData.data;
|
|
|
|
// 如果有密码,需要验证
|
|
if (basicNote.has_password) {
|
|
const password = prompt('此笔记需要密码访问,请输入密码:');
|
|
if (!password) {
|
|
return; // 用户取消
|
|
}
|
|
|
|
const accessRes = await fetch(`${API}/notes/${id}/access`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: password })
|
|
});
|
|
const accessData = await accessRes.json();
|
|
|
|
if (accessData.code !== 0) {
|
|
showToast(accessData.message || '密码错误', 'error');
|
|
return;
|
|
}
|
|
|
|
currentNote = accessData.data;
|
|
} else {
|
|
currentNote = basicNote;
|
|
}
|
|
|
|
renderTree(); // 重新渲染以更新 active 状态
|
|
renderNoteDetail();
|
|
} catch (err) {
|
|
showToast('加载笔记失败', 'error');
|
|
}
|
|
}
|
|
|
|
function renderNoteDetail() {
|
|
if (!currentNote) {
|
|
document.getElementById('emptyState').style.display = 'block';
|
|
document.getElementById('noteDetail').style.display = 'none';
|
|
document.getElementById('tocSidebar').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('emptyState').style.display = 'none';
|
|
document.getElementById('noteDetail').style.display = 'block';
|
|
|
|
document.getElementById('noteTitle').textContent = currentNote.title;
|
|
document.getElementById('noteCategory').textContent = currentNote.category || '未分类';
|
|
document.getElementById('noteDate').textContent = formatDate(currentNote.updated_at);
|
|
|
|
const tags = parseTags(currentNote.tags);
|
|
document.getElementById('noteTags').innerHTML = tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('');
|
|
|
|
const renderedContent = renderMarkdown(currentNote.content || '');
|
|
document.getElementById('noteContent').innerHTML = renderedContent;
|
|
|
|
// 生成目录
|
|
generateTOC(currentNote.content || '');
|
|
}
|
|
|
|
function generateTOC(content) {
|
|
const tocList = document.getElementById('tocList');
|
|
const tocSidebar = document.getElementById('tocSidebar');
|
|
|
|
// 提取标题
|
|
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
|
|
const headings = [];
|
|
let match;
|
|
while ((match = headingRegex.exec(content)) !== null) {
|
|
const level = match[1].length;
|
|
const text = match[2].trim();
|
|
const id = text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
headings.push({ level, text, id });
|
|
}
|
|
|
|
if (headings.length === 0) {
|
|
tocSidebar.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
tocSidebar.style.display = 'block';
|
|
tocList.innerHTML = headings.map(h =>
|
|
`<li class="toc-item">
|
|
<a href="#${h.id}" class="toc-link level-${h.level}" onclick="scrollToHeading('${h.id}')">${escapeHtml(h.text)}</a>
|
|
</li>`
|
|
).join('');
|
|
}
|
|
|
|
function scrollToHeading(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}
|
|
|
|
async function filterAll() {
|
|
currentFilter = { type: 'all' };
|
|
updateFilterUI();
|
|
await loadTree();
|
|
}
|
|
|
|
async function filterFavorites() {
|
|
currentFilter = { type: 'favorites' };
|
|
updateFilterUI();
|
|
try {
|
|
const res = await fetch(`${API}/notes?favorite=true&page_size=100`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
flatNotes = data.data || [];
|
|
renderFlatNotes();
|
|
}
|
|
} catch (err) {
|
|
showToast('加载失败', 'error');
|
|
}
|
|
}
|
|
|
|
function renderFlatNotes() {
|
|
const container = document.getElementById('treeView');
|
|
if (flatNotes.length === 0) {
|
|
container.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">暂无收藏笔记</div>';
|
|
return;
|
|
}
|
|
|
|
flatNotes.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
|
|
|
container.innerHTML = flatNotes.map(note => {
|
|
const tags = parseTags(note.tags);
|
|
const isActive = currentNote && currentNote.id === note.id;
|
|
return `
|
|
<div class="tree-item">
|
|
<div class="tree-node ${isActive ? 'active' : ''} ${note.is_pinned ? 'pinned' : ''}"
|
|
onclick="viewNote(${note.id})">
|
|
<span class="tree-toggle"></span>
|
|
<span class="tree-icon note">
|
|
<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>
|
|
</span>
|
|
<span class="tree-label">${escapeHtml(note.title)}</span>
|
|
${note.is_pinned ? '<span style="color:#f57c00;font-size:11px;">📌</span>' : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateFilterUI() {
|
|
document.querySelectorAll('.filter-item').forEach(el => el.classList.remove('active'));
|
|
const items = document.querySelectorAll('.filter-item');
|
|
if (currentFilter.type === 'all') {
|
|
items[0].classList.add('active');
|
|
} else if (currentFilter.type === 'favorites') {
|
|
items[1].classList.add('active');
|
|
}
|
|
}
|
|
|
|
async function handleSearch(event) {
|
|
if (event.key !== 'Enter') return;
|
|
const keyword = event.target.value.trim();
|
|
if (!keyword) {
|
|
await loadTree();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${API}/notes/search?q=${encodeURIComponent(keyword)}`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
flatNotes = data.data || [];
|
|
renderFlatNotes();
|
|
}
|
|
} catch (err) {
|
|
showToast('搜索失败', 'error');
|
|
}
|
|
}
|
|
|
|
function parseTags(tagsJson) {
|
|
if (!tagsJson) return [];
|
|
try { return JSON.parse(tagsJson); } catch { return []; }
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
|
year: 'numeric', month: 'long', day: 'numeric'
|
|
});
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = `toast show ${type}`;
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
|
|
// 简单的 Markdown 渲染
|
|
function renderMarkdown(text) {
|
|
if (!text) return '<p style="color: #999;">无内容</p>';
|
|
|
|
// 先处理代码块,保留语言标识
|
|
const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g;
|
|
const codeBlocks = [];
|
|
let index = 0;
|
|
|
|
text = text.replace(codeBlockRegex, (match, lang, code) => {
|
|
const placeholder = `__CODE_BLOCK_${index}__`;
|
|
codeBlocks.push({ lang: lang || 'plaintext', code: code.trim() });
|
|
index++;
|
|
return placeholder;
|
|
});
|
|
|
|
// 用占位符保护图片,避免被后续处理影响
|
|
const imgPlaceholders = [];
|
|
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
|
|
const placeholder = '__IMG_' + imgPlaceholders.length + '__';
|
|
imgPlaceholders.push('<img src="' + src + '" alt="' + alt + '" style="max-width: 100%; border-radius: 4px;">');
|
|
return placeholder;
|
|
});
|
|
|
|
// 用占位符保护链接
|
|
const linkPlaceholders = [];
|
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text_, url) {
|
|
const placeholder = '__LINK_' + linkPlaceholders.length + '__';
|
|
linkPlaceholders.push('<a href="' + url + '" target="_blank">' + text_ + '</a>');
|
|
return placeholder;
|
|
});
|
|
|
|
// 转义 HTML(处理代码块内的内容和其他文本)
|
|
text = escapeHtml(text);
|
|
|
|
// 恢复链接(在转义之后)
|
|
linkPlaceholders.forEach(function(link, i) {
|
|
text = text.replace('__LINK_' + i + '__', link);
|
|
});
|
|
|
|
// 恢复图片(在转义之后)
|
|
imgPlaceholders.forEach(function(img, i) {
|
|
text = text.replace('__IMG_' + i + '__', img);
|
|
});
|
|
|
|
// 处理行内代码
|
|
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
|
|
// 恢复代码块并应用高亮
|
|
codeBlocks.forEach((block, i) => {
|
|
const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
|
|
text = text.replace(
|
|
`__CODE_BLOCK_${i}__`,
|
|
`<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`
|
|
);
|
|
});
|
|
|
|
// 标题(带 id 用于目录导航)
|
|
text = text.replace(/^### (.+)$/gm, (match, content) => {
|
|
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
return `<h3 id="${id}">${content}</h3>`;
|
|
});
|
|
text = text.replace(/^## (.+)$/gm, (match, content) => {
|
|
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
return `<h2 id="${id}">${content}</h2>`;
|
|
});
|
|
text = text.replace(/^# (.+)$/gm, (match, content) => {
|
|
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
return `<h1 id="${id}">${content}</h1>`;
|
|
});
|
|
|
|
// 粗体斜体
|
|
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
|
|
// 删除线
|
|
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
|
|
|
// 引用
|
|
text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
|
|
|
|
// 无序列表
|
|
text = text.replace(/^[\-\*] (.*$)/gm, '<li>$1</li>');
|
|
text = text.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
|
|
|
// 有序列表
|
|
text = text.replace(/^\d+\. (.*$)/gm, '<li>$1</li>');
|
|
|
|
// 换行
|
|
text = text.replace(/\n\n/g, '</p><p>');
|
|
text = text.replace(/\n/g, '<br>');
|
|
|
|
return `<p>${text}</p>`;
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|