5 Commits
Author SHA1 Message Date
hubian 68e3d30f3f feat: 空内容保存确认 + 重命名标题功能 2026-04-09 00:31:33 +08:00
hubian a30514e6da feat: 操作按钮隐藏在···下拉菜单中
- 置顶、导出、删除三个按钮合并到'更多'菜单
- 点击···图标展开/收起菜单
- 点击其他地方自动关闭菜单
2026-04-08 18:55:38 +08:00
hubian cbe014e10d feat: 新增导出功能,合并左侧操作按钮
- 新建记录输入内容超过20字时自动生成标题
- 左侧列表新增导出按钮(导出为Markdown文件)
- 置顶、导出、删除三个按钮合并,hover时显示
2026-04-08 18:45:34 +08:00
hubian a21a813d83 fix: 移除编辑时自动生成标题的逻辑
只在手动点击'重新生成标题'按钮时才触发标题生成
2026-04-08 18:38:55 +08:00
hubian 0e84111ffe feat: 增加置顶功能和列表显示开关
- 标题生成后左侧列表实时刷新(2秒轮询)
- 增加显示内容预览开关
- 增加置顶/取消置顶功能
- 列表项hover显示隐藏菜单(置顶、删除)
2026-04-08 18:34:13 +08:00
3 changed files with 323 additions and 21 deletions
+54 -9
View File
@@ -33,7 +33,9 @@ LLM_CONFIG = {
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'))
# 按置顶和更新时间排序
return sorted(notes, key=lambda x: (not x.get('pinned', False), x.get('updated_at', '')), reverse=True)
return []
def save_notes(notes):
@@ -118,6 +120,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>')
@@ -164,22 +167,46 @@ def api_update_note(note_id):
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
# 如果内容变化较大,异步生成标题
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: (not 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 +220,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: (not 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 +235,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: (not 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 +263,12 @@ 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)
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__':
Regular → Executable
View File
+269 -12
View File
@@ -9,10 +9,19 @@
<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;
}
</style>
</head>
<body class="bg-gray-100 h-screen overflow-hidden">
@@ -27,6 +36,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 +64,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 +108,15 @@
<script>
let currentNoteId = null;
let currentNotePinned = false;
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 +135,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 +179,7 @@
const note = await res.json();
currentNoteId = note.id;
currentNotePinned = false;
loadNotes();
showEditor(note);
}
@@ -129,8 +191,9 @@
const res = await fetch(`/api/notes/${id}`);
const note = await res.json();
currentNotePinned = note.pinned || false;
showEditor(note);
loadNotes(); // 更新高亮状态
loadNotes();
}
// 显示编辑器
@@ -139,7 +202,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,12 +220,24 @@
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' },
@@ -162,11 +246,11 @@
const note = await res.json();
// 更新显示
document.getElementById('currentTitle').textContent = note.title;
// 更新标题显示
document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
// 新列表
// 立即刷新列表
loadNotes();
}, 500);
}
@@ -189,7 +273,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 +285,138 @@
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}`);
if (menu) {
// 先隐藏其他所有菜单
document.querySelectorAll('[id^="menu-"]').forEach(m => {
if (m.id !== `menu-${id}`) m.classList.add('hidden');
});
menu.classList.toggle('hidden');
}
}
function hideMenu(id) {
const menu = document.getElementById(`menu-${id}`);
if (menu) menu.classList.add('hidden');
}
// 点击其他地方关闭所有菜单
document.addEventListener('click', () => {
document.querySelectorAll('[id^="menu-"]').forEach(m => m.classList.add('hidden'));
});
// 删除当前笔记
async function deleteCurrentNote() {
if (!currentNoteId) return;
@@ -217,8 +433,49 @@
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);
}
// 初始化
loadNotes();
startTitlePolling();
</script>
</body>
</html>