Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56ff1e8163 | |||
| 9ec479415a | |||
| 7652718803 | |||
| d2f64f98a1 | |||
| 161b93f368 | |||
| 31f2d8b428 | |||
| 5d6dd10dfa | |||
| d0f7b07260 | |||
| 0be768ca8e | |||
| 68ecb16303 | |||
| 82d928f497 | |||
| c99eca35f0 | |||
| 47b195ed1c | |||
| 1f1528979c | |||
| bcb24e474d | |||
| a19b260ef7 | |||
| 1d8ce5cfb9 | |||
| cb2cbd4c6b | |||
| c0d221c2a3 |
44
README.md
44
README.md
@@ -141,6 +141,50 @@ 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(月-日 时:分)
|
||||
- 有更新时显示:创建 → 更新
|
||||
- 字体更小更淡,不影响卡片高度
|
||||
- **v2.2.0** (2026-04-16): 快捷添加按钮,一键选择类型
|
||||
- 顶部按钮栏分离为4个快捷添加按钮(文本、链接、待办、专栏)
|
||||
- 点击直接进入对应类型的添加弹窗
|
||||
- 弹窗标题显示类型图标和名称
|
||||
- 不再需要下拉选择类型,操作更快捷
|
||||
- **v2.1.0** (2026-04-16): 待办截止时间支持日期+时间
|
||||
- 截止日期改为日期时间选择器
|
||||
- 列表显示友好格式:今天 18:30、明天 09:00 等
|
||||
- 详情页显示完整格式:2026年4月16日 18:30
|
||||
- 后端支持多种日期格式解析
|
||||
- 只有日期的待办视为当天 23:59 到期
|
||||
- **v2.0.1** (2026-04-16): 转换弹窗优化
|
||||
- 内容预览保留换行格式,提高可读性
|
||||
- 转换方式默认改为复制创建
|
||||
- **v2.0.0** (2026-04-16): 收藏转待办功能(大版本更新)
|
||||
- 新增 `/api/items/<id>/convert` API
|
||||
- **直接转换**:原收藏变为待办,数据合并
|
||||
- **复制创建**:保留原收藏,新建待办任务
|
||||
- 文本/链接/专栏类型可转换
|
||||
- 内容自动合并到待办备注
|
||||
- 前端列表和详情页添加转换按钮
|
||||
- 弹窗配置:标题、状态、优先级、截止日期
|
||||
- v1.11.0 (2026-04-16): 已完成待办可重新打开
|
||||
- 新增 `/api/items/<id>/reopen` 接口
|
||||
- 前端列表已完成待办显示重新打开按钮(↻图标)
|
||||
- 可恢复为 pending 或 in_progress 状态
|
||||
- v1.10.0 (2026-04-16): 网页端待办到期提醒功能
|
||||
- 新增提醒 API `/api/reminders`
|
||||
- 获取已过期、今天到期、即将到期的待办
|
||||
|
||||
35
scripts/auto_backup.py
Executable file
35
scripts/auto_backup.py
Executable 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()
|
||||
@@ -134,6 +134,71 @@ def reopen_item(item_id):
|
||||
return jsonify({'success': True, 'data': item})
|
||||
|
||||
|
||||
@app.route('/api/items/<int:item_id>/convert', methods=['POST'])
|
||||
def convert_item(item_id):
|
||||
"""将收藏转换为待办"""
|
||||
item = db.get_item(item_id)
|
||||
if not item:
|
||||
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||
|
||||
# 不能转换已经是待办的
|
||||
if item['type'] == 'todo':
|
||||
return jsonify({'success': False, 'error': '已经是待办事项'}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode', 'convert') # convert 或 copy
|
||||
|
||||
if mode not in ['convert', 'copy']:
|
||||
return jsonify({'success': False, 'error': '无效转换模式'}), 400
|
||||
|
||||
# 构建待办数据
|
||||
todo_data = {
|
||||
'type': 'todo',
|
||||
'title': data.get('title') or item['title'] or f'{item["type"]}收藏',
|
||||
'status': data.get('status', 'pending'),
|
||||
'priority': data.get('priority', 'medium'),
|
||||
'due_date': data.get('due_date'),
|
||||
'tags': item['tags'], # 继承原标签
|
||||
}
|
||||
|
||||
# 根据原类型处理内容
|
||||
if item['type'] == 'text':
|
||||
# 文本:content 放到 note
|
||||
todo_data['note'] = item['content'] or ''
|
||||
if item['note']:
|
||||
todo_data['note'] += '\n\n' + item['note']
|
||||
elif item['type'] == 'link':
|
||||
# 链接:url 放到 note,保留链接可点击
|
||||
todo_data['note'] = f'链接: {item["url"]}\n\n'
|
||||
if item['content']:
|
||||
todo_data['note'] += item['content'] + '\n\n'
|
||||
if item['note']:
|
||||
todo_data['note'] += item['note']
|
||||
# url 字段也保留(方便后续操作)
|
||||
todo_data['content'] = item['url']
|
||||
elif item['type'] == 'column':
|
||||
# 专栏:url + source 放到 note
|
||||
todo_data['note'] = f'专栏: {item["url"]}\n'
|
||||
if item['source']:
|
||||
todo_data['note'] += f'来源: {item["source"]}\n\n'
|
||||
if item['content']:
|
||||
todo_data['note'] += item['content'] + '\n\n'
|
||||
if item['note']:
|
||||
todo_data['note'] += item['note']
|
||||
todo_data['content'] = item['url']
|
||||
|
||||
if mode == 'convert':
|
||||
# 直接转换:更新原条目
|
||||
db.update_item(item_id, **todo_data)
|
||||
result = db.get_item(item_id)
|
||||
return jsonify({'success': True, 'data': result, 'mode': 'convert'})
|
||||
else:
|
||||
# 复制创建:新建待办,原条目保留
|
||||
new_id = db.create_item(**todo_data)
|
||||
result = db.get_item(new_id)
|
||||
return jsonify({'success': True, 'data': result, 'mode': 'copy', 'original_id': item_id})
|
||||
|
||||
|
||||
@app.route('/api/tags', methods=['GET'])
|
||||
def list_tags():
|
||||
"""列出标签"""
|
||||
@@ -446,6 +511,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('/')
|
||||
@@ -474,11 +577,11 @@ INDEX_TEMPLATE = '''
|
||||
.content { padding: 20px; }
|
||||
.card { margin-bottom: 8px; transition: transform 0.2s; }
|
||||
.card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||
.card-body { padding: 10px 15px; }
|
||||
.card-body { padding: 8px 12px; }
|
||||
.tag { margin-right: 5px; }
|
||||
.item-card { font-size: 14px; }
|
||||
.item-card h6 { font-size: 14px; margin-bottom: 4px; }
|
||||
.item-card p { margin-bottom: 4px; }
|
||||
.item-card h6 { font-size: 14px; margin-bottom: 2px; }
|
||||
.item-card p { margin-bottom: 2px; }
|
||||
.item-card .text-muted.small { font-size: 12px; }
|
||||
.type-text { border-left: 4px solid #17a2b8; }
|
||||
.type-link { border-left: 4px solid #28a745; }
|
||||
@@ -514,6 +617,7 @@ 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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -544,12 +648,21 @@ INDEX_TEMPLATE = '''
|
||||
<button class="btn btn-outline-info me-2" onclick="showAIAddModal()" title="AI自动添加">
|
||||
<i class="bi bi-robot"></i> AI添加
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-1" onclick="showAddModal('text')" title="添加文本">
|
||||
<i class="bi bi-file-text"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-success me-1" onclick="showAddModal('link')" title="添加链接">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-warning me-1" onclick="showAddModal('todo')" title="添加待办">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary me-1" onclick="showAddModal('column')" title="添加专栏">
|
||||
<i class="bi bi-newspaper"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-success me-2" onclick="exportData()" title="导出JSON">
|
||||
<i class="bi bi-download"></i> 导出
|
||||
</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
|
||||
<i class="bi bi-plus-lg"></i> 添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
@@ -602,20 +715,15 @@ INDEX_TEMPLATE = '''
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">添加条目</h5>
|
||||
<h5 class="modal-title">
|
||||
<span id="addModalIcon"></span>
|
||||
<span id="addModalTitle">添加条目</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">类型</label>
|
||||
<select id="addType" class="form-select">
|
||||
<option value="text">📝 文本</option>
|
||||
<option value="link">🔗 链接</option>
|
||||
<option value="column">📰 专栏</option>
|
||||
<option value="todo">✅ 待办</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" id="addType" value="text">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">标题</label>
|
||||
<input type="text" id="addTitle" class="form-control" placeholder="可选">
|
||||
@@ -626,11 +734,11 @@ INDEX_TEMPLATE = '''
|
||||
</div>
|
||||
<div class="mb-3" id="urlGroup" style="display:none;">
|
||||
<label class="form-label">URL</label>
|
||||
<input type="url" id="addUrl" class="form-control">
|
||||
<input type="url" id="addUrl" class="form-control" placeholder="https://...">
|
||||
</div>
|
||||
<div class="mb-3" id="sourceGroup" style="display:none;">
|
||||
<label class="form-label">来源</label>
|
||||
<input type="text" id="addSource" class="form-control">
|
||||
<input type="text" id="addSource" class="form-control" placeholder="专栏来源">
|
||||
</div>
|
||||
<div class="mb-3" id="todoFields" style="display:none;">
|
||||
<div class="row">
|
||||
@@ -653,8 +761,8 @@ INDEX_TEMPLATE = '''
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">截止日期</label>
|
||||
<input type="date" id="addDueDate" class="form-control">
|
||||
<label class="form-label">截止时间</label>
|
||||
<input type="datetime-local" id="addDueDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -691,6 +799,9 @@ INDEX_TEMPLATE = '''
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="detailConvertBtn" onclick="showConvertModalFromDetail()" style="display:none;">
|
||||
<i class="bi bi-arrow-repeat"></i> 转为待办
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="openEditModalFromDetail()">
|
||||
<i class="bi bi-pencil"></i> 编辑
|
||||
</button>
|
||||
@@ -757,8 +868,8 @@ INDEX_TEMPLATE = '''
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">截止日期</label>
|
||||
<input type="date" id="editDueDate" class="form-control">
|
||||
<label class="form-label">截止时间</label>
|
||||
<input type="datetime-local" id="editDueDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -882,6 +993,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">
|
||||
@@ -940,13 +1086,101 @@ INDEX_TEMPLATE = '''
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 转换为待办模态框 -->
|
||||
<div class="modal fade" id="convertModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-arrow-repeat"></i> 转为待办</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="convertItemId">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">转换方式</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="convertMode" id="modeConvert" value="convert">
|
||||
<label class="form-check-label" for="modeConvert">
|
||||
<strong>直接转换</strong> - 原收藏变为待办,数据合并
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="convertMode" id="modeCopy" value="copy" checked>
|
||||
<label class="form-check-label" for="modeCopy">
|
||||
<strong>复制创建</strong> - 保留原收藏,新建待办任务
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">待办标题</label>
|
||||
<input type="text" id="convertTitle" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label">状态</label>
|
||||
<select id="convertStatus" class="form-select">
|
||||
<option value="pending">⏳ 待处理</option>
|
||||
<option value="in_progress">🔄 进行中</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">优先级</label>
|
||||
<select id="convertPriority" class="form-select">
|
||||
<option value="low">🟢 低</option>
|
||||
<option value="medium" selected>🟡 中</option>
|
||||
<option value="high">🟠 高</option>
|
||||
<option value="urgent">🔴 紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">截止时间</label>
|
||||
<input type="datetime-local" id="convertDueDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">内容预览(转换后会合并到待办备注)</label>
|
||||
<div id="convertPreview" class="border rounded p-2 bg-light" style="max-height: 150px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">
|
||||
<!-- 动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="executeConvert()">
|
||||
<i class="bi bi-check"></i> 确认转换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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: '' };
|
||||
|
||||
await loadStats(); // 先加载统计,确保总数可用
|
||||
loadItems();
|
||||
loadTags();
|
||||
@@ -962,22 +1196,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 标签搜索实时过滤
|
||||
document.getElementById('tagSearch')?.addEventListener('input', debounce(loadTagManagerList, 300));
|
||||
|
||||
// 类型切换时显示/隐藏字段
|
||||
document.getElementById('addType').addEventListener('change', (e) => {
|
||||
const type = e.target.value;
|
||||
document.getElementById('contentGroup').style.display = type === 'text' ? 'block' : 'none';
|
||||
document.getElementById('urlGroup').style.display = ['link', 'column'].includes(type) ? 'block' : 'none';
|
||||
document.getElementById('sourceGroup').style.display = type === 'column' ? 'block' : 'none';
|
||||
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// 编辑时类型切换
|
||||
document.getElementById('editType').addEventListener('change', (e) => {
|
||||
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) => {
|
||||
@@ -1006,9 +1235,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
// 加载列表
|
||||
let currentPage = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
async function loadItems(page = 1) {
|
||||
currentPage = page;
|
||||
const keyword = document.getElementById('searchInput').value;
|
||||
@@ -1042,14 +1268,18 @@ function renderItems(items) {
|
||||
<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.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>
|
||||
</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) : ''}
|
||||
${item.type === 'todo' ? `${getStatusLabelShort(item.status)} ${getPriorityLabelShort(item.priority)} ${item.due_date ? '📅' + item.due_date : ''}` : ''}
|
||||
${item.type === 'todo' ? `${getStatusLabelShort(item.status)} ${getPriorityLabelShort(item.priority)} ${item.due_date ? '📅' + formatDueDate(item.due_date) : ''}` : ''}
|
||||
</p>
|
||||
</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('')}
|
||||
${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>
|
||||
${item.type === 'todo' && item.status !== 'completed' ? `<button class="btn btn-sm btn-outline-success py-0 px-1" onclick="completeItem(${item.id})" title="完成"><i class="bi bi-check-lg" style="font-size:11px;"></i></button>` : ''}
|
||||
@@ -1127,6 +1357,37 @@ async function refreshData() {
|
||||
loadItems(currentPage);
|
||||
}
|
||||
|
||||
// ============ 添加功能 ============
|
||||
|
||||
// 快捷添加按钮
|
||||
function showAddModal(type) {
|
||||
// 设置类型
|
||||
document.getElementById('addType').value = type;
|
||||
|
||||
// 设置弹窗标题和图标
|
||||
const typeInfo = {
|
||||
text: { icon: '📝', title: '添加文本' },
|
||||
link: { icon: '🔗', title: '添加链接' },
|
||||
column: { icon: '📰', title: '添加专栏' },
|
||||
todo: { icon: '✅', title: '添加待办' }
|
||||
};
|
||||
|
||||
document.getElementById('addModalIcon').textContent = typeInfo[type].icon;
|
||||
document.getElementById('addModalTitle').textContent = typeInfo[type].title;
|
||||
|
||||
// 显示/隐藏对应字段
|
||||
document.getElementById('contentGroup').style.display = type === 'text' ? 'block' : 'none';
|
||||
document.getElementById('urlGroup').style.display = ['link', 'column'].includes(type) ? 'block' : 'none';
|
||||
document.getElementById('sourceGroup').style.display = type === 'column' ? 'block' : 'none';
|
||||
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
|
||||
|
||||
// 清空表单
|
||||
document.getElementById('addForm').reset();
|
||||
|
||||
// 打开弹窗
|
||||
new bootstrap.Modal(document.getElementById('addModal')).show();
|
||||
}
|
||||
|
||||
// 添加条目
|
||||
async function addItem() {
|
||||
const type = document.getElementById('addType').value;
|
||||
@@ -1176,6 +1437,80 @@ async function reopenItem(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 转换为待办 ============
|
||||
|
||||
let convertItemData = null;
|
||||
|
||||
async function showConvertModal(itemId) {
|
||||
const res = await fetch(`${API_BASE}/items/${itemId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) return;
|
||||
|
||||
convertItemData = data.data;
|
||||
document.getElementById('convertItemId').value = itemId;
|
||||
|
||||
// 设置默认标题
|
||||
document.getElementById('convertTitle').value = convertItemData.title || '';
|
||||
|
||||
// 重置选项(默认复制创建)
|
||||
document.getElementById('modeCopy').checked = true;
|
||||
document.getElementById('convertStatus').value = 'pending';
|
||||
document.getElementById('convertPriority').value = 'medium';
|
||||
document.getElementById('convertDueDate').value = '';
|
||||
|
||||
// 显示内容预览
|
||||
let preview = '';
|
||||
if (convertItemData.type === 'text') {
|
||||
preview = convertItemData.content || '';
|
||||
} else if (convertItemData.type === 'link') {
|
||||
preview = `链接: ${convertItemData.url}`;
|
||||
if (convertItemData.content) preview += `\n${convertItemData.content}`;
|
||||
} else if (convertItemData.type === 'column') {
|
||||
preview = `专栏: ${convertItemData.url}`;
|
||||
if (convertItemData.source) preview += `\n来源: ${convertItemData.source}`;
|
||||
}
|
||||
if (convertItemData.note) preview += `\n\n备注: ${convertItemData.note}`;
|
||||
|
||||
document.getElementById('convertPreview').textContent = preview || '(无内容)';
|
||||
|
||||
new bootstrap.Modal(document.getElementById('convertModal')).show();
|
||||
}
|
||||
|
||||
async function executeConvert() {
|
||||
const itemId = document.getElementById('convertItemId').value;
|
||||
const mode = document.querySelector('input[name="convertMode"]:checked').value;
|
||||
|
||||
const data = {
|
||||
mode: mode,
|
||||
title: document.getElementById('convertTitle').value,
|
||||
status: document.getElementById('convertStatus').value,
|
||||
priority: document.getElementById('convertPriority').value,
|
||||
due_date: document.getElementById('convertDueDate').value || null
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/items/${itemId}/convert`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('convertModal')).hide();
|
||||
|
||||
if (mode === 'copy') {
|
||||
alert(`已创建新待办,原收藏保留\n新待办ID: ${result.data.id}`);
|
||||
}
|
||||
|
||||
refreshData();
|
||||
loadReminders();
|
||||
} else {
|
||||
alert(result.error || '转换失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除条目
|
||||
async function deleteItem(id) {
|
||||
if (!confirm('确认删除?')) return;
|
||||
@@ -1198,6 +1533,10 @@ async function showDetail(id) {
|
||||
document.getElementById('detailTypeIcon').textContent = getTypeIcon(item.type);
|
||||
document.getElementById('detailTitle').textContent = item.title || '(无标题)';
|
||||
|
||||
// 非待办类型显示转换按钮
|
||||
const convertBtn = document.getElementById('detailConvertBtn');
|
||||
convertBtn.style.display = item.type !== 'todo' ? 'inline-block' : 'none';
|
||||
|
||||
let html = `<div class="mb-3"><strong>类型:</strong> ${getTypeLabel(item.type)}</div>`;
|
||||
|
||||
if (item.url) {
|
||||
@@ -1216,7 +1555,7 @@ async function showDetail(id) {
|
||||
html += `<div class="mb-3"><strong>状态:</strong> ${getStatusLabel(item.status)}</div>`;
|
||||
html += `<div class="mb-3"><strong>优先级:</strong> ${getPriorityLabel(item.priority)}</div>`;
|
||||
if (item.due_date) {
|
||||
html += `<div class="mb-3"><strong>截止日期:</strong> ${item.due_date}</div>`;
|
||||
html += `<div class="mb-3"><strong>截止时间:</strong> ${formatDueDateFull(item.due_date)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,6 +1588,12 @@ async function showDetail(id) {
|
||||
new bootstrap.Modal(document.getElementById('detailModal')).show();
|
||||
}
|
||||
|
||||
// 从详情页打开转换
|
||||
function showConvertModalFromDetail() {
|
||||
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
|
||||
setTimeout(() => showConvertModal(currentDetailId), 300);
|
||||
}
|
||||
|
||||
// 从详情页打开编辑
|
||||
function openEditModalFromDetail() {
|
||||
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
|
||||
@@ -1282,7 +1627,21 @@ async function openEditModal(id) {
|
||||
if (type === 'todo') {
|
||||
document.getElementById('editStatus').value = item.status;
|
||||
document.getElementById('editPriority').value = item.priority;
|
||||
document.getElementById('editDueDate').value = item.due_date || '';
|
||||
// 处理 datetime-local 格式
|
||||
if (item.due_date) {
|
||||
// 尝试解析日期时间格式
|
||||
let dueDate = item.due_date;
|
||||
if (dueDate.includes('T')) {
|
||||
// ISO 格式,截取到分钟
|
||||
dueDate = dueDate.substring(0, 16);
|
||||
} else if (dueDate.length === 10) {
|
||||
// 只有日期,添加默认时间 00:00
|
||||
dueDate = dueDate + 'T00:00';
|
||||
}
|
||||
document.getElementById('editDueDate').value = dueDate;
|
||||
} else {
|
||||
document.getElementById('editDueDate').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||
@@ -1361,10 +1720,79 @@ function truncate(str, len) {
|
||||
return str && str.length > len ? str.substring(0, len) + '...' : str || '';
|
||||
}
|
||||
|
||||
// 简短日期格式(用于卡片显示)
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const min = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${month}-${day} ${hour}:${min}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
// 格式化截止时间(友好显示)
|
||||
function formatDueDate(dueDate) {
|
||||
if (!dueDate) return '';
|
||||
|
||||
let date;
|
||||
if (dueDate.includes('T')) {
|
||||
date = new Date(dueDate);
|
||||
} else if (dueDate.length === 10) {
|
||||
// 只有日期
|
||||
date = new Date(dueDate + 'T00:00:00');
|
||||
} else {
|
||||
date = new Date(dueDate);
|
||||
}
|
||||
|
||||
// 格式化为友好格式
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const targetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const daysDiff = Math.floor((targetDay - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let datePart;
|
||||
if (daysDiff === 0) {
|
||||
datePart = '今天';
|
||||
} else if (daysDiff === 1) {
|
||||
datePart = '明天';
|
||||
} else if (daysDiff === -1) {
|
||||
datePart = '昨天';
|
||||
} else {
|
||||
datePart = date.toLocaleDateString('zh-CN', {month: 'short', day: 'numeric'});
|
||||
}
|
||||
|
||||
const timePart = date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
|
||||
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
// 格式化截止时间(完整显示)
|
||||
function formatDueDateFull(dueDate) {
|
||||
if (!dueDate) return '';
|
||||
|
||||
let date;
|
||||
if (dueDate.includes('T')) {
|
||||
date = new Date(dueDate);
|
||||
} else if (dueDate.length === 10) {
|
||||
date = new Date(dueDate + 'T00:00:00');
|
||||
} else {
|
||||
date = new Date(dueDate);
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -1680,6 +2108,106 @@ async function showEmailManager() {
|
||||
new bootstrap.Modal(document.getElementById('emailManagerModal')).show();
|
||||
}
|
||||
|
||||
// ============ 备份管理 ============
|
||||
|
||||
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();
|
||||
@@ -1857,14 +2385,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;
|
||||
@@ -1935,7 +2455,7 @@ function showRemindersModal() {
|
||||
html += `<div class="list-group-item list-group-item-danger d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${item.title || '(无标题)'}</strong>
|
||||
<br><small class="text-muted">截止: ${item.due_date} | 已过期 ${item.days_overdue} 天</small>
|
||||
<br><small class="text-muted">截止: ${formatDueDateFull(item.due_date)} | 已过期 ${item.days_overdue} 天</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-success" onclick="completeReminder(${item.id})" title="完成">
|
||||
@@ -1958,7 +2478,7 @@ function showRemindersModal() {
|
||||
html += `<div class="list-group-item list-group-item-warning d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${item.title || '(无标题)'}</strong>
|
||||
<br><small class="text-muted">截止: ${item.due_date}</small>
|
||||
<br><small class="text-muted">截止: ${formatDueDateFull(item.due_date)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-success" onclick="completeReminder(${item.id})" title="完成">
|
||||
@@ -1981,7 +2501,7 @@ function showRemindersModal() {
|
||||
html += `<div class="list-group-item list-group-item-info d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${item.title || '(无标题)'}</strong>
|
||||
<br><small class="text-muted">截止: ${item.due_date}</small>
|
||||
<br><small class="text-muted">截止: ${formatDueDateFull(item.due_date)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-success" onclick="completeReminder(${item.id})" title="完成">
|
||||
|
||||
222
xian_favor/db.py
222
xian_favor/db.py
@@ -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数据库管理"""
|
||||
@@ -464,21 +469,36 @@ class Database:
|
||||
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||
|
||||
try:
|
||||
due_date = datetime.strptime(item['due_date'], '%Y-%m-%d')
|
||||
# 计算距离到期的时间
|
||||
days_left = (due_date.date() - now.date()).days
|
||||
due_date_str = item['due_date']
|
||||
# 支持多种日期格式
|
||||
if 'T' in due_date_str:
|
||||
# ISO 格式:2026-04-16T14:30
|
||||
due_date = datetime.strptime(due_date_str[:16], '%Y-%m-%dT%H:%M')
|
||||
elif len(due_date_str) == 10:
|
||||
# 只有日期:2026-04-16,视为当天 23:59:59
|
||||
due_date = datetime.strptime(due_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
||||
else:
|
||||
# 其他格式,尝试解析
|
||||
due_date = datetime.strptime(due_date_str.split('.')[0], '%Y-%m-%dT%H:%M:%S')
|
||||
|
||||
if days_left < 0:
|
||||
# 计算距离到期的时间
|
||||
time_left = due_date - now
|
||||
|
||||
if time_left.total_seconds() < 0:
|
||||
# 已过期
|
||||
item['days_overdue'] = abs(days_left)
|
||||
days_overdue = abs(int(time_left.total_seconds() / 86400))
|
||||
item['days_overdue'] = days_overdue
|
||||
reminders['overdue'].append(item)
|
||||
elif days_left == 0:
|
||||
# 今天到期
|
||||
elif time_left.total_seconds() < 86400: # 24小时内
|
||||
# 判断是今天还是明天
|
||||
if due_date.date() == now.date():
|
||||
reminders['due_today'].append(item)
|
||||
else:
|
||||
reminders['due_soon'].append(item)
|
||||
elif due_date.date() == now.date():
|
||||
# 今天到期(超过24小时的情况,比如现在凌晨,截止时间是晚上)
|
||||
reminders['due_today'].append(item)
|
||||
elif days_left == 1:
|
||||
# 明天到期(24小时内)
|
||||
reminders['due_soon'].append(item)
|
||||
except ValueError:
|
||||
except (ValueError, AttributeError) as e:
|
||||
# 日期格式错误,跳过
|
||||
continue
|
||||
|
||||
@@ -486,6 +506,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'])
|
||||
|
||||
|
||||
# 全局数据库实例
|
||||
|
||||
Reference in New Issue
Block a user