Compare commits

...

19 Commits

Author SHA1 Message Date
56ff1e8163 fix: 搜索输入实时响应修复
- 改用直接 setTimeout 方式,不用 debounce 函数
- 避免函数绑定问题导致搜索不触发
2026-04-16 14:03:05 +08:00
9ec479415a docs: 更新版本历史 v2.4.0 2026-04-16 13:55:07 +08:00
7652718803 feat: 数据库备份机制 v2.4.0
- 新增备份管理 API 和页面
- 自动备份:每天 04:00 执行
- 手动备份:页面一键备份
- 备份清理规则:
  - 保留最近 30 天
  - 每月第一天永久保留
  - 手动备份最多保留 10 个
- 支持恢复和删除备份
2026-04-16 13:54:07 +08:00
d2f64f98a1 fix: 初始化时清空搜索框和筛选状态
- 页面加载时强制清空 searchInput
- 重置 typeFilter 和 currentFilter
- 删除重复的 pageSize 定义
2026-04-16 13:47:46 +08:00
161b93f368 docs: 更新版本历史 v2.3.2 2026-04-16 13:41:06 +08:00
31f2d8b428 fix: 搜索功能修复 - debounce函数定义顺序
- debounce函数在使用前必须先定义
- 之前定义在代码末尾,导致搜索事件绑定失败
- 现移到代码开头,确保正确执行
2026-04-16 13:40:44 +08:00
5d6dd10dfa docs: 更新版本历史 v2.3.1 2026-04-16 12:20:51 +08:00
d0f7b07260 fix: 日期放到标题后面,不增加卡片高度
- 日期紧跟标题后面,同一行显示
- 格式更紧凑:04-16 11:09 →04-16 12:00
- 字体10px,透明度0.5,不影响阅读
2026-04-16 12:20:32 +08:00
0be768ca8e docs: 更新版本历史 v2.3.0 2026-04-16 12:17:24 +08:00
68ecb16303 feat: 卡片显示创建和更新日期
- 每个收藏卡片底部显示日期
- 格式:04-16 11:09(月-日 时:分)
- 有更新时显示:创建 → 更新
- 字体更小更淡,不影响卡片高度
2026-04-16 12:16:54 +08:00
82d928f497 docs: 更新版本历史 v2.2.0 2026-04-16 11:45:57 +08:00
c99eca35f0 feat: 快捷添加按钮,一键选择类型
- 顶部按钮栏分离为4个快捷添加按钮
- 点击直接进入对应类型的添加弹窗
- 弹窗标题显示类型图标和名称
- 不再需要下拉选择类型
2026-04-16 11:45:25 +08:00
47b195ed1c docs: 更新版本历史 v2.1.0 2026-04-16 11:37:49 +08:00
1f1528979c feat: 待办截止时间支持日期+时间
- 截止日期改为日期时间选择器(datetime-local)
- 新增友好显示函数:今天/明天/昨天 + 时间
- 详情页显示完整日期时间格式
- 提醒弹窗显示友好格式
- 后端支持多种日期格式解析
- 只有日期的待办视为当天23:59到期
2026-04-16 11:37:04 +08:00
bcb24e474d docs: 更新版本历史 v2.0.1 2026-04-16 11:26:06 +08:00
a19b260ef7 fix: 转换弹窗优化
- 内容预览保留换行格式,提高可读性
- 转换方式默认改为复制创建
2026-04-16 11:25:49 +08:00
1d8ce5cfb9 docs: 更新版本历史 v2.0.0 2026-04-16 11:18:54 +08:00
cb2cbd4c6b feat: 收藏转待办功能 v2.0.0
- 新增 /api/items/<id>/convert API
- 支持两种转换方式:
  - 直接转换(convert):原收藏变为待办
  - 复制创建(copy):保留原收藏,新建待办
- 文本/链接/专栏类型可转换
- 内容自动合并到待办备注
- 前端列表和详情页添加转换按钮
- 弹窗配置:标题、状态、优先级、截止日期
2026-04-16 11:18:26 +08:00
c0d221c2a3 docs: 更新版本历史 v1.11.0 2026-04-16 11:06:02 +08:00
4 changed files with 859 additions and 62 deletions

View File

@@ -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
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

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -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="完成">

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数据库管理"""
@@ -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'])
# 全局数据库实例