Files
snippet-notes/templates/index.html
hubian e8ebd83d3c feat: 乐观锁版本号机制解决并发编辑冲突
- 每条记录增加version字段,每次保存版本号递增
- 客户端保存时携带版本号,服务端检查是否一致
- 版本冲突时返回HTTP 409,包含服务端最新内容
- 前端显示冲突对话框,用户可选择放弃或强制覆盖
- 旧数据自动添加version=1

测试场景:
- A和B同时加载版本1
- A保存成功 → 版本2
- B保存失败 → 409冲突
- B选择覆盖 → 版本3
2026-04-15 16:26:13 +08:00

666 lines
30 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="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
.note-item:hover { background: #f3f4f6; }
.note-item.active { background: #e0e7ff; border-left: 3px solid #6366f1; }
.note-item.pinned { background: #fef3c7; }
.note-item.pinned.active { background: #fde68a; border-left: 3px solid #f59e0b; }
.editor-area:focus { outline: none; }
.fade-in { animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.gradient-bg { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
.action-btn {
opacity: 0;
transition: opacity 0.2s;
}
.note-item:hover .action-btn {
opacity: 1;
}
/* 菜单弹出时强制显示,不被其他元素遮挡 */
.action-btn.show-menu {
opacity: 1 !important;
z-index: 100 !important;
}
.popup-menu {
z-index: 1000 !important;
}
</style>
</head>
<body class="bg-gray-100 h-screen overflow-hidden">
<div class="flex h-full">
<!-- 左侧:笔记列表 -->
<aside class="w-72 bg-white border-r flex flex-col">
<!-- 顶部:搜索和新建 -->
<div class="p-4 border-b">
<div class="relative mb-3">
<i class="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input type="text" id="searchInput" placeholder="搜索..."
class="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:border-purple-400"
oninput="searchNotes()">
</div>
<!-- 显示模式开关 -->
<div class="flex items-center gap-2 mb-3">
<label class="flex items-center gap-1 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" id="showPreview" checked onchange="loadNotes()" class="w-4 h-4 rounded">
<span>显示内容预览</span>
</label>
</div>
<button onclick="createNote()" class="w-full py-2 gradient-bg text-white rounded-lg hover:opacity-90 transition">
<i class="ri-add-line mr-1"></i> 新建记录
</button>
</div>
<!-- 笔记列表 -->
<div id="noteList" class="flex-1 overflow-auto">
<div class="p-4 text-center text-gray-400">
<i class="ri-file-text-line text-4xl mb-2"></i>
<p>暂无记录</p>
</div>
</div>
</aside>
<!-- 右侧:编辑区域 -->
<main class="flex-1 flex flex-col bg-gray-50">
<!-- 顶部工具栏 -->
<div id="toolbar" class="hidden p-4 bg-white border-b flex justify-between items-center">
<div>
<h2 id="currentTitle" class="text-lg font-semibold text-gray-800 flex items-center gap-2">
<span id="titleText"></span>
<span id="pinBadge" class="hidden px-2 py-0.5 bg-yellow-100 text-yellow-600 rounded text-xs">
<i class="ri-pushpin-line"></i> 置顶
</span>
</h2>
<p id="currentTime" class="text-sm text-gray-500"></p>
</div>
<div class="flex gap-2">
<button onclick="exportCurrentNote()" class="px-3 py-1 text-sm text-gray-600 hover:bg-gray-50 rounded-lg">
<i class="ri-download-line mr-1"></i> 导出
</button>
<button onclick="togglePin()" id="pinBtn" class="px-3 py-1 text-sm text-gray-600 hover:bg-gray-50 rounded-lg">
<i class="ri-pushpin-line mr-1"></i> <span id="pinBtnText">置顶</span>
</button>
<button onclick="regenerateTitle()" class="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg">
<i class="ri-magic-line mr-1"></i> 重新生成标题
</button>
<button onclick="deleteCurrentNote()" class="px-3 py-1 text-sm text-red-500 hover:bg-red-50 rounded-lg">
<i class="ri-delete-bin-line mr-1"></i> 删除
</button>
</div>
</div>
<!-- 编辑区域 -->
<div id="editorContainer" class="hidden flex-1 p-6 fade-in">
<textarea id="editor"
class="w-full h-full p-4 bg-white rounded-xl border border-gray-200 resize-none editor-area text-gray-700 leading-relaxed"
placeholder="在这里记录你的想法..."
oninput="saveContent()"></textarea>
</div>
<!-- 空状态 -->
<div id="emptyState" class="flex-1 flex items-center justify-center">
<div class="text-center text-gray-400">
<i class="ri-quill-pen-line text-6xl mb-4"></i>
<p class="text-lg">选择一个记录,或新建一个</p>
</div>
</div>
</main>
</div>
<script>
let currentNoteId = null;
let currentNotePinned = false;
let currentNoteVersion = 0; // 当前笔记版本号
let saveTimer = null;
let notes = [];
let titleUpdateTimer = null;
// 加载笔记列表
async function loadNotes() {
const keyword = document.getElementById('searchInput').value.trim();
const showPreview = document.getElementById('showPreview').checked;
const url = keyword ? `/api/search?q=${encodeURIComponent(keyword)}` : '/api/notes';
const res = await fetch(url);
notes = await res.json();
const container = document.getElementById('noteList');
if (notes.length === 0) {
container.innerHTML = `
<div class="p-4 text-center text-gray-400">
<i class="ri-file-text-line text-4xl mb-2"></i>
<p>${keyword ? '未找到相关记录' : '暂无记录'}</p>
</div>
`;
return;
}
container.innerHTML = notes.map(n => `
<div class="note-item relative p-4 cursor-pointer border-b ${currentNoteId === n.id ? 'active' : ''} ${n.pinned ? 'pinned' : ''}"
onclick="selectNote('${n.id}')">
<div class="flex items-center gap-2 pr-16">
${n.pinned ? '<i class="ri-pushpin-fill text-yellow-500 text-sm"></i>' : ''}
<h3 class="font-medium text-gray-800 truncate flex-1">${n.title || '新记录'}</h3>
</div>
${showPreview && n.preview ? `<p class="text-sm text-gray-500 truncate mt-1">${n.preview}</p>` : ''}
<p class="text-xs text-gray-400 mt-1">${n.updated_at}</p>
<!-- 操作按钮组 -->
<div class="action-btn absolute right-2 top-1/2 -translate-y-1/2">
<button onclick="event.stopPropagation(); toggleMenu('${n.id}')"
class="p-1.5 rounded hover:bg-gray-200 text-gray-500" title="更多操作">
<i class="ri-more-fill"></i>
</button>
<div id="menu-${n.id}" class="hidden absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border z-10 min-w-[100px]">
<button onclick="event.stopPropagation(); renameTitle('${n.id}', '${n.title || '新记录'}'); hideMenu('${n.id}')"
class="w-full px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-2">
<i class="ri-edit-line"></i> 重命名
</button>
<button onclick="event.stopPropagation(); exportItem('${n.id}'); hideMenu('${n.id}')"
class="w-full px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-2">
<i class="ri-download-line"></i> 导出
</button>
<button onclick="event.stopPropagation(); togglePinItem('${n.id}'); hideMenu('${n.id}')"
class="w-full px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-2">
<i class="ri-pushpin-line"></i> ${n.pinned ? '取消置顶' : '置顶'}
</button>
<button onclick="event.stopPropagation(); deleteItem('${n.id}'); hideMenu('${n.id}')"
class="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50 flex items-center gap-2">
<i class="ri-delete-bin-line"></i> 删除
</button>
</div>
</div>
</div>
`).join('');
}
// 创建新笔记
async function createNote() {
const res = await fetch('/api/notes', { method: 'POST' });
const note = await res.json();
currentNoteId = note.id;
currentNotePinned = false;
loadNotes();
showEditor(note);
}
// 选择笔记
async function selectNote(id) {
currentNoteId = id;
const res = await fetch(`/api/notes/${id}`);
const note = await res.json();
currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1; // 保存版本号
showEditor(note);
loadNotes();
}
// 显示编辑器
function showEditor(note) {
document.getElementById('toolbar').classList.remove('hidden');
document.getElementById('editorContainer').classList.remove('hidden');
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('titleText').textContent = note.title || '新记录';
if (note.pinned) {
document.getElementById('pinBadge').classList.remove('hidden');
document.getElementById('pinBtnText').textContent = '取消置顶';
} else {
document.getElementById('pinBadge').classList.add('hidden');
document.getElementById('pinBtnText').textContent = '置顶';
}
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
document.getElementById('editor').value = note.content || '';
}
// 保存内容(延迟保存)
function saveContent() {
if (!currentNoteId) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
const content = document.getElementById('editor').value;
// 检查内容是否为空(只有空白字符)
if (content.trim() === '') {
const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || '');
// 如果原来有内容,现在变空了,需要确认
if (oldContent.trim() !== '') {
if (!confirm('内容已清空,确定要保存为空吗?\n可能发生意外清空请确认')) {
// 用户取消,恢复旧内容
document.getElementById('editor').value = oldContent;
return;
}
}
}
// 发送版本号进行保存
const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, version: currentNoteVersion })
});
if (res.status === 409) {
// 版本冲突!
const conflict = await res.json();
showConflictDialog(conflict, content);
return;
}
const note = await res.json();
// 更新本地版本号
currentNoteVersion = note.version;
// 更新标题显示
document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
// 立即刷新列表
loadNotes();
}, 500);
}
// 显示冲突对话框
function showConflictDialog(conflict, myContent) {
// 移除旧的对话框
const oldDialog = document.getElementById('conflictDialog');
if (oldDialog) oldDialog.remove();
const dialog = document.createElement('div');
dialog.id = 'conflictDialog';
dialog.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
dialog.innerHTML = `
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full mx-4 p-6">
<h3 class="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
<i class="ri-error-warning-line"></i> 检测到同步冲突
</h3>
<p class="text-gray-600 mb-4">该记录在其他设备上已被修改。您可以选择:</p>
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="border rounded-lg p-4 bg-gray-50">
<h4 class="font-medium text-gray-700 mb-2">其他设备的版本 (最新)</h4>
<p class="text-xs text-gray-500 mb-2">更新时间: ${conflict.server_updated_at}</p>
<div class="bg-white border rounded p-3 max-h-40 overflow-auto text-sm">
${conflict.server_content || '<span class="text-gray-400">空内容</span>'}
</div>
</div>
<div class="border rounded-lg p-4 bg-purple-50">
<h4 class="font-medium text-purple-700 mb-2">您的版本</h4>
<p class="text-xs text-gray-500 mb-2">刚才编辑的内容</p>
<div class="bg-white border rounded p-3 max-h-40 overflow-auto text-sm">
${myContent || '<span class="text-gray-400">空内容</span>'}
</div>
</div>
</div>
<div class="flex gap-3 justify-end">
<button onclick="handleConflictChoice('discard')"
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
放弃我的修改,保留最新版本
</button>
<button onclick="handleConflictChoice('overwrite')"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
强制保存我的版本
</button>
</div>
</div>
`;
document.body.appendChild(dialog);
// 存储冲突数据供后续处理
window.conflictData = { conflict, myContent };
}
// 处理冲突选择
async function handleConflictChoice(choice) {
const { conflict, myContent } = window.conflictData;
// 关闭对话框
const dialog = document.getElementById('conflictDialog');
if (dialog) dialog.remove();
if (choice === 'discard') {
// 放弃我的修改,加载服务端最新版本
document.getElementById('editor').value = conflict.server_content;
document.getElementById('titleText').textContent = conflict.server_title;
document.getElementById('currentTime').textContent = `创建于 ${conflict.server_updated_at}`;
currentNoteVersion = conflict.server_version;
showSaveToast('已加载最新版本');
} else if (choice === 'overwrite') {
// 强制保存我的版本(不带版本号,直接覆盖)
const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: myContent, version: 0 }) // version=0 表示强制覆盖
});
const note = await res.json();
currentNoteVersion = note.version;
document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
showSaveToast('已强制保存');
loadNotes();
}
}
// 搜索笔记
function searchNotes() {
loadNotes();
}
// 重新生成标题
async function regenerateTitle() {
if (!currentNoteId) return;
const btn = event.target.closest('button');
btn.disabled = true;
btn.innerHTML = '<i class="ri-loader-4-line animate-spin mr-1"></i> 生成中...';
try {
const res = await fetch(`/api/notes/${currentNoteId}/title`, { method: 'POST' });
const data = await res.json();
if (data.success) {
document.getElementById('titleText').textContent = data.title;
// 立即刷新列表
loadNotes();
}
} catch (e) {
console.error(e);
}
btn.disabled = false;
btn.innerHTML = '<i class="ri-magic-line mr-1"></i> 重新生成标题';
}
// 置顶当前笔记
async function togglePin() {
if (!currentNoteId) return;
const res = await fetch(`/api/notes/${currentNoteId}/pin`, { method: 'POST' });
const data = await res.json();
if (data.success) {
currentNotePinned = data.pinned;
if (data.pinned) {
document.getElementById('pinBadge').classList.remove('hidden');
document.getElementById('pinBtnText').textContent = '取消置顶';
} else {
document.getElementById('pinBadge').classList.add('hidden');
document.getElementById('pinBtnText').textContent = '置顶';
}
loadNotes();
}
}
// 置顶列表项
async function togglePinItem(id) {
const res = await fetch(`/api/notes/${id}/pin`, { method: 'POST' });
const data = await res.json();
if (data.success) {
if (currentNoteId === id) {
currentNotePinned = data.pinned;
if (data.pinned) {
document.getElementById('pinBadge').classList.remove('hidden');
document.getElementById('pinBtnText').textContent = '取消置顶';
} else {
document.getElementById('pinBadge').classList.add('hidden');
document.getElementById('pinBtnText').textContent = '置顶';
}
}
loadNotes();
}
}
// 删除列表项
async function deleteItem(id) {
if (!confirm('确定删除这条记录?')) return;
await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (currentNoteId === id) {
currentNoteId = null;
document.getElementById('toolbar').classList.add('hidden');
document.getElementById('editorContainer').classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden');
}
loadNotes();
}
// 导出笔记
async function exportItem(id) {
const res = await fetch(`/api/notes/${id}`);
const note = await res.json();
if (!note) return;
// 创建下载内容
const content = `# ${note.title}\n\n创建时间: ${note.created_at}\n更新时间: ${note.updated_at}\n\n---\n\n${note.content}`;
// 创建Blob并下载
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${note.title || '记录'}.md`;
a.click();
URL.revokeObjectURL(url);
}
// 重命名标题
async function renameTitle(id, currentTitle) {
const newTitle = prompt('请输入新标题:', currentTitle);
if (newTitle === null) return; // 用户取消
const trimmedTitle = newTitle.trim();
if (!trimmedTitle) {
alert('标题不能为空!');
return;
}
const res = await fetch(`/api/notes/${id}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: trimmedTitle })
});
const data = await res.json();
if (data.success) {
// 如果是当前编辑的笔记,更新标题显示
if (currentNoteId === id) {
document.getElementById('titleText').textContent = data.title;
}
loadNotes();
} else {
alert(data.error || '重命名失败');
}
}
// 显示/隐藏菜单
function toggleMenu(id) {
const menu = document.getElementById(`menu-${id}`);
const actionBtn = menu?.closest('.action-btn');
if (menu) {
// 先隐藏其他所有菜单
document.querySelectorAll('[id^="menu-"]').forEach(m => {
m.classList.remove('popup-menu');
m.classList.add('hidden');
m.closest('.action-btn')?.classList.remove('show-menu');
});
const isHidden = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
if (!menu.classList.contains('hidden')) {
menu.classList.add('popup-menu');
actionBtn?.classList.add('show-menu');
}
}
}
function hideMenu(id) {
const menu = document.getElementById(`menu-${id}`);
const actionBtn = menu?.closest('.action-btn');
if (menu) {
menu.classList.add('hidden');
menu.classList.remove('popup-menu');
actionBtn?.classList.remove('show-menu');
}
}
// 点击其他地方关闭所有菜单
document.addEventListener('click', () => {
document.querySelectorAll('[id^="menu-"]').forEach(m => m.classList.add('hidden'));
});
// 删除当前笔记
async function deleteCurrentNote() {
if (!currentNoteId) return;
if (!confirm('确定删除这条记录?')) return;
await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' });
currentNoteId = null;
document.getElementById('toolbar').classList.add('hidden');
document.getElementById('editorContainer').classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden');
loadNotes();
}
// 导出当前笔记
async function exportCurrentNote() {
if (!currentNoteId) return;
const title = document.getElementById('titleText').textContent;
const content = document.getElementById('editor').value;
const timeInfo = document.getElementById('currentTime').textContent;
// 创建下载内容
const exportContent = `# ${title}\n\n${timeInfo}\n\n---\n\n${content}`;
// 创建Blob并下载
const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title}.md`;
a.click();
URL.revokeObjectURL(url);
}
// 定时检查标题更新(用于异步生成标题后刷新)
function startTitlePolling() {
if (titleUpdateTimer) clearInterval(titleUpdateTimer);
titleUpdateTimer = setInterval(async () => {
if (currentNoteId) {
const res = await fetch(`/api/notes/${currentNoteId}`);
const note = await res.json();
const currentTitle = document.getElementById('titleText').textContent;
if (note.title !== currentTitle && note.title !== '新记录') {
document.getElementById('titleText').textContent = note.title;
loadNotes();
}
}
}, 2000);
}
// Ctrl+S 快捷键保存
document.addEventListener('keydown', async (e) => {
// 检测 Ctrl+S (Windows/Linux) 或 Cmd+S (Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); // 阻止浏览器默认保存页面
if (currentNoteId) {
// 立即保存(清除延迟计时器)
if (saveTimer) clearTimeout(saveTimer);
const content = document.getElementById('editor').value;
const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, version: currentNoteVersion })
});
if (res.status === 409) {
// 版本冲突!
const conflict = await res.json();
showConflictDialog(conflict, content);
return;
}
const note = await res.json();
// 更新本地版本号
currentNoteVersion = note.version;
// 更新标题显示
document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
// 显示保存提示
showSaveToast();
// 刷新列表
loadNotes();
}
}
});
// 显示保存成功提示
function showSaveToast(message = '已保存') {
// 移除旧的提示
const oldToast = document.getElementById('saveToast');
if (oldToast) oldToast.remove();
// 创建新提示
const toast = document.createElement('div');
toast.id = 'saveToast';
toast.className = 'fixed bottom-4 right-4 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg fade-in flex items-center gap-2';
toast.innerHTML = `<i class="ri-check-line"></i> ${message}`;
document.body.appendChild(toast);
// 2秒后消失
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
// 初始化
loadNotes();
startTitlePolling();
</script>
</body>
</html>