5 Commits

Author SHA1 Message Date
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
3 changed files with 307 additions and 15 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 - 初始版本

54
app.py
View File

@@ -23,19 +23,25 @@ DATA_DIR = Path(__file__).parent / 'data'
DATA_DIR.mkdir(exist_ok=True)
NOTES_FILE = DATA_DIR / 'notes.json'
# 大模型配置
# 大模型配置 (LLM Proxy)
LLM_CONFIG = {
'base_url': 'http://192.168.2.5:1234/v1',
'api_key': 'sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j',
'model': 'qwen3.5-4b',
'base_url': 'http://192.168.2.17:19007/v1',
'api_key': 'xxxx',
'model': 'auto',
}
def load_notes():
"""加载所有笔记"""
if NOTES_FILE.exists():
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 []
def save_notes(notes):
@@ -110,8 +116,8 @@ def api_notes():
if keyword:
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([{
@@ -132,6 +138,10 @@ def api_note_detail(note_id):
if not note:
return jsonify({'error': 'Note not found'}), 404
# 确保有版本号
if 'version' not in note:
note['version'] = 1
return jsonify(note)
@app.route('/api/notes', methods=['POST'])
@@ -155,9 +165,10 @@ def api_create_note():
@app.route('/api/notes/<note_id>', methods=['PUT'])
def api_update_note(note_id):
"""更新笔记内容"""
"""更新笔记内容(带版本检查)"""
data = request.get_json()
content = data.get('content', '')
client_version = data.get('version', 0) # 客户端提交的版本号
notes = load_notes()
note = next((n for n in notes if n['id'] == note_id), None)
@@ -165,6 +176,21 @@ def api_update_note(note_id):
if not note:
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')
# 新记录第一次输入内容时,自动生成标题
@@ -173,6 +199,7 @@ def api_update_note(note_id):
note['content'] = content
note['updated_at'] = now
note['version'] = server_version + 1 # 版本号递增
# 如果是新记录第一次输入内容,异步生成标题
if need_generate_title:
@@ -202,7 +229,7 @@ def api_rename_note(note_id):
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)
return jsonify({'success': True, 'title': new_title, 'note': note})
@@ -221,7 +248,7 @@ def api_regenerate_title(note_id):
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)
return jsonify({'success': True, 'title': title, 'note': note})
@@ -247,7 +274,7 @@ def api_pin_note(note_id):
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)
return jsonify({'success': True, 'pinned': note['pinned'], 'note': note})
@@ -263,6 +290,9 @@ def api_search():
notes = load_notes()
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([{
'id': n['id'],
'title': n['title'],

View File

@@ -4,6 +4,7 @@
<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>
@@ -22,6 +23,14 @@
.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">
@@ -109,6 +118,7 @@
<script>
let currentNoteId = null;
let currentNotePinned = false;
let currentNoteVersion = 0; // 当前笔记版本号
let saveTimer = null;
let notes = [];
let titleUpdateTimer = null;
@@ -192,6 +202,7 @@
const note = await res.json();
currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1; // 保存版本号
showEditor(note);
loadNotes();
}
@@ -238,14 +249,25 @@
}
}
// 发送版本号进行保存
const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT',
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}`;
@@ -254,6 +276,89 @@
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() {
@@ -397,18 +502,34 @@
// 显示/隐藏菜单
function toggleMenu(id) {
const menu = document.getElementById(`menu-${id}`);
const actionBtn = menu?.closest('.action-btn');
if (menu) {
// 先隐藏其他所有菜单
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');
if (!menu.classList.contains('hidden')) {
menu.classList.add('popup-menu');
actionBtn?.classList.add('show-menu');
}
}
}
function hideMenu(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');
}
}
// 点击其他地方关闭所有菜单
@@ -473,6 +594,70 @@
}, 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();