Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d3c5c2ae1 | |||
| 51c76ebd24 | |||
| facf39e778 | |||
| 51cecf1f4e | |||
| 79e4eb4de0 |
@@ -174,6 +174,122 @@ def increment_views(item_id):
|
|||||||
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Draft 草稿箱 API ============
|
||||||
|
|
||||||
|
@app.route('/api/drafts', methods=['GET'])
|
||||||
|
def list_drafts():
|
||||||
|
"""列出草稿"""
|
||||||
|
limit = int(request.args.get('limit', 50))
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
drafts = db.list_drafts(limit=limit, offset=offset)
|
||||||
|
total = db.count_drafts()
|
||||||
|
return jsonify({'success': True, 'data': drafts, 'total': total})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/drafts', methods=['POST'])
|
||||||
|
def save_draft():
|
||||||
|
"""保存草稿"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': '无数据'}), 400
|
||||||
|
|
||||||
|
draft_id = db.save_draft(
|
||||||
|
type=data.get('type', 'text'),
|
||||||
|
title=data.get('title'),
|
||||||
|
content=data.get('content'),
|
||||||
|
url=data.get('url'),
|
||||||
|
source=data.get('source'),
|
||||||
|
status=data.get('status', 'pending'),
|
||||||
|
priority=data.get('priority', 'medium'),
|
||||||
|
due_date=data.get('due_date'),
|
||||||
|
note=data.get('note'),
|
||||||
|
tags=data.get('tags'),
|
||||||
|
is_starred=data.get('is_starred', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = db.get_draft(draft_id)
|
||||||
|
return jsonify({'success': True, 'data': draft})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/drafts/<int:draft_id>', methods=['GET'])
|
||||||
|
def get_draft(draft_id):
|
||||||
|
"""获取草稿"""
|
||||||
|
draft = db.get_draft(draft_id)
|
||||||
|
if draft:
|
||||||
|
return jsonify({'success': True, 'data': draft})
|
||||||
|
return jsonify({'success': False, 'error': '草稿不存在'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/drafts/<int:draft_id>', methods=['PUT'])
|
||||||
|
def update_draft(draft_id):
|
||||||
|
"""更新草稿"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': '无数据'}), 400
|
||||||
|
|
||||||
|
if db.update_draft(draft_id, **data):
|
||||||
|
draft = db.get_draft(draft_id)
|
||||||
|
return jsonify({'success': True, 'data': draft})
|
||||||
|
return jsonify({'success': False, 'error': '草稿不存在'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/drafts/<int:draft_id>', methods=['DELETE'])
|
||||||
|
def delete_draft(draft_id):
|
||||||
|
"""删除草稿"""
|
||||||
|
if db.delete_draft(draft_id):
|
||||||
|
return jsonify({'success': True})
|
||||||
|
return jsonify({'success': False, 'error': '草稿不存在'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/drafts/<int:draft_id>/publish', methods=['POST'])
|
||||||
|
def publish_draft(draft_id):
|
||||||
|
"""将草稿发布为正式条目"""
|
||||||
|
item_id = db.draft_to_item(draft_id)
|
||||||
|
if item_id:
|
||||||
|
item = db.get_item(item_id)
|
||||||
|
return jsonify({'success': True, 'data': item})
|
||||||
|
return jsonify({'success': False, 'error': '草稿不存在'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/trash', methods=['GET'])
|
||||||
|
def list_trash():
|
||||||
|
"""列出回收站数据"""
|
||||||
|
limit = int(request.args.get('limit', 50))
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
items = db.list_trash(limit=limit, offset=offset)
|
||||||
|
total = db.count_trash()
|
||||||
|
|
||||||
|
# 为每个条目添加内容统计
|
||||||
|
for item in items:
|
||||||
|
item['content_stats'] = calculate_content_stats(item)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'data': items, 'total': total})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/items/<int:item_id>/restore', methods=['POST'])
|
||||||
|
def restore_item(item_id):
|
||||||
|
"""从回收站恢复数据"""
|
||||||
|
if db.restore_item(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>/permanent', methods=['DELETE'])
|
||||||
|
def delete_permanently(item_id):
|
||||||
|
"""彻底删除数据"""
|
||||||
|
if db.delete_permanently(item_id):
|
||||||
|
return jsonify({'success': True})
|
||||||
|
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/trash', methods=['DELETE'])
|
||||||
|
def empty_trash():
|
||||||
|
"""清空回收站"""
|
||||||
|
deleted_count = db.empty_trash()
|
||||||
|
return jsonify({'success': True, 'deleted_count': deleted_count})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/items/<int:item_id>/done', methods=['POST'])
|
@app.route('/api/items/<int:item_id>/done', methods=['POST'])
|
||||||
def complete_item(item_id):
|
def complete_item(item_id):
|
||||||
"""完成待办"""
|
"""完成待办"""
|
||||||
@@ -699,6 +815,8 @@ INDEX_TEMPLATE = '''
|
|||||||
<a href="#" onclick="showTagManager(); return false;"><i class="bi bi-tags"></i> 标签管理</a>
|
<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="showEmailManager(); return false;"><i class="bi bi-envelope"></i> 邮箱管理</a>
|
||||||
<a href="#" onclick="showBackupManager(); return false;"><i class="bi bi-archive"></i> 备份管理</a>
|
<a href="#" onclick="showBackupManager(); return false;"><i class="bi bi-archive"></i> 备份管理</a>
|
||||||
|
<a href="#" onclick="showDrafts(); return false;"><i class="bi bi-file-earmark"></i> 草稿箱</a>
|
||||||
|
<a href="#" onclick="showTrash(); return false;"><i class="bi bi-trash"></i> 回收站</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -808,6 +926,7 @@ INDEX_TEMPLATE = '''
|
|||||||
<h5 class="modal-title">
|
<h5 class="modal-title">
|
||||||
<span id="addModalIcon"></span>
|
<span id="addModalIcon"></span>
|
||||||
<span id="addModalTitle">添加条目</span>
|
<span id="addModalTitle">添加条目</span>
|
||||||
|
<span id="draftIndicator" class="badge bg-secondary ms-2" style="display:none;">已自动保存</span>
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1322,9 +1441,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 侧边栏过滤
|
// 侧边栏过滤
|
||||||
document.querySelectorAll('.sidebar a').forEach(a => {
|
document.querySelectorAll('.sidebar a[data-filter]').forEach(a => {
|
||||||
a.addEventListener('click', (e) => {
|
a.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 如果在回收站视图,先退出
|
||||||
|
if (trashView) {
|
||||||
|
trashView = false;
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
||||||
a.classList.add('active');
|
a.classList.add('active');
|
||||||
|
|
||||||
@@ -1485,10 +1610,259 @@ function changeSort() {
|
|||||||
// ============ 添加功能 ============
|
// ============ 添加功能 ============
|
||||||
|
|
||||||
// 快捷添加按钮
|
// 快捷添加按钮
|
||||||
|
// ============ 草稿箱 ============
|
||||||
|
|
||||||
|
let draftView = false;
|
||||||
|
let currentDraftId = null;
|
||||||
|
let autoSaveTimer = null;
|
||||||
|
let autoSaveDelay = parseInt(localStorage.getItem('autoSaveDelay') || '5') * 1000; // 默认5秒
|
||||||
|
|
||||||
|
// 显示草稿箱
|
||||||
|
async function showDrafts() {
|
||||||
|
draftView = true;
|
||||||
|
await loadDrafts();
|
||||||
|
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载草稿列表
|
||||||
|
async function loadDrafts() {
|
||||||
|
const res = await fetch(`${API_BASE}/drafts`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
renderDrafts(data.data, data.total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染草稿列表
|
||||||
|
function renderDrafts(drafts, total) {
|
||||||
|
const container = document.getElementById('itemList');
|
||||||
|
const paginationContainer = document.getElementById('pagination');
|
||||||
|
paginationContainer.innerHTML = '';
|
||||||
|
|
||||||
|
let header = `
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h5><i class="bi bi-file-earmark"></i> 草稿箱 (${total} 条)</h5>
|
||||||
|
<small class="text-muted">自动保存间隔:
|
||||||
|
<select id="autoSaveDelaySelect" onchange="setAutoSaveDelay()" style="width:60px;">
|
||||||
|
<option value="2" ${autoSaveDelay/1000==2?'selected':''}>2秒</option>
|
||||||
|
<option value="5" ${autoSaveDelay/1000==5?'selected':''}>5秒</option>
|
||||||
|
<option value="10" ${autoSaveDelay/1000==10?'selected':''}>10秒</option>
|
||||||
|
<option value="30" ${autoSaveDelay/1000==30?'selected':''}>30秒</option>
|
||||||
|
<option value="60" ${autoSaveDelay/1000==60?'selected':''}>60秒</option>
|
||||||
|
</select>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="hideDrafts()">
|
||||||
|
<i class="bi bi-arrow-left"></i> 返回列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!drafts.length) {
|
||||||
|
container.innerHTML = header + '<div class="text-center text-muted py-5">草稿箱为空</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = header + drafts.map(draft => `
|
||||||
|
<div class="card type-${draft.type} item-card" style="opacity: 0.85;">
|
||||||
|
<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">
|
||||||
|
${getTypeIcon(draft.type)} ${draft.title || '(无标题)'}
|
||||||
|
</h6>
|
||||||
|
<p class="card-text text-muted small mb-0">
|
||||||
|
${draft.content ? truncate(draft.content, 50) : draft.url ? truncate(draft.url, 50) : ''}
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">保存于: ${formatShortDate(draft.updated_at)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="editDraft(${draft.id})" title="编辑">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-success" onclick="publishDraft(${draft.id})" title="发布">
|
||||||
|
<i class="bi bi-send"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteDraft(${draft.id})" title="删除">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置自动保存间隔
|
||||||
|
function setAutoSaveDelay() {
|
||||||
|
const delay = parseInt(document.getElementById('autoSaveDelaySelect').value);
|
||||||
|
autoSaveDelay = delay * 1000;
|
||||||
|
localStorage.setItem('autoSaveDelay', delay.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏草稿箱
|
||||||
|
function hideDrafts() {
|
||||||
|
draftView = false;
|
||||||
|
currentDraftId = null;
|
||||||
|
refreshData();
|
||||||
|
document.querySelector('.sidebar a[data-filter="all"]').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑草稿(恢复到添加表单)
|
||||||
|
async function editDraft(draftId) {
|
||||||
|
const res = await fetch(`${API_BASE}/drafts/${draftId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const draft = data.data;
|
||||||
|
currentDraftId = draftId;
|
||||||
|
|
||||||
|
// 打开对应的添加弹框
|
||||||
|
showAddModal(draft.type);
|
||||||
|
|
||||||
|
// 填充数据
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('addTitle').value = draft.title || '';
|
||||||
|
if (draft.type === 'text') {
|
||||||
|
document.getElementById('addContent').value = draft.content || '';
|
||||||
|
}
|
||||||
|
if (['link', 'column'].includes(draft.type)) {
|
||||||
|
document.getElementById('addUrl').value = draft.url || '';
|
||||||
|
}
|
||||||
|
if (draft.type === 'column') {
|
||||||
|
document.getElementById('addSource').value = draft.source || '';
|
||||||
|
}
|
||||||
|
if (draft.type === 'todo') {
|
||||||
|
document.getElementById('addStatus').value = draft.status || 'pending';
|
||||||
|
document.getElementById('addPriority').value = draft.priority || 'medium';
|
||||||
|
document.getElementById('addDueDate').value = draft.due_date || '';
|
||||||
|
}
|
||||||
|
document.getElementById('addNote').value = draft.note || '';
|
||||||
|
document.getElementById('addTags').value = draft.tags || '';
|
||||||
|
document.getElementById('addStarred').checked = draft.is_starred || false;
|
||||||
|
|
||||||
|
showDraftIndicator();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布草稿(转为正式条目)
|
||||||
|
async function publishDraft(draftId) {
|
||||||
|
if (!confirm('确认发布这条草稿?')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/drafts/${draftId}/publish`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('草稿已发布!');
|
||||||
|
loadDrafts();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('发布失败: ' + data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除草稿
|
||||||
|
async function deleteDraft(draftId) {
|
||||||
|
if (!confirm('确认删除这条草稿?')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/drafts/${draftId}`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadDrafts();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('删除失败: ' + data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存草稿到服务器
|
||||||
|
async function saveDraftToServer() {
|
||||||
|
const type = document.getElementById('addType')?.value;
|
||||||
|
if (!type) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
type,
|
||||||
|
title: document.getElementById('addTitle')?.value || '',
|
||||||
|
content: type === 'text' ? document.getElementById('addContent')?.value : null,
|
||||||
|
url: ['link', 'column'].includes(type) ? document.getElementById('addUrl')?.value : null,
|
||||||
|
source: type === 'column' ? document.getElementById('addSource')?.value : null,
|
||||||
|
status: type === 'todo' ? document.getElementById('addStatus')?.value : null,
|
||||||
|
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 || '',
|
||||||
|
is_starred: document.getElementById('addStarred')?.checked || false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只在有内容时保存
|
||||||
|
if (!data.title && !data.content && !data.url && !data.note) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentDraftId) {
|
||||||
|
// 更新现有草稿
|
||||||
|
await fetch(`${API_BASE}/drafts/${currentDraftId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 创建新草稿
|
||||||
|
const res = await fetch(`${API_BASE}/drafts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success) {
|
||||||
|
currentDraftId = result.data.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showDraftIndicator();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存草稿失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动自动保存
|
||||||
|
function startAutoSave() {
|
||||||
|
// 立即保存一次
|
||||||
|
saveDraftToServer();
|
||||||
|
|
||||||
|
// 设置定时保存
|
||||||
|
autoSaveTimer = setInterval(saveDraftToServer, autoSaveDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止自动保存
|
||||||
|
function stopAutoSave() {
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearInterval(autoSaveTimer);
|
||||||
|
autoSaveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示草稿指示器
|
||||||
|
function showDraftIndicator() {
|
||||||
|
const indicator = document.getElementById('draftIndicator');
|
||||||
|
if (indicator) indicator.style.display = 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏草稿指示器
|
||||||
|
function hideDraftIndicator() {
|
||||||
|
const indicator = document.getElementById('draftIndicator');
|
||||||
|
if (indicator) indicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function showAddModal(type) {
|
function showAddModal(type) {
|
||||||
// 设置类型
|
// 设置类型
|
||||||
document.getElementById('addType').value = type;
|
document.getElementById('addType').value = type;
|
||||||
|
|
||||||
|
// 重置草稿ID
|
||||||
|
currentDraftId = null;
|
||||||
|
|
||||||
// 设置弹窗标题和图标
|
// 设置弹窗标题和图标
|
||||||
const typeInfo = {
|
const typeInfo = {
|
||||||
text: { icon: '📝', title: '添加文本' },
|
text: { icon: '📝', title: '添加文本' },
|
||||||
@@ -1506,11 +1880,23 @@ function showAddModal(type) {
|
|||||||
document.getElementById('sourceGroup').style.display = type === 'column' ? 'block' : 'none';
|
document.getElementById('sourceGroup').style.display = type === 'column' ? 'block' : 'none';
|
||||||
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
|
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
|
||||||
|
|
||||||
// 清空表单
|
// 清空表单(如果不是从草稿恢复)
|
||||||
document.getElementById('addForm').reset();
|
if (!currentDraftId) {
|
||||||
|
document.getElementById('addForm').reset();
|
||||||
|
hideDraftIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
// 打开弹窗
|
// 打开弹窗
|
||||||
new bootstrap.Modal(document.getElementById('addModal')).show();
|
new bootstrap.Modal(document.getElementById('addModal')).show();
|
||||||
|
|
||||||
|
// 启动自动保存
|
||||||
|
startAutoSave();
|
||||||
|
|
||||||
|
// 弹框关闭时停止自动保存
|
||||||
|
document.getElementById('addModal').addEventListener('hidden.bs.modal', () => {
|
||||||
|
stopAutoSave();
|
||||||
|
currentDraftId = null; // 重置草稿ID
|
||||||
|
}, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加条目
|
// 添加条目
|
||||||
@@ -1539,6 +1925,15 @@ async function addItem() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
||||||
document.getElementById('addForm').reset();
|
document.getElementById('addForm').reset();
|
||||||
|
|
||||||
|
// 成功添加后删除草稿
|
||||||
|
if (currentDraftId) {
|
||||||
|
await fetch(`${API_BASE}/drafts/${currentDraftId}`, { method: 'DELETE' });
|
||||||
|
currentDraftId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoSave();
|
||||||
|
hideDraftIndicator();
|
||||||
refreshData();
|
refreshData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2271,6 +2666,134 @@ async function showEmailManager() {
|
|||||||
new bootstrap.Modal(document.getElementById('emailManagerModal')).show();
|
new bootstrap.Modal(document.getElementById('emailManagerModal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 回收站 ============
|
||||||
|
|
||||||
|
let trashView = false;
|
||||||
|
|
||||||
|
async function showTrash() {
|
||||||
|
trashView = true;
|
||||||
|
await loadTrash();
|
||||||
|
|
||||||
|
// 更新侧边栏状态
|
||||||
|
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrash() {
|
||||||
|
const res = await fetch(`${API_BASE}/trash`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
renderTrash(data.data, data.total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrash(items, total) {
|
||||||
|
const container = document.getElementById('itemList');
|
||||||
|
const paginationContainer = document.getElementById('pagination');
|
||||||
|
|
||||||
|
// 清空分页
|
||||||
|
paginationContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// 显示回收站标题和操作按钮
|
||||||
|
let header = `
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h5><i class="bi bi-trash"></i> 回收站 (${total} 条数据)</h5>
|
||||||
|
<small class="text-muted">回收站数据可随时恢复或彻底删除</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="hideTrash()">
|
||||||
|
<i class="bi bi-arrow-left"></i> 返回列表
|
||||||
|
</button>
|
||||||
|
${total > 0 ? `<button class="btn btn-outline-danger ms-2" onclick="emptyTrash()">
|
||||||
|
<i class="bi bi-trash-fill"></i> 清空回收站
|
||||||
|
</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = header + '<div class="text-center text-muted py-5">回收站为空</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = header + items.map(item => `
|
||||||
|
<div class="card type-${item.type} item-card" style="opacity: 0.7;">
|
||||||
|
<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">
|
||||||
|
${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)}
|
||||||
|
</h6>
|
||||||
|
<p class="card-text text-muted small mb-0">
|
||||||
|
删除时间: ${formatShortDate(item.deleted_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-outline-success" onclick="restoreItem(${item.id})" title="恢复">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i> 恢复
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePermanently(${item.id})" title="彻底删除">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hideTrash() {
|
||||||
|
trashView = false;
|
||||||
|
refreshData();
|
||||||
|
|
||||||
|
// 更新侧边栏状态
|
||||||
|
document.querySelector('.sidebar a[data-filter="all"]').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreItem(id) {
|
||||||
|
if (!confirm('确认恢复这条数据?')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/items/${id}/restore`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadTrash();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('恢复失败: ' + data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePermanently(id) {
|
||||||
|
if (!confirm('确认彻底删除这条数据?此操作不可恢复!')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/items/${id}/permanent`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadTrash();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('删除失败: ' + data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyTrash() {
|
||||||
|
if (!confirm('确认清空回收站?此操作不可恢复!')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/trash`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(`已清空回收站,删除了 ${data.deleted_count} 条数据`);
|
||||||
|
loadTrash();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('清空失败: ' + data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 备份管理 ============
|
// ============ 备份管理 ============
|
||||||
|
|
||||||
async function showBackupManager() {
|
async function showBackupManager() {
|
||||||
|
|||||||
200
xian_favor/db.py
200
xian_favor/db.py
@@ -61,6 +61,8 @@ class Database:
|
|||||||
note TEXT,
|
note TEXT,
|
||||||
is_starred INTEGER DEFAULT 0,
|
is_starred INTEGER DEFAULT 0,
|
||||||
views INTEGER DEFAULT 0,
|
views INTEGER DEFAULT 0,
|
||||||
|
is_deleted INTEGER DEFAULT 0,
|
||||||
|
deleted_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
@@ -97,6 +99,26 @@ class Database:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 草稿表
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS drafts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
url TEXT,
|
||||||
|
source TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
priority TEXT DEFAULT 'medium',
|
||||||
|
due_date TEXT,
|
||||||
|
note TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
is_starred INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# 邮件发送记录表
|
# 邮件发送记录表
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS email_logs (
|
CREATE TABLE IF NOT EXISTS email_logs (
|
||||||
@@ -131,6 +153,16 @@ class Database:
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
cursor.execute("ALTER TABLE items ADD COLUMN views INTEGER DEFAULT 0")
|
cursor.execute("ALTER TABLE items ADD COLUMN views INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
# 检查并添加 is_deleted 和 deleted_at 字段(兼容旧数据库)
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT is_deleted FROM items LIMIT 1")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
cursor.execute("ALTER TABLE items ADD COLUMN is_deleted INTEGER DEFAULT 0")
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT deleted_at FROM items LIMIT 1")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# ============ Item 操作 ============
|
# ============ Item 操作 ============
|
||||||
@@ -192,6 +224,9 @@ class Database:
|
|||||||
params = []
|
params = []
|
||||||
conditions = []
|
conditions = []
|
||||||
|
|
||||||
|
# 只显示未删除的数据
|
||||||
|
conditions.append("i.is_deleted = 0")
|
||||||
|
|
||||||
# 标签过滤需要JOIN
|
# 标签过滤需要JOIN
|
||||||
if tag:
|
if tag:
|
||||||
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
|
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
|
||||||
@@ -329,13 +364,73 @@ class Database:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_item(self, item_id: int) -> bool:
|
def delete_item(self, item_id: int) -> bool:
|
||||||
"""删除条目"""
|
"""删除条目(移动到回收站)"""
|
||||||
with self.get_conn() as conn:
|
with self.get_conn() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
cursor.execute("UPDATE items SET is_deleted = 1, deleted_at = ? WHERE id = ?", (now, item_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def list_trash(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||||
|
"""列出回收站数据"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM items WHERE is_deleted = 1 ORDER BY deleted_at DESC LIMIT ? OFFSET ?", (limit, offset))
|
||||||
|
items = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def count_trash(self) -> int:
|
||||||
|
"""计算回收站数据总数"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM items WHERE is_deleted = 1")
|
||||||
|
return cursor.fetchone()['count']
|
||||||
|
|
||||||
|
def restore_item(self, item_id: int) -> bool:
|
||||||
|
"""从回收站恢复数据"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE items SET is_deleted = 0, deleted_at = NULL WHERE id = ?", (item_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def delete_permanently(self, item_id: int) -> bool:
|
||||||
|
"""彻底删除数据(从数据库中删除)"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# 删除标签关联
|
||||||
|
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
|
||||||
|
# 删除邮件发送记录
|
||||||
|
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
|
||||||
|
# 删除数据
|
||||||
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def empty_trash(self) -> int:
|
||||||
|
"""清空回收站"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# 获取所有回收站数据ID
|
||||||
|
cursor.execute("SELECT id FROM items WHERE is_deleted = 1")
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# 删除所有关联数据
|
||||||
|
for item_id in ids:
|
||||||
|
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
|
||||||
|
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
|
||||||
|
|
||||||
|
# 删除所有回收站数据
|
||||||
|
cursor.execute("DELETE FROM items WHERE is_deleted = 1")
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
def toggle_star(self, item_id: int) -> bool:
|
def toggle_star(self, item_id: int) -> bool:
|
||||||
"""切换重点关注状态"""
|
"""切换重点关注状态"""
|
||||||
with self.get_conn() as conn:
|
with self.get_conn() as conn:
|
||||||
@@ -369,6 +464,109 @@ class Database:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
# ============ Draft 草稿操作 ============
|
||||||
|
|
||||||
|
def save_draft(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: str = None, is_starred: bool = False) -> int:
|
||||||
|
"""保存草稿"""
|
||||||
|
self._ensure_init()
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO drafts (type, title, content, url, source, status, priority, due_date, note, tags, is_starred, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (type, title, content, url, source, status, priority, due_date, note, tags, 1 if is_starred else 0, now, now))
|
||||||
|
draft_id = cursor.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
return draft_id
|
||||||
|
|
||||||
|
def update_draft(self, draft_id: int, **kwargs) -> bool:
|
||||||
|
"""更新草稿"""
|
||||||
|
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'tags', 'is_starred']
|
||||||
|
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM drafts WHERE id = ?", (draft_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return False
|
||||||
|
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
|
||||||
|
set_clause += ", updated_at = ?"
|
||||||
|
values = list(update_fields.values()) + [now, draft_id]
|
||||||
|
cursor.execute(f"UPDATE drafts SET {set_clause} WHERE id = ?", values)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_drafts(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||||
|
"""列出草稿"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM drafts ORDER BY updated_at DESC LIMIT ? OFFSET ?", (limit, offset))
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def count_drafts(self) -> int:
|
||||||
|
"""计算草稿总数"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM drafts")
|
||||||
|
return cursor.fetchone()['count']
|
||||||
|
|
||||||
|
def get_draft(self, draft_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""获取单个草稿"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM drafts WHERE id = ?", (draft_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_draft(self, draft_id: int) -> bool:
|
||||||
|
"""删除草稿"""
|
||||||
|
with self.get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM drafts WHERE id = ?", (draft_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def draft_to_item(self, draft_id: int) -> Optional[int]:
|
||||||
|
"""将草稿转为正式条目"""
|
||||||
|
draft = self.get_draft(draft_id)
|
||||||
|
if not draft:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 创建条目
|
||||||
|
tags_list = draft['tags'].split(',') if draft['tags'] else []
|
||||||
|
tags_list = [t.strip() for t in tags_list if t.strip()]
|
||||||
|
|
||||||
|
item_id = self.create_item(
|
||||||
|
type=draft['type'],
|
||||||
|
title=draft['title'],
|
||||||
|
content=draft['content'],
|
||||||
|
url=draft['url'],
|
||||||
|
source=draft['source'],
|
||||||
|
status=draft['status'],
|
||||||
|
priority=draft['priority'],
|
||||||
|
due_date=draft['due_date'],
|
||||||
|
note=draft['note'],
|
||||||
|
tags=tags_list,
|
||||||
|
is_starred=draft['is_starred']
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除草稿
|
||||||
|
if item_id:
|
||||||
|
self.delete_draft(draft_id)
|
||||||
|
|
||||||
|
return item_id
|
||||||
|
|
||||||
# ============ Tag 操作 ============
|
# ============ Tag 操作 ============
|
||||||
|
|
||||||
def create_tag(self, name: str, color: str = "#3498db") -> int:
|
def create_tag(self, name: str, color: str = "#3498db") -> int:
|
||||||
|
|||||||
Reference in New Issue
Block a user