feat: 新增回收站功能
- 数据库添加 is_deleted 和 deleted_at 字段 - 删除数据改为移动到回收站(软删除) - 回收站支持查看、恢复、彻底删除 - 支持一键清空回收站 - 侧边栏添加回收站入口
This commit is contained in:
@@ -174,6 +174,45 @@ def increment_views(item_id):
|
||||
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||
|
||||
|
||||
@app.route('/api/trash', methods=['GET'])
|
||||
def list_trash():
|
||||
"""列出回收站数据"""
|
||||
limit = int(request.args.get('limit', 50))
|
||||
offset = int(request.args.get('offset', 0))
|
||||
items = db.list_trash(limit=limit, offset=offset)
|
||||
total = db.count_trash()
|
||||
|
||||
# 为每个条目添加内容统计
|
||||
for item in items:
|
||||
item['content_stats'] = calculate_content_stats(item)
|
||||
|
||||
return jsonify({'success': True, 'data': items, 'total': total})
|
||||
|
||||
|
||||
@app.route('/api/items/<int:item_id>/restore', methods=['POST'])
|
||||
def restore_item(item_id):
|
||||
"""从回收站恢复数据"""
|
||||
if db.restore_item(item_id):
|
||||
item = db.get_item(item_id)
|
||||
return jsonify({'success': True, 'data': item})
|
||||
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||
|
||||
|
||||
@app.route('/api/items/<int:item_id>/permanent', methods=['DELETE'])
|
||||
def delete_permanently(item_id):
|
||||
"""彻底删除数据"""
|
||||
if db.delete_permanently(item_id):
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||
|
||||
|
||||
@app.route('/api/trash', methods=['DELETE'])
|
||||
def empty_trash():
|
||||
"""清空回收站"""
|
||||
deleted_count = db.empty_trash()
|
||||
return jsonify({'success': True, 'deleted_count': deleted_count})
|
||||
|
||||
|
||||
@app.route('/api/items/<int:item_id>/done', methods=['POST'])
|
||||
def complete_item(item_id):
|
||||
"""完成待办"""
|
||||
@@ -699,6 +738,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="showTrash(); return false;"><i class="bi bi-trash"></i> 回收站</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -2271,6 +2311,130 @@ async function showEmailManager() {
|
||||
new bootstrap.Modal(document.getElementById('emailManagerModal')).show();
|
||||
}
|
||||
|
||||
// ============ 回收站 ============
|
||||
|
||||
let trashView = false;
|
||||
|
||||
async function showTrash() {
|
||||
trashView = true;
|
||||
await loadTrash();
|
||||
|
||||
// 更新侧边栏状态
|
||||
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
const res = await fetch(`${API_BASE}/trash`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderTrash(data.data, data.total);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrash(items, total) {
|
||||
const container = document.getElementById('itemList');
|
||||
|
||||
// 显示回收站标题和操作按钮
|
||||
let header = `
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5><i class="bi bi-trash"></i> 回收站 (${total} 条数据)</h5>
|
||||
<small class="text-muted">删除的数据可在30天内恢复</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary" onclick="hideTrash()">
|
||||
<i class="bi bi-arrow-left"></i> 返回列表
|
||||
</button>
|
||||
${total > 0 ? `<button class="btn btn-outline-danger ms-2" onclick="emptyTrash()">
|
||||
<i class="bi bi-trash-fill"></i> 清空回收站
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!items.length) {
|
||||
container.innerHTML = header + '<div class="text-center text-muted py-5">回收站为空</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = header + items.map(item => `
|
||||
<div class="card type-${item.type} item-card" style="opacity: 0.7;">
|
||||
<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(item.type)} ${item.title || truncate(item.content || item.url, 30)}
|
||||
</h6>
|
||||
<p class="card-text text-muted small mb-0">
|
||||
删除时间: ${formatShortDate(item.deleted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-success" onclick="restoreItem(${item.id})" title="恢复">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> 恢复
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePermanently(${item.id})" title="彻底删除">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function hideTrash() {
|
||||
trashView = false;
|
||||
refreshData();
|
||||
|
||||
// 更新侧边栏状态
|
||||
document.querySelector('.sidebar a[data-filter="all"]').classList.add('active');
|
||||
}
|
||||
|
||||
async function restoreItem(id) {
|
||||
if (!confirm('确认恢复这条数据?')) return;
|
||||
|
||||
const res = await fetch(`${API_BASE}/items/${id}/restore`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
loadTrash();
|
||||
loadStats();
|
||||
} else {
|
||||
alert('恢复失败: ' + data.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePermanently(id) {
|
||||
if (!confirm('确认彻底删除这条数据?此操作不可恢复!')) return;
|
||||
|
||||
const res = await fetch(`${API_BASE}/items/${id}/permanent`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
loadTrash();
|
||||
loadStats();
|
||||
} else {
|
||||
alert('删除失败: ' + data.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function emptyTrash() {
|
||||
if (!confirm('确认清空回收站?此操作不可恢复!')) return;
|
||||
|
||||
const res = await fetch(`${API_BASE}/trash`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(`已清空回收站,删除了 ${data.deleted_count} 条数据`);
|
||||
loadTrash();
|
||||
loadStats();
|
||||
} else {
|
||||
alert('清空失败: ' + data.error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 备份管理 ============
|
||||
|
||||
async function showBackupManager() {
|
||||
|
||||
@@ -61,6 +61,8 @@ class Database:
|
||||
note TEXT,
|
||||
is_starred INTEGER DEFAULT 0,
|
||||
views INTEGER DEFAULT 0,
|
||||
is_deleted INTEGER DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
@@ -131,6 +133,16 @@ class Database:
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN views INTEGER DEFAULT 0")
|
||||
|
||||
# 检查并添加 is_deleted 和 deleted_at 字段(兼容旧数据库)
|
||||
try:
|
||||
cursor.execute("SELECT is_deleted FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN is_deleted INTEGER DEFAULT 0")
|
||||
try:
|
||||
cursor.execute("SELECT deleted_at FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ============ Item 操作 ============
|
||||
@@ -192,6 +204,9 @@ class Database:
|
||||
params = []
|
||||
conditions = []
|
||||
|
||||
# 只显示未删除的数据
|
||||
conditions.append("i.is_deleted = 0")
|
||||
|
||||
# 标签过滤需要JOIN
|
||||
if tag:
|
||||
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
|
||||
@@ -329,13 +344,73 @@ class Database:
|
||||
return True
|
||||
|
||||
def delete_item(self, item_id: int) -> bool:
|
||||
"""删除条目"""
|
||||
"""删除条目(移动到回收站)"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("UPDATE items SET is_deleted = 1, deleted_at = ? WHERE id = ?", (now, item_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_trash(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""列出回收站数据"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM items WHERE is_deleted = 1 ORDER BY deleted_at DESC LIMIT ? OFFSET ?", (limit, offset))
|
||||
items = []
|
||||
for row in cursor.fetchall():
|
||||
item = dict(row)
|
||||
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def count_trash(self) -> int:
|
||||
"""计算回收站数据总数"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as count FROM items WHERE is_deleted = 1")
|
||||
return cursor.fetchone()['count']
|
||||
|
||||
def restore_item(self, item_id: int) -> bool:
|
||||
"""从回收站恢复数据"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE items SET is_deleted = 0, deleted_at = NULL WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_permanently(self, item_id: int) -> bool:
|
||||
"""彻底删除数据(从数据库中删除)"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 删除标签关联
|
||||
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
|
||||
# 删除邮件发送记录
|
||||
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
|
||||
# 删除数据
|
||||
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def empty_trash(self) -> int:
|
||||
"""清空回收站"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 获取所有回收站数据ID
|
||||
cursor.execute("SELECT id FROM items WHERE is_deleted = 1")
|
||||
ids = [row['id'] for row in cursor.fetchall()]
|
||||
|
||||
# 删除所有关联数据
|
||||
for item_id in ids:
|
||||
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
|
||||
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
|
||||
|
||||
# 删除所有回收站数据
|
||||
cursor.execute("DELETE FROM items WHERE is_deleted = 1")
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted_count
|
||||
|
||||
def toggle_star(self, item_id: int) -> bool:
|
||||
"""切换重点关注状态"""
|
||||
with self.get_conn() as conn:
|
||||
|
||||
Reference in New Issue
Block a user