Files
note-manager/web/index.html
T
Your Name d9793300f9 feat: 多租户账号体系 + 前台收藏按钮
- 新增 users 表(user_id 数据隔离,bcrypt 密码)
- 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据
- 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离
- 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记
- 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404
- 冒烟测试重构+新增多租户隔离用例(78/78)
2026-08-11 12:58:12 +08:00

1439 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 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();
currentUser = data.code === 0 && data.data ? data.data : null;
} catch (e) {
currentUser = null;
}
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 {
area.innerHTML = `
<button class="auth-link" onclick="openAuth('login')">登录</button>
<button class="auth-link" onclick="openAuth('register')">注册</button>
`;
}
}
function openAuth(mode) {
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>