Files
note-manager/web/admin/index.html
T
Your Name 74fa759274 feat: 云笔记增强 - 安全加固 + 回收站/版本历史/分享/批量导出 + 前端优化
- 安全: 认证改随机token会话(弃固定cookie), 笔记密码SHA256升级为bcrypt(自动迁移),
  堵住GET /api/notes/:id泄露带密码笔记, CORS收紧+SameSite防CSRF, 上传图片内容嗅探
- 回收站: 软删除(deleted_at), 列表/恢复/彻底删除/清空, 目录子树连删连恢复
- 版本历史: note_versions表存快照, 每次保存自动留档, 支持查看/回滚
- 分享: 生成随机token分享链接, 支持过期时间, 公开阅读页share.html
- 批量导出: 全部笔记打包zip(按目录结构+front matter)
- 前端: 深色模式, Mermaid图表, 待办清单checkbox, 字数统计;
  后台新增回收站/历史/分享面板和批量导出按钮
- 新增deploy/note-manager.service systemd单元与smoke_test.py
2026-08-11 11:21:29 +08:00

1305 lines
58 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: #f5f5f5;
height: 100vh;
overflow: hidden;
}
header {
background: #fff;
padding: 12px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
}
header h1 { font-size: 18px; color: #333; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary { background: #1a73e8; color: #fff; }
.btn-primary:hover { background: #1557b0; }
.btn-secondary { background: #f1f3f4; color: #333; }
.btn-secondary:hover { background: #e8eaed; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-danger:hover { background: #c82333; }
.btn-icon {
padding: 8px;
background: transparent;
color: #666;
min-width: 36px;
}
.btn-icon:hover { background: #f1f3f4; color: #333; }
.main {
display: flex;
height: calc(100vh - 56px);
}
/* 左侧边栏 - 树形目录 */
.sidebar {
width: 280px;
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 { font-size: 14px; color: #666; }
.tree-container {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.tree-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
user-select: none;
}
.tree-item:hover { background: #f1f3f4; }
.tree-item.active { background: #e8f0fe; color: #1a73e8; }
.tree-item.folder { font-weight: 500; }
.tree-item .icon {
width: 20px;
height: 20px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.tree-item .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-item .actions {
display: none;
gap: 4px;
}
.tree-item:hover .actions { display: flex; }
.tree-item .actions button {
padding: 2px 6px;
font-size: 12px;
background: transparent;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.tree-item .actions button:hover { background: #e8eaed; }
.tree-children { padding-left: 20px; }
/* 右侧内容区 - 左右分栏 */
.content {
flex: 1;
display: flex;
overflow: hidden;
}
/* 编辑区 */
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #e0e0e0;
background: #fff;
}
.editor-header {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 12px;
}
.editor-header input {
flex: 1;
padding: 10px 14px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
}
.editor-header input:focus {
outline: none;
border-color: #1a73e8;
}
.editor-meta {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.editor-meta input {
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 13px;
}
.editor-meta input:focus {
outline: none;
border-color: #1a73e8;
}
.editor-content {
flex: 1;
padding: 16px;
}
.editor-content textarea {
width: 100%;
height: 100%;
border: none;
resize: none;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 14px;
line-height: 1.7;
padding: 0;
outline: none;
}
/* 预览区 */
.preview-pane {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
}
.preview-header {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 8px;
}
.preview-header svg { width: 18px; height: 18px; }
.preview-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.preview-content h1, .preview-content h2, .preview-content h3 {
margin: 20px 0 10px 0;
color: #202124;
}
.preview-content h1 { font-size: 28px; }
.preview-content h2 { font-size: 22px; }
.preview-content h3 { font-size: 18px; }
.preview-content p { margin: 10px 0; line-height: 1.8; }
.preview-content code {
background: #f1f3f4;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
.preview-content pre {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
}
.preview-content pre code { background: none; padding: 0; }
.preview-content blockquote {
border-left: 4px solid #1a73e8;
padding-left: 16px;
margin: 16px 0;
color: #555;
}
.preview-content ul, .preview-content ol {
margin: 10px 0;
padding-left: 24px;
}
.preview-content li { margin: 6px 0; }
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-state svg {
width: 64px;
height: 64px;
fill: #ddd;
margin-bottom: 16px;
}
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: #666; }
.empty-state p { font-size: 14px; }
/* 弹窗 */
.modal {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal.active { display: flex; }
.modal-content {
background: #fff;
padding: 24px;
border-radius: 12px;
max-width: 400px;
width: 90%;
}
.modal-content h3 { margin-bottom: 16px; font-size: 16px; }
.modal-content input {
width: 100%;
padding: 10px 14px;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
/* Toast */
.toast {
position: fixed;
bottom: 20px; right: 20px;
padding: 12px 20px;
background: #333;
color: #fff;
border-radius: 8px;
display: none;
z-index: 1001;
}
.toast.show { display: block; }
.toast.success { background: #28a745; }
.toast.error { background: #dc3545; }
/* Resize handle */
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.2s;
}
.resize-handle:hover { background: #1a73e8; }
/* ───── 深色模式覆盖 ───── */
body.dark { background: #1a1d21; color: #e0e0e0; }
body.dark header { background: #20242a; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
body.dark .sidebar { background: #23272e; border-color: #333; }
body.dark .content { background: #1a1d21; }
body.dark .tree-item:hover { background: #2d323a; }
body.dark .tree-item.active { background: #355; color:#7ab8ff; }
body.dark .editor-pane, body.dark .preview-pane { background: #1e2228; border-color:#333; }
body.dark input, body.dark textarea, body.dark select {
background: #24282f; color: #e0e0e0; border-color: #3a3f47;
}
body.dark .modal-content { background: #24282f; color:#e0e0e0; }
body.dark .preview-content code, body.dark .preview-content pre { background:#111; }
body.dark .empty-state { color:#888; }
body.dark .btn-secondary { background:#2d323a; color:#ccc; border-color:#3a3f47; }
body.dark .tree-item .folder { color:#7ab8ff; }
</style>
</head>
<body>
<header>
<h1>笔记管理后台</h1>
<div class="header-actions">
<button class="btn btn-secondary" onclick="toggleDarkMode()" title="切换深色模式" id="darkModeBtn">🌙 深色</button>
<button class="btn btn-secondary" onclick="exportAll()" title="批量导出全部笔记为 zip">📦 批量导出</button>
<button class="btn btn-secondary" onclick="openTrash()" title="回收站">🗑 回收站</button>
<a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
<button class="btn btn-secondary" onclick="logout()">退出登录</button>
</div>
</header>
<div class="main">
<!-- 左侧边栏 - 树形目录 -->
<div class="sidebar">
<div class="sidebar-header">
<h2>笔记目录</h2>
<div>
<button class="btn btn-icon" onclick="showNewFolderModal(0)" title="新建目录">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z"/></svg>
</button>
<button class="btn btn-icon" onclick="showNewNoteModal(0)" title="新建笔记">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
</div>
</div>
<div class="tree-container" id="treeContainer">
<div class="loading">加载中...</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>
<h3>选择一个笔记或目录</h3>
<p>从左侧列表选择,或点击 + 新建</p>
</div>
<!-- 编辑器和预览(隐藏直到选中笔记) -->
<div id="editorView" style="display: none; flex: 1; display: none; flex-direction: column;">
<!-- 编辑区 -->
<div class="editor-pane" id="editorPane">
<div class="editor-header">
<input type="text" id="noteTitle" placeholder="笔记标题">
<button class="btn btn-primary" onclick="saveNote()">保存</button>
<button class="btn btn-secondary" onclick="exportNote()">导出</button>
<button class="btn btn-secondary" onclick="openVersions()" title="版本历史">🕘 历史</button>
<button class="btn btn-secondary" onclick="openShare()" title="分享链接">🔗 分享</button>
<button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button>
<input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])">
<button class="btn btn-danger" onclick="confirmDelete()">删除</button>
</div>
<div class="editor-meta">
<input type="text" id="noteCategory" placeholder="分类(可选)" style="flex: 1;">
<input type="text" id="noteTags" placeholder="标签,多个用逗号分隔" style="flex: 1;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #666; white-space: nowrap;">
<input type="checkbox" id="notePublic" checked style="width: 16px; height: 16px;">
<svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: #666;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
公开
</label>
<div style="display: flex; align-items: center; gap: 6px; margin-left: 12px;">
<svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: #666;"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
<input type="password" id="notePassword" placeholder="访问密码(可选)" style="width: 120px; padding: 6px 10px; border: 1px solid #e0e0e0; border-radius: 4px; font-size: 12px;">
</div>
</div>
<div class="editor-content">
<textarea id="noteContent" placeholder="使用 Markdown 编写内容..."></textarea>
</div>
</div>
<div class="resize-handle" id="resizeHandle"></div>
<!-- 预览区 -->
<div class="preview-pane" id="previewPane">
<div class="preview-header">
<svg viewBox="0 0 24 24" fill="#666"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
预览
</div>
<div class="preview-content" id="previewContent">
<p style="color: #999;">开始编辑以查看预览...</p>
</div>
</div>
</div>
</div>
</div>
<!-- 新建/编辑目录弹窗 -->
<div class="modal" id="folderModal">
<div class="modal-content">
<h3 id="folderModalTitle">新建目录</h3>
<input type="hidden" id="folderParentId">
<input type="hidden" id="folderEditId">
<input type="text" id="folderName" placeholder="目录名称">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeFolderModal()">取消</button>
<button class="btn btn-primary" onclick="saveFolder()">保存</button>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div class="modal" id="deleteModal">
<div class="modal-content">
<h3>确认删除</h3>
<p id="deleteMessage">确定要删除吗?删除后可在回收站中恢复。</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
<button class="btn btn-danger" onclick="doDelete()">删除</button>
</div>
</div>
</div>
<!-- 回收站弹窗 -->
<div class="modal" id="trashModal">
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🗑 回收站 <span style="font-size:13px;color:#999;font-weight:normal;">(删除的笔记会在这里保留,可恢复或彻底清除)</span></h3>
<div style="flex:1; overflow-y:auto;" id="trashList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
<div class="modal-actions" style="margin-top:12px;justify-content:space-between;">
<button class="btn btn-danger" onclick="emptyTrash()">清空回收站</button>
<div>
<button class="btn btn-secondary" onclick="closeTrash()">关闭</button>
</div>
</div>
</div>
</div>
<!-- 版本历史弹窗 -->
<div class="modal" id="versionsModal">
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🕘 版本历史</h3>
<div style="flex:1; overflow-y:auto;" id="versionsList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
<div class="modal-actions" style="margin-top:12px;">
<button class="btn btn-secondary" onclick="closeVersions()">关闭</button>
</div>
</div>
</div>
<!-- 分享弹窗 -->
<div class="modal" id="shareModal">
<div class="modal-content" style="min-width: 520px;">
<h3 style="margin-bottom: 16px;">🔗 分享链接</h3>
<div id="shareNoToken" style="display:block;">
<p style="color:#666;font-size:14px;margin-bottom:14px;">生成一个链接,无需登录即可查看该笔记。</p>
<label style="font-size:13px;color:#888;">可选:过期时间</label>
<select id="shareExpire" style="width:100%;padding:8px;margin:6px 0 14px;border:1px solid #ccc;border-radius:6px;">
<option value="0">永久有效</option>
<option value="24">24 小时</option>
<option value="72">3 天</option>
<option value="168">7 天</option>
<option value="720">30 天</option>
</select>
<button class="btn btn-primary" style="width:100%;" onclick="createShare()">生成分享链接</button>
</div>
<div id="shareHasToken" style="display:none;">
<label style="font-size:13px;color:#888;">分享链接</label>
<div style="display:flex;gap:8px;margin:6px 0 12px;">
<input type="text" id="shareUrl" readonly style="flex:1;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f5f5f5;">
<button class="btn btn-secondary" onclick="copyShareUrl()">复制</button>
</div>
<button class="btn btn-danger" style="width:100%;" onclick="revokeShare()">撤销分享</button>
</div>
<div class="modal-actions" style="margin-top:16px;">
<button class="btn btn-secondary" onclick="closeShare()">关闭</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const API = '/api';
const ADMIN_API = '/admin/api';
let treeData = [];
let currentItem = null;
let isFolderMode = false;
// 初始化
async function init() {
await loadTree();
setupResize();
setupImagePaste();
}
// 设置粘贴图片监听
function setupImagePaste() {
const textarea = document.getElementById('noteContent');
if (!textarea) return;
textarea.addEventListener('paste', async (e) => {
// 检查剪贴板中是否有图片
const items = e.clipboardData?.items;
if (!items) return;
for (let item of items) {
if (item.type.indexOf('image') === 0) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
// 显示上传提示
const originalValue = textarea.value;
const cursorPos = textarea.selectionStart;
const beforeCursor = originalValue.substring(0, cursorPos);
const afterCursor = originalValue.substring(cursorPos);
textarea.value = beforeCursor + '\n![上传中...](uploading)\n' + afterCursor;
textarea.selectionStart = textarea.selectionEnd = cursorPos + 14;
textarea.focus();
try {
const url = await uploadImage(file);
// 替换上传提示为实际图片
textarea.value = textarea.value.replace('![上传中...](uploading)', `![image](${url})`);
// 手动触发预览更新
updatePreview();
} catch (err) {
// 替换为错误信息
textarea.value = textarea.value.replace('![上传中...](uploading)', '![上传失败](failed)');
showToast('图片上传失败', 'error');
// 手动触发预览更新
updatePreview();
}
return;
}
}
});
}
// 上传图片
async function uploadImage(file) {
const formData = new FormData();
formData.append('image', file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(data.message || '上传失败');
}
return data.data.url;
}
// 加载树形数据
async function loadTree() {
try {
const res = await fetch(`/admin/api/tree`);
const data = await res.json();
if (data.code === 0) {
treeData = data.data;
renderTree();
}
} catch (err) {
showToast('加载目录失败', 'error');
}
}
// 渲染树形结构
function renderTree() {
const container = document.getElementById('treeContainer');
if (treeData.length === 0) {
container.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">暂无内容,点击 + 新建</div>';
return;
}
// 构建树形结构
const rootItems = treeData.filter(item => item.parent_id === 0);
container.innerHTML = renderTreeItems(rootItems);
}
function renderTreeItems(items) {
return items.map(item => {
const children = treeData.filter(c => c.parent_id === item.id);
const hasChildren = children.length > 0;
const isActive = currentItem && currentItem.id === item.id;
if (item.is_folder) {
return `
<div class="tree-item folder ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
<span class="icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="${isActive ? '#1a73e8' : '#fbbc04'}">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
</svg>
</span>
<span class="title">${escapeHtml(item.title)}</span>
<div class="actions">
<button onclick="event.stopPropagation(); showNewFolderModal(${item.id})">+目录</button>
<button onclick="event.stopPropagation(); showNewNoteModal(${item.id})">+笔记</button>
</div>
</div>
${hasChildren ? `<div class="tree-children">${renderTreeItems(children)}</div>` : ''}
`;
} else {
return `
<div class="tree-item ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#666">
<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>
</span>
<span class="title">${escapeHtml(item.title)}</span>
</div>
`;
}
}).join('');
}
// 选择项目
async function selectItem(id) {
try {
const res = await fetch(`${ADMIN_API}/notes/${id}`);
const data = await res.json();
if (data.code === 0) {
currentItem = data.data;
isFolderMode = currentItem.is_folder;
document.getElementById('emptyState').style.display = 'none';
const editorView = document.getElementById('editorView');
if (isFolderMode) {
// 目录模式
document.getElementById('editorPane').style.display = 'none';
document.getElementById('resizeHandle').style.display = 'none';
document.getElementById('previewPane').style.display = 'none';
editorView.style.display = 'flex';
editorView.innerHTML = `
<div style="padding: 40px; text-align: center;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="#fbbc04" style="margin-bottom: 16px;">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
</svg>
<h3 style="margin-bottom: 8px;">${escapeHtml(currentItem.title)}</h3>
<p style="color: #666; margin-bottom: 20px;">目录:包含 ${treeData.filter(t => t.parent_id === id).length} 个项目</p>
<button class="btn btn-secondary" onclick="showEditFolderModal()">编辑目录</button>
</div>
`;
} else {
// 笔记模式
document.getElementById('editorPane').style.display = 'flex';
document.getElementById('resizeHandle').style.display = 'block';
document.getElementById('previewPane').style.display = 'flex';
editorView.style.display = 'flex';
editorView.style.flexDirection = 'row';
document.getElementById('noteTitle').value = currentItem.title || '';
document.getElementById('noteCategory').value = currentItem.category || '';
document.getElementById('noteTags').value = parseTags(currentItem.tags).join(', ');
document.getElementById('noteContent').value = currentItem.content || '';
document.getElementById('notePublic').checked = currentItem.is_public !== false;
document.getElementById('notePassword').value = ''; // 不显示密码
updatePreview();
}
renderTree();
}
} catch (err) {
showToast('加载失败', 'error');
}
}
// 显示新建目录弹窗
function showNewFolderModal(parentId) {
document.getElementById('folderModalTitle').textContent = '新建目录';
document.getElementById('folderParentId').value = parentId;
document.getElementById('folderEditId').value = '';
document.getElementById('folderName').value = '';
document.getElementById('folderModal').classList.add('active');
}
// 显示编辑目录弹窗
function showEditFolderModal() {
if (!currentItem) return;
document.getElementById('folderModalTitle').textContent = '编辑目录';
document.getElementById('folderParentId').value = currentItem.parent_id;
document.getElementById('folderEditId').value = currentItem.id;
document.getElementById('folderName').value = currentItem.title;
document.getElementById('folderModal').classList.add('active');
}
function closeFolderModal() {
document.getElementById('folderModal').classList.remove('active');
}
async function saveFolder() {
const name = document.getElementById('folderName').value.trim();
const parentId = parseInt(document.getElementById('folderParentId').value) || 0;
const editId = document.getElementById('folderEditId').value;
if (!name) {
showToast('请输入目录名称', 'error');
return;
}
try {
let res, data;
if (editId) {
// 更新
res = await fetch(`${ADMIN_API}/notes/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: name })
});
} else {
// 新建
res = await fetch(`${ADMIN_API}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
});
}
data = await res.json();
if (data.code === 0) {
showToast(editId ? '目录已更新' : '目录已创建', 'success');
closeFolderModal();
await loadTree();
if (editId && currentItem && currentItem.id == editId) {
selectItem(parseInt(editId));
}
} else {
showToast(data.message || '保存失败', 'error');
}
} catch (err) {
showToast('保存失败', 'error');
}
}
// 显示新建笔记弹窗
function showNewNoteModal(parentId) {
if (!parentId && parentId !== 0) return;
currentItem = { parent_id: parentId || 0, is_folder: false, id: null };
document.getElementById('emptyState').style.display = 'none';
document.getElementById('editorView').style.display = 'flex';
document.getElementById('editorView').style.flexDirection = 'row';
document.getElementById('editorPane').style.display = 'flex';
document.getElementById('resizeHandle').style.display = 'block';
document.getElementById('previewPane').style.display = 'flex';
document.getElementById('noteTitle').value = '';
document.getElementById('noteCategory').value = '';
document.getElementById('noteTags').value = '';
document.getElementById('noteContent').value = '';
updatePreview();
}
// 保存笔记
async function saveNote() {
const title = document.getElementById('noteTitle').value.trim();
const content = document.getElementById('noteContent').value;
const category = document.getElementById('noteCategory').value.trim();
const tagsInput = document.getElementById('noteTags').value.trim();
const isPublic = document.getElementById('notePublic').checked;
const password = document.getElementById('notePassword').value;
if (!title) {
showToast('请输入标题', 'error');
return;
}
let tags = '[]';
if (tagsInput) {
const arr = tagsInput.split(',').map(t => t.trim()).filter(t => t);
tags = JSON.stringify(arr);
}
const payload = {
title,
content,
category,
tags,
is_public: isPublic,
parent_id: currentItem && currentItem.parent_id ? currentItem.parent_id : 0
};
// 只有填写了密码才传递
if (password) {
payload.password = password;
} else {
// 如果没填密码但原有笔记有密码,不清除
// 如果没填密码且原有笔记也没有密码,不传
payload.remove_password = !currentItem?.has_password;
}
try {
let res, data;
if (currentItem && currentItem.id && !currentItem.is_folder) {
// 更新
res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
// 新建
res = await fetch(`${ADMIN_API}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
data = await res.json();
if (data.code === 0) {
showToast(currentItem && currentItem.id ? '笔记已更新' : '笔记已创建', 'success');
await loadTree();
if (data.data && data.data.id) {
selectItem(data.data.id);
}
} else {
showToast(data.message || '保存失败', 'error');
}
} catch (err) {
showToast('保存失败', 'error');
}
}
// 导出笔记为 Markdown 文件
function exportNote() {
if (!currentItem || !currentItem.id || currentItem.is_folder) {
showToast('请先选择一个笔记(不是目录)', 'error');
return;
}
window.location.href = `${ADMIN_API}/export/${currentItem.id}`;
}
// 导入 Markdown 文件
async function importNote(file) {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`${ADMIN_API}/import`, {
method: 'POST',
credentials: 'include',
body: formData
});
const data = await res.json();
if (data.code === 0) {
showToast('导入成功', 'success');
await loadTree();
if (data.data && data.data.id) {
selectItem(data.data.id);
}
} else {
showToast(data.message || '导入失败', 'error');
}
} catch (err) {
showToast('导入失败', 'error');
}
// 清空文件输入
document.getElementById('importFile').value = '';
}
// 删除确认
function confirmDelete() {
if (!currentItem || !currentItem.id) return;
const message = currentItem.is_folder
? '确定要删除这个目录及其下所有内容吗?此操作不可撤销。'
: '确定要删除这篇笔记吗?此操作不可撤销。';
document.getElementById('deleteMessage').textContent = message;
document.getElementById('deleteModal').classList.add('active');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('active');
}
async function doDelete() {
if (!currentItem || !currentItem.id) return;
try {
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, { method: 'DELETE' });
const data = await res.json();
if (data.code === 0) {
showToast('已删除', 'success');
closeDeleteModal();
currentItem = null;
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('editorView').style.display = 'none';
await loadTree();
} else {
showToast(data.message || '删除失败', 'error');
}
} catch (err) {
showToast('删除失败', 'error');
}
}
// 更新预览
function updatePreview() {
const content = document.getElementById('noteContent').value;
document.getElementById('previewContent').innerHTML = renderMarkdown(content);
}
// 监听输入更新预览
document.addEventListener('DOMContentLoaded', () => {
const contentArea = document.getElementById('noteContent');
if (contentArea) {
contentArea.addEventListener('input', updatePreview);
}
init();
});
// 退出登录
async function logout() {
await fetch('/admin/logout', { method: 'POST' });
window.location.href = '/admin/login';
}
// 工具函数
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 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>';
// 第一步:所有 Markdown 语法先转为占位符,不输出 HTML
const placeholders = [];
let placeholderIndex = 0;
// 代码块占位符
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, function(match, lang, code) {
const ph = '__PH_' + placeholderIndex++ + '__';
placeholders.push({ ph: ph, type: 'code', lang: lang || 'plaintext', code: code.trim() });
return ph;
});
// 图片占位符
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
const ph = '__PH_' + placeholderIndex++ + '__';
placeholders.push({ ph: ph, type: 'img', src: src, alt: alt });
return ph;
});
// 链接占位符
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, href) {
const ph = '__PH_' + placeholderIndex++ + '__';
placeholders.push({ ph: ph, type: 'link', text: text, href: href });
return ph;
});
// 行内代码占位符
text = text.replace(/`([^`]+)`/g, function(match, code) {
const ph = '__PH_' + placeholderIndex++ + '__';
placeholders.push({ ph: ph, type: 'inlinecode', code: code });
return ph;
});
// 标题占位符
text = text.replace(/^### (.+)$/gm, function(match, content) {
const ph = '__PH_' + placeholderIndex++ + '__';
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
placeholders.push({ ph: ph, type: 'h3', content: content, id: id });
return ph;
});
text = text.replace(/^## (.+)$/gm, function(match, content) {
const ph = '__PH_' + placeholderIndex++ + '__';
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
placeholders.push({ ph: ph, type: 'h2', content: content, id: id });
return ph;
});
text = text.replace(/^# (.+)$/gm, function(match, content) {
const ph = '__PH_' + placeholderIndex++ + '__';
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
placeholders.push({ ph: ph, type: 'h1', content: content, id: id });
return ph;
});
// 转义剩余的 HTML 特殊字符
text = escapeHtml(text);
// 恢复所有占位符为 HTML
placeholders.forEach(function(item) {
let html = '';
switch(item.type) {
case 'code':
var highlighted = hljs.highlightAuto(item.code, item.lang !== 'plaintext' ? [item.lang] : undefined).value;
html = '<pre><code class="hljs language-' + item.lang + '">' + highlighted + '</code></pre>';
break;
case 'img':
html = '<img src="' + item.src + '" alt="' + item.alt + '" style="max-width: 100%; border-radius: 4px;">';
break;
case 'link':
html = '<a href="' + item.href + '" target="_blank">' + item.text + '</a>';
break;
case 'inlinecode':
html = '<code>' + item.code + '</code>';
break;
case 'h3':
html = '<h3 id="' + item.id + '">' + item.content + '</h3>';
break;
case 'h2':
html = '<h2 id="' + item.id + '">' + item.content + '</h2>';
break;
case 'h1':
html = '<h1 id="' + item.id + '">' + item.content + '</h1>';
break;
}
text = text.replace(item.ph, html);
});
// 其他格式
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(/\n\n/g, '</p><p>');
text = text.replace(/\n/g, '<br>');
return '<p>' + text + '</p>';
}
// 分栏拖动调整
function setupResize() {
const handle = document.getElementById('resizeHandle');
const editorPane = document.getElementById('editorPane');
const previewPane = document.getElementById('previewPane');
let isResizing = false;
handle?.addEventListener('mousedown', (e) => {
isResizing = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const container = document.getElementById('contentArea');
const containerRect = container.getBoundingClientRect();
const percentage = ((e.clientX - containerRect.left) / containerRect.width) * 100;
if (percentage > 20 && percentage < 80) {
editorPane.style.flex = `0 0 ${percentage}%`;
previewPane.style.flex = `0 0 ${100 - percentage}%`;
}
});
document.addEventListener('mouseup', () => {
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
}
// ═══════════ 增强功能:深色模式 / 批量导出 / 回收站 / 版本历史 / 分享 ═══════════
// ── 深色模式 ──
function toggleDarkMode() {
const isDark = document.body.classList.toggle('dark');
localStorage.setItem('nm_dark', isDark ? '1' : '0');
document.getElementById('darkModeBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
}
function initDarkMode() {
const dark = localStorage.getItem('nm_dark') === '1';
if (dark) {
document.body.classList.add('dark');
document.getElementById('darkModeBtn').textContent = '☀️ 白天';
}
}
// ── 批量导出全部笔记为 zip ──
async function exportAll() {
showToast('正在导出全部笔记...', 'info');
try {
const res = await fetch(`${ADMIN_API}/export-all`);
if (!res.ok) { showToast('导出失败', 'error'); return; }
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'notes-export.zip';
a.click();
URL.revokeObjectURL(a.href);
showToast('导出成功', 'success');
} catch (e) {
showToast('导出失败', 'error');
}
}
// ── 回收站 ──
async function openTrash() {
const modal = document.getElementById('trashModal');
modal.style.display = 'flex';
document.getElementById('trashList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
const res = await fetch(`${ADMIN_API}/trash`);
const data = await res.json();
if (data.code === 0) {
renderTrash(data.data);
} else {
document.getElementById('trashList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
}
function closeTrash() {
document.getElementById('trashModal').style.display = 'none';
}
function renderTrash(items) {
const box = document.getElementById('trashList');
if (!items || !items.length) {
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">回收站是空的</p>';
return;
}
const rows = items.map(item => {
const icon = item.is_folder ? '📁' : '📄';
return `<div style="display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid #eee;">
<span>${icon}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(item.title || '(无标题)')}</span>
<span style="font-size:12px;color:#999;">${item.is_folder ? '目录' : ''}</span>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreTrashItem(${item.id})">恢复</button>
<button class="btn btn-danger" style="font-size:12px;padding:4px 10px;" onclick="purgeTrashItem(${item.id})">彻底删除</button>
</div>`;
}).join('');
box.innerHTML = rows;
}
async function restoreTrashItem(id) {
const res = await fetch(`${ADMIN_API}/restore/${id}`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) { showToast('已恢复', 'success'); openTrash(); loadTree(); }
else showToast(data.message || '恢复失败', 'error');
}
async function purgeTrashItem(id) {
if (!confirm('确定彻底删除这条记录吗?此操作不可恢复!')) return;
const res = await fetch(`${ADMIN_API}/purge/${id}`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) { showToast('已彻底删除', 'success'); openTrash(); }
else showToast(data.message || '删除失败', 'error');
}
async function emptyTrash() {
if (!confirm('确定清空回收站吗?所有记录将被彻底删除,不可恢复!')) return;
const res = await fetch(`${ADMIN_API}/empty-trash`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) { showToast('回收站已清空', 'success'); openTrash(); }
else showToast(data.message || '清空失败', 'error');
}
// ── 版本历史 ──
async function openVersions() {
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
if (currentItem.is_folder) { showToast('目录没有版本历史', 'error'); return; }
const modal = document.getElementById('versionsModal');
modal.style.display = 'flex';
document.getElementById('versionsList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/versions`);
const data = await res.json();
if (data.code === 0) renderVersions(data.data);
else document.getElementById('versionsList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
function closeVersions() {
document.getElementById('versionsModal').style.display = 'none';
}
function renderVersions(versions) {
const box = document.getElementById('versionsList');
if (!versions || !versions.length) {
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">暂无历史版本</p>';
return;
}
const rows = versions.map((v, idx) => {
const d = new Date(v.created_at);
const preview = (v.content || '').replace(/[#*`>\-\[\]]/g, '').slice(0, 80);
return `<div style="padding:10px;border-bottom:1px solid #eee;">
<div style="display:flex;align-items:center;gap:8px;">
<strong>${idx === 0 ? '当前版本' : '版本 ' + (versions.length - idx)}</strong>
<span style="font-size:12px;color:#999;">${d.toLocaleString('zh-CN')}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#777;">${escapeHtml(preview)}</span>
${idx !== 0 ? `<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreVersion(${v.id})">恢复此版本</button>` : ''}
</div>
</div>`;
}).join('');
box.innerHTML = rows;
}
async function restoreVersion(versionId) {
if (!confirm('确定恢复到该版本吗?当前内容会先保存为新版本。')) return;
const fd = new FormData();
fd.append('version_id', versionId);
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/restore-version`, { method: 'POST', body: fd });
const data = await res.json();
if (data.code === 0) {
showToast('已恢复', 'success');
closeVersions();
await selectItem(currentItem.id);
loadTree();
} else showToast(data.message || '恢复失败', 'error');
}
// ── 分享 ──
function openShare() {
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
if (currentItem.is_folder) { showToast('目录不能分享', 'error'); return; }
const modal = document.getElementById('shareModal');
modal.style.display = 'flex';
if (currentItem.share_token) {
document.getElementById('shareNoToken').style.display = 'none';
document.getElementById('shareHasToken').style.display = 'block';
document.getElementById('shareUrl').value = location.origin + '/share/' + currentItem.share_token;
} else {
document.getElementById('shareNoToken').style.display = 'block';
document.getElementById('shareHasToken').style.display = 'none';
}
}
function closeShare() {
document.getElementById('shareModal').style.display = 'none';
}
async function createShare() {
const expire = document.getElementById('shareExpire').value;
const fd = new FormData();
fd.append('expire_hours', expire);
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/share`, { method: 'POST', body: fd });
const data = await res.json();
if (data.code === 0) {
showToast('分享链接已生成', 'success');
currentItem.share_token = data.data.share_token;
openShare();
} else showToast(data.message || '生成失败', 'error');
}
async function revokeShare() {
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/revoke-share`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) {
showToast('已撤销分享', 'success');
currentItem.share_token = '';
openShare();
} else showToast(data.message || '操作失败', 'error');
}
function copyShareUrl() {
const url = document.getElementById('shareUrl').value;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(() => showToast('链接已复制', 'success')).catch(() => showToast('复制失败', 'error'));
} else {
document.getElementById('shareUrl').select();
document.execCommand('copy');
showToast('链接已复制', 'success');
}
}
// 初始化深色模式
initDarkMode();
</script>
</body>
</html>