Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ebd83d3c | |||
| 8c4c0af053 | |||
| c67d81d277 | |||
| 75026508b8 | |||
| 43b625d31a | |||
| 68e3d30f3f | |||
| a30514e6da | |||
| cbe014e10d | |||
| a21a813d83 | |||
| 0e84111ffe |
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 - 初始版本
|
||||
105
app.py
105
app.py
@@ -23,17 +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():
|
||||
return json.loads(NOTES_FILE.read_text(encoding='utf-8'))
|
||||
notes = json.loads(NOTES_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
# 自动为旧数据添加版本号
|
||||
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):
|
||||
@@ -108,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([{
|
||||
@@ -118,6 +126,7 @@ def api_notes():
|
||||
'updated_at': n['updated_at'],
|
||||
'created_at': n['created_at'],
|
||||
'preview': n['content'][:50] if n['content'] else '',
|
||||
'pinned': n.get('pinned', False),
|
||||
} for n in notes])
|
||||
|
||||
@app.route('/api/notes/<note_id>')
|
||||
@@ -129,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'])
|
||||
@@ -152,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)
|
||||
@@ -162,24 +176,64 @@ 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')
|
||||
|
||||
# 判断是否需要生成新标题
|
||||
# 新记录第一次输入内容时,自动生成标题
|
||||
old_content = note.get('content', '')
|
||||
need_new_title = (not note['title'] or note['title'] == '新记录' or
|
||||
len(old_content) < 50 and len(content) >= 50)
|
||||
need_generate_title = (note['title'] == '新记录' and len(content) >= 20)
|
||||
|
||||
note['content'] = content
|
||||
note['updated_at'] = now
|
||||
note['version'] = server_version + 1 # 版本号递增
|
||||
|
||||
# 如果内容变化较大,异步生成新标题
|
||||
if need_new_title and len(content) >= 20:
|
||||
# 如果是新记录第一次输入内容,异步生成标题
|
||||
if need_generate_title:
|
||||
threading.Thread(target=generate_title_async, args=(note_id, content)).start()
|
||||
|
||||
save_notes(notes)
|
||||
|
||||
return jsonify(note)
|
||||
|
||||
|
||||
@app.route('/api/notes/<note_id>/rename', methods=['POST'])
|
||||
def api_rename_note(note_id):
|
||||
"""重命名笔记标题"""
|
||||
data = request.get_json()
|
||||
new_title = data.get('title', '').strip()
|
||||
|
||||
if not new_title:
|
||||
return jsonify({'error': '标题不能为空'}), 400
|
||||
|
||||
notes = load_notes()
|
||||
note = next((n for n in notes if n['id'] == note_id), None)
|
||||
|
||||
if not note:
|
||||
return jsonify({'error': 'Note not found'}), 404
|
||||
|
||||
note['title'] = new_title
|
||||
note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 重新排序
|
||||
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})
|
||||
|
||||
@app.route('/api/notes/<note_id>/title', methods=['POST'])
|
||||
def api_regenerate_title(note_id):
|
||||
"""手动重新生成标题"""
|
||||
@@ -193,9 +247,11 @@ def api_regenerate_title(note_id):
|
||||
note['title'] = title
|
||||
note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 重新排序
|
||||
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})
|
||||
return jsonify({'success': True, 'title': title, 'note': note})
|
||||
|
||||
@app.route('/api/notes/<note_id>', methods=['DELETE'])
|
||||
def api_delete_note(note_id):
|
||||
@@ -206,6 +262,23 @@ def api_delete_note(note_id):
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/notes/<note_id>/pin', methods=['POST'])
|
||||
def api_pin_note(note_id):
|
||||
"""置顶/取消置顶笔记"""
|
||||
notes = load_notes()
|
||||
note = next((n for n in notes if n['id'] == note_id), None)
|
||||
|
||||
if not note:
|
||||
return jsonify({'error': 'Note not found'}), 404
|
||||
|
||||
note['pinned'] = not note.get('pinned', False)
|
||||
|
||||
# 重新排序
|
||||
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})
|
||||
|
||||
@app.route('/api/search')
|
||||
def api_search():
|
||||
"""搜索笔记"""
|
||||
@@ -217,13 +290,15 @@ 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: x.get('updated_at', ''), reverse=True)
|
||||
# 按置顶和更新时间排序(置顶在前)
|
||||
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'],
|
||||
'updated_at': n['updated_at'],
|
||||
'preview': n['content'][:100] if n['content'] else '',
|
||||
'pinned': n.get('pinned', False),
|
||||
} for n in results])
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -4,15 +4,33 @@
|
||||
<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">
|
||||
@@ -27,6 +45,15 @@
|
||||
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>
|
||||
@@ -46,10 +73,21 @@
|
||||
<!-- 顶部工具栏 -->
|
||||
<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"></h2>
|
||||
<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>
|
||||
@@ -79,12 +117,16 @@
|
||||
|
||||
<script>
|
||||
let currentNoteId = null;
|
||||
let currentNotePinned = false;
|
||||
let currentNoteVersion = 0; // 当前笔记版本号
|
||||
let saveTimer = null;
|
||||
let notes = [];
|
||||
let titleUpdateTimer = null;
|
||||
|
||||
// 加载笔记列表
|
||||
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);
|
||||
@@ -103,11 +145,40 @@
|
||||
}
|
||||
|
||||
container.innerHTML = notes.map(n => `
|
||||
<div class="note-item p-4 cursor-pointer border-b ${currentNoteId === n.id ? 'active' : ''}"
|
||||
<div class="note-item relative p-4 cursor-pointer border-b ${currentNoteId === n.id ? 'active' : ''} ${n.pinned ? 'pinned' : ''}"
|
||||
onclick="selectNote('${n.id}')">
|
||||
<h3 class="font-medium text-gray-800 truncate">${n.title || '新记录'}</h3>
|
||||
<p class="text-sm text-gray-500 truncate mt-1">${n.preview || '空白记录'}</p>
|
||||
<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('');
|
||||
}
|
||||
@@ -118,6 +189,7 @@
|
||||
const note = await res.json();
|
||||
|
||||
currentNoteId = note.id;
|
||||
currentNotePinned = false;
|
||||
loadNotes();
|
||||
showEditor(note);
|
||||
}
|
||||
@@ -129,8 +201,10 @@
|
||||
const res = await fetch(`/api/notes/${id}`);
|
||||
const note = await res.json();
|
||||
|
||||
currentNotePinned = note.pinned || false;
|
||||
currentNoteVersion = note.version || 1; // 保存版本号
|
||||
showEditor(note);
|
||||
loadNotes(); // 更新高亮状态
|
||||
loadNotes();
|
||||
}
|
||||
|
||||
// 显示编辑器
|
||||
@@ -139,7 +213,16 @@
|
||||
document.getElementById('editorContainer').classList.remove('hidden');
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
|
||||
document.getElementById('currentTitle').textContent = note.title || '新记录';
|
||||
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 || '';
|
||||
}
|
||||
@@ -148,28 +231,134 @@
|
||||
function saveContent() {
|
||||
if (!currentNoteId) 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('内容已清空,确定要保存为空吗?\n(可能发生意外清空,请确认)')) {
|
||||
// 用户取消,恢复旧内容
|
||||
document.getElementById('editor').value = oldContent;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送版本号进行保存
|
||||
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();
|
||||
|
||||
// 更新显示
|
||||
document.getElementById('currentTitle').textContent = note.title;
|
||||
// 更新本地版本号
|
||||
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 }) // 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() {
|
||||
@@ -189,7 +378,8 @@
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('currentTitle').textContent = data.title;
|
||||
document.getElementById('titleText').textContent = data.title;
|
||||
// 立即刷新列表
|
||||
loadNotes();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -200,7 +390,154 @@
|
||||
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;
|
||||
|
||||
@@ -217,8 +554,113 @@
|
||||
loadNotes();
|
||||
}
|
||||
|
||||
// 导出当前笔记
|
||||
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}`;
|
||||
|
||||
// 创建Blob并下载
|
||||
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) {
|
||||
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) => {
|
||||
// 检测 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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user