Compare commits

...

5 Commits

Author SHA1 Message Date
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
6b775c99f9 feat: 已完成待办可重新打开
- 新增 /api/items/<id>/reopen 接口
- 前端列表已完成待办显示重新打开按钮
- 恢复为 pending 或 in_progress 状态
2026-04-16 11:05:34 +08:00
061bfa3b55 docs: 更新版本历史 v1.10.0 2026-04-16 10:59:44 +08:00
2eccc11369 feat: 网页端待办到期提醒功能
- 新增 /api/reminders API 接口
- 获取已过期、今天到期、即将到期的待办
- 前端显示提醒栏和弹窗详情
- 每5分钟自动刷新提醒
- 支持快速完成待办
2026-04-16 10:59:26 +08:00
3 changed files with 529 additions and 1 deletions

View File

@@ -141,6 +141,16 @@ xian-favor/
## 版本历史
- v1.11.0 (2026-04-16): 已完成待办可重新打开
- 新增 `/api/items/<id>/reopen` 接口
- 前端列表已完成待办显示重新打开按钮(↻图标)
- 可恢复为 pending 或 in_progress 状态
- v1.10.0 (2026-04-16): 网页端待办到期提醒功能
- 新增提醒 API `/api/reminders`
- 获取已过期、今天到期、即将到期的待办
- 前端顶部提醒栏显示提醒数量和概要
- 弹窗详情支持快速完成待办
- 每5分钟自动刷新提醒
- v1.9.2 (2026-04-14): 邮件发送历史记录
- 详情页面显示邮件发送记录(发送邮箱、时间)
- 支持多次发送,显示多条记录

View File

@@ -112,6 +112,93 @@ def complete_item(item_id):
return jsonify({'success': True, 'data': item})
@app.route('/api/items/<int:item_id>/reopen', methods=['POST'])
def reopen_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
# 获取请求中的目标状态,默认为 pending
data = request.get_json() or {}
new_status = data.get('status', 'pending')
if new_status not in ['pending', 'in_progress']:
return jsonify({'success': False, 'error': '无效状态'}), 400
db.update_item(item_id, status=new_status)
item = db.get_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():
"""列出标签"""
@@ -167,6 +254,13 @@ def get_stats():
return jsonify({'success': True, 'data': stats})
@app.route('/api/reminders', methods=['GET'])
def get_reminders():
"""获取提醒信息"""
reminders = db.get_reminders()
return jsonify({'success': True, 'data': reminders})
@app.route('/api/ai-process', methods=['POST'])
def ai_process():
"""AI处理文本"""
@@ -490,6 +584,16 @@ INDEX_TEMPLATE = '''
<!-- 主内容 -->
<div class="col-md-10 content">
<!-- 提醒栏 -->
<div id="reminderBar" class="alert alert-warning alert-dismissible fade show mb-3" style="display:none;" role="alert">
<i class="bi bi-bell-fill"></i>
<span id="reminderText">有待办事项需要关注</span>
<button type="button" class="btn btn-sm btn-warning ms-2" onclick="showRemindersModal()">
<i class="bi bi-eye"></i> 查看详情
</button>
<button type="button" class="btn-close" data-bs-dismiss="alert" onclick="dismissReminderBar()"></button>
</div>
<!-- 顶部操作栏 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex gap-2">
@@ -652,6 +756,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>
@@ -881,6 +988,100 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 提醒详情模态框 -->
<div class="modal fade" id="remindersModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title"><i class="bi bi-bell-fill"></i> 待办提醒</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="remindersContent">
<!-- 动态填充 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</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" checked>
<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">
<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="date" 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;">
<!-- 动态填充 -->
</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';
@@ -891,6 +1092,10 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadStats(); // 先加载统计,确保总数可用
loadItems();
loadTags();
loadReminders(); // 加载提醒
// 定时刷新提醒每5分钟
setInterval(loadReminders, 5 * 60 * 1000);
// 标签输入自动提示
document.getElementById('addTags').addEventListener('input', showTagSuggestions);
@@ -976,8 +1181,9 @@ function renderItems(items) {
<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">
<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' ? '' : ''}
</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) : ''}
@@ -986,9 +1192,11 @@ 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('')}
${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>` : ''}
${item.type === 'todo' && item.status === 'completed' ? `<button class="btn btn-sm btn-outline-warning py-0 px-1" onclick="reopenItem(${item.id})" title="重新打开"><i class="bi bi-arrow-counterclockwise" style="font-size:11px;"></i></button>` : ''}
<button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteItem(${item.id})" title="删除"><i class="bi bi-trash" style="font-size:11px;"></i></button>
</div>
</div>
@@ -1097,6 +1305,94 @@ async function completeItem(id) {
refreshData();
}
// 重新打开待办
async function reopenItem(id) {
const res = await fetch(`${API_BASE}/items/${id}/reopen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'pending' })
});
if (res.ok) {
refreshData();
loadReminders(); // 刷新提醒
}
}
// ============ 转换为待办 ============
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('modeConvert').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;
@@ -1119,6 +1415,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) {
@@ -1170,6 +1470,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();
@@ -1785,6 +2091,165 @@ function debounce(fn, delay) {
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// ============ 提醒功能 ============
let reminderData = null;
let reminderDismissed = false;
async function loadReminders() {
const res = await fetch(`${API_BASE}/reminders`);
const data = await res.json();
if (data.success) {
reminderData = data.data;
updateReminderBar();
}
}
function updateReminderBar() {
if (!reminderData || reminderDismissed) return;
const total = reminderData.total;
if (total === 0) {
document.getElementById('reminderBar').style.display = 'none';
return;
}
// 显示提醒栏
document.getElementById('reminderBar').style.display = 'block';
// 构建提醒文本
let text = '';
if (reminderData.overdue.length > 0) {
text += `<span class="text-danger fw-bold">${reminderData.overdue.length}个已过期</span> `;
}
if (reminderData.due_today.length > 0) {
text += `<span class="text-warning fw-bold">${reminderData.due_today.length}个今天到期</span> `;
}
if (reminderData.due_soon.length > 0) {
text += `<span>${reminderData.due_soon.length}个即将到期</span>`;
}
document.getElementById('reminderText').innerHTML = text;
// 更新提醒角标样式
const bar = document.getElementById('reminderBar');
if (reminderData.overdue.length > 0) {
bar.className = 'alert alert-danger alert-dismissible fade show mb-3';
} else if (reminderData.due_today.length > 0) {
bar.className = 'alert alert-warning alert-dismissible fade show mb-3';
} else {
bar.className = 'alert alert-info alert-dismissible fade show mb-3';
}
}
function dismissReminderBar() {
reminderDismissed = true;
document.getElementById('reminderBar').style.display = 'none';
}
function showRemindersModal() {
if (!reminderData) return;
let html = '';
// 已过期
if (reminderData.overdue.length > 0) {
html += `<div class="mb-3"><h6 class="text-danger"><i class="bi bi-exclamation-circle"></i> 已过期 (${reminderData.overdue.length})</h6>`;
html += `<div class="list-group">`;
reminderData.overdue.forEach(item => {
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>
</div>
<div>
<button class="btn btn-sm btn-success" onclick="completeReminder(${item.id})" title="完成">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="openEditModal(${item.id}); bootstrap.Modal.getInstance(document.getElementById('remindersModal')).hide();" title="编辑">
<i class="bi bi-pencil"></i>
</button>
</div>
</div>`;
});
html += `</div></div>`;
}
// 今天到期
if (reminderData.due_today.length > 0) {
html += `<div class="mb-3"><h6 class="text-warning"><i class="bi bi-clock-fill"></i> 今天到期 (${reminderData.due_today.length})</h6>`;
html += `<div class="list-group">`;
reminderData.due_today.forEach(item => {
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>
</div>
<div>
<button class="btn btn-sm btn-success" onclick="completeReminder(${item.id})" title="完成">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="openEditModal(${item.id}); bootstrap.Modal.getInstance(document.getElementById('remindersModal')).hide();" title="编辑">
<i class="bi bi-pencil"></i>
</button>
</div>
</div>`;
});
html += `</div></div>`;
}
// 即将到期
if (reminderData.due_soon.length > 0) {
html += `<div class="mb-3"><h6 class="text-info"><i class="bi bi-hourglass-split"></i> 即将到期 (${reminderData.due_soon.length})</h6>`;
html += `<div class="list-group">`;
reminderData.due_soon.forEach(item => {
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>
</div>
<div>
<button class="btn btn-sm btn-success" onclick="completeReminder(${item.id})" title="完成">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="openEditModal(${item.id}); bootstrap.Modal.getInstance(document.getElementById('remindersModal')).hide();" title="编辑">
<i class="bi bi-pencil"></i>
</button>
</div>
</div>`;
});
html += `</div></div>`;
}
if (!html) {
html = '<div class="text-center text-muted py-3">暂无待办提醒</div>';
}
document.getElementById('remindersContent').innerHTML = html;
new bootstrap.Modal(document.getElementById('remindersModal')).show();
}
async function completeReminder(id) {
await fetch(`${API_BASE}/items/${id}/done`, { method: 'POST' });
// 刷新数据
await loadReminders();
refreshData();
// 如果弹窗打开,更新显示
const modalEl = document.getElementById('remindersModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
// 检查是否还有提醒
if (reminderData && reminderData.total > 0) {
showRemindersModal();
} else {
modal.hide();
}
}
}
</script>
</body>
</html>

View File

@@ -433,6 +433,59 @@ class Database:
stats['tags'] = cursor.fetchone()['count']
return stats
# ============ 提醒相关 ============
def get_reminders(self) -> Dict[str, Any]:
"""获取提醒信息:即将到期和已过期的待办"""
self._ensure_init()
with self.get_conn() as conn:
cursor = conn.cursor()
now = datetime.now()
reminders = {
'overdue': [], # 已过期
'due_today': [], # 今天到期
'due_soon': [] # 24小时内到期不含今天
}
# 查询未完成的待办(有截止日期的)
cursor.execute("""
SELECT * FROM items
WHERE type = 'todo'
AND status != 'completed'
AND due_date IS NOT NULL
AND due_date != ''
ORDER BY due_date ASC
""")
for row in cursor.fetchall():
item = dict(row)
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
if days_left < 0:
# 已过期
item['days_overdue'] = abs(days_left)
reminders['overdue'].append(item)
elif days_left == 0:
# 今天到期
reminders['due_today'].append(item)
elif days_left == 1:
# 明天到期24小时内
reminders['due_soon'].append(item)
except ValueError:
# 日期格式错误,跳过
continue
# 统计总数
reminders['total'] = len(reminders['overdue']) + len(reminders['due_today']) + len(reminders['due_soon'])
return reminders
# 全局数据库实例