feat: 重构草稿箱功能

- 数据库添加 drafts 表存储草稿数据
- 草稿箱独立页面,侧边栏添加入口
- 自动保存间隔可配置(2/5/10/30/60秒)
- 草稿可编辑、发布为正式条目、删除
- 编辑时自动保存到服务器数据库
This commit is contained in:
2026-04-19 17:55:29 +08:00
parent 51c76ebd24
commit 7d3c5c2ae1
2 changed files with 442 additions and 107 deletions

View File

@@ -174,6 +174,83 @@ def increment_views(item_id):
return jsonify({'success': False, 'error': '条目不存在'}), 404
# ============ Draft 草稿箱 API ============
@app.route('/api/drafts', methods=['GET'])
def list_drafts():
"""列出草稿"""
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
drafts = db.list_drafts(limit=limit, offset=offset)
total = db.count_drafts()
return jsonify({'success': True, 'data': drafts, 'total': total})
@app.route('/api/drafts', methods=['POST'])
def save_draft():
"""保存草稿"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '无数据'}), 400
draft_id = db.save_draft(
type=data.get('type', 'text'),
title=data.get('title'),
content=data.get('content'),
url=data.get('url'),
source=data.get('source'),
status=data.get('status', 'pending'),
priority=data.get('priority', 'medium'),
due_date=data.get('due_date'),
note=data.get('note'),
tags=data.get('tags'),
is_starred=data.get('is_starred', False)
)
draft = db.get_draft(draft_id)
return jsonify({'success': True, 'data': draft})
@app.route('/api/drafts/<int:draft_id>', methods=['GET'])
def get_draft(draft_id):
"""获取草稿"""
draft = db.get_draft(draft_id)
if draft:
return jsonify({'success': True, 'data': draft})
return jsonify({'success': False, 'error': '草稿不存在'}), 404
@app.route('/api/drafts/<int:draft_id>', methods=['PUT'])
def update_draft(draft_id):
"""更新草稿"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '无数据'}), 400
if db.update_draft(draft_id, **data):
draft = db.get_draft(draft_id)
return jsonify({'success': True, 'data': draft})
return jsonify({'success': False, 'error': '草稿不存在'}), 404
@app.route('/api/drafts/<int:draft_id>', methods=['DELETE'])
def delete_draft(draft_id):
"""删除草稿"""
if db.delete_draft(draft_id):
return jsonify({'success': True})
return jsonify({'success': False, 'error': '草稿不存在'}), 404
@app.route('/api/drafts/<int:draft_id>/publish', methods=['POST'])
def publish_draft(draft_id):
"""将草稿发布为正式条目"""
item_id = db.draft_to_item(draft_id)
if item_id:
item = db.get_item(item_id)
return jsonify({'success': True, 'data': item})
return jsonify({'success': False, 'error': '草稿不存在'}), 404
@app.route('/api/trash', methods=['GET'])
def list_trash():
"""列出回收站数据"""
@@ -738,6 +815,7 @@ INDEX_TEMPLATE = '''
<a href="#" onclick="showTagManager(); return false;"><i class="bi bi-tags"></i> 标签管理</a>
<a href="#" onclick="showEmailManager(); return false;"><i class="bi bi-envelope"></i> 邮箱管理</a>
<a href="#" onclick="showBackupManager(); return false;"><i class="bi bi-archive"></i> 备份管理</a>
<a href="#" onclick="showDrafts(); return false;"><i class="bi bi-file-earmark"></i> 草稿箱</a>
<a href="#" onclick="showTrash(); return false;"><i class="bi bi-trash"></i> 回收站</a>
</nav>
</div>
@@ -1534,45 +1612,236 @@ function changeSort() {
// 快捷添加按钮
// ============ 草稿箱 ============
const DRAFT_KEY = 'xian_favor_draft';
let draftTimer = null;
let draftView = false;
let currentDraftId = null;
let autoSaveTimer = null;
let autoSaveDelay = parseInt(localStorage.getItem('autoSaveDelay') || '5') * 1000; // 默认5秒
// 保存草稿到 localStorage
function saveDraft() {
const type = document.getElementById('addType')?.value || 'text';
const draft = {
type,
title: document.getElementById('addTitle')?.value || '',
content: document.getElementById('addContent')?.value || '',
url: document.getElementById('addUrl')?.value || '',
source: document.getElementById('addSource')?.value || '',
status: document.getElementById('addStatus')?.value || 'pending',
priority: document.getElementById('addPriority')?.value || 'medium',
due_date: document.getElementById('addDueDate')?.value || '',
note: document.getElementById('addNote')?.value || '',
tags: document.getElementById('addTags')?.value || '',
is_starred: document.getElementById('addStarred')?.checked || false,
saved_at: new Date().toISOString()
};
// 显示草稿箱
async function showDrafts() {
draftView = true;
await loadDrafts();
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
}
// 加载草稿列表
async function loadDrafts() {
const res = await fetch(`${API_BASE}/drafts`);
const data = await res.json();
// 只在有内容时保存
if (draft.title || draft.content || draft.url || draft.note) {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
showDraftIndicator();
if (data.success) {
renderDrafts(data.data, data.total);
}
}
// 加载草稿
function loadDraft() {
const draft = localStorage.getItem(DRAFT_KEY);
if (!draft) return null;
return JSON.parse(draft);
// 渲染草稿列表
function renderDrafts(drafts, total) {
const container = document.getElementById('itemList');
const paginationContainer = document.getElementById('pagination');
paginationContainer.innerHTML = '';
let header = `
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5><i class="bi bi-file-earmark"></i> 草稿箱 (${total} 条)</h5>
<small class="text-muted">自动保存间隔:
<select id="autoSaveDelaySelect" onchange="setAutoSaveDelay()" style="width:60px;">
<option value="2" ${autoSaveDelay/1000==2?'selected':''}>2秒</option>
<option value="5" ${autoSaveDelay/1000==5?'selected':''}>5秒</option>
<option value="10" ${autoSaveDelay/1000==10?'selected':''}>10秒</option>
<option value="30" ${autoSaveDelay/1000==30?'selected':''}>30秒</option>
<option value="60" ${autoSaveDelay/1000==60?'selected':''}>60秒</option>
</select>
</small>
</div>
<button class="btn btn-outline-secondary" onclick="hideDrafts()">
<i class="bi bi-arrow-left"></i> 返回列表
</button>
</div>
`;
if (!drafts.length) {
container.innerHTML = header + '<div class="text-center text-muted py-5">草稿箱为空</div>';
return;
}
container.innerHTML = header + drafts.map(draft => `
<div class="card type-${draft.type} item-card" style="opacity: 0.85;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div style="flex: 1; min-width: 0;">
<h6 class="card-title text-truncate mb-1">
${getTypeIcon(draft.type)} ${draft.title || '(无标题)'}
</h6>
<p class="card-text text-muted small mb-0">
${draft.content ? truncate(draft.content, 50) : draft.url ? truncate(draft.url, 50) : ''}
</p>
<small class="text-muted">保存于: ${formatShortDate(draft.updated_at)}</small>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-primary" onclick="editDraft(${draft.id})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-success" onclick="publishDraft(${draft.id})" title="发布">
<i class="bi bi-send"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDraft(${draft.id})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
// 清除草稿
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
hideDraftIndicator();
// 设置自动保存间隔
function setAutoSaveDelay() {
const delay = parseInt(document.getElementById('autoSaveDelaySelect').value);
autoSaveDelay = delay * 1000;
localStorage.setItem('autoSaveDelay', delay.toString());
}
// 隐藏草稿箱
function hideDrafts() {
draftView = false;
currentDraftId = null;
refreshData();
document.querySelector('.sidebar a[data-filter="all"]').classList.add('active');
}
// 编辑草稿(恢复到添加表单)
async function editDraft(draftId) {
const res = await fetch(`${API_BASE}/drafts/${draftId}`);
const data = await res.json();
if (data.success) {
const draft = data.data;
currentDraftId = draftId;
// 打开对应的添加弹框
showAddModal(draft.type);
// 填充数据
setTimeout(() => {
document.getElementById('addTitle').value = draft.title || '';
if (draft.type === 'text') {
document.getElementById('addContent').value = draft.content || '';
}
if (['link', 'column'].includes(draft.type)) {
document.getElementById('addUrl').value = draft.url || '';
}
if (draft.type === 'column') {
document.getElementById('addSource').value = draft.source || '';
}
if (draft.type === 'todo') {
document.getElementById('addStatus').value = draft.status || 'pending';
document.getElementById('addPriority').value = draft.priority || 'medium';
document.getElementById('addDueDate').value = draft.due_date || '';
}
document.getElementById('addNote').value = draft.note || '';
document.getElementById('addTags').value = draft.tags || '';
document.getElementById('addStarred').checked = draft.is_starred || false;
showDraftIndicator();
}, 100);
}
}
// 发布草稿(转为正式条目)
async function publishDraft(draftId) {
if (!confirm('确认发布这条草稿?')) return;
const res = await fetch(`${API_BASE}/drafts/${draftId}/publish`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('草稿已发布!');
loadDrafts();
loadStats();
} else {
alert('发布失败: ' + data.error);
}
}
// 删除草稿
async function deleteDraft(draftId) {
if (!confirm('确认删除这条草稿?')) return;
const res = await fetch(`${API_BASE}/drafts/${draftId}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
loadDrafts();
loadStats();
} else {
alert('删除失败: ' + data.error);
}
}
// 保存草稿到服务器
async function saveDraftToServer() {
const type = document.getElementById('addType')?.value;
if (!type) return;
const data = {
type,
title: document.getElementById('addTitle')?.value || '',
content: type === 'text' ? document.getElementById('addContent')?.value : null,
url: ['link', 'column'].includes(type) ? document.getElementById('addUrl')?.value : null,
source: type === 'column' ? document.getElementById('addSource')?.value : null,
status: type === 'todo' ? document.getElementById('addStatus')?.value : null,
priority: type === 'todo' ? document.getElementById('addPriority')?.value : null,
due_date: type === 'todo' ? document.getElementById('addDueDate')?.value : null,
note: document.getElementById('addNote')?.value || '',
tags: document.getElementById('addTags')?.value || '',
is_starred: document.getElementById('addStarred')?.checked || false
};
// 只在有内容时保存
if (!data.title && !data.content && !data.url && !data.note) return;
try {
if (currentDraftId) {
// 更新现有草稿
await fetch(`${API_BASE}/drafts/${currentDraftId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
// 创建新草稿
const res = await fetch(`${API_BASE}/drafts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
currentDraftId = result.data.id;
}
}
showDraftIndicator();
} catch (e) {
console.error('保存草稿失败:', e);
}
}
// 启动自动保存
function startAutoSave() {
// 立即保存一次
saveDraftToServer();
// 设置定时保存
autoSaveTimer = setInterval(saveDraftToServer, autoSaveDelay);
}
// 停止自动保存
function stopAutoSave() {
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
autoSaveTimer = null;
}
}
// 显示草稿指示器
@@ -1587,78 +1856,13 @@ function hideDraftIndicator() {
if (indicator) indicator.style.display = 'none';
}
// 检查是否有草稿并提示恢复
function checkAndRestoreDraft(type) {
const draft = loadDraft();
if (!draft) return false;
// 草稿类型匹配才提示
if (draft.type === type && (draft.title || draft.content || draft.url || draft.note)) {
const savedTime = new Date(draft.saved_at).toLocaleString('zh-CN');
if (confirm(`发现未保存的草稿(${savedTime}),是否恢复?`)) {
restoreDraftToForm(draft);
return true;
} else {
clearDraft(); // 用户选择不恢复,清除草稿
}
}
return false;
}
// 将草稿恢复到表单
function restoreDraftToForm(draft) {
document.getElementById('addTitle').value = draft.title || '';
if (draft.type === 'text') {
document.getElementById('addContent').value = draft.content || '';
}
if (['link', 'column'].includes(draft.type)) {
document.getElementById('addUrl').value = draft.url || '';
}
if (draft.type === 'column') {
document.getElementById('addSource').value = draft.source || '';
}
if (draft.type === 'todo') {
document.getElementById('addStatus').value = draft.status || 'pending';
document.getElementById('addPriority').value = draft.priority || 'medium';
document.getElementById('addDueDate').value = draft.due_date || '';
}
document.getElementById('addNote').value = draft.note || '';
document.getElementById('addTags').value = draft.tags || '';
document.getElementById('addStarred').checked = draft.is_starred || false;
showDraftIndicator();
}
// 启动自动保存
function startAutoSave() {
// 每5秒自动保存一次
draftTimer = setInterval(saveDraft, 5000);
// 监听输入事件立即保存(带延迟)
const form = document.getElementById('addForm');
if (form) {
form.addEventListener('input', () => {
clearTimeout(draftTimer);
draftTimer = setTimeout(saveDraft, 2000); // 输入后2秒保存
// 重新启动定时保存
clearInterval(draftTimer);
draftTimer = setInterval(saveDraft, 5000);
});
}
}
// 停止自动保存
function stopAutoSave() {
if (draftTimer) {
clearInterval(draftTimer);
clearTimeout(draftTimer);
draftTimer = null;
}
}
function showAddModal(type) {
// 设置类型
document.getElementById('addType').value = type;
// 重置草稿ID
currentDraftId = null;
// 设置弹窗标题和图标
const typeInfo = {
text: { icon: '📝', title: '添加文本' },
@@ -1676,11 +1880,11 @@ function showAddModal(type) {
document.getElementById('sourceGroup').style.display = type === 'column' ? 'block' : 'none';
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
// 清空表单
document.getElementById('addForm').reset();
// 检查是否有草稿
const restored = checkAndRestoreDraft(type);
// 清空表单(如果不是从草稿恢复)
if (!currentDraftId) {
document.getElementById('addForm').reset();
hideDraftIndicator();
}
// 打开弹窗
new bootstrap.Modal(document.getElementById('addModal')).show();
@@ -1691,6 +1895,7 @@ function showAddModal(type) {
// 弹框关闭时停止自动保存
document.getElementById('addModal').addEventListener('hidden.bs.modal', () => {
stopAutoSave();
currentDraftId = null; // 重置草稿ID
}, { once: true });
}
@@ -1720,8 +1925,15 @@ async function addItem() {
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
document.getElementById('addForm').reset();
clearDraft(); // 成功添加后清除草稿
stopAutoSave(); // 停止自动保存
// 成功添加后删除草稿
if (currentDraftId) {
await fetch(`${API_BASE}/drafts/${currentDraftId}`, { method: 'DELETE' });
currentDraftId = null;
}
stopAutoSave();
hideDraftIndicator();
refreshData();
}
}

View File

@@ -99,6 +99,26 @@ class Database:
)
""")
# 草稿表
cursor.execute("""
CREATE TABLE IF NOT EXISTS drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL DEFAULT 'text',
title TEXT,
content TEXT,
url TEXT,
source TEXT,
status TEXT DEFAULT 'pending',
priority TEXT DEFAULT 'medium',
due_date TEXT,
note TEXT,
tags TEXT,
is_starred INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
# 邮件发送记录表
cursor.execute("""
CREATE TABLE IF NOT EXISTS email_logs (
@@ -444,6 +464,109 @@ class Database:
conn.commit()
return cursor.rowcount > 0
# ============ Draft 草稿操作 ============
def save_draft(self, type: str = "text", title: str = None, content: str = None,
url: str = None, source: str = None, status: str = "pending",
priority: str = "medium", due_date: str = None, note: str = None,
tags: str = None, is_starred: bool = False) -> int:
"""保存草稿"""
self._ensure_init()
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO drafts (type, title, content, url, source, status, priority, due_date, note, tags, is_starred, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, tags, 1 if is_starred else 0, now, now))
draft_id = cursor.lastrowid
conn.commit()
return draft_id
def update_draft(self, draft_id: int, **kwargs) -> bool:
"""更新草稿"""
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'tags', 'is_starred']
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
if not update_fields:
return False
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id FROM drafts WHERE id = ?", (draft_id,))
if not cursor.fetchone():
return False
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
set_clause += ", updated_at = ?"
values = list(update_fields.values()) + [now, draft_id]
cursor.execute(f"UPDATE drafts SET {set_clause} WHERE id = ?", values)
conn.commit()
return True
def list_drafts(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出草稿"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM drafts ORDER BY updated_at DESC LIMIT ? OFFSET ?", (limit, offset))
return [dict(row) for row in cursor.fetchall()]
def count_drafts(self) -> int:
"""计算草稿总数"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM drafts")
return cursor.fetchone()['count']
def get_draft(self, draft_id: int) -> Optional[Dict[str, Any]]:
"""获取单个草稿"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM drafts WHERE id = ?", (draft_id,))
row = cursor.fetchone()
return dict(row) if row else None
def delete_draft(self, draft_id: int) -> bool:
"""删除草稿"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM drafts WHERE id = ?", (draft_id,))
conn.commit()
return cursor.rowcount > 0
def draft_to_item(self, draft_id: int) -> Optional[int]:
"""将草稿转为正式条目"""
draft = self.get_draft(draft_id)
if not draft:
return None
# 创建条目
tags_list = draft['tags'].split(',') if draft['tags'] else []
tags_list = [t.strip() for t in tags_list if t.strip()]
item_id = self.create_item(
type=draft['type'],
title=draft['title'],
content=draft['content'],
url=draft['url'],
source=draft['source'],
status=draft['status'],
priority=draft['priority'],
due_date=draft['due_date'],
note=draft['note'],
tags=tags_list,
is_starred=draft['is_starred']
)
# 删除草稿
if item_id:
self.delete_draft(draft_id)
return item_id
# ============ Tag 操作 ============
def create_tag(self, name: str, color: str = "#3498db") -> int: