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
This commit is contained in:
Your Name
2026-08-11 11:21:29 +08:00
parent f0eb822e68
commit 74fa759274
18 changed files with 1968 additions and 228 deletions
+275 -7
View File
@@ -317,12 +317,32 @@
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>
@@ -364,6 +384,8 @@
<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>
@@ -418,7 +440,7 @@
<div class="modal" id="deleteModal">
<div class="modal-content">
<h3>确认删除</h3>
<p id="deleteMessage">确定要删除吗?此操作不可撤销</p>
<p id="deleteMessage">确定要删除吗?删除后可在回收站中恢复</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
<button class="btn btn-danger" onclick="doDelete()">删除</button>
@@ -426,6 +448,61 @@
</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>
@@ -574,7 +651,7 @@
// 选择项目
async function selectItem(id) {
try {
const res = await fetch(`${API}/notes/${id}`);
const res = await fetch(`${ADMIN_API}/notes/${id}`);
const data = await res.json();
if (data.code === 0) {
currentItem = data.data;
@@ -660,14 +737,14 @@
let res, data;
if (editId) {
// 更新
res = await fetch(`${API}/notes/${editId}`, {
res = await fetch(`${ADMIN_API}/notes/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: name })
});
} else {
// 新建
res = await fetch(`${API}/notes`, {
res = await fetch(`${ADMIN_API}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
@@ -749,14 +826,14 @@
let res, data;
if (currentItem && currentItem.id && !currentItem.is_folder) {
// 更新
res = await fetch(`${API}/notes/${currentItem.id}`, {
res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
// 新建
res = await fetch(`${API}/notes`, {
res = await fetch(`${ADMIN_API}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
@@ -837,7 +914,7 @@
if (!currentItem || !currentItem.id) return;
try {
const res = await fetch(`${API}/notes/${currentItem.id}`, { method: 'DELETE' });
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, { method: 'DELETE' });
const data = await res.json();
if (data.code === 0) {
@@ -1031,6 +1108,197 @@
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>