13c53fea0a
- 管理员可管理任意用户笔记(读取/修改/删除/回收站/标签/图谱/FTS 全平台) - 普通用户仍数据隔离, 越权返回404 - 新增注册开关: 管理员后台⚙设置可开/关, 支持REGISTRATION_ENABLED环境变量 - 注册关闭时前台/登录页隐藏注册入口, 注册接口返回400 - 冒烟测试扩展到91用例全过
1452 lines
56 KiB
HTML
1452 lines
56 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>
|
||
<link rel="manifest" href="/manifest.json">
|
||
<meta name="theme-color" content="#1a73e8">
|
||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||
<!-- 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>
|
||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||
<script>
|
||
// 初始化 Mermaid(延迟到 DOM 加载)
|
||
window.addEventListener('load', function() {
|
||
try {
|
||
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });
|
||
} catch(e) { console.warn('mermaid init failed', e); }
|
||
});
|
||
</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;
|
||
}
|
||
/* 待办清单 */
|
||
.task-item { list-style: none; margin: 2px 0; }
|
||
.task-checkbox { margin-right: 6px; vertical-align: middle; width: 15px; height: 15px; }
|
||
.task-done { text-decoration: line-through; color: #888; }
|
||
.mermaid { text-align: center; margin: 16px 0; overflow-x: auto; }
|
||
/* ───── 深色模式(专业协调配色)───── */
|
||
body.dark {
|
||
background: #0f1115;
|
||
color: #e6e6e6;
|
||
}
|
||
body.dark header {
|
||
background: #15181e;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||
border-bottom: 1px solid #232733;
|
||
}
|
||
body.dark header h1 { color: #6aa1f7; }
|
||
body.dark .search-box { background: #20242e; }
|
||
body.dark .search-box svg { fill: #8b93a3; }
|
||
body.dark .search-box input { color: #e6e6e6; }
|
||
body.dark .search-box input::placeholder { color: #6b7280; }
|
||
|
||
body.dark .main { background: #0f1115; }
|
||
body.dark .sidebar {
|
||
background: #15181e;
|
||
border-right: 1px solid #232733;
|
||
}
|
||
body.dark .sidebar-header { border-bottom: 1px solid #232733; }
|
||
body.dark .sidebar-header h3 { color: #c9ced8; }
|
||
body.dark .sidebar-section h4 { color: #6b7280; }
|
||
|
||
body.dark .tree-node:hover { background: #1e232d; }
|
||
body.dark .tree-node.active { background: #1b2a44; color: #7aa2f7; }
|
||
body.dark .tree-toggle svg { fill: #6b7280; }
|
||
body.dark .tree-label { color: #c9ced8; }
|
||
body.dark .tree-icon.folder svg { fill: #d9963d; }
|
||
body.dark .tree-icon.note svg { fill: #4a9cf5; }
|
||
|
||
body.dark .filter-area {
|
||
background: #15181e;
|
||
border-top: 1px solid #232733;
|
||
}
|
||
body.dark .filter-item { color: #9aa0aa; }
|
||
body.dark .filter-item svg { fill: #9aa0aa; }
|
||
body.dark .filter-item:hover { background: #1e232d; }
|
||
body.dark .filter-item.active { background: #1b2a44; color: #7aa2f7; }
|
||
body.dark .filter-item.active svg { fill: #7aa2f7; }
|
||
body.dark .filter-chip { background: #1a1e26; border-color: #2a3040; color: #c9ced8; }
|
||
body.dark .filter-chip:hover { background: #2a3040; }
|
||
body.dark .filter-chip.active { background: #1976d2; color: #fff; border-color: #1976d2; }
|
||
|
||
body.dark .note-list { border-top: 1px solid #232733; }
|
||
body.dark .note-item:hover { background: #1e232d; }
|
||
body.dark .note-item.active { background: #1b2a44; }
|
||
body.dark .note-item h4 { color: #e6e6e6; }
|
||
body.dark .note-item .preview { color: #9aa0aa; }
|
||
body.dark .note-item .meta { color: #6b7280; }
|
||
body.dark .note-item .tag { background: #1e3a5f; color: #7aa2f7; }
|
||
|
||
body.dark .content { background: #0f1115; }
|
||
body.dark .toc-sidebar {
|
||
background: #101318;
|
||
border-right: 1px solid #232733;
|
||
}
|
||
body.dark .toc-title { color: #6b7280; }
|
||
body.dark .toc-link { color: #9aa0aa; }
|
||
body.dark .toc-link:hover { background: #1e232d; color: #e6e6e6; }
|
||
body.dark .toc-link.active { background: #1b2a44; color: #7aa2f7; border-left-color: #7aa2f7; }
|
||
body.dark .toc-link.level-3 { color: #6b7280; }
|
||
|
||
body.dark .note-detail h1 { color: #f0f2f5; }
|
||
body.dark .note-detail .meta {
|
||
color: #9aa0aa;
|
||
border-bottom: 1px solid #232733;
|
||
}
|
||
body.dark .note-detail .tag { background: #1e3a5f; color: #7aa2f7; }
|
||
|
||
body.dark .note-content { color: #c9ced8; }
|
||
body.dark .note-content h1, body.dark .note-content h2, body.dark .note-content h3 { color: #f0f2f5; }
|
||
body.dark .note-content code { background: #1e232d; color: #e0a46a; }
|
||
body.dark .note-content pre { background: #0b0d12; border: 1px solid #232733; }
|
||
body.dark .note-content pre code { background: none; color: #d4d7de; }
|
||
body.dark .note-content blockquote { border-left-color: #6aa1f7; color: #9aa0aa; }
|
||
body.dark .note-content a { color: #7aa2f7; }
|
||
|
||
body.dark .empty-state { color: #9aa0aa; }
|
||
body.dark .empty-state svg { fill: #2a2f3a; }
|
||
body.dark .empty-state h2 { color: #c9ced8; }
|
||
|
||
body.dark footer { color: #6b7280; }
|
||
body.dark footer a { color: #7aa2f7; }
|
||
|
||
body.dark .task-done { color: #6b7280; }
|
||
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; }
|
||
|
||
/* 分类筛选 chips */
|
||
.filter-chip {
|
||
padding: 4px 11px;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 14px;
|
||
background: #fff;
|
||
color: #555;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: all 0.2s;
|
||
}
|
||
.filter-chip:hover { background: #f1f3f4; }
|
||
.filter-chip.active { background: #1976d2; color: #fff; border-color: #1976d2; }
|
||
|
||
.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) {
|
||
header {
|
||
padding: 10px 12px;
|
||
}
|
||
header h1 { font-size: 16px; }
|
||
#menuBtn {
|
||
display: block;
|
||
flex-shrink: 0;
|
||
}
|
||
.header-right {
|
||
gap: 8px;
|
||
}
|
||
.search-box {
|
||
flex: 1;
|
||
padding: 7px 12px;
|
||
}
|
||
.search-box input {
|
||
width: 100%;
|
||
font-size: 14px;
|
||
}
|
||
.search-box svg { width: 18px; height: 18px; }
|
||
#darkBtn {
|
||
font-size: 0;
|
||
padding: 0;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
#darkBtn::after {
|
||
content: '🌙';
|
||
font-size: 18px;
|
||
}
|
||
body.dark #darkBtn::after {
|
||
content: '☀️';
|
||
}
|
||
#darkBtn.btn-light::after { content: '☀️'; }
|
||
|
||
.main {
|
||
flex-direction: column;
|
||
height: auto;
|
||
min-height: calc(100vh - 57px);
|
||
}
|
||
/* 边栏在手机端默认隐藏,点开汉堡菜单后显示 */
|
||
.sidebar {
|
||
width: 100%;
|
||
max-height: 50vh;
|
||
display: none;
|
||
border-right: none;
|
||
border-bottom: 1px solid #e0e0e0;
|
||
}
|
||
.sidebar.show { display: flex; }
|
||
.content {
|
||
height: auto;
|
||
min-height: 60vh;
|
||
padding: 0;
|
||
flex-direction: column;
|
||
}
|
||
.toc-sidebar {
|
||
display: none !important;
|
||
}
|
||
.note-area { padding: 16px; }
|
||
.note-detail h1 { font-size: 24px; }
|
||
.note-content { font-size: 15px; }
|
||
.note-content pre { overflow-x: auto; }
|
||
.empty-state { padding: 60px 20px; }
|
||
.note-item { padding: 12px; }
|
||
.filter-item { padding: 12px; font-size: 14px; }
|
||
.tree-node { padding: 12px 10px; }
|
||
.tree-label { font-size: 14px; }
|
||
header a { font-size: 13px; }
|
||
footer { font-size: 11px; padding: 16px 12px; }
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
header h1 { display: none; }
|
||
.header-right .search-box { max-width: none; }
|
||
.wrap-text { word-break: break-word; }
|
||
}
|
||
/* 登录/注册弹窗 */
|
||
.auth-modal {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||
display: flex; align-items: center; justify-content: center; z-index: 9999;
|
||
}
|
||
.auth-box {
|
||
background: #fff; border-radius: 12px; padding: 30px; width: 92%; max-width: 380px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.3); position: relative;
|
||
}
|
||
body.dark .auth-box { background: #1a1e26; color: #e8eaed; }
|
||
.auth-box h3 { margin: 0 0 20px; text-align: center; color: #333; }
|
||
body.dark .auth-box h3 { color: #e8eaed; }
|
||
.auth-form-group { margin-bottom: 14px; }
|
||
.auth-form-group label { display: block; margin-bottom: 6px; font-size: 13px; color: #666; }
|
||
body.dark .auth-form-group label { color: #9aa0aa; }
|
||
.auth-form-group input {
|
||
width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px;
|
||
}
|
||
body.dark .auth-form-group input { background: #11141a; border-color: #2a3040; color: #e8eaed; }
|
||
.auth-error { color: #dc3545; font-size: 13px; margin: 8px 0; }
|
||
.auth-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||
.auth-btn {
|
||
padding: 11px; background: #1976d2; color: #fff; border: none; border-radius: 6px;
|
||
font-size: 15px; cursor: pointer;
|
||
}
|
||
.auth-switch {
|
||
padding: 8px; background: none; border: none; color: #1976d2; cursor: pointer; font-size: 13px;
|
||
}
|
||
.auth-close {
|
||
position: absolute; top: 12px; right: 14px; background: none; border: none;
|
||
font-size: 16px; cursor: pointer; color: #888;
|
||
}
|
||
body.dark .auth-close { color: #9aa0aa; }
|
||
.auth-link { background: none; border: 1px solid #1976d2; color: #1976d2; border-radius: 16px; padding: 4px 12px; font-size: 13px; cursor: pointer; margin-right: 4px; }
|
||
.auth-user { font-size: 13px; color: #666; }
|
||
body.dark .auth-user { color: #c9ced8; }
|
||
.auth-user b { color: #1976d2; }
|
||
|
||
/* 收藏按钮 */
|
||
.fav-btn {
|
||
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
|
||
background: none; border: 1px solid #ddd; border-radius: 16px; padding: 3px 12px;
|
||
font-size: 13px; cursor: pointer; color: #666;
|
||
}
|
||
.fav-btn:hover { border-color: #f5a623; }
|
||
.fav-btn.on { border-color: #f5a623; color: #f5a623; }
|
||
body.dark .fav-btn { border-color: #2a3040; color: #c9ced8; }
|
||
body.dark .fav-btn.on { border-color: #f5a623; color: #f5a623; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<button id="menuBtn" aria-label="切换目录" style="display:none;background:none;border:1px solid #ccc;border-radius:8px;width:40px;height:40px;cursor:pointer;color:#666;font-size:20px;line-height:1;">☰</button>
|
||
<h1>云笔记</h1>
|
||
</div>
|
||
<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>
|
||
<span id="userArea" style="display:flex;align-items:center;gap:8px;"></span>
|
||
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
|
||
<button onclick="toggleSiteDark()" id="darkBtn" style="background:none;border:1px solid #ccc;border-radius:20px;padding:5px 12px;font-size:13px;cursor:pointer;color:#666;">🌙 深色</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 登录/注册弹窗 -->
|
||
<div class="auth-modal" id="authModal" style="display:none;">
|
||
<div class="auth-box">
|
||
<h3 id="authTitle">登录</h3>
|
||
<div class="auth-form-group"><label>用户名</label><input type="text" id="authUsername" autocomplete="username"></div>
|
||
<div class="auth-form-group" id="authDisplayGroup" style="display:none;"><label>昵称(可选)</label><input type="text" id="authDisplay" autocomplete="nickname"></div>
|
||
<div class="auth-form-group"><label>密码</label><input type="password" id="authPassword" autocomplete="current-password"></div>
|
||
<p class="auth-error" id="authError" style="display:none;"></p>
|
||
<div class="auth-actions">
|
||
<button class="auth-btn" id="authSubmit">登 录</button>
|
||
<button class="auth-switch" id="authSwitch" type="button">没有账号?注册</button>
|
||
</div>
|
||
<button class="auth-close" id="authClose" type="button">✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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 class="cat-filters" id="catFilters" style="padding: 4px 16px 6px; display: flex; flex-wrap: wrap; gap: 6px;"></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>
|
||
<span id="noteStats" style="color:#999;font-size:12px;margin-left:8px;"></span>
|
||
<div class="tags" id="noteTags"></div>
|
||
<button id="favBtn" class="fav-btn" style="display:none;" onclick="toggleFavoritePublic()">
|
||
<span id="favStar" style="color:#ccc;">★</span>
|
||
<span id="favText">收藏</span>
|
||
</button>
|
||
</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();
|
||
let currentUser = null; // 当前登录用户
|
||
let registrationEnabled = true; // 是否开放注册
|
||
let authMode = 'login';
|
||
|
||
async function init() {
|
||
await checkAuth();
|
||
await loadTree();
|
||
await loadCategories();
|
||
}
|
||
|
||
// ─────────── 认证 ───────────
|
||
async function checkAuth() {
|
||
try {
|
||
const res = await fetch(`${API}/auth/me`);
|
||
const data = await res.json();
|
||
const me = data.data;
|
||
currentUser = me && me.id ? me : null;
|
||
registrationEnabled = !me || me.registration_enabled !== false;
|
||
} catch (e) {
|
||
currentUser = null;
|
||
registrationEnabled = true;
|
||
}
|
||
renderUserArea();
|
||
}
|
||
|
||
function renderUserArea() {
|
||
const area = document.getElementById('userArea');
|
||
if (!area) return;
|
||
if (currentUser) {
|
||
area.innerHTML = `
|
||
<span class="auth-user">你好,<b>${escapeHtml(currentUser.display_name || currentUser.username)}</b></span>
|
||
<button class="auth-link" onclick="logout()">退出</button>
|
||
`;
|
||
} else {
|
||
const regBtn = registrationEnabled
|
||
? `<button class="auth-link" onclick="openAuth('register')">注册</button>`
|
||
: '';
|
||
area.innerHTML = `
|
||
<button class="auth-link" onclick="openAuth('login')">登录</button>
|
||
${regBtn}
|
||
`;
|
||
}
|
||
// 同步登录弹窗:注册开关关闭时隐藏「去注册」
|
||
const switchEl = document.getElementById('authSwitch');
|
||
if (switchEl) {
|
||
switchEl.style.display = registrationEnabled ? '' : 'none';
|
||
}
|
||
}
|
||
|
||
function openAuth(mode) {
|
||
if (mode === 'register' && !registrationEnabled) mode = 'login';
|
||
authMode = mode;
|
||
document.getElementById('authTitle').textContent = mode === 'login' ? '登录' : '注册';
|
||
document.getElementById('authDisplayGroup').style.display = mode === 'login' ? 'none' : 'block';
|
||
document.getElementById('authSwitch').textContent = mode === 'login' ? '没有账号?注册' : '已有账号?登录';
|
||
document.getElementById('authSubmit').textContent = mode === 'login' ? '登 录' : '注 册';
|
||
document.getElementById('authError').style.display = 'none';
|
||
document.getElementById('authModal').style.display = 'flex';
|
||
}
|
||
function closeAuth() {
|
||
document.getElementById('authModal').style.display = 'none';
|
||
}
|
||
document.getElementById('authClose').addEventListener('click', closeAuth);
|
||
document.getElementById('authSwitch').addEventListener('click', () => {
|
||
openAuth(authMode === 'login' ? 'register' : 'login');
|
||
});
|
||
document.getElementById('authSubmit').addEventListener('click', async () => {
|
||
const username = document.getElementById('authUsername').value.trim();
|
||
const password = document.getElementById('authPassword').value;
|
||
const displayName = document.getElementById('authDisplay').value.trim();
|
||
const errEl = document.getElementById('authError');
|
||
errEl.style.display = 'none';
|
||
if (!username || !password) {
|
||
errEl.textContent = '请输入用户名和密码';
|
||
errEl.style.display = 'block';
|
||
return;
|
||
}
|
||
try {
|
||
const url = authMode === 'login' ? `${API}/auth/login` : `${API}/auth/register`;
|
||
const body = authMode === 'login'
|
||
? { username, password }
|
||
: { username, password, display_name: displayName };
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
closeAuth();
|
||
await checkAuth();
|
||
await loadTree(); // 登录后树变为自己的笔记
|
||
await loadCategories();
|
||
if (currentFilter.type === 'favorites') await filterFavorites();
|
||
} else {
|
||
errEl.textContent = data.message;
|
||
errEl.style.display = 'block';
|
||
}
|
||
} catch (e) {
|
||
errEl.textContent = '请求失败,请重试';
|
||
errEl.style.display = 'block';
|
||
}
|
||
});
|
||
async function logout() {
|
||
await fetch(`${API}/auth/logout`, { method: 'POST' });
|
||
currentUser = null;
|
||
renderUserArea();
|
||
await loadTree(); // 登出后树变为公开笔记
|
||
await loadCategories();
|
||
if (currentFilter.type === 'favorites') await filterFavorites();
|
||
}
|
||
|
||
// 前台收藏按钮:登录用户可收藏/取消收藏自己的笔记
|
||
async function toggleFavoritePublic() {
|
||
if (!currentUser || !currentNote) {
|
||
showToast('请先登录', 'error');
|
||
return;
|
||
}
|
||
const newVal = !(currentNote.is_favorite === true);
|
||
try {
|
||
const res = await fetch(`/admin/api/notes/${currentNote.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ is_favorite: newVal })
|
||
});
|
||
const data = await res.json();
|
||
if (data.code !== 0) {
|
||
showToast(data.message || '操作失败', 'error');
|
||
return;
|
||
}
|
||
currentNote.is_favorite = newVal;
|
||
updateFavUI();
|
||
showToast(newVal ? '已收藏' : '已取消收藏', 'success');
|
||
} catch (e) {
|
||
showToast('操作失败', 'error');
|
||
}
|
||
}
|
||
function updateFavUI() {
|
||
const btn = document.getElementById('favBtn');
|
||
if (!btn) return;
|
||
const fav = currentNote && currentNote.is_favorite === true;
|
||
btn.style.display = currentUser ? 'inline-flex' : 'none';
|
||
btn.classList.toggle('on', !!fav);
|
||
document.getElementById('favStar').textContent = fav ? '★' : '☆';
|
||
document.getElementById('favText').textContent = fav ? '已收藏' : '收藏';
|
||
document.getElementById('favStar').style.color = fav ? '#f5a623' : '#ccc';
|
||
}
|
||
|
||
// 加载分类并在筛选区渲染 chips
|
||
async function loadCategories() {
|
||
try {
|
||
const res = await fetch(`${API}/categories`);
|
||
const data = await res.json();
|
||
const cats = data.data || [];
|
||
const box = document.getElementById('catFilters');
|
||
if (!box) return;
|
||
if (cats.length === 0) {
|
||
box.innerHTML = '';
|
||
return;
|
||
}
|
||
box.innerHTML = cats.map(cat =>
|
||
`<span class="filter-chip ${currentFilter.type === 'category' && currentFilter.value === cat ? 'active' : ''}" onclick="filterCategory('${escapeHtml(cat)}')">${escapeHtml(cat)}</span>`
|
||
).join('');
|
||
} catch (e) { /* 忽略 */ }
|
||
}
|
||
|
||
// 按分类筛选
|
||
async function filterCategory(cat) {
|
||
currentFilter = { type: 'category', value: cat };
|
||
updateFilterUI();
|
||
renderCatFilters();
|
||
try {
|
||
const res = await fetch(`${API}/notes?category=${encodeURIComponent(cat)}&page_size=100`);
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
flatNotes = data.data || [];
|
||
renderFlatNotes();
|
||
}
|
||
} catch (err) {
|
||
showToast('加载失败', 'error');
|
||
}
|
||
}
|
||
|
||
function renderCatFilters() {
|
||
const box = document.getElementById('catFilters');
|
||
if (!box) return;
|
||
const chips = box.querySelectorAll('.filter-chip');
|
||
chips.forEach(chip => {
|
||
chip.classList.toggle('active',
|
||
currentFilter.type === 'category' && chip.textContent === currentFilter.value);
|
||
});
|
||
}
|
||
|
||
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;
|
||
|
||
// 字数统计
|
||
const statsEl = document.getElementById('noteStats');
|
||
if (statsEl) {
|
||
const text = (currentNote.content || '').replace(/\s+/g, '');
|
||
const words = text.length;
|
||
const readMin = Math.max(1, Math.ceil(words / 400));
|
||
statsEl.textContent = `${words} 字 · 约 ${readMin} 分钟阅读`;
|
||
}
|
||
|
||
// 生成目录
|
||
generateTOC(currentNote.content || '');
|
||
|
||
// 渲染 mermaid 图表(异步)
|
||
setTimeout(renderMermaid, 50);
|
||
|
||
// 更新收藏按钮状态
|
||
updateFavUI();
|
||
}
|
||
|
||
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();
|
||
renderCatFilters();
|
||
await loadTree();
|
||
}
|
||
|
||
async function filterFavorites() {
|
||
currentFilter = { type: 'favorites' };
|
||
updateFilterUI();
|
||
renderCatFilters();
|
||
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 渲染
|
||
// Mermaid 图表渲染支持
|
||
let mmdCounter = 0;
|
||
let mmdItems = [];
|
||
|
||
// 渲染页面中的 mermaid 图表
|
||
function renderMermaid() {
|
||
if (typeof mermaid === 'undefined' || !mmdItems.length) return;
|
||
const items = mmdItems.splice(0, mmdItems.length);
|
||
items.forEach(item => {
|
||
const el = document.getElementById(item.uid);
|
||
if (!el) return;
|
||
try {
|
||
mermaid.render(item.uid, item.code, el).then(({svg}) => {
|
||
el.innerHTML = svg;
|
||
}).catch(() => {
|
||
el.innerHTML = '<pre style="color:#c0392b;background:#fdf0f0;padding:12px;border-radius:6px;">Mermaid 渲染失败(请检查图表语法)</pre>';
|
||
});
|
||
} catch(e) {
|
||
el.innerHTML = '<pre style="color:#c0392b;background:#fdf0f0;padding:12px;border-radius:6px;">Mermaid 渲染失败(请检查图表语法)</pre>';
|
||
}
|
||
});
|
||
}
|
||
|
||
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>');
|
||
|
||
// 恢复代码块并应用高亮(mermaid 代码块渲染为图表)
|
||
codeBlocks.forEach((block, i) => {
|
||
let rendered;
|
||
if (block.lang === 'mermaid') {
|
||
// 生成 mermaid 图表占位
|
||
const uid = 'mmd_' + (++mmdCounter);
|
||
mmdItems.push({ uid: uid, code: block.code });
|
||
rendered = `<div class="mermaid" id="${uid}">${block.code}</div>`;
|
||
} else {
|
||
const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
|
||
rendered = `<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`;
|
||
}
|
||
text = text.replace(`__CODE_BLOCK_${i}__`, rendered);
|
||
});
|
||
|
||
// 标题(带 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>');
|
||
|
||
// 待办清单(- [ ] / - [x])
|
||
text = text.replace(/^[-*] \[([ xX])\] (.*$)/gm, (m, checked, content) => {
|
||
const state = (checked === 'x' || checked === 'X') ? 'checked' : '';
|
||
const cls = state ? 'task-done' : '';
|
||
return `<li class="task-item"><input type="checkbox" class="task-checkbox" ${state} disabled> <span class="${cls}">${content}</span></li>`;
|
||
});
|
||
|
||
// 引用
|
||
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>`;
|
||
}
|
||
|
||
// 深色模式切换(前台)
|
||
function toggleSiteDark() {
|
||
const isDark = document.body.classList.toggle('dark');
|
||
localStorage.setItem('nm_dark', isDark ? '1' : '0');
|
||
document.getElementById('darkBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
|
||
}
|
||
function initSiteDark() {
|
||
if (localStorage.getItem('nm_dark') === '1') {
|
||
document.body.classList.add('dark');
|
||
document.getElementById('darkBtn').textContent = '☀️ 白天';
|
||
}
|
||
}
|
||
|
||
// 移动端:汉堡菜单切换左侧目录
|
||
function toggleSidebar() {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
sidebar.classList.toggle('show');
|
||
}
|
||
|
||
// PWA:注册 Service Worker(简单、幂等;失败不阻塞页面)
|
||
function registerServiceWorker() {
|
||
if (!('serviceWorker' in navigator)) return;
|
||
window.addEventListener('load', function() {
|
||
navigator.serviceWorker.register('/sw.js').catch(function(err) {
|
||
console.warn('Service Worker 注册失败:', err);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 菜单按钮绑定
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const menuBtn = document.getElementById('menuBtn');
|
||
if (menuBtn) menuBtn.addEventListener('click', toggleSidebar);
|
||
});
|
||
|
||
init();
|
||
initSiteDark();
|
||
registerServiceWorker();
|
||
</script>
|
||
</body>
|
||
</html>
|