Compare commits

..

16 Commits

Author SHA1 Message Date
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
27e24e2a86 fix: 修复数据库初始化索引创建顺序 2026-04-19 00:13:35 +08:00
0864c99b75 feat: 新增重点关注功能
- 数据库新增 is_starred 字段,兼容旧数据库自动添加
- 所有类别数据支持一键设置/取消重点关注
- 侧边栏新增"重点关注"过滤选项,重点关注数据优先显示
- 新增数据时可直接勾选"设为重点关注"开关
- 编辑时可切换重点关注状态
- 卡片显示重点关注标记(星标图标)和特殊样式
- API新增 /api/items/<id>/star 接口用于切换重点关注状态
- 重点关注数据按创建时间倒序排列并优先显示
2026-04-19 00:11:24 +08:00
0c6057de28 feat: 内容统计信息显示(有效行数/总字数) 2026-04-17 10:51:58 +08:00
6f20e5978d fix: 分页正确显示当前筛选的总数
- API返回 total 字段(筛选后的实际总数)
- 新增 count_items 函数计算筛选条件下的总数
- 分页使用API返回的total而不是全局统计
- 解决筛选后分页显示不正确的问题
2026-04-16 14:08:12 +08:00
56ff1e8163 fix: 搜索输入实时响应修复
- 改用直接 setTimeout 方式,不用 debounce 函数
- 避免函数绑定问题导致搜索不触发
2026-04-16 14:03:05 +08:00
9ec479415a docs: 更新版本历史 v2.4.0 2026-04-16 13:55:07 +08:00
7652718803 feat: 数据库备份机制 v2.4.0
- 新增备份管理 API 和页面
- 自动备份:每天 04:00 执行
- 手动备份:页面一键备份
- 备份清理规则:
  - 保留最近 30 天
  - 每月第一天永久保留
  - 手动备份最多保留 10 个
- 支持恢复和删除备份
2026-04-16 13:54:07 +08:00
d2f64f98a1 fix: 初始化时清空搜索框和筛选状态
- 页面加载时强制清空 searchInput
- 重置 typeFilter 和 currentFilter
- 删除重复的 pageSize 定义
2026-04-16 13:47:46 +08:00
161b93f368 docs: 更新版本历史 v2.3.2 2026-04-16 13:41:06 +08:00
31f2d8b428 fix: 搜索功能修复 - debounce函数定义顺序
- debounce函数在使用前必须先定义
- 之前定义在代码末尾,导致搜索事件绑定失败
- 现移到代码开头,确保正确执行
2026-04-16 13:40:44 +08:00
5d6dd10dfa docs: 更新版本历史 v2.3.1 2026-04-16 12:20:51 +08:00
4 changed files with 1003 additions and 55 deletions

View File

@@ -141,6 +141,19 @@ xian-favor/
## 版本历史
- **v2.4.0** (2026-04-16): 数据库备份机制
- 自动备份:每天 04:00 执行
- 手动备份:页面一键操作
- 备份清理规则保留30天 + 每月第一天永久保留
- 手动备份最多保留10个
- 支持恢复备份和删除备份
- 备份管理页面入口在侧边栏
- **v2.3.2** (2026-04-16): 搜索功能修复
- 修复 debounce 函数定义顺序问题
- 搜索框输入后可正常过滤列表
- **v2.3.1** (2026-04-16): 日期放到标题后面,不增加卡片高度
- 日期紧跟标题,同一行显示
- 格式紧凑:`04-16 11:09→04-16 12:00`
- **v2.3.0** (2026-04-16): 卡片显示创建和更新日期
- 每个收藏卡片底部显示日期
- 格式04-16 11:09月-日 时:分)

35
scripts/auto_backup.py Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
Xian Favor 自动备份脚本
定时任务调用此脚本进行自动备份
"""
import sys
import os
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xian_favor.db import db
def main():
"""执行自动备份"""
print(f"[{datetime.now().isoformat()}] 开始自动备份...")
try:
backup_info = db.create_backup(manual=False)
print(f"备份成功: {backup_info['name']}")
print(f"大小: {backup_info['size']} bytes")
print(f"位置: {backup_info['path']}")
# 清理旧备份
db._cleanup_old_backups()
print("旧备份清理完成")
except Exception as e:
print(f"备份失败: {e}")
sys.exit(1)
if __name__ == '__main__':
from datetime import datetime
main()

View File

@@ -17,15 +17,39 @@ CORS(app)
@app.route('/api/items', methods=['GET'])
def list_items():
"""列出条目"""
starred_param = request.args.get('starred')
starred = None
if starred_param == 'true' or starred_param == '1':
starred = True
elif starred_param == 'false' or starred_param == '0':
starred = False
items = db.list_items(
type=request.args.get('type'),
status=request.args.get('status'),
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))
)
return jsonify({'success': True, 'data': items})
# 为每个条目添加内容统计
for item in items:
item['content_stats'] = calculate_content_stats(item)
# 获取符合条件的总数(用于分页)
total = db.count_items(
type=request.args.get('type'),
status=request.args.get('status'),
tag=request.args.get('tag'),
keyword=request.args.get('keyword'),
starred=starred
)
return jsonify({'success': True, 'data': items, 'total': total})
@app.route('/api/items', methods=['POST'])
@@ -51,7 +75,8 @@ def create_item():
priority=data.get('priority', 'medium'),
due_date=data.get('due_date'),
note=data.get('note'),
tags=data.get('tags', [])
tags=data.get('tags', []),
is_starred=data.get('is_starred', False)
)
item = db.get_item(item_id)
return jsonify({'success': True, 'data': item}), 201
@@ -68,9 +93,33 @@ def get_item(item_id):
# 获取邮件发送历史
email_logs = db.get_email_logs(item_id)
item['email_logs'] = email_logs
# 添加内容统计
item['content_stats'] = calculate_content_stats(item)
return jsonify({'success': True, 'data': item})
def calculate_content_stats(item):
"""计算内容统计:有效行数、总字数"""
# 合计所有文本内容
all_text = ''
if item.get('content'):
all_text += item['content'] + '\n'
if item.get('note'):
all_text += item['note'] + '\n'
if not all_text.strip():
return {'lines': 0, 'chars': 0}
# 有效行数(去除空行)
lines = [line for line in all_text.split('\n') if line.strip()]
line_count = len(lines)
# 总字数(去除空格后的字符数)
char_count = len(all_text.replace(' ', '').replace('\n', '').replace('\t', '').strip())
return {'lines': line_count, 'chars': char_count}
@app.route('/api/items/<int:item_id>', methods=['PUT'])
def update_item(item_id):
"""更新条目"""
@@ -97,6 +146,73 @@ def delete_item(item_id):
return jsonify({'success': False, 'error': '条目不存在'}), 404
@app.route('/api/items/<int:item_id>/star', methods=['POST'])
def toggle_star_item(item_id):
"""切换重点关注状态"""
if db.toggle_star(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>/star/<int:status>', methods=['POST'])
def set_star_item(item_id, status):
"""设置重点关注状态 (status: 1=重点关注, 0=取消重点关注)"""
starred = status == 1
if db.set_star(item_id, starred):
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>/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):
"""完成待办"""
@@ -511,6 +627,44 @@ def send_email():
return jsonify({'success': False, 'error': f'发送失败: {str(e)}'}), 500
# ============ 备份管理 API ============
@app.route('/api/backups', methods=['GET'])
def list_backups():
"""列出所有备份"""
backups = db.list_backups()
return jsonify({'success': True, 'data': backups})
@app.route('/api/backups', methods=['POST'])
def create_backup():
"""创建手动备份"""
try:
backup_info = db.create_backup(manual=True)
return jsonify({'success': True, 'data': backup_info}), 201
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/backups/<backup_name>', methods=['POST'])
def restore_backup(backup_name):
"""恢复备份"""
try:
if db.restore_backup(backup_name):
return jsonify({'success': True, 'message': f'已恢复备份 {backup_name}'})
return jsonify({'success': False, 'error': '备份不存在'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/backups/<backup_name>', methods=['DELETE'])
def delete_backup(backup_name):
"""删除备份"""
if db.delete_backup(backup_name):
return jsonify({'success': True})
return jsonify({'success': False, 'error': '备份不存在'}), 404
# ============ Web 页面 ============
@app.route('/')
@@ -549,6 +703,9 @@ INDEX_TEMPLATE = '''
.type-link { border-left: 4px solid #28a745; }
.type-column { border-left: 4px solid #6f42c1; }
.type-todo { border-left: 4px solid #ffc107; }
.is-starred { border-left: 4px solid #ffc107; background: #fffbe6; }
.is-starred:hover { background: #fff9e0; }
.star-btn { font-size: 11px; }
.status-pending { color: #ffc107; }
.status-in_progress { color: #17a2b8; }
.status-completed { color: #28a745; text-decoration: line-through; }
@@ -568,6 +725,7 @@ INDEX_TEMPLATE = '''
</div>
<nav>
<a href="#" class="active" data-filter="all"><i class="bi bi-inbox"></i> 全部</a>
<a href="#" data-filter="starred"><i class="bi bi-star-fill" style="color:#ffc107;"></i> 重点关注</a>
<a href="#" data-filter="text"><i class="bi bi-file-text"></i> 文本</a>
<a href="#" data-filter="link"><i class="bi bi-link-45deg"></i> 链接</a>
<a href="#" data-filter="column"><i class="bi bi-newspaper"></i> 专栏</a>
@@ -579,6 +737,8 @@ INDEX_TEMPLATE = '''
<hr class="border-secondary">
<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>
@@ -605,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添加
@@ -736,6 +905,14 @@ INDEX_TEMPLATE = '''
<label class="form-label">详情/备注</label>
<textarea id="addNote" class="form-control" rows="5"></textarea>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="addStarred">
<label class="form-check-label" for="addStarred">
<i class="bi bi-star-fill" style="color:#ffc107;"></i> 设为重点关注
</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
@@ -842,6 +1019,14 @@ INDEX_TEMPLATE = '''
<label class="form-label">详情/备注</label>
<textarea id="editNote" class="form-control" rows="5"></textarea>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editStarred">
<label class="form-check-label" for="editStarred">
<i class="bi bi-star-fill" style="color:#ffc107;"></i> 重点关注
</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
@@ -954,6 +1139,41 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 备份管理模态框 -->
<div class="modal fade" id="backupModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title"><i class="bi bi-archive"></i> 备份管理</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<button class="btn btn-primary" onclick="createManualBackup()">
<i class="bi bi-plus-circle"></i> 立即备份
</button>
<span class="text-muted ms-3">自动备份:每天 04:00</span>
</div>
<div class="alert alert-info">
<strong>备份规则:</strong>
<ul class="mb-0">
<li>自动备份每天 04:00 执行</li>
<li>保留最近 30 天的备份</li>
<li>每月第一天的备份永久保留</li>
<li>手动备份最多保留 10 个</li>
</ul>
</div>
<div id="backupList">
<!-- 动态填充 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- AI自动添加模态框 -->
<div class="modal fade" id="aiAddModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@@ -1089,10 +1309,25 @@ INDEX_TEMPLATE = '''
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const API_BASE = '/api';
let currentFilter = { type: '', status: '' };
let currentFilter = { type: '', status: '', starred: null };
let currentSort = { sort_by: '', sort_order: '' };
let currentPage = 1;
const pageSize = 20;
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 初始化
document.addEventListener('DOMContentLoaded', async () => {
// 确保初始状态清空
document.getElementById('searchInput').value = '';
document.getElementById('typeFilter').value = '';
currentFilter = { type: '', status: '', starred: null };
await loadStats(); // 先加载统计,确保总数可用
loadItems();
loadTags();
@@ -1113,8 +1348,12 @@ document.addEventListener('DOMContentLoaded', async () => {
updateEditFieldsByType(e.target.value);
});
// 搜索
document.getElementById('searchInput').addEventListener('input', debounce(loadItems, 300));
// 搜索 - 直接绑定,不用 debounce
let searchTimer;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadItems(), 300);
});
// 类型过滤
document.getElementById('typeFilter').addEventListener('change', (e) => {
@@ -1123,19 +1362,27 @@ 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');
const filter = a.dataset.filter;
if (['text', 'link', 'column', 'todo'].includes(filter)) {
currentFilter = { type: filter, status: '' };
if (filter === 'starred') {
currentFilter = { type: '', status: '', starred: true };
} else if (['text', 'link', 'column', 'todo'].includes(filter)) {
currentFilter = { type: filter, status: '', starred: null };
} else if (['pending', 'in_progress', 'completed'].includes(filter)) {
currentFilter = { type: 'todo', status: filter };
currentFilter = { type: 'todo', status: filter, starred: null };
} else {
currentFilter = { type: '', status: '' };
currentFilter = { type: '', status: '', starred: null };
}
loadItems();
});
@@ -1143,23 +1390,23 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// 加载列表
let currentPage = 1;
const pageSize = 20;
async function loadItems(page = 1) {
currentPage = page;
const keyword = document.getElementById('searchInput').value;
let url = `${API_BASE}/items?limit=${pageSize}&offset=${(page-1)*pageSize}`;
if (currentFilter.type) url += `&type=${currentFilter.type}`;
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();
if (data.success) {
renderItems(data.data);
renderPagination(data.data.length, page);
renderPagination(data.total, page); // 使用API返回的total
}
}
@@ -1172,16 +1419,19 @@ function renderItems(items) {
}
container.innerHTML = items.map(item => `
<div class="card type-${item.type} item-card" style="cursor: pointer;" onclick="showDetail(${item.id})">
<div class="card type-${item.type} item-card ${item.is_starred ? 'is-starred' : ''}" style="cursor: pointer;" onclick="showDetail(${item.id})">
<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 ${item.type === 'todo' && item.status === 'completed' ? 'text-muted' : ''}">
${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)}
${item.is_starred ? '<i class="bi bi-star-fill" style="color:#ffc107;"></i>' : ''} ${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)}
${item.type === 'todo' && item.status === 'completed' ? '' : ''}
<span style="font-size:10px; opacity:0.5; margin-left:8px;">
${formatShortDate(item.created_at)}${item.updated_at && item.updated_at !== item.created_at ? '' + formatShortDate(item.updated_at) : ''}
</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) : ''}
@@ -1190,6 +1440,9 @@ 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" 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>` : ''}
<button class="btn btn-sm btn-outline-info py-0 px-1" onclick="showSendEmailModal(${item.id})" title="发送邮件"><i class="bi bi-envelope" style="font-size:11px;"></i></button>
<button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="openEditModal(${item.id})" title="编辑"><i class="bi bi-pencil" style="font-size:11px;"></i></button>
@@ -1204,9 +1457,8 @@ function renderItems(items) {
}
// 渲染分页
function renderPagination(itemCount, page) {
function renderPagination(total, page) {
const container = document.getElementById('pagination');
const total = parseInt(document.getElementById('statTotal').textContent);
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) {
@@ -1268,6 +1520,14 @@ 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); // 切换排序时回到第一页
}
// ============ 添加功能 ============
// 快捷添加按钮
@@ -1312,7 +1572,8 @@ async function addItem() {
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.split(',').map(t => t.trim()).filter(t => t)
tags: document.getElementById('addTags').value.split(',').map(t => t.trim()).filter(t => t),
is_starred: document.getElementById('addStarred').checked
};
const res = await fetch(`${API_BASE}/items`, {
@@ -1435,6 +1696,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();
@@ -1450,6 +1715,19 @@ 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>`;
}
// 显示内容统计
if (item.content_stats && (item.content_stats.lines > 0 || item.content_stats.chars > 0)) {
html += `<div class="mb-3"><strong>统计:</strong> <span class="badge bg-info">${item.content_stats.lines} 有效行 / ${item.content_stats.chars} 字</span></div>`;
}
if (item.url) {
html += `<div class="mb-3"><strong>URL:</strong> <a href="${item.url}" target="_blank">${item.url}</a></div>`;
}
@@ -1555,6 +1833,9 @@ async function openEditModal(id) {
}
}
// 设置重点关注状态
document.getElementById('editStarred').checked = item.is_starred === 1;
new bootstrap.Modal(document.getElementById('editModal')).show();
}
@@ -1581,17 +1862,34 @@ async function saveEdit() {
priority: type === 'todo' ? document.getElementById('editPriority').value : null,
due_date: type === 'todo' ? document.getElementById('editDueDate').value : null,
note: document.getElementById('editNote').value,
tags: document.getElementById('editTags').value.split(',').map(t => t.trim()).filter(t => t)
tags: document.getElementById('editTags').value.split(',').map(t => t.trim()).filter(t => t),
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)
});
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);
}
}
// 切换重点关注状态
async function toggleStar(id) {
const res = await fetch(`${API_BASE}/items/${id}/star`, { method: 'POST' });
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
refreshData();
}
}
@@ -2019,6 +2317,230 @@ 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() {
await loadBackupList();
new bootstrap.Modal(document.getElementById('backupModal')).show();
}
async function loadBackupList() {
const res = await fetch(`${API_BASE}/backups`);
const data = await res.json();
if (!data.success) return;
const container = document.getElementById('backupList');
if (!data.data.length) {
container.innerHTML = '<div class="text-center text-muted py-3">暂无备份</div>';
return;
}
container.innerHTML = data.data.map(backup => `
<div class="d-flex justify-content-between align-items-center p-2 border-bottom rounded mb-1 ${backup.manual ? 'bg-light' : ''}">
<div>
<strong>${backup.name}</strong>
${backup.manual ? '<span class="badge bg-primary ms-1">手动</span>' : ''}
${backup.is_first_of_month ? '<span class="badge bg-success ms-1">月首</span>' : ''}
<br>
<small class="text-muted">
${formatBackupDate(backup.created_at)} |
${formatBackupSize(backup.size)}
</small>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-success" onclick="restoreBackup('${backup.name}')" title="恢复">
<i class="bi bi-arrow-counterclockwise"></i> 恢复
</button>
<button class="btn btn-outline-danger" onclick="deleteBackupConfirm('${backup.name}')" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`).join('');
}
async function createManualBackup() {
const res = await fetch(`${API_BASE}/backups`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('备份创建成功!');
loadBackupList();
loadStats();
} else {
alert('备份失败: ' + data.error);
}
}
async function restoreBackup(name) {
if (!confirm(`确认恢复备份 "${name}"\n当前数据将被覆盖!`)) return;
const res = await fetch(`${API_BASE}/backups/${name}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('备份已恢复!页面将刷新...');
location.reload();
} else {
alert('恢复失败: ' + data.error);
}
}
async function deleteBackupConfirm(name) {
if (!confirm(`确认删除备份 "${name}"`)) return;
const res = await fetch(`${API_BASE}/backups/${name}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
loadBackupList();
} else {
alert('删除失败: ' + data.error);
}
}
function formatBackupDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function formatBackupSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}
async function loadEmailManagerList() {
const res = await fetch(`${API_BASE}/emails`);
const data = await res.json();
@@ -2196,14 +2718,6 @@ async function sendItemEmail() {
}
}
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// ============ 提醒功能 ============
let reminderData = null;

View File

@@ -2,12 +2,17 @@
import sqlite3
import json
from datetime import datetime
import shutil
import os
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from contextlib import contextmanager
from .config import DATABASE_URL, TODO_STATUS, PRIORITY_LEVELS
# 备份目录
BACKUP_DIR = os.path.join(os.path.dirname(DATABASE_URL), 'backups')
class Database:
"""SQLite数据库管理"""
@@ -54,6 +59,10 @@ class Database:
priority TEXT DEFAULT 'medium',
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
)
@@ -109,6 +118,31 @@ class Database:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_tags_item ON item_tags(item_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_tags_tag ON item_tags(tag_id)")
# 检查并添加 is_starred 字段(兼容旧数据库)
try:
cursor.execute("SELECT is_starred FROM items LIMIT 1")
except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN is_starred INTEGER DEFAULT 0")
# 创建 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 操作 ============
@@ -116,7 +150,7 @@ class Database:
def create_item(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: List[str] = None) -> int:
tags: List[str] = None, is_starred: bool = False) -> int:
"""创建新条目"""
self._ensure_init()
now = datetime.now().isoformat()
@@ -130,9 +164,9 @@ class Database:
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, now, now))
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, now, now))
item_id = cursor.lastrowid
# 添加标签
@@ -156,8 +190,13 @@ class Database:
return item
def list_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = 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()
@@ -165,6 +204,81 @@ 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"
conditions.append("t.name = ?")
params.append(tag)
if type:
conditions.append("i.type = ?")
params.append(type)
if status:
conditions.append("i.status = ?")
params.append(status)
if starred is not None:
conditions.append("i.is_starred = ?")
params.append(1 if starred else 0)
if keyword:
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
keyword_pattern = f"%{keyword}%"
params.extend([keyword_pattern, keyword_pattern, keyword_pattern])
if conditions:
query += " WHERE " + " AND ".join(conditions)
# 排序逻辑
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)
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_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, starred: bool = None) -> int:
"""计算符合条件的条目总数"""
with self.get_conn() as conn:
cursor = conn.cursor()
query = "SELECT COUNT(DISTINCT i.id) as count FROM items i"
params = []
conditions = []
# 标签过滤需要JOIN
if tag:
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
@@ -179,6 +293,10 @@ class Database:
conditions.append("i.status = ?")
params.append(status)
if starred is not None:
conditions.append("i.is_starred = ?")
params.append(1 if starred else 0)
if keyword:
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
keyword_pattern = f"%{keyword}%"
@@ -187,23 +305,15 @@ class Database:
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY i.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
items = []
for row in cursor.fetchall():
item = dict(row)
item['tags'] = self._get_item_tags(conn, item['id'])
items.append(item)
return items
return cursor.fetchone()['count']
def update_item(self, item_id: int, **kwargs) -> bool:
"""更新条目"""
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note']
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
@@ -212,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 = ?"
@@ -226,16 +341,109 @@ 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:
cursor = conn.cursor()
# 先获取当前状态
cursor.execute("SELECT is_starred FROM items WHERE id = ?", (item_id,))
row = cursor.fetchone()
if not row:
return False
new_status = 0 if row['is_starred'] else 1
now = datetime.now().isoformat()
cursor.execute("UPDATE items SET is_starred = ?, updated_at = ? WHERE id = ?", (new_status, now, item_id))
conn.commit()
return True
def set_star(self, item_id: int, starred: bool = True) -> bool:
"""设置重点关注状态"""
with self.get_conn() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute("UPDATE items SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if starred else 0, now, item_id))
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:
@@ -501,6 +709,184 @@ class Database:
reminders['total'] = len(reminders['overdue']) + len(reminders['due_today']) + len(reminders['due_soon'])
return reminders
# ============ 备份操作 ============
def create_backup(self, manual: bool = False) -> Dict[str, Any]:
"""创建数据库备份"""
import os
# 确保备份目录存在
os.makedirs(BACKUP_DIR, exist_ok=True)
now = datetime.now()
backup_name = now.strftime('%Y-%m-%d_%H%M%S')
if manual:
backup_name += '_manual'
backup_path = os.path.join(BACKUP_DIR, f'{backup_name}.db')
# 复制数据库文件
shutil.copy2(self.db_path, backup_path)
# 获取备份信息
backup_info = {
'name': backup_name,
'path': backup_path,
'size': os.path.getsize(backup_path),
'created_at': now.isoformat(),
'manual': manual,
'is_first_of_month': now.day == 1
}
# 保存备份元数据
self._save_backup_meta(backup_info)
# 清理旧备份
self._cleanup_old_backups()
return backup_info
def list_backups(self) -> List[Dict[str, Any]]:
"""列出所有备份"""
import os
if not os.path.exists(BACKUP_DIR):
return []
# 读取备份元数据
meta_path = os.path.join(BACKUP_DIR, 'backup_meta.json')
if os.path.exists(meta_path):
with open(meta_path, 'r') as f:
backups = json.load(f)
else:
# 从文件重建元数据
backups = []
for f in os.listdir(BACKUP_DIR):
if f.endswith('.db'):
path = os.path.join(BACKUP_DIR, f)
backups.append({
'name': f.replace('.db', ''),
'path': path,
'size': os.path.getsize(path),
'created_at': datetime.fromtimestamp(os.path.getmtime(path)).isoformat(),
'manual': '_manual' in f,
'is_first_of_month': self._is_first_of_month_filename(f)
})
# 按时间倒序排列
backups.sort(key=lambda x: x['created_at'], reverse=True)
return backups
def restore_backup(self, backup_name: str) -> bool:
"""恢复备份"""
import os
backup_path = os.path.join(BACKUP_DIR, f'{backup_name}.db')
if not os.path.exists(backup_path):
return False
# 先备份当前数据库(以防万一)
current_backup = self.db_path + '.before_restore'
shutil.copy2(self.db_path, current_backup)
# 恢复备份
shutil.copy2(backup_path, self.db_path)
return True
def delete_backup(self, backup_name: str) -> bool:
"""删除备份"""
import os
backup_path = os.path.join(BACKUP_DIR, f'{backup_name}.db')
if not os.path.exists(backup_path):
return False
os.remove(backup_path)
# 更新元数据
self._remove_backup_meta(backup_name)
return True
def _save_backup_meta(self, backup_info: Dict[str, Any]):
"""保存备份元数据"""
import os
meta_path = os.path.join(BACKUP_DIR, 'backup_meta.json')
# 读取现有元数据
backups = []
if os.path.exists(meta_path):
with open(meta_path, 'r') as f:
backups = json.load(f)
# 添加新备份
backups.append(backup_info)
# 保存
with open(meta_path, 'w') as f:
json.dump(backups, f, indent=2)
def _remove_backup_meta(self, backup_name: str):
"""从元数据中删除备份"""
import os
meta_path = os.path.join(BACKUP_DIR, 'backup_meta.json')
if not os.path.exists(meta_path):
return
with open(meta_path, 'r') as f:
backups = json.load(f)
backups = [b for b in backups if b['name'] != backup_name]
with open(meta_path, 'w') as f:
json.dump(backups, f, indent=2)
def _is_first_of_month_filename(self, filename: str) -> bool:
"""判断是否是每月第一天的备份"""
# 格式2026-04-01_040000.db 或 2026-05-01_...
try:
date_part = filename.split('_')[0]
day = int(date_part.split('-')[2])
return day == 1
except:
return False
def _cleanup_old_backups(self):
"""清理旧备份保留30天 + 每月第一天"""
import os
backups = self.list_backups()
now = datetime.now()
keep_paths = []
for backup in backups:
backup_date = datetime.fromisoformat(backup['created_at'])
days_old = (now - backup_date).days
# 保留条件:
# 1. 手动备份永久保留最多10个
# 2. 30天内的备份
# 3. 每月第一天的备份
if backup['manual']:
# 手动备份保留但最多10个
manual_backups = [b for b in backups if b['manual']]
if manual_backups.index(backup) < 10:
keep_paths.append(backup['path'])
else:
self.delete_backup(backup['name'])
elif days_old <= 30:
keep_paths.append(backup['path'])
elif backup['is_first_of_month']:
# 每月第一天永久保留
keep_paths.append(backup['path'])
else:
# 删除
self.delete_backup(backup['name'])
# 全局数据库实例