feat: 编辑锁机制防止并发编辑
- 点击记录时获取锁,阻止其他人同时编辑 - 锁超时30秒自动释放(无心跳) - 心跳每10秒发送保持锁活跃 - 强制抢锁功能(打断对方编辑) - 页面离开自动释放锁 - 锁丢失弹窗提醒 双重保护:编辑锁(主防线) + 版本号(备用防线) 测试验证: - A获取锁成功 - B获取锁失败(423) - A释放锁后B成功获取 - 超时35秒后锁自动释放
This commit is contained in:
@@ -119,9 +119,24 @@
|
||||
let currentNoteId = null;
|
||||
let currentNotePinned = false;
|
||||
let currentNoteVersion = 0; // 当前笔记版本号
|
||||
let currentSessionId = ''; // 当前会话ID(用于锁)
|
||||
let hasLock = false; // 是否持有当前记录的锁
|
||||
let saveTimer = null;
|
||||
let heartbeatTimer = null; // 心跳计时器
|
||||
let notes = [];
|
||||
let titleUpdateTimer = null;
|
||||
|
||||
// 生成或获取session_id
|
||||
function getSessionId() {
|
||||
if (!currentSessionId) {
|
||||
currentSessionId = localStorage.getItem('snippet_session_id') || '';
|
||||
if (!currentSessionId) {
|
||||
currentSessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('snippet_session_id', currentSessionId);
|
||||
}
|
||||
}
|
||||
return currentSessionId;
|
||||
}
|
||||
|
||||
// 加载笔记列表
|
||||
async function loadNotes() {
|
||||
@@ -196,16 +211,193 @@
|
||||
|
||||
// 选择笔记
|
||||
async function selectNote(id) {
|
||||
// 如果当前正在编辑另一条记录,先释放锁
|
||||
if (currentNoteId && currentNoteId !== id && hasLock) {
|
||||
await releaseLock(currentNoteId);
|
||||
}
|
||||
|
||||
currentNoteId = id;
|
||||
|
||||
// 尝试获取锁
|
||||
const lockRes = await fetch(`/api/notes/${id}/lock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: getSessionId() })
|
||||
});
|
||||
|
||||
const lockData = await lockRes.json();
|
||||
|
||||
if (lockRes.status === 423 || !lockData.is_owner) {
|
||||
// 锁被其他人持有,显示警告
|
||||
hasLock = false;
|
||||
showLockedWarning(id, lockData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 成功获取锁
|
||||
hasLock = true;
|
||||
|
||||
// 获取笔记内容
|
||||
const res = await fetch(`/api/notes/${id}`);
|
||||
const note = await res.json();
|
||||
|
||||
currentNotePinned = note.pinned || false;
|
||||
currentNoteVersion = note.version || 1; // 保存版本号
|
||||
currentNoteVersion = note.version || 1;
|
||||
showEditor(note);
|
||||
|
||||
// 启动心跳
|
||||
startHeartbeat(id);
|
||||
|
||||
loadNotes();
|
||||
}
|
||||
|
||||
// 显示锁警告
|
||||
function showLockedWarning(noteId, lockData) {
|
||||
const oldWarning = document.getElementById('lockWarning');
|
||||
if (oldWarning) oldWarning.remove();
|
||||
|
||||
const warning = document.createElement('div');
|
||||
warning.id = 'lockWarning';
|
||||
warning.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
|
||||
warning.innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<i class="ri-lock-line text-5xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">记录正在被编辑</h3>
|
||||
<p class="text-gray-600 mb-4">${lockData.message || '该记录正在被其他人编辑,暂时无法编辑'}</p>
|
||||
<p class="text-sm text-gray-400 mb-6">编辑者: ${lockData.lock_owner || '其他用户'}</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button onclick="tryForceLock('${noteId}')"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
|
||||
强制抢锁(会打断对方编辑)
|
||||
</button>
|
||||
<button onclick="closeLockWarning()"
|
||||
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(warning);
|
||||
}
|
||||
|
||||
// 关闭锁警告
|
||||
function closeLockWarning() {
|
||||
const warning = document.getElementById('lockWarning');
|
||||
if (warning) warning.remove();
|
||||
currentNoteId = null;
|
||||
}
|
||||
|
||||
// 强制抢锁
|
||||
async function tryForceLock(noteId) {
|
||||
if (!confirm('强制抢锁会导致对方编辑中断,确定要抢锁吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先释放对方的锁(用不同的session_id抢锁)
|
||||
const newSessionId = 'force_' + getSessionId();
|
||||
|
||||
const lockRes = await fetch(`/api/notes/${noteId}/lock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: newSessionId })
|
||||
});
|
||||
|
||||
const lockData = await lockRes.json();
|
||||
|
||||
if (lockData.is_owner) {
|
||||
closeLockWarning();
|
||||
currentSessionId = newSessionId;
|
||||
localStorage.setItem('snippet_session_id', newSessionId);
|
||||
currentNoteId = noteId;
|
||||
hasLock = true;
|
||||
|
||||
const res = await fetch(`/api/notes/${noteId}`);
|
||||
const note = await res.json();
|
||||
|
||||
currentNotePinned = note.pinned || false;
|
||||
currentNoteVersion = note.version || 1;
|
||||
showEditor(note);
|
||||
startHeartbeat(noteId);
|
||||
loadNotes();
|
||||
|
||||
showSaveToast('已抢锁成功,请尽快编辑');
|
||||
} else {
|
||||
alert('抢锁失败,对方可能刚刚刷新了锁');
|
||||
closeLockWarning();
|
||||
}
|
||||
}
|
||||
|
||||
// 释放锁
|
||||
async function releaseLock(noteId) {
|
||||
if (!noteId) return;
|
||||
|
||||
await fetch(`/api/notes/${noteId}/unlock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: getSessionId() })
|
||||
});
|
||||
|
||||
hasLock = false;
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
function startHeartbeat(noteId) {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
|
||||
// 每10秒发送心跳
|
||||
heartbeatTimer = setInterval(async () => {
|
||||
if (currentNoteId === noteId && hasLock) {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: getSessionId() })
|
||||
});
|
||||
|
||||
if (res.status === 403 || res.status === 404) {
|
||||
// 锁丢失!显示警告
|
||||
showLockLostWarning();
|
||||
hasLock = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('心跳失败:', e);
|
||||
}
|
||||
}
|
||||
}, 10000); // 10秒心跳
|
||||
}
|
||||
|
||||
// 锁丢失警告
|
||||
function showLockLostWarning() {
|
||||
const oldWarning = document.getElementById('lockWarning');
|
||||
if (oldWarning) oldWarning.remove();
|
||||
|
||||
const warning = document.createElement('div');
|
||||
warning.id = 'lockWarning';
|
||||
warning.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
|
||||
warning.innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 text-center">
|
||||
<div class="text-orange-500 mb-4">
|
||||
<i class="ri-lock-unlock-line text-5xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">编辑锁已丢失</h3>
|
||||
<p class="text-gray-600 mb-4">您的编辑锁已被其他人抢走,或网络中断导致锁超时释放。</p>
|
||||
<p class="text-sm text-gray-500 mb-6">您的修改可能未保存,建议重新获取锁。</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button onclick="tryForceLock('${currentNoteId}')"
|
||||
class="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600">
|
||||
重新抢锁
|
||||
</button>
|
||||
<button onclick="closeLockWarning(); location.reload();"
|
||||
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(warning);
|
||||
}
|
||||
|
||||
// 显示编辑器
|
||||
function showEditor(note) {
|
||||
@@ -231,6 +423,12 @@
|
||||
function saveContent() {
|
||||
if (!currentNoteId) return;
|
||||
|
||||
// 必须持有锁才能保存
|
||||
if (!hasLock) {
|
||||
showLockLostWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
|
||||
saveTimer = setTimeout(async () => {
|
||||
@@ -239,17 +437,14 @@
|
||||
// 检查内容是否为空(只有空白字符)
|
||||
if (content.trim() === '') {
|
||||
const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || '');
|
||||
// 如果原来有内容,现在变空了,需要确认
|
||||
if (oldContent.trim() !== '') {
|
||||
if (!confirm('内容已清空,确定要保存为空吗?\n(可能发生意外清空,请确认)')) {
|
||||
// 用户取消,恢复旧内容
|
||||
if (!confirm('内容已清空,确定要保存为空吗?')) {
|
||||
document.getElementById('editor').value = oldContent;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送版本号进行保存
|
||||
const res = await fetch(`/api/notes/${currentNoteId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -257,29 +452,21 @@
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -323,32 +510,26 @@
|
||||
</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 表示强制覆盖
|
||||
body: JSON.stringify({ content: myContent, version: 0 })
|
||||
});
|
||||
|
||||
const note = await res.json();
|
||||
@@ -543,9 +724,15 @@
|
||||
|
||||
if (!confirm('确定删除这条记录?')) return;
|
||||
|
||||
// 释放锁
|
||||
await releaseLock(currentNoteId);
|
||||
|
||||
await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' });
|
||||
|
||||
currentNoteId = null;
|
||||
hasLock = false;
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
|
||||
document.getElementById('toolbar').classList.add('hidden');
|
||||
document.getElementById('editorContainer').classList.add('hidden');
|
||||
@@ -553,7 +740,20 @@
|
||||
|
||||
loadNotes();
|
||||
}
|
||||
|
||||
|
||||
// 页面离开时释放锁
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (currentNoteId && hasLock) {
|
||||
// 使用fetch keepalive模式发送释放锁请求
|
||||
fetch(`/api/notes/${currentNoteId}/unlock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: getSessionId() }),
|
||||
keepalive: true // 页面关闭后仍能发送
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 导出当前笔记
|
||||
async function exportCurrentNote() {
|
||||
if (!currentNoteId) return;
|
||||
@@ -562,10 +762,8 @@
|
||||
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');
|
||||
@@ -574,13 +772,13 @@
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
||||
// 定时检查标题更新(用于异步生成标题后刷新)
|
||||
function startTitlePolling() {
|
||||
if (titleUpdateTimer) clearInterval(titleUpdateTimer);
|
||||
|
||||
titleUpdateTimer = setInterval(async () => {
|
||||
if (currentNoteId) {
|
||||
if (currentNoteId && hasLock) {
|
||||
const res = await fetch(`/api/notes/${currentNoteId}`);
|
||||
const note = await res.json();
|
||||
|
||||
@@ -593,15 +791,13 @@
|
||||
}
|
||||
}, 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(); // 阻止浏览器默认保存页面
|
||||
e.preventDefault();
|
||||
|
||||
if (currentNoteId) {
|
||||
// 立即保存(清除延迟计时器)
|
||||
if (currentNoteId && hasLock) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
|
||||
const content = document.getElementById('editor').value;
|
||||
@@ -613,25 +809,16 @@
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -639,28 +826,24 @@
|
||||
|
||||
// 显示保存成功提示
|
||||
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>
|
||||
Reference in New Issue
Block a user