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:
+275
-7
@@ -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>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<!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="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #fafafa; color: #333; line-height: 1.7;
|
||||
}
|
||||
.container { max-width: 860px; margin: 40px auto; background: #fff; padding: 48px; border-radius: 10px; box-shadow: 0 1px 6px rgba(0,0,0,0.08); }
|
||||
.badge { display: inline-block; background: #e8f0fe; color: #1a73e8; padding: 4px 12px; border-radius: 20px; font-size: 13px; margin-bottom: 16px; }
|
||||
h1 { font-size: 28px; margin-bottom: 12px; color: #1a1a1a; }
|
||||
.meta { color: #888; font-size: 14px; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eee; }
|
||||
.tag { display: inline-block; background: #f1f3f4; color: #5f6368; padding: 2px 10px; border-radius: 12px; font-size: 13px; margin-right: 6px; }
|
||||
.note-content { word-wrap: break-word; }
|
||||
.note-content h1,.note-content h2,.note-content h3,.note-content h4 { margin: 20px 0 10px; color: #1a1a1a; }
|
||||
.note-content p { margin: 10px 0; }
|
||||
.note-content pre { background: #f6f8fa; padding: 14px; border-radius: 6px; overflow-x: auto; }
|
||||
.note-content code { background: #f6f8fa; padding: 2px 5px; border-radius: 3px; font-size: 14px; }
|
||||
.note-content pre code { background: none; padding: 0; }
|
||||
.note-content img { max-width: 100%; }
|
||||
.note-content blockquote { border-left: 4px solid #ddd; padding-left: 14px; color: #666; margin: 12px 0; }
|
||||
.note-content table { border-collapse: collapse; margin: 12px 0; }
|
||||
.note-content th,.note-content td { border: 1px solid #ddd; padding: 8px 12px; }
|
||||
.note-content a { color: #1a73e8; }
|
||||
.password-prompt { text-align: center; padding: 60px 20px; }
|
||||
.password-prompt input { padding: 10px 14px; border: 1px solid #ccc; border-radius: 6px; width: 260px; font-size: 14px; }
|
||||
.password-prompt button { padding: 10px 24px; background: #1a73e8; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; margin-left: 8px; }
|
||||
.footer { text-align: center; color: #bbb; font-size: 13px; margin: 24px 0 40px; }
|
||||
.hidden { display: none; }
|
||||
.mermaid { text-align: center; margin: 16px 0; }
|
||||
.task-done { text-decoration: line-through; color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<span class="badge">📄 分享的笔记</span>
|
||||
<div id="noteDetail" class="hidden">
|
||||
<h1 id="noteTitle"></h1>
|
||||
<div class="meta">
|
||||
<span>分类:<span id="noteCategory">未分类</span></span>
|
||||
<span style="margin-left:16px" id="noteDate"></span>
|
||||
<div style="margin-top:10px" id="noteTags"></div>
|
||||
</div>
|
||||
<div class="note-content" id="noteContent"></div>
|
||||
</div>
|
||||
<div class="password-prompt" id="passwordPrompt" style="display:none">
|
||||
<h3>🔒 该笔记已加密</h3>
|
||||
<p style="color:#888;margin:10px 0 20px">请输入访问密码查看内容</p>
|
||||
<input type="password" id="passwordInput" placeholder="输入访问密码">
|
||||
<button onclick="accessWithPassword()">查看</button>
|
||||
<p id="pwdError" style="color:#d32f2f;margin-top:12px"></p>
|
||||
</div>
|
||||
<div id="errorBox" class="password-prompt" style="display:none">
|
||||
<h3>😕 无法访问</h3>
|
||||
<p id="errorMsg" style="color:#888;margin-top:10px"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">Powered by 云笔记</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = '{{.token}}';
|
||||
const API = '/api/share/' + TOKEN;
|
||||
|
||||
async function load(password) {
|
||||
let url = API;
|
||||
if (password) url += '?password=' + encodeURIComponent(password);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
render(data.data);
|
||||
} else if (res.status === 401) {
|
||||
document.getElementById('passwordPrompt').style.display = 'block';
|
||||
document.getElementById('passwordPrompt').style.display = 'flex';
|
||||
} else {
|
||||
document.getElementById('errorBox').style.display = 'block';
|
||||
document.getElementById('errorMsg').textContent = data.message || '笔记不存在或链接已失效';
|
||||
}
|
||||
}
|
||||
|
||||
async function accessWithPassword() {
|
||||
const pwd = document.getElementById('passwordInput').value;
|
||||
document.getElementById('pwdError').textContent = '';
|
||||
let url = API + '?password=' + encodeURIComponent(pwd);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
document.getElementById('passwordPrompt').style.display = 'none';
|
||||
render(data.data);
|
||||
} else {
|
||||
document.getElementById('pwdError').textContent = '密码错误,请重试';
|
||||
}
|
||||
}
|
||||
|
||||
function render(note) {
|
||||
document.getElementById('noteDetail').classList.remove('hidden');
|
||||
document.getElementById('noteTitle').textContent = note.title;
|
||||
if (note.category) document.getElementById('noteCategory').textContent = note.category;
|
||||
if (note.created_at) {
|
||||
const d = new Date(note.created_at);
|
||||
document.getElementById('noteDate').textContent = '发布于 ' + d.toLocaleString('zh-CN');
|
||||
}
|
||||
const tagsBox = document.getElementById('noteTags');
|
||||
try {
|
||||
const tags = JSON.parse(note.tags || '[]');
|
||||
if (Array.isArray(tags) && tags.length) {
|
||||
tagsBox.innerHTML = tags.map(t => `<span class="tag">#${escapeHtml(t)}</span>`).join('');
|
||||
}
|
||||
} catch(e) {}
|
||||
document.getElementById('noteContent').innerHTML = renderMarkdown(note.content || '');
|
||||
// 高亮代码
|
||||
document.querySelectorAll('pre code').forEach(block => {
|
||||
try { hljs.highlightBlock(block); } catch(e){}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 简单 Markdown 渲染(支持代码块、标题、列表、表格、链接、图片、加粗、待办清单)
|
||||
function renderMarkdown(text) {
|
||||
let html = escapeHtml(text);
|
||||
// 代码块
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
|
||||
return '<pre><code class="language-' + (lang || '') + '">' + code + '</code></pre>';
|
||||
});
|
||||
// 行内代码
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// 待办清单
|
||||
html = html.replace(/\[ \] /g, '☐ ');
|
||||
html = html.replace(/\[x\] /g, '☑ ');
|
||||
// 标题
|
||||
html = html.replace(/^### (.*)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*)$/gm, '<h1>$1</h1>');
|
||||
// 图片
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
|
||||
// 链接
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
// 加粗
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
// 引用
|
||||
html = html.replace(/^> (.*)$/gm, '<blockquote>$1</blockquote>');
|
||||
// 无序列表
|
||||
html = html.replace(/^- (.*)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, function(m){ return m.includes('<li></li>') ? m : '<ul>'+m+'</ul>'; });
|
||||
// 表格
|
||||
html = html.replace(/\|(.+)\|\n\|[\s-|]+\|\n((?:\|[^|]+\|\n?)*)/g, function(m, head, body) {
|
||||
const hd = head.split('|').filter((x,i)=>i!==0 && i!==head.split('|').length-1).map(c=>`<th>${c.trim()}</th>`).join('');
|
||||
const rows = body.split('\n').filter(r=>r.trim()).map(r => {
|
||||
const cells = r.split('|').filter((x,i)=>i!==0 && i!==r.split('|').length-1).map(c=>`<td>${c.trim()}</td>`).join('');
|
||||
return `<tr>${cells}</tr>`;
|
||||
}).join('');
|
||||
return `<table><thead><tr>${hd}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
});
|
||||
// 段落
|
||||
html = html.replace(/\n{2,}/g, '</p><p>');
|
||||
html = '<p>' + html + '</p>';
|
||||
return html;
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+97
-8
@@ -7,6 +7,15 @@
|
||||
<!-- 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 {
|
||||
@@ -14,6 +23,23 @@
|
||||
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: #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 .main { background: #1a1d21; }
|
||||
body.dark .search-box { background: #2d323a; }
|
||||
body.dark .search-box input { color: #e0e0e0; }
|
||||
body.dark .note-detail { background: #1e2228; }
|
||||
body.dark .note-content pre, body.dark .note-content code { background: #111; }
|
||||
body.dark .tree-item:hover { background: #2d323a; }
|
||||
body.dark .toc-sidebar { border-color: #333; }
|
||||
body.dark a { color: #7ab8ff; }
|
||||
header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
@@ -426,6 +452,7 @@
|
||||
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -471,6 +498,7 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="note-content" id="noteContent"></div>
|
||||
@@ -659,8 +687,20 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function generateTOC(content) {
|
||||
@@ -803,6 +843,29 @@
|
||||
}
|
||||
|
||||
// 简单的 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>';
|
||||
|
||||
@@ -816,8 +879,7 @@
|
||||
codeBlocks.push({ lang: lang || 'plaintext', code: code.trim() });
|
||||
index++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
});
|
||||
// 用占位符保护图片,避免被后续处理影响
|
||||
const imgPlaceholders = [];
|
||||
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
|
||||
@@ -850,13 +912,19 @@
|
||||
// 处理行内代码
|
||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// 恢复代码块并应用高亮
|
||||
// 恢复代码块并应用高亮(mermaid 代码块渲染为图表)
|
||||
codeBlocks.forEach((block, i) => {
|
||||
const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
|
||||
text = text.replace(
|
||||
`__CODE_BLOCK_${i}__`,
|
||||
`<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`
|
||||
);
|
||||
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 用于目录导航)
|
||||
@@ -881,6 +949,13 @@
|
||||
// 删除线
|
||||
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>');
|
||||
|
||||
@@ -898,7 +973,21 @@
|
||||
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 = '☀️ 白天';
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
initSiteDark();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user