6 Commits
v0.2.1 ... main

Author SHA1 Message Date
2b5ca93b51 feat: 编辑锁机制防止并发编辑
- 点击记录时获取锁,阻止其他人同时编辑
- 锁超时30秒自动释放(无心跳)
- 心跳每10秒发送保持锁活跃
- 强制抢锁功能(打断对方编辑)
- 页面离开自动释放锁
- 锁丢失弹窗提醒

双重保护:编辑锁(主防线) + 版本号(备用防线)

测试验证:
- A获取锁成功
- B获取锁失败(423)
- A释放锁后B成功获取
- 超时35秒后锁自动释放
2026-04-16 09:29:06 +08:00
e8ebd83d3c feat: 乐观锁版本号机制解决并发编辑冲突
- 每条记录增加version字段,每次保存版本号递增
- 客户端保存时携带版本号,服务端检查是否一致
- 版本冲突时返回HTTP 409,包含服务端最新内容
- 前端显示冲突对话框,用户可选择放弃或强制覆盖
- 旧数据自动添加version=1

测试场景:
- A和B同时加载版本1
- A保存成功 → 版本2
- B保存失败 → 409冲突
- B选择覆盖 → 版本3
2026-04-15 16:26:13 +08:00
8c4c0af053 fix: 正确修复置顶排序逻辑 2026-04-09 00:46:19 +08:00
c67d81d277 fix: 修复置顶功能排序问题 2026-04-09 00:43:46 +08:00
75026508b8 fix: 修复弹出菜单被其他记录遮挡的问题 2026-04-09 00:40:57 +08:00
43b625d31a docs: 添加 README.md 说明文档 2026-04-09 00:34:23 +08:00
6 changed files with 61617 additions and 32 deletions

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# 碎片信息记录网站
一个简洁的碎片信息记录工具,支持实时保存、大模型自动生成标题、搜索功能。
## 功能特点
- ✅ 实时保存 - 输入内容自动保存到本地
- ✅ AI生成标题 - 使用大模型自动生成标题
- ✅ 搜索功能 - 快速搜索历史记录
- ✅ 置顶功能 - 重要记录置顶显示
- ✅ 导出功能 - 支持导出为 Markdown 格式
- ✅ 重命名标题 - 自定义修改标题
## 技术栈
- Python 3 + Flask
- Tailwind CSS
- 大模型 API本地 LM Studio
## 安装使用
```bash
# 安装依赖
pip install -r requirements.txt
# 运行服务
python app.py
# 访问地址
http://localhost:19009
```
## 目录结构
```
snippet-notes/
├── app.py # Flask 应用主文件
├── templates/
│ └── index.html # 前端页面
├── static/
│ ├── css/ # CSS 文件目录
│ └── js/ # JS 文件目录
├── data/ # 数据存储目录(不提交到仓库)
│ └── notes.json # 笔记数据
├── logs/ # 日志目录
├── requirements.txt # Python 依赖
├── run.sh # 启动脚本
└── README.md # 说明文档
```
## 大模型配置
默认使用本地 LM Studio
- 地址http://192.168.2.5:1234/v1
- 模型qwen3.5-4b
可在 `app.py` 中修改 `LLM_CONFIG` 配置。
## API 接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/notes` | GET | 获取笔记列表 |
| `/api/notes` | POST | 创建新笔记 |
| `/api/notes/<id>` | GET | 获取笔记详情 |
| `/api/notes/<id>` | PUT | 更新笔记内容 |
| `/api/notes/<id>` | DELETE | 删除笔记 |
| `/api/notes/<id>/pin` | POST | 置顶/取消置顶 |
| `/api/notes/<id>/title` | POST | 重新生成标题 |
| `/api/notes/<id>/rename` | POST | 重命名标题 |
| `/api/search` | GET | 搜索笔记 |
## 版本历史
- v0.2.1 - 新增空内容保存确认、重命名标题功能
- v0.2.0 - 新增置顶、导出功能
- v0.1.0 - 初始版本

190
app.py
View File

@@ -3,6 +3,7 @@
- 实时保存到本地 - 实时保存到本地
- 大模型生成标题 - 大模型生成标题
- 搜索功能 - 搜索功能
- 编辑锁机制(防止并发编辑)
""" """
from flask import Flask, render_template, jsonify, request from flask import Flask, render_template, jsonify, request
@@ -10,7 +11,7 @@ from flask_cors import CORS
import json import json
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
import requests import requests
import threading import threading
@@ -23,19 +24,30 @@ DATA_DIR = Path(__file__).parent / 'data'
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
NOTES_FILE = DATA_DIR / 'notes.json' NOTES_FILE = DATA_DIR / 'notes.json'
# 大模型配置 # 编辑锁存储(内存,不持久化)
# 格式: {note_id: {"session_id": xxx, "locked_at": timestamp, "last_heartbeat": timestamp}}
EDIT_LOCKS = {}
LOCK_TIMEOUT = 30 # 锁超时时间(秒),无心跳自动释放
# 大模型配置 (LLM Proxy)
LLM_CONFIG = { LLM_CONFIG = {
'base_url': 'http://192.168.2.5:1234/v1', 'base_url': 'http://192.168.2.17:19007/v1',
'api_key': 'sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j', 'api_key': 'xxxx',
'model': 'qwen3.5-4b', 'model': 'auto',
} }
def load_notes(): def load_notes():
"""加载所有笔记""" """加载所有笔记"""
if NOTES_FILE.exists(): if NOTES_FILE.exists():
notes = json.loads(NOTES_FILE.read_text(encoding='utf-8')) notes = json.loads(NOTES_FILE.read_text(encoding='utf-8'))
# 按置顶和更新时间排序
return sorted(notes, key=lambda x: (not x.get('pinned', False), x.get('updated_at', '')), reverse=True) # 自动为旧数据添加版本号
for note in notes:
if 'version' not in note:
note['version'] = 1
# 按置顶和更新时间排序(置顶在前,更新时间降序)
return sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
return [] return []
def save_notes(notes): def save_notes(notes):
@@ -110,8 +122,8 @@ def api_notes():
if keyword: if keyword:
notes = [n for n in notes if keyword in n.get('title', '').lower() or keyword in n.get('content', '').lower()] notes = [n for n in notes if keyword in n.get('title', '').lower() or keyword in n.get('content', '').lower()]
# 按更新时间排序(最近在前 # 按置顶和更新时间排序(置顶在前,然后按更新时间降序
notes = sorted(notes, key=lambda x: x.get('updated_at', ''), reverse=True) notes = sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
# 返回列表信息 # 返回列表信息
return jsonify([{ return jsonify([{
@@ -132,6 +144,10 @@ def api_note_detail(note_id):
if not note: if not note:
return jsonify({'error': 'Note not found'}), 404 return jsonify({'error': 'Note not found'}), 404
# 确保有版本号
if 'version' not in note:
note['version'] = 1
return jsonify(note) return jsonify(note)
@app.route('/api/notes', methods=['POST']) @app.route('/api/notes', methods=['POST'])
@@ -155,9 +171,10 @@ def api_create_note():
@app.route('/api/notes/<note_id>', methods=['PUT']) @app.route('/api/notes/<note_id>', methods=['PUT'])
def api_update_note(note_id): def api_update_note(note_id):
"""更新笔记内容""" """更新笔记内容(带版本检查)"""
data = request.get_json() data = request.get_json()
content = data.get('content', '') content = data.get('content', '')
client_version = data.get('version', 0) # 客户端提交的版本号
notes = load_notes() notes = load_notes()
note = next((n for n in notes if n['id'] == note_id), None) note = next((n for n in notes if n['id'] == note_id), None)
@@ -165,6 +182,21 @@ def api_update_note(note_id):
if not note: if not note:
return jsonify({'error': 'Note not found'}), 404 return jsonify({'error': 'Note not found'}), 404
# 确保服务端版本号存在
server_version = note.get('version', 1)
# 版本冲突检查:如果客户端版本 != 服务端版本,说明有并发编辑
if client_version > 0 and client_version != server_version:
# 返回冲突响应,包含服务端最新内容
return jsonify({
'error': 'version_conflict',
'message': '该记录已被其他设备修改,请先查看最新内容',
'server_version': server_version,
'server_content': note.get('content', ''),
'server_title': note.get('title', ''),
'server_updated_at': note.get('updated_at', '')
}), 409 # HTTP 409 Conflict
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 新记录第一次输入内容时,自动生成标题 # 新记录第一次输入内容时,自动生成标题
@@ -173,6 +205,7 @@ def api_update_note(note_id):
note['content'] = content note['content'] = content
note['updated_at'] = now note['updated_at'] = now
note['version'] = server_version + 1 # 版本号递增
# 如果是新记录第一次输入内容,异步生成标题 # 如果是新记录第一次输入内容,异步生成标题
if need_generate_title: if need_generate_title:
@@ -202,7 +235,7 @@ def api_rename_note(note_id):
note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 重新排序 # 重新排序
notes = sorted(notes, key=lambda x: (not x.get('pinned', False), x.get('updated_at', '')), reverse=True) notes = sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
save_notes(notes) save_notes(notes)
return jsonify({'success': True, 'title': new_title, 'note': note}) return jsonify({'success': True, 'title': new_title, 'note': note})
@@ -221,7 +254,7 @@ def api_regenerate_title(note_id):
note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 重新排序 # 重新排序
notes = sorted(notes, key=lambda x: (not x.get('pinned', False), x.get('updated_at', '')), reverse=True) notes = sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
save_notes(notes) save_notes(notes)
return jsonify({'success': True, 'title': title, 'note': note}) return jsonify({'success': True, 'title': title, 'note': note})
@@ -247,7 +280,7 @@ def api_pin_note(note_id):
note['pinned'] = not note.get('pinned', False) note['pinned'] = not note.get('pinned', False)
# 重新排序 # 重新排序
notes = sorted(notes, key=lambda x: (not x.get('pinned', False), x.get('updated_at', '')), reverse=True) notes = sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
save_notes(notes) save_notes(notes)
return jsonify({'success': True, 'pinned': note['pinned'], 'note': note}) return jsonify({'success': True, 'pinned': note['pinned'], 'note': note})
@@ -263,6 +296,9 @@ def api_search():
notes = load_notes() notes = load_notes()
results = [n for n in notes if keyword in n.get('title', '').lower() or keyword in n.get('content', '').lower()] results = [n for n in notes if keyword in n.get('title', '').lower() or keyword in n.get('content', '').lower()]
# 按置顶和更新时间排序(置顶在前)
results = sorted(results, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
return jsonify([{ return jsonify([{
'id': n['id'], 'id': n['id'],
'title': n['title'], 'title': n['title'],
@@ -271,6 +307,134 @@ def api_search():
'pinned': n.get('pinned', False), 'pinned': n.get('pinned', False),
} for n in results]) } for n in results])
# ============ 编辑锁 API ============
def check_lock_valid(note_id):
"""检查锁是否有效(未超时)"""
if note_id not in EDIT_LOCKS:
return False
lock = EDIT_LOCKS[note_id]
now = time.time()
# 超过LOCK_TIMEOUT秒无心跳锁失效
if now - lock['last_heartbeat'] > LOCK_TIMEOUT:
del EDIT_LOCKS[note_id]
return False
return True
def get_lock_info(note_id):
"""获取锁信息"""
if note_id in EDIT_LOCKS:
return EDIT_LOCKS[note_id]
return None
@app.route('/api/notes/<note_id>/lock', methods=['POST'])
def api_acquire_lock(note_id):
"""获取编辑锁"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if not session_id:
# 生成临时session_id
session_id = uuid.uuid4().hex[:16]
now = time.time()
# 检查是否已被锁定
if check_lock_valid(note_id):
lock = EDIT_LOCKS[note_id]
# 同一个session可以重复获取刷新锁
if lock['session_id'] == session_id:
lock['last_heartbeat'] = now
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': True
})
# 被其他人锁定
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': False,
'lock_owner': lock['session_id'],
'locked_at': lock['locked_at'],
'message': '该记录正在被其他人编辑,暂时无法编辑'
}), 423 # HTTP 423 Locked
# 获取新锁
EDIT_LOCKS[note_id] = {
'session_id': session_id,
'locked_at': now,
'last_heartbeat': now
}
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': True
})
@app.route('/api/notes/<note_id>/unlock', methods=['POST'])
def api_release_lock(note_id):
"""释放编辑锁"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if note_id in EDIT_LOCKS:
lock = EDIT_LOCKS[note_id]
# 只有锁持有者才能释放
if lock['session_id'] == session_id:
del EDIT_LOCKS[note_id]
return jsonify({'success': True, 'message': '锁已释放'})
return jsonify({'error': '非锁持有者,无法释放'}), 403
return jsonify({'success': True, 'message': '锁已不存在'})
@app.route('/api/notes/<note_id>/heartbeat', methods=['POST'])
def api_lock_heartbeat(note_id):
"""发送心跳保持锁活跃"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if note_id not in EDIT_LOCKS:
return jsonify({'error': '锁不存在'}), 404
lock = EDIT_LOCKS[note_id]
if lock['session_id'] != session_id:
return jsonify({'error': '非锁持有者'}), 403
# 更新心跳时间
lock['last_heartbeat'] = time.time()
return jsonify({
'success': True,
'remaining_time': LOCK_TIMEOUT
})
@app.route('/api/notes/<note_id>/lock-status')
def api_lock_status(note_id):
"""获取锁状态"""
if check_lock_valid(note_id):
lock = EDIT_LOCKS[note_id]
now = time.time()
remaining = LOCK_TIMEOUT - (now - lock['last_heartbeat'])
return jsonify({
'locked': True,
'lock_owner': lock['session_id'],
'locked_at': lock['locked_at'],
'remaining_time': max(0, remaining)
})
return jsonify({'locked': False})
if __name__ == '__main__': if __name__ == '__main__':
print("=" * 50) print("=" * 50)
print("碎片信息记录网站") print("碎片信息记录网站")

60966
logs/app.log Normal file

File diff suppressed because it is too large Load Diff

2
run.sh
View File

@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
cd "$(dirname "$0")" cd "$(dirname "$0")"
python app.py python3 app.py

10
static/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- 背景 -->
<rect x="4" y="4" width="24" height="24" rx="4" fill="#6366f1"/>
<!-- 纸张效果 -->
<rect x="8" y="8" width="16" height="16" rx="2" fill="white"/>
<!-- 文字线条 -->
<line x1="10" y1="12" x2="22" y2="12" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="16" x2="18" y2="16" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="20" x2="20" y2="20" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>碎片记录</title> <title>碎片记录</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>
@@ -22,6 +23,14 @@
.note-item:hover .action-btn { .note-item:hover .action-btn {
opacity: 1; opacity: 1;
} }
/* 菜单弹出时强制显示,不被其他元素遮挡 */
.action-btn.show-menu {
opacity: 1 !important;
z-index: 100 !important;
}
.popup-menu {
z-index: 1000 !important;
}
</style> </style>
</head> </head>
<body class="bg-gray-100 h-screen overflow-hidden"> <body class="bg-gray-100 h-screen overflow-hidden">
@@ -109,10 +118,26 @@
<script> <script>
let currentNoteId = null; let currentNoteId = null;
let currentNotePinned = false; let currentNotePinned = false;
let currentNoteVersion = 0; // 当前笔记版本号
let currentSessionId = ''; // 当前会话ID用于锁
let hasLock = false; // 是否持有当前记录的锁
let saveTimer = null; let saveTimer = null;
let heartbeatTimer = null; // 心跳计时器
let notes = []; let notes = [];
let titleUpdateTimer = null; 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() { async function loadNotes() {
const keyword = document.getElementById('searchInput').value.trim(); const keyword = document.getElementById('searchInput').value.trim();
@@ -186,16 +211,194 @@
// 选择笔记 // 选择笔记
async function selectNote(id) { async function selectNote(id) {
// 如果当前正在编辑另一条记录,先释放锁
if (currentNoteId && currentNoteId !== id && hasLock) {
await releaseLock(currentNoteId);
}
currentNoteId = id; 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 res = await fetch(`/api/notes/${id}`);
const note = await res.json(); const note = await res.json();
currentNotePinned = note.pinned || false; currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1;
showEditor(note); showEditor(note);
// 启动心跳
startHeartbeat(id);
loadNotes(); 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) { function showEditor(note) {
document.getElementById('toolbar').classList.remove('hidden'); document.getElementById('toolbar').classList.remove('hidden');
@@ -220,6 +423,12 @@
function saveContent() { function saveContent() {
if (!currentNoteId) return; if (!currentNoteId) return;
// 必须持有锁才能保存
if (!hasLock) {
showLockLostWarning();
return;
}
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => { saveTimer = setTimeout(async () => {
@@ -228,10 +437,8 @@
// 检查内容是否为空(只有空白字符) // 检查内容是否为空(只有空白字符)
if (content.trim() === '') { if (content.trim() === '') {
const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || ''); const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || '');
// 如果原来有内容,现在变空了,需要确认
if (oldContent.trim() !== '') { if (oldContent.trim() !== '') {
if (!confirm('内容已清空,确定要保存为空吗?\n可能发生意外清空请确认')) { if (!confirm('内容已清空,确定要保存为空吗?')) {
// 用户取消,恢复旧内容
document.getElementById('editor').value = oldContent; document.getElementById('editor').value = oldContent;
return; return;
} }
@@ -241,18 +448,97 @@
const res = await fetch(`/api/notes/${currentNoteId}`, { const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }) 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(); const note = await res.json();
currentNoteVersion = note.version;
// 更新标题显示
document.getElementById('titleText').textContent = note.title; document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`; document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
showSaveToast('已强制保存');
// 立即刷新列表
loadNotes(); loadNotes();
}, 500); }
} }
// 搜索笔记 // 搜索笔记
@@ -397,18 +683,34 @@
// 显示/隐藏菜单 // 显示/隐藏菜单
function toggleMenu(id) { function toggleMenu(id) {
const menu = document.getElementById(`menu-${id}`); const menu = document.getElementById(`menu-${id}`);
const actionBtn = menu?.closest('.action-btn');
if (menu) { if (menu) {
// 先隐藏其他所有菜单 // 先隐藏其他所有菜单
document.querySelectorAll('[id^="menu-"]').forEach(m => { document.querySelectorAll('[id^="menu-"]').forEach(m => {
if (m.id !== `menu-${id}`) m.classList.add('hidden'); 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'); menu.classList.toggle('hidden');
if (!menu.classList.contains('hidden')) {
menu.classList.add('popup-menu');
actionBtn?.classList.add('show-menu');
}
} }
} }
function hideMenu(id) { function hideMenu(id) {
const menu = document.getElementById(`menu-${id}`); const menu = document.getElementById(`menu-${id}`);
if (menu) menu.classList.add('hidden'); const actionBtn = menu?.closest('.action-btn');
if (menu) {
menu.classList.add('hidden');
menu.classList.remove('popup-menu');
actionBtn?.classList.remove('show-menu');
}
} }
// 点击其他地方关闭所有菜单 // 点击其他地方关闭所有菜单
@@ -422,9 +724,15 @@
if (!confirm('确定删除这条记录?')) return; if (!confirm('确定删除这条记录?')) return;
// 释放锁
await releaseLock(currentNoteId);
await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' }); await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' });
currentNoteId = null; currentNoteId = null;
hasLock = false;
if (heartbeatTimer) clearInterval(heartbeatTimer);
document.getElementById('toolbar').classList.add('hidden'); document.getElementById('toolbar').classList.add('hidden');
document.getElementById('editorContainer').classList.add('hidden'); document.getElementById('editorContainer').classList.add('hidden');
@@ -433,6 +741,19 @@
loadNotes(); 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() { async function exportCurrentNote() {
if (!currentNoteId) return; if (!currentNoteId) return;
@@ -441,10 +762,8 @@
const content = document.getElementById('editor').value; const content = document.getElementById('editor').value;
const timeInfo = document.getElementById('currentTime').textContent; const timeInfo = document.getElementById('currentTime').textContent;
// 创建下载内容
const exportContent = `# ${title}\n\n${timeInfo}\n\n---\n\n${content}`; const exportContent = `# ${title}\n\n${timeInfo}\n\n---\n\n${content}`;
// 创建Blob并下载
const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' }); const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -459,7 +778,7 @@
if (titleUpdateTimer) clearInterval(titleUpdateTimer); if (titleUpdateTimer) clearInterval(titleUpdateTimer);
titleUpdateTimer = setInterval(async () => { titleUpdateTimer = setInterval(async () => {
if (currentNoteId) { if (currentNoteId && hasLock) {
const res = await fetch(`/api/notes/${currentNoteId}`); const res = await fetch(`/api/notes/${currentNoteId}`);
const note = await res.json(); const note = await res.json();
@@ -473,9 +792,58 @@
}, 2000); }, 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(); loadNotes();
startTitlePolling();
</script> </script>
</body> </body>
</html> </html>