Compare commits

..

5 Commits

Author SHA1 Message Date
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
4 changed files with 488 additions and 7 deletions

View File

@@ -141,6 +141,13 @@ xian-favor/
## 版本历史
- **v2.4.0** (2026-04-16): 数据库备份机制
- 自动备份:每天 04:00 执行
- 手动备份:页面一键操作
- 备份清理规则保留30天 + 每月第一天永久保留
- 手动备份最多保留10个
- 支持恢复备份和删除备份
- 备份管理页面入口在侧边栏
- **v2.3.2** (2026-04-16): 搜索功能修复
- 修复 debounce 函数定义顺序问题
- 搜索框输入后可正常过滤列表

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

@@ -25,7 +25,20 @@ def list_items():
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')
)
return jsonify({'success': True, 'data': items, 'total': total})
@app.route('/api/items', methods=['POST'])
@@ -68,9 +81,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):
"""更新条目"""
@@ -511,6 +548,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('/')
@@ -579,6 +654,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>
@@ -954,6 +1030,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">
@@ -1127,8 +1238,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) => {
@@ -1170,7 +1285,7 @@ async function loadItems(page = 1) {
if (data.success) {
renderItems(data.data);
renderPagination(data.data.length, page);
renderPagination(data.total, page); // 使用API返回的total
}
}
@@ -1193,6 +1308,8 @@ function renderItems(items) {
<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) : ''}
@@ -1215,9 +1332,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) {
@@ -1461,6 +1577,11 @@ async function showDetail(id) {
let html = `<div class="mb-3"><strong>类型:</strong> ${getTypeLabel(item.type)}</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>`;
}
@@ -2030,6 +2151,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();

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数据库管理"""
@@ -199,6 +204,41 @@ class Database:
return items
def count_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = 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 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']
@@ -501,6 +541,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'])
# 全局数据库实例