Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e24e2a86 | |||
| 0864c99b75 | |||
| 0c6057de28 | |||
| 6f20e5978d | |||
| 56ff1e8163 | |||
| 9ec479415a | |||
| 7652718803 | |||
| d2f64f98a1 | |||
| 161b93f368 |
10
README.md
10
README.md
@@ -141,6 +141,16 @@ 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`
|
||||
|
||||
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()
|
||||
@@ -17,15 +17,37 @@ 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,
|
||||
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 +73,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 +91,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 +144,25 @@ 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>/done', methods=['POST'])
|
||||
def complete_item(item_id):
|
||||
"""完成待办"""
|
||||
@@ -511,6 +577,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 +653,10 @@ 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; }
|
||||
.star-btn.active { color: #ffc107; }
|
||||
.status-pending { color: #ffc107; }
|
||||
.status-in_progress { color: #17a2b8; }
|
||||
.status-completed { color: #28a745; text-decoration: line-through; }
|
||||
@@ -568,6 +676,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 +688,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>
|
||||
|
||||
@@ -736,6 +846,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 +960,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 +1080,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,11 +1250,9 @@ 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 currentPage = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
// 工具函数 - 必须先定义
|
||||
function debounce(fn, delay) {
|
||||
let timer;
|
||||
return function(...args) {
|
||||
@@ -1104,6 +1263,11 @@ function debounce(fn, delay) {
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 确保初始状态清空
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('typeFilter').value = '';
|
||||
currentFilter = { type: '', status: '', starred: null };
|
||||
|
||||
await loadStats(); // 先加载统计,确保总数可用
|
||||
loadItems();
|
||||
loadTags();
|
||||
@@ -1124,8 +1288,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) => {
|
||||
@@ -1141,12 +1309,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
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();
|
||||
});
|
||||
@@ -1154,15 +1324,13 @@ 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)}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
@@ -1170,7 +1338,7 @@ async function loadItems(page = 1) {
|
||||
|
||||
if (data.success) {
|
||||
renderItems(data.data);
|
||||
renderPagination(data.data.length, page);
|
||||
renderPagination(data.total, page); // 使用API返回的total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,16 +1351,18 @@ 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>` : ''}
|
||||
</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) : ''}
|
||||
@@ -1201,6 +1371,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 ${item.is_starred ? 'active' : ''}" 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>
|
||||
@@ -1215,9 +1388,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) {
|
||||
@@ -1323,7 +1495,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`, {
|
||||
@@ -1461,6 +1634,16 @@ async function showDetail(id) {
|
||||
|
||||
let html = `<div class="mb-3"><strong>类型:</strong> ${getTypeLabel(item.type)}</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>`;
|
||||
}
|
||||
@@ -1566,6 +1749,9 @@ async function openEditModal(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置重点关注状态
|
||||
document.getElementById('editStarred').checked = item.is_starred === 1;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||
}
|
||||
|
||||
@@ -1592,7 +1778,8 @@ 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}`, {
|
||||
@@ -1607,6 +1794,14 @@ async function saveEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换重点关注状态
|
||||
async function toggleStar(id) {
|
||||
const res = await fetch(`${API_BASE}/items/${id}/star`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getTypeIcon(type) {
|
||||
const icons = { text: '📝', link: '🔗', column: '📰', todo: '✅' };
|
||||
@@ -2030,6 +2225,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();
|
||||
|
||||
278
xian_favor/db.py
278
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数据库管理"""
|
||||
@@ -54,6 +59,7 @@ class Database:
|
||||
priority TEXT DEFAULT 'medium',
|
||||
due_date TEXT,
|
||||
note TEXT,
|
||||
is_starred INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
@@ -109,6 +115,15 @@ 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)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ============ Item 操作 ============
|
||||
@@ -116,7 +131,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 +145,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,7 +171,7 @@ 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, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""列出条目"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -179,6 +194,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,7 +206,8 @@ class Database:
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " ORDER BY i.created_at DESC LIMIT ? OFFSET ?"
|
||||
# 重点关注优先显示
|
||||
query += " ORDER BY i.is_starred DESC, i.created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
@@ -199,9 +219,48 @@ class Database:
|
||||
|
||||
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"
|
||||
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)
|
||||
|
||||
cursor.execute(query, params)
|
||||
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}
|
||||
|
||||
if not update_fields and 'tags' not in kwargs:
|
||||
@@ -236,6 +295,31 @@ class Database:
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
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
|
||||
|
||||
# ============ Tag 操作 ============
|
||||
|
||||
def create_tag(self, name: str, color: str = "#3498db") -> int:
|
||||
@@ -501,6 +585,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