Compare commits

...

7 Commits

Author SHA1 Message Date
51c76ebd24 feat: 新增草稿箱自动保存功能
- 编辑时自动保存到 localStorage(每5秒或输入后2秒)
- 打开添加弹框时检查是否有草稿并提示恢复
- 成功添加后清除草稿
- 弹框标题显示“已自动保存”指示器
2026-04-19 17:31:08 +08:00
facf39e778 fix: 修复回收站显示问题
- 修改提示文字:回收站数据可随时恢复,无30天限制
- 清空分页组件:避免显示错误的分页
2026-04-19 17:09:58 +08:00
51cecf1f4e fix: 修复回收站点击事件被侧边栏通用处理器捕获的问题
- 只为有 data-filter 属性的链接添加过滤事件处理器
- 回收站链接使用 onclick 内联事件,不会被通用处理器干扰
- 从回收站返回时正确重置 trashView 状态
2026-04-19 17:07:48 +08:00
79e4eb4de0 feat: 新增回收站功能
- 数据库添加 is_deleted 和 deleted_at 字段
- 删除数据改为移动到回收站(软删除)
- 回收站支持查看、恢复、彻底删除
- 支持一键清空回收站
- 侧边栏添加回收站入口
2026-04-19 16:57:47 +08:00
70b40cb90b feat: 新增阅读数功能
- 数据库添加 views 字段,兼容旧数据库自动添加
- API 新增 /api/items/<id>/view 接口增加阅读数
- 列表显示阅读数(👁图标)
- 详情页显示阅读数,点击详情时自动增加
2026-04-19 10:44:05 +08:00
22c32a9f3d fix: 编辑保存失败时显示错误提示;修复只修改标签时返回False的问题 2026-04-19 09:25:59 +08:00
c3791ce961 fix: 重点关注图标样式优化,只有五角星变实心,按钮保持outline样式 2026-04-19 09:03:44 +08:00
2 changed files with 499 additions and 18 deletions

View File

@@ -30,6 +30,8 @@ def list_items():
tag=request.args.get('tag'),
keyword=request.args.get('keyword'),
starred=starred,
sort_by=request.args.get('sort_by'),
sort_order=request.args.get('sort_order'),
limit=int(request.args.get('limit', 50)),
offset=int(request.args.get('offset', 0))
)
@@ -163,6 +165,54 @@ def set_star_item(item_id, status):
return jsonify({'success': False, 'error': '条目不存在'}), 404
@app.route('/api/items/<int:item_id>/view', methods=['POST'])
def increment_views(item_id):
"""增加阅读数"""
if db.increment_views(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():
"""列出回收站数据"""
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):
"""完成待办"""
@@ -656,7 +706,6 @@ INDEX_TEMPLATE = '''
.is-starred { border-left: 4px solid #ffc107; background: #fffbe6; }
.is-starred:hover { background: #fff9e0; }
.star-btn { font-size: 11px; }
.star-btn.active { color: #ffc107; }
.status-pending { color: #ffc107; }
.status-in_progress { color: #17a2b8; }
.status-completed { color: #28a745; text-decoration: line-through; }
@@ -689,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>
@@ -715,6 +765,15 @@ INDEX_TEMPLATE = '''
<option value="column">专栏</option>
<option value="todo">待办</option>
</select>
<select id="sortBy" class="form-select" style="width: 130px;" onchange="changeSort()">
<option value="">默认排序</option>
<option value="created_at">创建时间</option>
<option value="updated_at">更新时间</option>
</select>
<select id="sortOrder" class="form-select" style="width: 100px;" onchange="changeSort()">
<option value="desc">降序 ↓</option>
<option value="asc">升序 ↑</option>
</select>
</div>
<button class="btn btn-outline-info me-2" onclick="showAIAddModal()" title="AI自动添加">
<i class="bi bi-robot"></i> AI添加
@@ -789,6 +848,7 @@ INDEX_TEMPLATE = '''
<h5 class="modal-title">
<span id="addModalIcon"></span>
<span id="addModalTitle">添加条目</span>
<span id="draftIndicator" class="badge bg-secondary ms-2" style="display:none;">已自动保存</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
@@ -1251,6 +1311,7 @@ INDEX_TEMPLATE = '''
<script>
const API_BASE = '/api';
let currentFilter = { type: '', status: '', starred: null };
let currentSort = { sort_by: '', sort_order: '' };
let currentPage = 1;
const pageSize = 20;
function debounce(fn, delay) {
@@ -1302,9 +1363,15 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// 侧边栏过滤
document.querySelectorAll('.sidebar a').forEach(a => {
document.querySelectorAll('.sidebar a[data-filter]').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
// 如果在回收站视图,先退出
if (trashView) {
trashView = false;
}
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
a.classList.add('active');
@@ -1332,6 +1399,8 @@ async function loadItems(page = 1) {
if (currentFilter.status) url += `&status=${currentFilter.status}`;
if (currentFilter.starred !== null) url += `&starred=${currentFilter.starred ? 'true' : 'false'}`;
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
if (currentSort.sort_by) url += `&sort_by=${currentSort.sort_by}`;
if (currentSort.sort_order) url += `&sort_order=${currentSort.sort_order}`;
const res = await fetch(url);
const data = await res.json();
@@ -1363,6 +1432,7 @@ function renderItems(items) {
</span>
${item.content_stats && (item.content_stats.lines > 0 || item.content_stats.chars > 0) ?
`<span style="font-size:10px; opacity:0.6; margin-left:4px;" title="有效行数/总字数">[${item.content_stats.lines}行/${item.content_stats.chars}字]</span>` : ''}
${item.views > 0 ? `<span style="font-size:10px; opacity:0.5; margin-left:4px;" title="阅读次数">👁${item.views}</span>` : ''}
</h6>
<p class="card-text text-muted small mb-0 text-truncate" style="font-size:12px;">
${item.url ? truncate(item.url, 50) : item.content ? truncate(item.content, 50) : item.note ? truncate(item.note, 50) : ''}
@@ -1371,7 +1441,7 @@ function renderItems(items) {
</div>
<div class="d-flex align-items-center gap-1 flex-wrap ms-2" onclick="event.stopPropagation();">
${item.tags.slice(0, 2).map(t => `<span class="badge bg-secondary" style="font-size:10px;">${t}</span>`).join('')}
<button class="btn btn-sm btn-outline-warning py-0 px-1 star-btn ${item.is_starred ? 'active' : ''}" onclick="toggleStar(${item.id})" title="${item.is_starred ? '取消重点关注' : '设为重点关注'}">
<button class="btn btn-sm btn-outline-warning py-0 px-1 star-btn" onclick="toggleStar(${item.id})" title="${item.is_starred ? '取消重点关注' : '设为重点关注'}">
<i class="bi bi-star${item.is_starred ? '-fill' : ''}" style="font-size:11px; ${item.is_starred ? 'color:#ffc107;' : ''}"></i>
</button>
${item.type !== 'todo' ? `<button class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="showConvertModal(${item.id})" title="转为待办"><i class="bi bi-arrow-repeat" style="font-size:11px;"></i></button>` : ''}
@@ -1451,9 +1521,140 @@ async function refreshData() {
loadItems(currentPage);
}
// 排序切换
function changeSort() {
const sortBy = document.getElementById('sortBy').value;
const sortOrder = document.getElementById('sortOrder').value;
currentSort = { sort_by: sortBy, sort_order: sortOrder };
loadItems(1); // 切换排序时回到第一页
}
// ============ 添加功能 ============
// 快捷添加按钮
// ============ 草稿箱 ============
const DRAFT_KEY = 'xian_favor_draft';
let draftTimer = null;
// 保存草稿到 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()
};
// 只在有内容时保存
if (draft.title || draft.content || draft.url || draft.note) {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
showDraftIndicator();
}
}
// 加载草稿
function loadDraft() {
const draft = localStorage.getItem(DRAFT_KEY);
if (!draft) return null;
return JSON.parse(draft);
}
// 清除草稿
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
hideDraftIndicator();
}
// 显示草稿指示器
function showDraftIndicator() {
const indicator = document.getElementById('draftIndicator');
if (indicator) indicator.style.display = 'inline';
}
// 隐藏草稿指示器
function hideDraftIndicator() {
const indicator = document.getElementById('draftIndicator');
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;
@@ -1478,8 +1679,19 @@ function showAddModal(type) {
// 清空表单
document.getElementById('addForm').reset();
// 检查是否有草稿
const restored = checkAndRestoreDraft(type);
// 打开弹窗
new bootstrap.Modal(document.getElementById('addModal')).show();
// 启动自动保存
startAutoSave();
// 弹框关闭时停止自动保存
document.getElementById('addModal').addEventListener('hidden.bs.modal', () => {
stopAutoSave();
}, { once: true });
}
// 添加条目
@@ -1508,6 +1720,8 @@ async function addItem() {
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
document.getElementById('addForm').reset();
clearDraft(); // 成功添加后清除草稿
stopAutoSave(); // 停止自动保存
refreshData();
}
}
@@ -1619,6 +1833,10 @@ let currentDetailId = null;
// 显示详情
async function showDetail(id) {
currentDetailId = id;
// 增加阅读数
await fetch(`${API_BASE}/items/${id}/view`, { method: 'POST' });
const res = await fetch(`${API_BASE}/items/${id}`);
const data = await res.json();
@@ -1634,6 +1852,9 @@ async function showDetail(id) {
let html = `<div class="mb-3"><strong>类型:</strong> ${getTypeLabel(item.type)}</div>`;
// 显示阅读数
html += `<div class="mb-3"><strong>阅读:</strong> <span class="badge bg-secondary">👁 ${item.views || 0} 次</span></div>`;
// 显示重点关注状态
if (item.is_starred) {
html += `<div class="mb-3"><strong>状态:</strong> <span class="badge bg-warning text-dark"><i class="bi bi-star-fill"></i> 重点关注</span></div>`;
@@ -1782,15 +2003,23 @@ async function saveEdit() {
is_starred: document.getElementById('editStarred').checked ? 1 : 0
};
const res = await fetch(`${API_BASE}/items/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
refreshData();
try {
const res = await fetch(`${API_BASE}/items/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (res.ok && result.success) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
refreshData();
} else {
alert('保存失败: ' + (result.error || '未知错误'));
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
@@ -2225,6 +2454,134 @@ 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');
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-trash"></i> 回收站 (${total} 条数据)</h5>
<small class="text-muted">回收站数据可随时恢复或彻底删除</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() {

View File

@@ -60,6 +60,9 @@ class Database:
due_date TEXT,
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
)
@@ -124,6 +127,22 @@ class Database:
# 创建 is_starred 索引(字段添加后再创建)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_starred ON items(is_starred)")
# 检查并添加 views 字段(兼容旧数据库)
try:
cursor.execute("SELECT views FROM items LIMIT 1")
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 操作 ============
@@ -171,8 +190,13 @@ class Database:
return item
def list_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, starred: bool = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出条目"""
keyword: str = None, starred: bool = None, sort_by: str = None,
sort_order: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出条目
sort_by: created_at, updated_at
sort_order: desc, asc
"""
with self.get_conn() as conn:
cursor = conn.cursor()
@@ -180,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"
@@ -206,8 +233,31 @@ class Database:
if conditions:
query += " WHERE " + " AND ".join(conditions)
# 重点关注优先显示
query += " ORDER BY i.is_starred DESC, i.created_at DESC LIMIT ? OFFSET ?"
# 排序逻辑
if sort_by == 'updated_at':
order_field = 'i.updated_at'
elif sort_by == 'created_at':
order_field = 'i.created_at'
else:
# 默认:重点关注优先 + 创建时间降序
order_field = 'i.created_at'
order_dir = 'DESC' if (sort_order == 'asc' or sort_order is None) else 'ASC'
# 这里反转逻辑:用户选择"降序"时用DESC选择"升序"时用ASC
if sort_order == 'asc':
order_dir = 'ASC'
elif sort_order == 'desc':
order_dir = 'DESC'
else:
order_dir = 'DESC' # 默认降序
# 如果有指定排序字段,按该字段排序;否则默认重点关注优先
if sort_by:
query += f" ORDER BY {order_field} {order_dir} LIMIT ? OFFSET ?"
else:
# 默认:重点关注优先,然后创建时间降序
query += f" ORDER BY i.is_starred DESC, i.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
@@ -263,6 +313,7 @@ class Database:
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'is_starred']
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
# 只有 tags 变化也算有效更新
if not update_fields and 'tags' not in kwargs:
return False
@@ -271,6 +322,11 @@ class Database:
with self.get_conn() as conn:
cursor = conn.cursor()
# 检查条目是否存在
cursor.execute("SELECT id FROM items WHERE id = ?", (item_id,))
if not cursor.fetchone():
return False
if update_fields:
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
set_clause += ", updated_at = ?"
@@ -285,16 +341,76 @@ class Database:
self._add_tags_to_item(conn, item_id, kwargs['tags'])
conn.commit()
return cursor.rowcount > 0
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:
@@ -320,6 +436,14 @@ class Database:
conn.commit()
return cursor.rowcount > 0
def increment_views(self, item_id: int) -> bool:
"""增加阅读数"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE items SET views = views + 1 WHERE id = ?", (item_id,))
conn.commit()
return cursor.rowcount > 0
# ============ Tag 操作 ============
def create_tag(self, name: str, color: str = "#3498db") -> int: