- 点击记录时获取锁,阻止其他人同时编辑 - 锁超时30秒自动释放(无心跳) - 心跳每10秒发送保持锁活跃 - 强制抢锁功能(打断对方编辑) - 页面离开自动释放锁 - 锁丢失弹窗提醒 双重保护:编辑锁(主防线) + 版本号(备用防线) 测试验证: - A获取锁成功 - B获取锁失败(423) - A释放锁后B成功获取 - 超时35秒后锁自动释放
849 lines
38 KiB
HTML
849 lines
38 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>
|
||
<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 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() {
|
||
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) {
|
||
// 如果当前正在编辑另一条记录,先释放锁
|
||
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;
|
||
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) {
|
||
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 (!hasLock) {
|
||
showLockLostWarning();
|
||
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('内容已清空,确定要保存为空吗?')) {
|
||
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 })
|
||
});
|
||
|
||
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 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');
|
||
document.getElementById('emptyState').classList.remove('hidden');
|
||
|
||
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;
|
||
|
||
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}`;
|
||
|
||
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 && hasLock) {
|
||
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) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault();
|
||
|
||
if (currentNoteId && hasLock) {
|
||
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);
|
||
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transition = 'opacity 0.3s';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 2000);
|
||
}
|
||
|
||
// 初始化
|
||
loadNotes();
|
||
</script>
|
||
</body>
|
||
</html> |