Compare commits

..

6 Commits

Author SHA1 Message Date
b92239fb1b feat(v4.4.0): 详情弹框添加导航按钮和固定底部操作栏
- 右侧固定显示向上/向下导航按钮
- 底部操作栏(转为待办、编辑、关闭)固定悬浮
2026-04-22 18:35:20 +08:00
105f4d5492 feat(v4.3.0): 新增/编辑弹框中添加文件夹选择
- 新增数据时可选择所属文件夹
- 编辑数据时可更改所属文件夹
- 切换类型时自动更新文件夹列表
2026-04-22 18:08:41 +08:00
e92349e111 feat(v4.2.1): 文件夹按钮样式优化+新增数据到文件夹功能
- hover文件夹时按钮悬浮在右侧,高度一致
- 新增数据按钮(绿色+)直接添加数据到该文件夹
2026-04-22 16:59:27 +08:00
0912d658b8 feat(v4.2.0): 文件夹支持重命名和删除
- hover文件夹时显示编辑和删除按钮
- 编辑按钮打开重命名模态框
- 删除按钮确认后删除,数据移到未分类
2026-04-22 16:27:38 +08:00
8e63db4424 fix(v4.1.1): 待办事务功能优化
- 修复完成状态切换问题(可来回切换完成/未完成)
- 添加全部/未完成/已完成过滤按钮
- 翻页时保持弹窗滚动位置
2026-04-22 15:54:10 +08:00
0335937312 feat(v4.1.0): 文本类别添加待办事务功能
- 可在文本数据详情中追加待办事务
- 支持事务内容、剩余天数(默认1天)、完成状态
- 历史事务按时间倒序排列,显示创建/更新时间
- 分页显示,每页10条
2026-04-22 13:00:00 +08:00
2 changed files with 773 additions and 27 deletions

View File

@@ -338,6 +338,105 @@ def reopen_item(item_id):
return jsonify({'success': True, 'data': item})
# ============ Todo Events 待办事务API ============
@app.route('/api/items/<int:item_id>/todo-events', methods=['GET'])
def list_todo_events(item_id):
"""列出待办事务"""
limit = int(request.args.get('limit', 10))
offset = int(request.args.get('offset', 0))
events = db.list_todo_events(item_id, limit=limit, offset=offset)
total = db.count_todo_events(item_id)
return jsonify({'success': True, 'data': events, 'total': total})
@app.route('/api/items/<int:item_id>/todo-events', methods=['POST'])
def create_todo_event(item_id):
"""创建待办事务"""
data = request.get_json()
if not data or not data.get('content'):
return jsonify({'success': False, 'error': '缺少事务内容'}), 400
# 检查item是否存在且是text类型
item = db.get_item(item_id)
if not item:
return jsonify({'success': False, 'error': '条目不存在'}), 404
if item['type'] != 'text':
return jsonify({'success': False, 'error': '只有文本类型可添加待办事务'}), 400
remaining_days = data.get('remaining_days', 1)
if remaining_days < 0:
remaining_days = 1
event_id = db.create_todo_event(item_id, data['content'], remaining_days)
event = db.get_todo_event(event_id)
return jsonify({'success': True, 'data': event}), 201
@app.route('/api/todo-events/<int:event_id>', methods=['GET'])
def get_todo_event(event_id):
"""获取单个待办事务"""
event = db.get_todo_event(event_id)
if not event:
return jsonify({'success': False, 'error': '事务不存在'}), 404
return jsonify({'success': True, 'data': event})
@app.route('/api/todo-events/<int:event_id>', methods=['PUT'])
def update_todo_event(event_id):
"""更新待办事务"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '无数据'}), 400
event = db.get_todo_event(event_id)
if not event:
return jsonify({'success': False, 'error': '事务不存在'}), 404
content = data.get('content')
remaining_days = data.get('remaining_days')
is_completed = data.get('is_completed')
if is_completed is not None:
is_completed = bool(is_completed)
db.update_todo_event(event_id, content=content, remaining_days=remaining_days, is_completed=is_completed)
event = db.get_todo_event(event_id)
return jsonify({'success': True, 'data': event})
@app.route('/api/todo-events/<int:event_id>', methods=['DELETE'])
def delete_todo_event(event_id):
"""删除待办事务"""
event = db.get_todo_event(event_id)
if not event:
return jsonify({'success': False, 'error': '事务不存在'}), 404
db.delete_todo_event(event_id)
return jsonify({'success': True})
@app.route('/api/todo-events/<int:event_id>/complete', methods=['POST'])
def complete_todo_event(event_id):
"""完成待办事务"""
event = db.get_todo_event(event_id)
if not event:
return jsonify({'success': False, 'error': '事务不存在'}), 404
db.update_todo_event(event_id, is_completed=True)
event = db.get_todo_event(event_id)
return jsonify({'success': True, 'data': event})
@app.route('/api/items/<int:item_id>/convert', methods=['POST'])
def convert_item(item_id):
"""将收藏转换为待办"""
@@ -986,6 +1085,41 @@ INDEX_TEMPLATE = '''
color: #fff;
}
.folder-list a i { margin-right: 5px; }
.folder-list .folder-item {
display: flex;
align-items: center;
padding: 6px 10px;
position: relative;
}
.folder-list .folder-item a {
padding: 6px 10px;
flex-grow: 1;
display: flex;
align-items: center;
}
.folder-list .folder-actions {
position: absolute;
right: 10px;
display: none;
gap: 4px;
background: #343a40;
padding: 4px 8px;
border-radius: 4px;
}
.folder-list .folder-item:hover .folder-actions {
display: flex;
}
.folder-list .folder-actions .btn {
padding: 2px 6px;
font-size: 12px;
line-height: 1;
}
.folder-list .folder-item:hover {
background: #495057;
}
.folder-list .folder-item:hover a {
color: #fff;
}
.folder-action {
font-size: 12px;
color: #6c757d;
@@ -1092,6 +1226,36 @@ INDEX_TEMPLATE = '''
.quick-insert-area:active {
background: #e8f4ff;
}
/* 详情弹框样式 */
.detail-modal-body {
max-height: 60vh;
overflow-y: auto;
padding-bottom: 20px;
}
.detail-nav-buttons {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 5px;
z-index: 10;
}
.detail-nav-buttons .btn {
padding: 8px 12px;
}
.detail-modal-footer {
position: sticky;
bottom: 0;
background: #fff;
border-top: 1px solid #dee2e6;
z-index: 10;
}
/* 解决底部按钮被遮挡的问题 */
.modal-dialog-scrollable .modal-body {
overflow-y: auto;
}
.star-btn { font-size: 11px; }
.status-pending { color: #ffc107; }
.status-in_progress { color: #17a2b8; }
@@ -1375,6 +1539,12 @@ INDEX_TEMPLATE = '''
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">所属文件夹</label>
<select id="addFolder" class="form-select">
<option value="">未分类(根目录)</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
@@ -1387,18 +1557,27 @@ INDEX_TEMPLATE = '''
<!-- 详情模态框 -->
<div class="modal fade" id="detailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><span id="detailTypeIcon"></span> <span id="detailTitle"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="modal-body detail-modal-body">
<!-- 导航按钮 -->
<div class="detail-nav-buttons">
<button class="btn btn-sm btn-outline-secondary" onclick="scrollDetailToTop()" title="回到顶部">
<i class="bi bi-arrow-up"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="scrollDetailToBottom()" title="去到底部">
<i class="bi bi-arrow-down"></i>
</button>
</div>
<div id="detailContent">
<!-- 动态填充 -->
</div>
</div>
<div class="modal-footer">
<div class="modal-footer detail-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>
@@ -1489,6 +1668,12 @@ INDEX_TEMPLATE = '''
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">所属文件夹</label>
<select id="editFolder" class="form-select">
<option value="">未分类(根目录)</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
@@ -1768,29 +1953,30 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 新建文件夹模态框 -->
<!-- 新建/编辑文件夹模态框 -->
<div class="modal fade" id="newFolderModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> 新建文件夹</h5>
<h5 class="modal-title" id="folderModalTitle"><i class="bi bi-folder-plus"></i> 新建文件夹</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="newFolderType">
<input type="hidden" id="editFolderId">
<div class="mb-3">
<label class="form-label">文件夹名称</label>
<input type="text" id="newFolderName" class="form-control" placeholder="输入文件夹名称">
</div>
<div class="mb-3">
<div class="mb-3" id="folderTypeRow">
<label class="form-label">所属类别</label>
<input type="text" id="newFolderTypeName" class="form-control" readonly>
</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="createFolder()">
<i class="bi bi-check"></i> 创建
<button type="button" class="btn btn-primary" onclick="saveFolder()">
<i class="bi bi-check"></i> <span id="folderSaveBtnText">创建</span>
</button>
</div>
</div>
@@ -1846,6 +2032,44 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 待办事务模态框 -->
<div class="modal fade" id="todoEventModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-list-check"></i> <span id="todoEventModalTitle">添加待办事务</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="todoEventId">
<input type="hidden" id="todoEventItemId">
<div class="mb-3">
<label class="form-label">事务内容</label>
<textarea id="todoEventContent" class="form-control" rows="3" placeholder="输入事务内容..."></textarea>
</div>
<div class="mb-3">
<label class="form-label">剩余天数</label>
<input type="number" id="todoEventDays" class="form-control" value="1" min="0">
<small class="text-muted">0表示已过期</small>
</div>
<div class="mb-3">
<label class="form-label">完成状态</label>
<select id="todoEventCompleted" class="form-select">
<option value="0">未完成</option>
<option value="1">已完成</option>
</select>
</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="saveTodoEvent()">
<i class="bi bi-check"></i> 保存
</button>
</div>
</div>
</div>
</div>
<!-- 移动到文件夹模态框 -->
<div class="modal fade" id="moveToFolderModal" tabindex="-1">
<div class="modal-dialog">
@@ -2040,6 +2264,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// 编辑时类型切换
document.getElementById('editType').addEventListener('change', (e) => {
updateEditFieldsByType(e.target.value);
// 切换类型时更新文件夹列表
loadFolderSelect(e.target.value, 'editFolder');
});
// 搜索 - 直接绑定,不用 debounce
@@ -2511,6 +2737,9 @@ function showAddModal(type) {
// 设置类型
document.getElementById('addType').value = type;
// 重置文件夹ID从顶部按钮添加时不指定文件夹
currentAddFolderId = null;
// 只有不是编辑草稿时才重置
// currentDraftId 在 editDraft 中已设置,不要覆盖
@@ -2540,6 +2769,9 @@ function showAddModal(type) {
// 打开弹窗
new bootstrap.Modal(document.getElementById('addModal')).show();
// 加载该类型下的文件夹列表
loadFolderSelect(type, 'addFolder', currentAddFolderId);
// 启动自动保存
startAutoSave();
@@ -2567,7 +2799,8 @@ async function addItem() {
due_date: type === 'todo' ? document.getElementById('addDueDate').value : null,
note: document.getElementById('addNote').value,
tags: document.getElementById('addTags').value.split(',').map(t => t.trim()).filter(t => t),
is_starred: document.getElementById('addStarred').checked
is_starred: document.getElementById('addStarred').checked,
folder_id: document.getElementById('addFolder').value ? parseInt(document.getElementById('addFolder').value) : currentAddFolderId
};
const res = await fetch(`${API_BASE}/items`, {
@@ -2588,6 +2821,7 @@ async function addItem() {
stopAutoSave();
hideDraftIndicator();
currentAddFolderId = null; // 重置文件夹ID
refreshData();
}
}
@@ -2709,11 +2943,19 @@ async function deleteItem(id) {
let currentDetailId = null;
// 显示详情
async function showDetail(id) {
async function showDetail(id, todoPage = 1, todoFilter = 'all') {
currentDetailId = id;
currentTodoPage = todoPage;
currentTodoFilter = todoFilter;
// 增加阅读数
await fetch(`${API_BASE}/items/${id}/view`, { method: 'POST' });
// 记录当前弹窗滚动位置
const detailContent = document.getElementById('detailContent');
const scrollTop = detailContent.scrollTop;
// 增加阅读数(首次加载时才增加)
if (todoPage === 1 && todoFilter === 'all') {
await fetch(`${API_BASE}/items/${id}/view`, { method: 'POST' });
}
const res = await fetch(`${API_BASE}/items/${id}`);
const data = await res.json();
@@ -2775,6 +3017,100 @@ async function showDetail(id) {
}
}
// 文本类型显示待办事务
if (item.type === 'text') {
const todoOffset = (todoPage - 1) * 10;
// 根据过滤状态获取数据
let todoRes = await fetch(`${API_BASE}/items/${id}/todo-events?limit=10&offset=${todoOffset}`);
let todoData = await todoRes.json();
// 客户端过滤因为API返回全部数据
let filteredEvents = todoData.data;
let totalTodo = todoData.total;
if (todoFilter === 'completed') {
filteredEvents = filteredEvents.filter(e => e.is_completed);
// 需要重新计算总数
const allRes = await fetch(`${API_BASE}/items/${id}/todo-events?limit=1000&offset=0`);
const allData = await allRes.json();
totalTodo = allData.data.filter(e => e.is_completed).length;
} else if (todoFilter === 'pending') {
filteredEvents = filteredEvents.filter(e => !e.is_completed);
const allRes = await fetch(`${API_BASE}/items/${id}/todo-events?limit=1000&offset=0`);
const allData = await allRes.json();
totalTodo = allData.data.filter(e => !e.is_completed).length;
}
html += `<hr><div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong><i class="bi bi-list-check"></i> 待办事务</strong>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTodoEventModal(${item.id})">
<i class="bi bi-plus"></i> 添加事务
</button>
</div>
<!-- 过滤按钮 -->
<div class="btn-group btn-group-sm mb-2" role="group">
<button class="btn ${todoFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}" onclick="showDetail(${id}, 1, 'all')">全部</button>
<button class="btn ${todoFilter === 'pending' ? 'btn-warning' : 'btn-outline-warning'}" onclick="showDetail(${id}, 1, 'pending')">未完成</button>
<button class="btn ${todoFilter === 'completed' ? 'btn-success' : 'btn-outline-success'}" onclick="showDetail(${id}, 1, 'completed')">已完成</button>
</div>
</div>`;
if (filteredEvents.length > 0) {
html += `<div class="border rounded p-2 bg-light" id="todoEventsList">`;
filteredEvents.forEach(event => {
const completedClass = event.is_completed ? 'text-muted' : '';
const checkIcon = event.is_completed ? '' : '';
const remainingText = event.remaining_days > 0 ? `${event.remaining_days}天` : '已过期';
const remainingClass = event.remaining_days <= 0 ? 'text-danger' : 'text-info';
html += `<div class="d-flex justify-content-between align-items-start py-2 border-bottom todo-event-item ${completedClass}">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2">
<span onclick="toggleTodoEventComplete(${event.id}, ${item.id}, ${event.is_completed})" style="cursor:pointer;" title="点击切换完成状态">${checkIcon}</span>
<span class="${event.is_completed ? 'text-decoration-line-through' : ''}">${escapeHtml(event.content)}</span>
<small class="${remainingClass}">(${remainingText})</small>
</div>
<div class="text-muted small mt-1">
创建: ${formatShortDate(event.created_at)} | 更新: ${formatShortDate(event.updated_at)}
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary btn-sm" onclick="showEditTodoEventModal(${event.id}, ${item.id})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteTodoEvent(${event.id}, ${item.id})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>`;
});
html += `</div>`;
// 分页
const totalPagesTodo = Math.ceil(totalTodo / 10);
if (totalPagesTodo > 1) {
html += `<div class="d-flex justify-content-center mt-2">
<nav><ul class="pagination pagination-sm">`;
for (let p = 1; p <= Math.min(totalPagesTodo, 5); p++) {
html += `<li class="page-item ${p === todoPage ? 'active' : ''}">
<a class="page-link" href="#" onclick="showDetail(${id}, ${p}, '${todoFilter}'); return false;">${p}</a>
</li>`;
}
if (totalPagesTodo > 5) {
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
html += `<li class="page-item"><a class="page-link" href="#" onclick="showDetail(${id}, ${totalPagesTodo}, '${todoFilter}'); return false;">${totalPagesTodo}</a></li>`;
}
html += `</ul></nav>
<small class="text-muted ms-2">共 ${totalTodo} 条事务</small>
</div>`;
}
} else {
html += `<div class="text-center text-muted py-3">${todoFilter === 'all' ? '暂无待办事务' : (todoFilter === 'completed' ? '暂无已完成事务' : '暂无未完成事务')}</div>`;
}
}
if (item.tags.length) {
html += `<div class="mb-3"><strong>标签:</strong> ${item.tags.map(t => `<span class="badge bg-secondary">${escapeHtml(t)}</span>`).join(' ')}</div>`;
}
@@ -2813,7 +3149,147 @@ async function showDetail(id) {
document.getElementById('detailContent').innerHTML = html;
new bootstrap.Modal(document.getElementById('detailModal')).show();
// 如果弹窗已经打开,保持滚动位置
const modal = bootstrap.Modal.getInstance(document.getElementById('detailModal'));
if (modal && modal._isShown) {
// 弹窗已打开,恢复滚动位置
setTimeout(() => {
document.getElementById('detailContent').scrollTop = scrollTop;
}, 10);
} else {
// 弹窗未打开,打开弹窗
new bootstrap.Modal(document.getElementById('detailModal')).show();
}
}
// ============ 待办事务功能 ============
// 显示添加待办事务模态框
function showAddTodoEventModal(itemId) {
document.getElementById('todoEventId').value = '';
document.getElementById('todoEventItemId').value = itemId;
document.getElementById('todoEventContent').value = '';
document.getElementById('todoEventDays').value = 1;
document.getElementById('todoEventCompleted').value = 0;
document.getElementById('todoEventModalTitle').textContent = '添加待办事务';
new bootstrap.Modal(document.getElementById('todoEventModal')).show();
}
// 显示编辑待办事务模态框
async function showEditTodoEventModal(eventId, itemId) {
const res = await fetch(`${API_BASE}/todo-events/${eventId}`);
const data = await res.json();
if (!data.success) {
alert('获取事务失败');
return;
}
const event = data.data;
document.getElementById('todoEventId').value = eventId;
document.getElementById('todoEventItemId').value = itemId;
document.getElementById('todoEventContent').value = event.content;
document.getElementById('todoEventDays').value = event.remaining_days;
document.getElementById('todoEventCompleted').value = event.is_completed;
document.getElementById('todoEventModalTitle').textContent = '编辑待办事务';
new bootstrap.Modal(document.getElementById('todoEventModal')).show();
}
// 保存待办事务
async function saveTodoEvent() {
const eventId = document.getElementById('todoEventId').value;
const itemId = document.getElementById('todoEventItemId').value;
const content = document.getElementById('todoEventContent').value.trim();
const days = parseInt(document.getElementById('todoEventDays').value) || 1;
const completed = document.getElementById('todoEventCompleted').value === '1';
if (!content) {
alert('请输入事务内容');
return;
}
// 离线检查
if (!checkOnlineBeforeAction('保存待办事务')) return;
if (eventId) {
// 更新
const res = await fetch(`${API_BASE}/todo-events/${eventId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, remaining_days: days, is_completed: completed })
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('todoEventModal')).hide();
showDetail(itemId, currentTodoPage, currentTodoFilter); // 刷新详情,保持页面和过滤状态
} else {
alert('更新失败: ' + data.error);
}
} else {
// 创建
const res = await fetch(`${API_BASE}/items/${itemId}/todo-events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, remaining_days: days })
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('todoEventModal')).hide();
showDetail(itemId, 1, currentTodoFilter); // 创建新事务后回到第一页
} else {
alert('创建失败: ' + data.error);
}
}
}
// 切换待办事务完成状态(可切换为完成或未完成)
async function toggleTodoEventComplete(eventId, itemId, currentCompleted) {
// 离线检查
if (!checkOnlineBeforeAction('切换完成状态')) return;
// 切换状态:已完成->未完成,未完成->已完成
const newCompleted = !currentCompleted;
const res = await fetch(`${API_BASE}/todo-events/${eventId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: newCompleted })
});
const data = await res.json();
if (data.success) {
// 保持当前页面和过滤状态
showDetail(itemId, currentTodoPage, currentTodoFilter);
} else {
alert('操作失败: ' + data.error);
}
}
// 当前待办事务的页面和过滤状态
let currentTodoPage = 1;
let currentTodoFilter = 'all'; // all, completed, pending
// 删除待办事务
async function deleteTodoEvent(eventId, itemId) {
// 离线检查
if (!checkOnlineBeforeAction('删除待办事务')) return;
if (!confirm('确认删除这条待办事务?')) return;
const res = await fetch(`${API_BASE}/todo-events/${eventId}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
showDetail(itemId, currentTodoPage, currentTodoFilter); // 刷新详情,保持页面和过滤状态
} else {
alert('删除失败: ' + data.error);
}
}
// 从详情页打开转换
@@ -2822,6 +3298,21 @@ function showConvertModalFromDetail() {
setTimeout(() => showConvertModal(currentDetailId), 300);
}
// 详情弹框导航
function scrollDetailToTop() {
const modalBody = document.querySelector('.detail-modal-body');
if (modalBody) {
modalBody.scrollTop = 0;
}
}
function scrollDetailToBottom() {
const modalBody = document.querySelector('.detail-modal-body');
if (modalBody) {
modalBody.scrollTop = modalBody.scrollHeight;
}
}
// 从详情页打开编辑
function openEditModalFromDetail() {
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
@@ -2887,6 +3378,9 @@ async function openEditModal(id) {
// 设置重点关注状态
document.getElementById('editStarred').checked = item.is_starred === 1;
// 加载文件夹列表并设置当前文件夹
await loadFolderSelect(type, 'editFolder', item.folder_id);
// 保存原始数据用于比较
window.editOriginalData = {
type,
@@ -2975,7 +3469,8 @@ async function saveEdit() {
due_date: type === 'todo' ? document.getElementById('editDueDate').value : null,
note: document.getElementById('editNote').value,
tags: document.getElementById('editTags').value.split(',').map(t => t.trim()).filter(t => t),
is_starred: document.getElementById('editStarred').checked ? 1 : 0
is_starred: document.getElementById('editStarred').checked ? 1 : 0,
folder_id: document.getElementById('editFolder').value ? parseInt(document.getElementById('editFolder').value) : null
};
try {
@@ -3794,9 +4289,22 @@ function renderFolderList(type) {
// if (section) section.classList.add('expanded'); // 已移除
container.innerHTML = folders.map(f => `
<a href="#" data-folder="${f.id}" onclick="filterByFolder('${type}', ${f.id}); return false;">
<i class="bi bi-folder"></i> ${f.name} <small class="text-muted">(${f.item_count || 0})</small>
</a>
<div class="d-flex justify-content-between align-items-center folder-item">
<a href="#" data-folder="${f.id}" onclick="filterByFolder('${type}', ${f.id}); return false;" class="flex-grow-1">
<i class="bi bi-folder"></i> ${f.name} <small class="text-muted">(${f.item_count || 0})</small>
</a>
<div class="folder-actions">
<button class="btn btn-outline-success" onclick="event.stopPropagation(); showAddToFolderModal('${type}', ${f.id}); return false;" title="新增数据到此文件夹">
<i class="bi bi-plus"></i>
</button>
<button class="btn btn-outline-secondary" onclick="event.stopPropagation(); showEditFolderModal(${f.id}, '${f.name}'); return false;" title="重命名">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger" onclick="event.stopPropagation(); deleteFolderConfirm(${f.id}, '${f.name}'); return false;" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`).join('');
}
@@ -3823,16 +4331,35 @@ async function showNewFolderModal(type) {
if (!checkOnlineBeforeAction('新建文件夹')) return;
document.getElementById('newFolderType').value = type;
document.getElementById('editFolderId').value = '';
document.getElementById('newFolderName').value = '';
const typeLabels = { text: '📝 文本', link: '🔗 链接', column: '📰 专栏', todo: '✅ 待办' };
document.getElementById('newFolderTypeName').value = typeLabels[type] || type;
document.getElementById('folderModalTitle').innerHTML = '<i class="bi bi-folder-plus"></i> 新建文件夹';
document.getElementById('folderSaveBtnText').textContent = '创建';
document.getElementById('folderTypeRow').style.display = 'block';
new bootstrap.Modal(document.getElementById('newFolderModal')).show();
}
async function createFolder() {
const type = document.getElementById('newFolderType').value;
// 显示编辑文件夹模态框
function showEditFolderModal(folderId, folderName) {
document.getElementById('editFolderId').value = folderId;
document.getElementById('newFolderName').value = folderName;
document.getElementById('newFolderType').value = '';
document.getElementById('folderModalTitle').innerHTML = '<i class="bi bi-pencil"></i> 重命名文件夹';
document.getElementById('folderSaveBtnText').textContent = '保存';
document.getElementById('folderTypeRow').style.display = 'none';
new bootstrap.Modal(document.getElementById('newFolderModal')).show();
}
// 保存文件夹(创建或编辑)
async function saveFolder() {
const editId = document.getElementById('editFolderId').value;
const name = document.getElementById('newFolderName').value.trim();
if (!name) {
@@ -3840,20 +4367,136 @@ async function createFolder() {
return;
}
const res = await fetch(`${API_BASE}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type })
});
// 离线检查
if (!checkOnlineBeforeAction(editId ? '重命名文件夹' : '新建文件夹')) return;
if (editId) {
// 编辑
const res = await fetch(`${API_BASE}/folders/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('newFolderModal')).hide();
loadFolders();
} else {
alert('重命名失败: ' + data.error);
}
} else {
// 创建
const type = document.getElementById('newFolderType').value;
const res = await fetch(`${API_BASE}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type })
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('newFolderModal')).hide();
loadFolders();
loadStats();
} else {
alert('创建失败: ' + data.error);
}
}
}
// 删除文件夹确认
async function deleteFolderConfirm(folderId, folderName) {
// 离线检查
if (!checkOnlineBeforeAction('删除文件夹')) return;
if (!confirm(`确认删除文件夹"${folderName}"\n文件夹内的数据将移到未分类。`)) return;
const res = await fetch(`${API_BASE}/folders/${folderId}?move_to_root=1`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('newFolderModal')).hide();
loadFolders();
loadStats();
// 如果当前正在过滤该文件夹,返回首页
if (currentFilter.folder_id === folderId) {
currentFilter.folder_id = null;
loadItems(1);
}
} else {
alert('创建失败: ' + data.error);
alert('删除失败: ' + data.error);
}
}
// 显示新增数据到文件夹的模态框
function showAddToFolderModal(type, folderId) {
// 离线检查
if (!checkOnlineBeforeAction('添加数据')) return;
// 设置类型
document.getElementById('addType').value = type;
document.getElementById('editFolderId').value = ''; // 重置编辑ID
// 设置弹窗标题和图标
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();
hideDraftIndicator();
// 设置默认文件夹(隐藏字段,需要在提交时带上)
currentAddFolderId = folderId;
// 打开弹窗
new bootstrap.Modal(document.getElementById('addModal')).show();
// 加载该类型下的文件夹列表,并选中指定文件夹
loadFolderSelect(type, 'addFolder', folderId);
// 启动自动保存
startAutoSave();
// 弹框关闭时停止自动保存并重置文件夹ID
document.getElementById('addModal').addEventListener('hidden.bs.modal', () => {
stopAutoSave();
currentAddFolderId = null;
}, { once: true });
}
// 当前新增时的文件夹ID
let currentAddFolderId = null;
// 加载文件夹选择列表
async function loadFolderSelect(type, selectId, selectedFolderId = null) {
const res = await fetch(`${API_BASE}/folders?type=${type}`);
const data = await res.json();
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = '<option value="">未分类(根目录)</option>';
if (data.success && data.data.length > 0) {
data.data.forEach(f => {
const selected = f.id == selectedFolderId ? 'selected' : '';
select.innerHTML += `<option value="${f.id}" ${selected}>${f.name}</option>`;
});
}
}

View File

@@ -187,6 +187,22 @@ class Database:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_folders_type ON folders(type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id)")
# 待办事务表关联到文本类别的items
cursor.execute("""
CREATE TABLE IF NOT EXISTS todo_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
remaining_days INTEGER DEFAULT 1,
is_completed INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_todo_events_item ON todo_events(item_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_todo_events_created ON todo_events(created_at)")
conn.commit()
# ============ Item 操作 ============
@@ -375,7 +391,7 @@ class Database:
def update_item(self, item_id: int, **kwargs) -> bool:
"""更新条目"""
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'is_starred']
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'is_starred', 'folder_id']
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
# 只有 tags 变化也算有效更新
@@ -1166,6 +1182,93 @@ class Database:
# 删除
self.delete_backup(backup['name'])
# ============ Todo Events 待办事务操作 ============
def create_todo_event(self, item_id: int, content: str, remaining_days: int = 1) -> int:
"""创建待办事务"""
self._ensure_init()
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO todo_events (item_id, content, remaining_days, is_completed, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?)
""", (item_id, content, remaining_days, now, now))
conn.commit()
return cursor.lastrowid
def list_todo_events(self, item_id: int, limit: int = 10, offset: int = 0) -> List[Dict[str, Any]]:
"""列出待办事务(按时间倒序)"""
self._ensure_init()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM todo_events
WHERE item_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""", (item_id, limit, offset))
rows = cursor.fetchall()
return [dict(row) for row in rows]
def count_todo_events(self, item_id: int) -> int:
"""计算待办事务总数"""
self._ensure_init()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM todo_events WHERE item_id = ?", (item_id,))
return cursor.fetchone()['count']
def get_todo_event(self, event_id: int) -> Optional[Dict[str, Any]]:
"""获取单个待办事务"""
self._ensure_init()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM todo_events WHERE id = ?", (event_id,))
row = cursor.fetchone()
return dict(row) if row else None
def update_todo_event(self, event_id: int, content: str = None, remaining_days: int = None, is_completed: bool = None) -> bool:
"""更新待办事务"""
self._ensure_init()
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
# 获取当前数据
cursor.execute("SELECT * FROM todo_events WHERE id = ?", (event_id,))
row = cursor.fetchone()
if not row:
return False
# 更新字段
new_content = content if content is not None else row['content']
new_days = remaining_days if remaining_days is not None else row['remaining_days']
new_completed = 1 if is_completed is True else (0 if is_completed is False else row['is_completed'])
cursor.execute("""
UPDATE todo_events
SET content = ?, remaining_days = ?, is_completed = ?, updated_at = ?
WHERE id = ?
""", (new_content, new_days, new_completed, now, event_id))
conn.commit()
return True
def delete_todo_event(self, event_id: int) -> bool:
"""删除待办事务"""
self._ensure_init()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM todo_events WHERE id = ?", (event_id,))
conn.commit()
return cursor.rowcount > 0
# 全局数据库实例
db = Database()