Compare commits

...

5 Commits

Author SHA1 Message Date
c8aecaeb03 fix: 修复文件夹过滤时分页总数计算错误 2026-04-22 11:18:51 +08:00
bf63610510 fix: 点击类别同时展开文件夹并过滤数据 2026-04-22 10:52:30 +08:00
7af3a7f21d feat: 侧边栏文件夹UI优化 - 折叠式设计
- 新建文件夹按钮移到类别右侧(文件夹+图标)
- 默认折叠状态,点击类别名称展开/折叠
- 投影箭头指示展开状态(▶折叠,▼展开)
- 按钮hover时显示绿色背景,更明显可见
2026-04-22 10:44:53 +08:00
4783e9d88e feat: 文件夹功能
- 数据库添加 folders 表和 items.folder_id 字段
- API 新增文件夹 CRUD 接口和条目移动接口
- 侧边栏每个类别下显示文件夹列表
- 新建文件夹按钮和模态框
- 条目卡片添加「移动到文件夹」按钮
- 点击文件夹过滤显示该文件夹下的数据
2026-04-21 22:23:20 +08:00
ccbd24be11 feat: 侧边栏和顶部按钮栏固定显示
- 侧边栏固定在左侧(position: fixed)
- 顶部按钮栏固定在顶部(position: sticky)
- 主内容区留出侧边栏空间(margin-left: 200px)
- 滚动页面时侧边栏和顶部栏保持不动
2026-04-21 22:12:03 +08:00
2 changed files with 642 additions and 26 deletions

View File

@@ -24,12 +24,21 @@ def list_items():
elif starred_param == 'false' or starred_param == '0': elif starred_param == 'false' or starred_param == '0':
starred = False starred = False
# 文件夹ID参数
folder_id_param = request.args.get('folder_id')
folder_id = None
if folder_id_param == '-1':
folder_id = -1 # 未分类
elif folder_id_param:
folder_id = int(folder_id_param)
items = db.list_items( items = db.list_items(
type=request.args.get('type'), type=request.args.get('type'),
status=request.args.get('status'), status=request.args.get('status'),
tag=request.args.get('tag'), tag=request.args.get('tag'),
keyword=request.args.get('keyword'), keyword=request.args.get('keyword'),
starred=starred, starred=starred,
folder_id=folder_id,
sort_by=request.args.get('sort_by'), sort_by=request.args.get('sort_by'),
sort_order=request.args.get('sort_order'), sort_order=request.args.get('sort_order'),
limit=int(request.args.get('limit', 50)), limit=int(request.args.get('limit', 50)),
@@ -46,7 +55,8 @@ def list_items():
status=request.args.get('status'), status=request.args.get('status'),
tag=request.args.get('tag'), tag=request.args.get('tag'),
keyword=request.args.get('keyword'), keyword=request.args.get('keyword'),
starred=starred starred=starred,
folder_id=folder_id
) )
return jsonify({'success': True, 'data': items, 'total': total}) return jsonify({'success': True, 'data': items, 'total': total})
@@ -76,7 +86,8 @@ def create_item():
due_date=data.get('due_date'), due_date=data.get('due_date'),
note=data.get('note'), note=data.get('note'),
tags=data.get('tags', []), tags=data.get('tags', []),
is_starred=data.get('is_starred', False) is_starred=data.get('is_starred', False),
folder_id=data.get('folder_id')
) )
item = db.get_item(item_id) item = db.get_item(item_id)
return jsonify({'success': True, 'data': item}), 201 return jsonify({'success': True, 'data': item}), 201
@@ -742,6 +753,84 @@ def delete_backup(backup_name):
return jsonify({'success': False, 'error': '备份不存在'}), 404 return jsonify({'success': False, 'error': '备份不存在'}), 404
# ============ Folder 文件夹管理 API ============
@app.route('/api/folders', methods=['GET'])
def list_folders():
"""列出文件夹"""
type_filter = request.args.get('type')
folders = db.list_folders(type=type_filter)
return jsonify({'success': True, 'data': folders})
@app.route('/api/folders', methods=['POST'])
def create_folder():
"""创建文件夹"""
data = request.get_json()
name = data.get('name', '').strip()
type_val = data.get('type', 'text')
if not name:
return jsonify({'success': False, 'error': '文件夹名称不能为空'}), 400
if type_val not in ITEM_TYPES:
return jsonify({'success': False, 'error': f'无效类型: {type_val}'}), 400
try:
folder_id = db.create_folder(name=name, type=type_val)
folder = db.get_folder(folder_id)
return jsonify({'success': True, 'data': folder}), 201
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/folders/<int:folder_id>', methods=['GET'])
def get_folder(folder_id):
"""获取文件夹"""
folder = db.get_folder(folder_id)
if not folder:
return jsonify({'success': False, 'error': '文件夹不存在'}), 404
return jsonify({'success': True, 'data': folder})
@app.route('/api/folders/<int:folder_id>', methods=['PUT'])
def update_folder(folder_id):
"""更新文件夹"""
data = request.get_json()
name = data.get('name', '').strip()
if not name:
return jsonify({'success': False, 'error': '文件夹名称不能为空'}), 400
if db.update_folder(folder_id, name=name):
folder = db.get_folder(folder_id)
return jsonify({'success': True, 'data': folder})
return jsonify({'success': False, 'error': '文件夹不存在'}), 404
@app.route('/api/folders/<int:folder_id>', methods=['DELETE'])
def delete_folder(folder_id):
"""删除文件夹"""
move_to_root = request.args.get('move_items', 'true') == 'true'
if db.delete_folder(folder_id, move_items_to_root=move_to_root):
return jsonify({'success': True})
return jsonify({'success': False, 'error': '文件夹不存在'}), 404
@app.route('/api/items/<int:item_id>/move', methods=['POST'])
def move_item_to_folder(item_id):
"""将条目移动到文件夹"""
data = request.get_json()
folder_id = data.get('folder_id') # None 表示移出文件夹
if db.move_item_to_folder(item_id, folder_id):
item = db.get_item(item_id)
return jsonify({'success': True, 'data': item})
return jsonify({'success': False, 'error': '条目不存在'}), 404
# ============ Web 页面 ============ # ============ Web 页面 ============
@app.route('/') @app.route('/')
@@ -764,10 +853,38 @@ INDEX_TEMPLATE = '''
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style> <style>
body { background: #f8f9fa; } body { background: #f8f9fa; }
.sidebar { height: 100vh; background: #343a40; color: #fff; } /* 侧边栏固定在左侧 */
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 200px;
background: #343a40;
color: #fff;
z-index: 1000;
overflow-y: auto;
}
.sidebar a { color: #adb5bd; text-decoration: none; padding: 10px 20px; display: block; } .sidebar a { color: #adb5bd; text-decoration: none; padding: 10px 20px; display: block; }
.sidebar a:hover, .sidebar a.active { background: #495057; color: #fff; } .sidebar a:hover, .sidebar a.active { background: #495057; color: #fff; }
.content { padding: 20px; }
/* 主内容区留出侧边栏空间 */
.content {
margin-left: 200px;
padding: 20px;
padding-top: 0; /* 顶部按钮栏有 sticky这里去掉顶部 padding */
}
/* 顶部操作栏固定在主内容区顶部 */
.top-bar {
position: sticky;
top: 0;
background: #f8f9fa;
padding: 15px 20px;
margin: -20px -20px 20px -20px;
z-index: 100;
border-bottom: 1px solid #dee2e6;
}
/* 连接状态指示器 - inline 显示在标题后面 */ /* 连接状态指示器 - inline 显示在标题后面 */
.connection-status { .connection-status {
@@ -813,6 +930,67 @@ INDEX_TEMPLATE = '''
opacity: 0.8; opacity: 0.8;
} }
/* 文件夹列表样式 - 折叠式 */
.sidebar-section { margin-bottom: 2px; }
.sidebar-section .section-header {
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 10px;
}
.sidebar-section .section-header .header-left {
display: flex;
align-items: center;
flex: 1;
}
.sidebar-section .section-header .toggle-arrow {
font-size: 10px;
margin-right: 8px;
transition: transform 0.2s;
}
.sidebar-section.expanded .section-header .toggle-arrow {
transform: rotate(90deg);
}
.sidebar-section .section-header .new-folder-btn {
font-size: 14px;
color: #adb5bd;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
background: rgba(255,255,255,0.1);
}
.sidebar-section .section-header .new-folder-btn:hover {
color: #fff;
background: #28a745;
}
.folder-list {
padding-left: 10px;
max-height: 200px;
overflow-y: auto;
display: none; /* 默认隐藏 */
}
.sidebar-section.expanded .folder-list {
display: block; /* 展开时显示 */
}
.folder-list a {
padding: 6px 20px;
font-size: 13px;
color: #adb5bd;
}
.folder-list a:hover, .folder-list a.active {
background: #495057;
color: #fff;
}
.folder-list a i { margin-right: 5px; }
.folder-action {
font-size: 12px;
color: #6c757d;
padding: 4px 20px;
}
.folder-action:hover { color: #adb5bd; }
/* 离线遮罩 */ /* 离线遮罩 */
.offline-overlay { .offline-overlay {
position: fixed; position: fixed;
@@ -886,7 +1064,7 @@ INDEX_TEMPLATE = '''
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<div class="col-md-2 sidebar p-0"> <div class="sidebar p-0">
<div class="p-3 border-bottom border-secondary sidebar-title" onclick="goHome()"> <div class="p-3 border-bottom border-secondary sidebar-title" onclick="goHome()">
<h5> <h5>
<i class="bi bi-bookmark-star"></i> Xian Favor <i class="bi bi-bookmark-star"></i> Xian Favor
@@ -899,10 +1077,63 @@ INDEX_TEMPLATE = '''
<nav> <nav>
<a href="#" class="active" data-filter="all"><i class="bi bi-inbox"></i> 全部</a> <a href="#" class="active" data-filter="all"><i class="bi bi-inbox"></i> 全部</a>
<a href="#" data-filter="starred"><i class="bi bi-star-fill" style="color:#ffc107;"></i> 重点关注</a> <a href="#" data-filter="starred"><i class="bi bi-star-fill" style="color:#ffc107;"></i> 重点关注</a>
<a href="#" data-filter="text"><i class="bi bi-file-text"></i> 文本</a>
<a href="#" data-filter="link"><i class="bi bi-link-45deg"></i> 链接</a> <!-- 文本类别 -->
<a href="#" data-filter="column"><i class="bi bi-newspaper"></i> 专栏</a> <div class="sidebar-section" id="section-text">
<a href="#" data-filter="todo"><i class="bi bi-check2-square"></i> 待办</a> <a href="#" class="section-header" onclick="toggleSection('text'); return false;">
<span class="header-left">
<span class="toggle-arrow"><i class="bi bi-chevron-right"></i></span>
<i class="bi bi-file-text"></i> 文本
</span>
<span class="new-folder-btn" onclick="event.stopPropagation(); showNewFolderModal('text'); return false;">
<i class="bi bi-folder-plus"></i>
</span>
</a>
<div class="folder-list" id="folderList-text"></div>
</div>
<!-- 链接类别 -->
<div class="sidebar-section" id="section-link">
<a href="#" class="section-header" onclick="toggleSection('link'); return false;">
<span class="header-left">
<span class="toggle-arrow"><i class="bi bi-chevron-right"></i></span>
<i class="bi bi-link-45deg"></i> 链接
</span>
<span class="new-folder-btn" onclick="event.stopPropagation(); showNewFolderModal('link'); return false;">
<i class="bi bi-folder-plus"></i>
</span>
</a>
<div class="folder-list" id="folderList-link"></div>
</div>
<!-- 专栏类别 -->
<div class="sidebar-section" id="section-column">
<a href="#" class="section-header" onclick="toggleSection('column'); return false;">
<span class="header-left">
<span class="toggle-arrow"><i class="bi bi-chevron-right"></i></span>
<i class="bi bi-newspaper"></i> 专栏
</span>
<span class="new-folder-btn" onclick="event.stopPropagation(); showNewFolderModal('column'); return false;">
<i class="bi bi-folder-plus"></i>
</span>
</a>
<div class="folder-list" id="folderList-column"></div>
</div>
<!-- 待办类别 -->
<div class="sidebar-section" id="section-todo">
<a href="#" class="section-header" onclick="toggleSection('todo'); return false;">
<span class="header-left">
<span class="toggle-arrow"><i class="bi bi-chevron-right"></i></span>
<i class="bi bi-check2-square"></i> 待办
</span>
<span class="new-folder-btn" onclick="event.stopPropagation(); showNewFolderModal('todo'); return false;">
<i class="bi bi-folder-plus"></i>
</span>
</a>
<div class="folder-list" id="folderList-todo"></div>
</div>
<hr class="border-secondary"> <hr class="border-secondary">
<a href="#" data-filter="pending"><i class="bi bi-clock"></i> 待处理</a> <a href="#" data-filter="pending"><i class="bi bi-clock"></i> 待处理</a>
<a href="#" data-filter="in_progress"><i class="bi bi-arrow-repeat"></i> 进行中</a> <a href="#" data-filter="in_progress"><i class="bi bi-arrow-repeat"></i> 进行中</a>
@@ -917,7 +1148,7 @@ INDEX_TEMPLATE = '''
</div> </div>
<!-- 主内容 --> <!-- 主内容 -->
<div class="col-md-10 content"> <div class="content">
<!-- 提醒栏 --> <!-- 提醒栏 -->
<div id="reminderBar" class="alert alert-warning alert-dismissible fade show mb-3" style="display:none;" role="alert"> <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> <i class="bi bi-bell-fill"></i>
@@ -929,7 +1160,7 @@ INDEX_TEMPLATE = '''
</div> </div>
<!-- 顶部操作栏 --> <!-- 顶部操作栏 -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="top-bar d-flex justify-content-between align-items-center">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<input type="text" id="searchInput" class="form-control" placeholder="搜索..." style="width: 300px;"> <input type="text" id="searchInput" class="form-control" placeholder="搜索..." style="width: 300px;">
<select id="typeFilter" class="form-select" style="width: 120px;"> <select id="typeFilter" class="form-select" style="width: 120px;">
@@ -1481,15 +1712,73 @@ INDEX_TEMPLATE = '''
</div> </div>
</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>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="newFolderType">
<div class="mb-3">
<label class="form-label">文件夹名称</label>
<input type="text" id="newFolderName" class="form-control" placeholder="输入文件夹名称">
</div>
<div class="mb-3">
<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>
</div>
</div>
</div>
</div>
<!-- 移动到文件夹模态框 -->
<div class="modal fade" id="moveToFolderModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-folder2-open"></i> 移动到文件夹</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="moveItemId">
<div class="mb-3">
<label class="form-label">选择目标文件夹</label>
<select id="moveFolderSelect" class="form-select">
<option value="">-- 移出文件夹(未分类)--</option>
</select>
</div>
<div id="moveFolderList" class="border rounded p-2 bg-light" style="max-height: 200px; overflow-y: auto;">
<!-- 动态填充文件夹列表 -->
</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="moveItemToFolder()">
<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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
const API_BASE = '/api'; const API_BASE = '/api';
let currentFilter = { type: '', status: '', starred: null }; let currentFilter = { type: '', status: '', starred: null, folder_id: null };
let currentSort = { sort_by: '', sort_order: '' }; let currentSort = { sort_by: '', sort_order: '' };
let currentPage = 1; let currentPage = 1;
const pageSize = 20; const pageSize = 20;
let allFolders = {}; // 按类型存储文件夹
// ============ 连接状态检测 ============
let isOnline = true; let isOnline = true;
let connectionCheckTimer = null; let connectionCheckTimer = null;
const CONNECTION_CHECK_INTERVAL = 5000; // 5秒检测一次 const CONNECTION_CHECK_INTERVAL = 5000; // 5秒检测一次
@@ -1621,11 +1910,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// 确保初始状态清空 // 确保初始状态清空
document.getElementById('searchInput').value = ''; document.getElementById('searchInput').value = '';
document.getElementById('typeFilter').value = ''; document.getElementById('typeFilter').value = '';
currentFilter = { type: '', status: '', starred: null }; currentFilter = { type: '', status: '', starred: null, folder_id: null };
await loadStats(); // 先加载统计,确保总数可用 await loadStats(); // 先加载统计,确保总数可用
loadItems(); loadItems();
loadTags(); loadTags();
loadFolders(); // 加载文件夹
loadReminders(); // 加载提醒 loadReminders(); // 加载提醒
// 定时刷新提醒每5分钟 // 定时刷新提醒每5分钟
@@ -1671,13 +1961,13 @@ document.addEventListener('DOMContentLoaded', async () => {
const filter = a.dataset.filter; const filter = a.dataset.filter;
if (filter === 'starred') { if (filter === 'starred') {
currentFilter = { type: '', status: '', starred: true }; currentFilter = { type: '', status: '', starred: true, folder_id: null };
} else if (['text', 'link', 'column', 'todo'].includes(filter)) { } else if (['text', 'link', 'column', 'todo'].includes(filter)) {
currentFilter = { type: filter, status: '', starred: null }; currentFilter = { type: filter, status: '', starred: null, folder_id: null };
} else if (['pending', 'in_progress', 'completed'].includes(filter)) { } else if (['pending', 'in_progress', 'completed'].includes(filter)) {
currentFilter = { type: 'todo', status: filter, starred: null }; currentFilter = { type: 'todo', status: filter, starred: null, folder_id: null };
} else { } else {
currentFilter = { type: '', status: '', starred: null }; currentFilter = { type: '', status: '', starred: null, folder_id: null };
} }
loadItems(); loadItems();
}); });
@@ -1692,6 +1982,7 @@ async function loadItems(page = 1) {
if (currentFilter.type) url += `&type=${currentFilter.type}`; if (currentFilter.type) url += `&type=${currentFilter.type}`;
if (currentFilter.status) url += `&status=${currentFilter.status}`; if (currentFilter.status) url += `&status=${currentFilter.status}`;
if (currentFilter.starred !== null) url += `&starred=${currentFilter.starred ? 'true' : 'false'}`; if (currentFilter.starred !== null) url += `&starred=${currentFilter.starred ? 'true' : 'false'}`;
if (currentFilter.folder_id !== null) url += `&folder_id=${currentFilter.folder_id}`;
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`; if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
if (currentSort.sort_by) url += `&sort_by=${currentSort.sort_by}`; if (currentSort.sort_by) url += `&sort_by=${currentSort.sort_by}`;
if (currentSort.sort_order) url += `&sort_order=${currentSort.sort_order}`; if (currentSort.sort_order) url += `&sort_order=${currentSort.sort_order}`;
@@ -1739,6 +2030,7 @@ function renderItems(items) {
<i class="bi bi-star${item.is_starred ? '-fill' : ''}" style="font-size:11px; ${item.is_starred ? 'color:#ffc107;' : ''}"></i> <i class="bi bi-star${item.is_starred ? '-fill' : ''}" style="font-size:11px; ${item.is_starred ? 'color:#ffc107;' : ''}"></i>
</button> </button>
${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>` : ''} ${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-dark py-0 px-1" onclick="showMoveToFolderModal(${item.id}, '${item.type}')" title="移动到文件夹"><i class="bi bi-folder2-open" 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-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> <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-success py-0 px-1" onclick="completeItem(${item.id})" title="完成"><i class="bi bi-check-lg" style="font-size:11px;"></i></button>` : ''}
@@ -3126,6 +3418,175 @@ async function emptyTrash() {
} }
} }
// ============ Folder 文件夹管理 ============
async function loadFolders() {
const res = await fetch(`${API_BASE}/folders`);
const data = await res.json();
if (data.success) {
// 按类型分组
allFolders = {};
data.data.forEach(f => {
if (!allFolders[f.type]) allFolders[f.type] = [];
allFolders[f.type].push(f);
});
// 渲染每个类型的文件夹列表
['text', 'link', 'column', 'todo'].forEach(type => {
renderFolderList(type);
});
}
}
function renderFolderList(type) {
const container = document.getElementById(`folderList-${type}`);
const section = document.getElementById(`section-${type}`);
if (!container) return;
const folders = allFolders[type] || [];
if (folders.length === 0) {
container.innerHTML = '';
if (section) section.classList.remove('expanded');
return;
}
// 默认不展开,保持折叠状态
// 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>
`).join('');
}
// 切换文件夹区域展开/折叠
// 切换文件夹区域展开/折叠,并过滤显示该类别数据
function toggleSection(type) {
const section = document.getElementById(`section-${type}`);
if (!section) return;
// 切换展开状态
section.classList.toggle('expanded');
// 更新侧边栏选中状态
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
section.querySelector('.section-header').classList.add('active');
// 设置过滤条件:只过滤类型,不限制文件夹
currentFilter = { type, status: '', starred: null, folder_id: null };
loadItems(1);
}
async function showNewFolderModal(type) {
// 离线检查
if (!checkOnlineBeforeAction('新建文件夹')) return;
document.getElementById('newFolderType').value = type;
document.getElementById('newFolderName').value = '';
const typeLabels = { text: '📝 文本', link: '🔗 链接', column: '📰 专栏', todo: '✅ 待办' };
document.getElementById('newFolderTypeName').value = typeLabels[type] || type;
new bootstrap.Modal(document.getElementById('newFolderModal')).show();
}
async function createFolder() {
const type = document.getElementById('newFolderType').value;
const name = document.getElementById('newFolderName').value.trim();
if (!name) {
alert('请输入文件夹名称');
return;
}
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);
}
}
function filterByFolder(type, folderId) {
// 更新侧边栏选中状态
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
document.querySelector(`a[data-folder="${folderId}"]`)?.classList.add('active');
// 设置过滤条件
currentFilter = { type, status: '', starred: null, folder_id: folderId };
loadItems(1);
}
// 显示移动到文件夹模态框
async function showMoveToFolderModal(itemId, itemType) {
// 离线检查
if (!checkOnlineBeforeAction('移动数据')) return;
document.getElementById('moveItemId').value = itemId;
// 加载该类型的文件夹列表
const res = await fetch(`${API_BASE}/folders?type=${itemType}`);
const data = await res.json();
if (data.success) {
const select = document.getElementById('moveFolderSelect');
const listContainer = document.getElementById('moveFolderList');
select.innerHTML = '<option value="">-- 移出文件夹(未分类)--</option>' +
data.data.map(f => `<option value="${f.id}">${f.name} (${f.item_count || 0}条数据)</option>`).join('');
// 可点击的列表
listContainer.innerHTML = data.data.map(f => `
<div class="p-2 border-bottom" style="cursor: pointer;" onclick="selectMoveFolder(${f.id}, '${f.name}')">
<i class="bi bi-folder"></i> ${f.name} <span class="text-muted small">(${f.item_count || 0}条)</span>
</div>
`).join('');
if (data.data.length === 0) {
listContainer.innerHTML = '<div class="text-center text-muted py-2">该类型暂无文件夹</div>';
}
}
new bootstrap.Modal(document.getElementById('moveToFolderModal')).show();
}
function selectMoveFolder(folderId, folderName) {
document.getElementById('moveFolderSelect').value = folderId;
}
async function moveItemToFolder() {
const itemId = document.getElementById('moveItemId').value;
const folderId = document.getElementById('moveFolderSelect').value || null;
const res = await fetch(`${API_BASE}/items/${itemId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_id: folderId ? parseInt(folderId) : null })
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('moveToFolderModal')).hide();
loadFolders(); // 更新文件夹计数
loadItems(currentPage);
} else {
alert('移动失败: ' + data.error);
}
}
// ============ 备份管理 ============ // ============ 备份管理 ============
async function showBackupManager() { async function showBackupManager() {

View File

@@ -131,6 +131,19 @@ class Database:
) )
""") """)
# 文件夹表
cursor.execute("""
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
parent_id INTEGER DEFAULT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE
)
""")
# 创建索引 # 创建索引
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_type ON items(type)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_type ON items(type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_status ON items(status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_status ON items(status)")
@@ -163,6 +176,17 @@ class Database:
except sqlite3.OperationalError: except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT") cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT")
# 检查并添加 folder_id 字段(兼容旧数据库)
try:
cursor.execute("SELECT folder_id FROM items LIMIT 1")
except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN folder_id INTEGER DEFAULT NULL")
# 创建 folder 相关索引
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_folder ON items(folder_id)")
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)")
conn.commit() conn.commit()
# ============ Item 操作 ============ # ============ Item 操作 ============
@@ -170,7 +194,8 @@ class Database:
def create_item(self, type: str = "text", title: str = None, content: str = None, def create_item(self, type: str = "text", title: str = None, content: str = None,
url: str = None, source: str = None, status: str = "pending", url: str = None, source: str = None, status: str = "pending",
priority: str = "medium", due_date: str = None, note: str = None, priority: str = "medium", due_date: str = None, note: str = None,
tags: List[str] = None, is_starred: bool = False) -> int: tags: List[str] = None, is_starred: bool = False,
folder_id: int = None) -> int:
"""创建新条目""" """创建新条目"""
self._ensure_init() self._ensure_init()
now = datetime.now().isoformat() now = datetime.now().isoformat()
@@ -184,9 +209,9 @@ class Database:
with self.get_conn() as conn: with self.get_conn() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, created_at, updated_at) INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, folder_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, now, now)) """, (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, folder_id, now, now))
item_id = cursor.lastrowid item_id = cursor.lastrowid
# 添加标签 # 添加标签
@@ -210,12 +235,14 @@ class Database:
return item return item
def list_items(self, type: str = None, status: str = None, tag: str = None, def list_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, starred: bool = None, sort_by: str = None, keyword: str = None, starred: bool = None, folder_id: int = None,
sort_order: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]: sort_by: str = None, sort_order: str = None,
limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出条目 """列出条目
sort_by: created_at, updated_at sort_by: created_at, updated_at
sort_order: desc, asc sort_order: desc, asc
folder_id: 文件夹IDNone表示不限制-1表示未分类folder_id为null
""" """
with self.get_conn() as conn: with self.get_conn() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -245,6 +272,15 @@ class Database:
conditions.append("i.is_starred = ?") conditions.append("i.is_starred = ?")
params.append(1 if starred else 0) params.append(1 if starred else 0)
# 文件夹过滤
if folder_id is not None:
if folder_id == -1:
# -1 表示未分类folder_id 为 null
conditions.append("i.folder_id IS NULL")
else:
conditions.append("i.folder_id = ?")
params.append(folder_id)
if keyword: if keyword:
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)") conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
keyword_pattern = f"%{keyword}%" keyword_pattern = f"%{keyword}%"
@@ -290,7 +326,7 @@ class Database:
return items return items
def count_items(self, type: str = None, status: str = None, tag: str = None, def count_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, starred: bool = None) -> int: keyword: str = None, starred: bool = None, folder_id: int = None) -> int:
"""计算符合条件的条目总数""" """计算符合条件的条目总数"""
with self.get_conn() as conn: with self.get_conn() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -322,6 +358,15 @@ class Database:
keyword_pattern = f"%{keyword}%" keyword_pattern = f"%{keyword}%"
params.extend([keyword_pattern, keyword_pattern, keyword_pattern]) params.extend([keyword_pattern, keyword_pattern, keyword_pattern])
# 文件夹过滤
if folder_id is not None:
if folder_id == -1:
# 未分类folder_id为NULL
conditions.append("i.folder_id IS NULL")
else:
conditions.append("i.folder_id = ?")
params.append(folder_id)
if conditions: if conditions:
query += " WHERE " + " AND ".join(conditions) query += " WHERE " + " AND ".join(conditions)
@@ -464,6 +509,116 @@ class Database:
conn.commit() conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
def move_item_to_folder(self, item_id: int, folder_id: int) -> bool:
"""将条目移动到文件夹"""
with self.get_conn() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute("UPDATE items SET folder_id = ?, updated_at = ? WHERE id = ?", (folder_id, now, item_id))
conn.commit()
return cursor.rowcount > 0
# ============ Folder 文件夹操作 ============
def create_folder(self, name: str, type: str, parent_id: int = None) -> int:
"""创建文件夹
Args:
name: 文件夹名称
type: 类别类型text/link/column/todo
parent_id: 父文件夹ID目前不支持嵌套预留
"""
self._ensure_init()
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO folders (name, type, parent_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
""", (name, type, parent_id, now, now))
folder_id = cursor.lastrowid
conn.commit()
return folder_id
def get_folder(self, folder_id: int) -> Optional[Dict[str, Any]]:
"""获取文件夹信息"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM folders WHERE id = ?", (folder_id,))
row = cursor.fetchone()
if not row:
return None
folder = dict(row)
# 获取文件夹内条目数量
cursor.execute("SELECT COUNT(*) FROM items WHERE folder_id = ? AND is_deleted = 0", (folder_id,))
folder['item_count'] = cursor.fetchone()[0]
return folder
def list_folders(self, type: str = None) -> List[Dict[str, Any]]:
"""列出文件夹
Args:
type: 按类型过滤None表示列出所有
"""
with self.get_conn() as conn:
cursor = conn.cursor()
if type:
cursor.execute("SELECT * FROM folders WHERE type = ? ORDER BY created_at DESC", (type,))
else:
cursor.execute("SELECT * FROM folders ORDER BY created_at DESC")
folders = []
for row in cursor.fetchall():
folder = dict(row)
# 获取文件夹内条目数量
cursor.execute("SELECT COUNT(*) FROM items WHERE folder_id = ? AND is_deleted = 0", (folder['id'],))
folder['item_count'] = cursor.fetchone()[0]
folders.append(folder)
return folders
def update_folder(self, folder_id: int, name: str = None) -> bool:
"""更新文件夹"""
with self.get_conn() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
if name:
cursor.execute("UPDATE folders SET name = ?, updated_at = ? WHERE id = ?", (name, now, folder_id))
conn.commit()
return cursor.rowcount > 0
return False
def delete_folder(self, folder_id: int, move_items_to_root: bool = True) -> bool:
"""删除文件夹
Args:
folder_id: 文件夹ID
move_items_to_root: 是否将条目移出文件夹到未分类True则移动False则一起删除
"""
with self.get_conn() as conn:
cursor = conn.cursor()
if move_items_to_root:
# 将条目移出文件夹folder_id设为null
cursor.execute("UPDATE items SET folder_id = NULL WHERE folder_id = ?", (folder_id,))
else:
# 删除文件夹内的所有条目
cursor.execute("DELETE FROM items WHERE folder_id = ?", (folder_id,))
# 删除文件夹
cursor.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
conn.commit()
return cursor.rowcount > 0
def count_items_by_folder(self, folder_id: int) -> int:
"""统计文件夹内的条目数量"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM items WHERE folder_id = ? AND is_deleted = 0", (folder_id,))
return cursor.fetchone()[0]
# ============ Draft 草稿操作 ============ # ============ Draft 草稿操作 ============
def save_draft(self, type: str = "text", title: str = None, content: str = None, def save_draft(self, type: str = "text", title: str = None, content: str = None,