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':
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(
type=request.args.get('type'),
status=request.args.get('status'),
tag=request.args.get('tag'),
keyword=request.args.get('keyword'),
starred=starred,
folder_id=folder_id,
sort_by=request.args.get('sort_by'),
sort_order=request.args.get('sort_order'),
limit=int(request.args.get('limit', 50)),
@@ -46,7 +55,8 @@ def list_items():
status=request.args.get('status'),
tag=request.args.get('tag'),
keyword=request.args.get('keyword'),
starred=starred
starred=starred,
folder_id=folder_id
)
return jsonify({'success': True, 'data': items, 'total': total})
@@ -76,7 +86,8 @@ def create_item():
due_date=data.get('due_date'),
note=data.get('note'),
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)
return jsonify({'success': True, 'data': item}), 201
@@ -742,6 +753,84 @@ def delete_backup(backup_name):
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 页面 ============
@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">
<style>
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: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 显示在标题后面 */
.connection-status {
@@ -813,6 +930,67 @@ INDEX_TEMPLATE = '''
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 {
position: fixed;
@@ -886,7 +1064,7 @@ INDEX_TEMPLATE = '''
<div class="container-fluid">
<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()">
<h5>
<i class="bi bi-bookmark-star"></i> Xian Favor
@@ -899,10 +1077,63 @@ INDEX_TEMPLATE = '''
<nav>
<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="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>
<a href="#" data-filter="todo"><i class="bi bi-check2-square"></i> 待办</a>
<!-- 文本类别 -->
<div class="sidebar-section" id="section-text">
<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">
<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>
@@ -917,7 +1148,7 @@ INDEX_TEMPLATE = '''
</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">
<i class="bi bi-bell-fill"></i>
@@ -929,7 +1160,7 @@ INDEX_TEMPLATE = '''
</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">
<input type="text" id="searchInput" class="form-control" placeholder="搜索..." style="width: 300px;">
<select id="typeFilter" class="form-select" style="width: 120px;">
@@ -1481,15 +1712,73 @@ 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>
<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>
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 currentPage = 1;
const pageSize = 20;
// ============ 连接状态检测 ============
let allFolders = {}; // 按类型存储文件夹
let isOnline = true;
let connectionCheckTimer = null;
const CONNECTION_CHECK_INTERVAL = 5000; // 5秒检测一次
@@ -1621,11 +1910,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// 确保初始状态清空
document.getElementById('searchInput').value = '';
document.getElementById('typeFilter').value = '';
currentFilter = { type: '', status: '', starred: null };
currentFilter = { type: '', status: '', starred: null, folder_id: null };
await loadStats(); // 先加载统计,确保总数可用
loadItems();
loadTags();
loadFolders(); // 加载文件夹
loadReminders(); // 加载提醒
// 定时刷新提醒每5分钟
@@ -1671,13 +1961,13 @@ document.addEventListener('DOMContentLoaded', async () => {
const filter = a.dataset.filter;
if (filter === 'starred') {
currentFilter = { type: '', status: '', starred: true };
currentFilter = { type: '', status: '', starred: true, folder_id: null };
} 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)) {
currentFilter = { type: 'todo', status: filter, starred: null };
currentFilter = { type: 'todo', status: filter, starred: null, folder_id: null };
} else {
currentFilter = { type: '', status: '', starred: null };
currentFilter = { type: '', status: '', starred: null, folder_id: null };
}
loadItems();
});
@@ -1692,6 +1982,7 @@ async function loadItems(page = 1) {
if (currentFilter.type) url += `&type=${currentFilter.type}`;
if (currentFilter.status) url += `&status=${currentFilter.status}`;
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 (currentSort.sort_by) url += `&sort_by=${currentSort.sort_by}`;
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>
</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-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>` : ''}
@@ -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() {

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_status ON items(status)")
@@ -163,6 +176,17 @@ class Database:
except sqlite3.OperationalError:
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()
# ============ Item 操作 ============
@@ -170,7 +194,8 @@ class Database:
def create_item(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: List[str] = None, is_starred: bool = False) -> int:
tags: List[str] = None, is_starred: bool = False,
folder_id: int = None) -> int:
"""创建新条目"""
self._ensure_init()
now = datetime.now().isoformat()
@@ -184,9 +209,9 @@ class Database:
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, now, now))
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, folder_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, folder_id, now, now))
item_id = cursor.lastrowid
# 添加标签
@@ -210,12 +235,14 @@ class Database:
return item
def list_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, starred: bool = None, sort_by: str = None,
sort_order: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
keyword: str = None, starred: bool = None, folder_id: int = None,
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_order: desc, asc
folder_id: 文件夹IDNone表示不限制-1表示未分类folder_id为null
"""
with self.get_conn() as conn:
cursor = conn.cursor()
@@ -245,6 +272,15 @@ class Database:
conditions.append("i.is_starred = ?")
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:
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
keyword_pattern = f"%{keyword}%"
@@ -290,7 +326,7 @@ class Database:
return items
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:
cursor = conn.cursor()
@@ -322,6 +358,15 @@ class Database:
keyword_pattern = f"%{keyword}%"
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:
query += " WHERE " + " AND ".join(conditions)
@@ -464,6 +509,116 @@ class Database:
conn.commit()
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 草稿操作 ============
def save_draft(self, type: str = "text", title: str = None, content: str = None,