From 79e4eb4de0279f2143d0c1a8d2de074e3daff0db Mon Sep 17 00:00:00 2001 From: hubian <908234780@qq.com> Date: Sun, 19 Apr 2026 16:57:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E7=AB=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库添加 is_deleted 和 deleted_at 字段 - 删除数据改为移动到回收站(软删除) - 回收站支持查看、恢复、彻底删除 - 支持一键清空回收站 - 侧边栏添加回收站入口 --- xian_favor/api.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++ xian_favor/db.py | 77 +++++++++++++++++++++- 2 files changed, 240 insertions(+), 1 deletion(-) diff --git a/xian_favor/api.py b/xian_favor/api.py index ba40364..0e16cd6 100644 --- a/xian_favor/api.py +++ b/xian_favor/api.py @@ -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//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//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//done', methods=['POST']) def complete_item(item_id): """完成待办""" @@ -699,6 +738,7 @@ INDEX_TEMPLATE = ''' 标签管理 邮箱管理 备份管理 + 回收站 @@ -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 = ` +
+
+
回收站 (${total} 条数据)
+ 删除的数据可在30天内恢复 +
+
+ + ${total > 0 ? `` : ''} +
+
+ `; + + if (!items.length) { + container.innerHTML = header + '
回收站为空
'; + return; + } + + container.innerHTML = header + items.map(item => ` +
+
+
+
+
+ ${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)} +
+

+ 删除时间: ${formatShortDate(item.deleted_at)} +

+
+
+ + +
+
+
+
+ `).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() { diff --git a/xian_favor/db.py b/xian_favor/db.py index 1d4cc61..e6de863 100644 --- a/xian_favor/db.py +++ b/xian_favor/db.py @@ -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: