Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b5ca93b51 | |||
| e8ebd83d3c | |||
| 8c4c0af053 | |||
| c67d81d277 | |||
| 75026508b8 | |||
| 43b625d31a |
77
README.md
Normal file
77
README.md
Normal 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
190
app.py
@@ -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
60966
logs/app.log
Normal file
File diff suppressed because it is too large
Load Diff
2
run.sh
2
run.sh
@@ -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
10
static/favicon.svg
Normal 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 |
@@ -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>
|
||||||
Reference in New Issue
Block a user