Compare commits

...

5 Commits

Author SHA1 Message Date
47cbcb25bc feat: 添加显示模式切换按钮(单行/双行) 2026-04-22 11:56:59 +08:00
527c411d87 fix: 卡片布局自适应,操作按钮不再被挤出可视区域 2026-04-22 11:35:13 +08:00
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
2 changed files with 196 additions and 33 deletions

View File

@@ -55,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})
@@ -872,6 +873,8 @@ INDEX_TEMPLATE = '''
margin-left: 200px; margin-left: 200px;
padding: 20px; padding: 20px;
padding-top: 0; /* 顶部按钮栏有 sticky这里去掉顶部 padding */ padding-top: 0; /* 顶部按钮栏有 sticky这里去掉顶部 padding */
width: -webkit-fill-available;
width: fill-available;
} }
/* 顶部操作栏固定在主内容区顶部 */ /* 顶部操作栏固定在主内容区顶部 */
@@ -929,12 +932,49 @@ INDEX_TEMPLATE = '''
opacity: 0.8; opacity: 0.8;
} }
/* 文件夹列表样式 */ /* 文件夹列表样式 - 折叠式 */
.sidebar-section .section-header { font-weight: 500; } .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 { .folder-list {
padding-left: 10px; padding-left: 10px;
max-height: 150px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
display: none; /* 默认隐藏 */
}
.sidebar-section.expanded .folder-list {
display: block; /* 展开时显示 */
} }
.folder-list a { .folder-list a {
padding: 6px 20px; padding: 6px 20px;
@@ -995,6 +1035,39 @@ INDEX_TEMPLATE = '''
.item-card h6 { font-size: 14px; margin-bottom: 2px; } .item-card h6 { font-size: 14px; margin-bottom: 2px; }
.item-card p { margin-bottom: 2px; } .item-card p { margin-bottom: 2px; }
.item-card .text-muted.small { font-size: 12px; } .item-card .text-muted.small { font-size: 12px; }
/* 卡片内容自适应布局 */
.item-card .card-content {
display: flex;
align-items: flex-start;
gap: 8px;
}
.item-card .card-main {
flex: 1;
min-width: 0;
}
.item-card .card-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.item-card .card-actions .btn {
white-space: nowrap;
}
/* 单行显示模式 */
.item-card.compact .card-content {
align-items: center;
}
.item-card.compact .card-main h6 {
margin-bottom: 0;
}
.item-card.compact .card-main p {
display: none;
}
/* 双行显示模式(默认) */
.item-card.double-line .card-main p {
display: block;
}
.type-text { border-left: 4px solid #17a2b8; } .type-text { border-left: 4px solid #17a2b8; }
.type-link { border-left: 4px solid #28a745; } .type-link { border-left: 4px solid #28a745; }
.type-column { border-left: 4px solid #6f42c1; } .type-column { border-left: 4px solid #6f42c1; }
@@ -1041,31 +1114,59 @@ INDEX_TEMPLATE = '''
<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>
<!-- 文本类别 --> <!-- 文本类别 -->
<div class="sidebar-section"> <div class="sidebar-section" id="section-text">
<a href="#" data-filter="text" class="section-header"><i class="bi bi-file-text"></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 class="folder-list" id="folderList-text"></div>
<a href="#" class="folder-action" onclick="showNewFolderModal('text'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
</div> </div>
<!-- 链接类别 --> <!-- 链接类别 -->
<div class="sidebar-section"> <div class="sidebar-section" id="section-link">
<a href="#" data-filter="link" class="section-header"><i class="bi bi-link-45deg"></i> 链接</a> <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 class="folder-list" id="folderList-link"></div>
<a href="#" class="folder-action" onclick="showNewFolderModal('link'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
</div> </div>
<!-- 专栏类别 --> <!-- 专栏类别 -->
<div class="sidebar-section"> <div class="sidebar-section" id="section-column">
<a href="#" data-filter="column" class="section-header"><i class="bi bi-newspaper"></i> 专栏</a> <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 class="folder-list" id="folderList-column"></div>
<a href="#" class="folder-action" onclick="showNewFolderModal('column'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
</div> </div>
<!-- 待办类别 --> <!-- 待办类别 -->
<div class="sidebar-section"> <div class="sidebar-section" id="section-todo">
<a href="#" data-filter="todo" class="section-header"><i class="bi bi-check2-square"></i> 待办</a> <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 class="folder-list" id="folderList-todo"></div>
<a href="#" class="folder-action" onclick="showNewFolderModal('todo'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
</div> </div>
<hr class="border-secondary"> <hr class="border-secondary">
@@ -1117,6 +1218,9 @@ INDEX_TEMPLATE = '''
<button class="btn btn-outline-info me-2" onclick="showAIAddModal()" title="AI自动添加"> <button class="btn btn-outline-info me-2" onclick="showAIAddModal()" title="AI自动添加">
<i class="bi bi-robot"></i> AI添加 <i class="bi bi-robot"></i> AI添加
</button> </button>
<button class="btn btn-outline-secondary me-1" onclick="toggleDisplayMode()" title="切换显示模式" id="displayModeBtn">
<i class="bi bi-layout-text-sidebar"></i>
</button>
<button class="btn btn-outline-secondary me-1" onclick="showAddModal('text')" title="添加文本"> <button class="btn btn-outline-secondary me-1" onclick="showAddModal('text')" title="添加文本">
<i class="bi bi-file-text"></i> <i class="bi bi-file-text"></i>
</button> </button>
@@ -1841,6 +1945,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// 启动连接状态检测 // 启动连接状态检测
startConnectionCheck(); startConnectionCheck();
// 初始化显示模式
updateDisplayMode();
// 确保初始状态清空 // 确保初始状态清空
document.getElementById('searchInput').value = ''; document.getElementById('searchInput').value = '';
document.getElementById('typeFilter').value = ''; document.getElementById('typeFilter').value = '';
@@ -1938,11 +2045,13 @@ function renderItems(items) {
return; return;
} }
const modeClass = displayMode === 'single' ? 'compact' : 'double-line';
container.innerHTML = items.map(item => ` container.innerHTML = items.map(item => `
<div class="card type-${item.type} item-card ${item.is_starred ? 'is-starred' : ''}" style="cursor: pointer;" onclick="showDetail(${item.id})"> <div class="card type-${item.type} item-card ${modeClass} ${item.is_starred ? 'is-starred' : ''}" style="cursor: pointer;" onclick="showDetail(${item.id})">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="card-content">
<div style="flex: 1; min-width: 0;"> <div class="card-main">
<h6 class="card-title text-truncate mb-1 ${item.type === 'todo' && item.status === 'completed' ? 'text-muted' : ''}"> <h6 class="card-title text-truncate mb-1 ${item.type === 'todo' && item.status === 'completed' ? 'text-muted' : ''}">
${item.is_starred ? '<i class="bi bi-star-fill" style="color:#ffc107;"></i>' : ''} ${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)} ${item.is_starred ? '<i class="bi bi-star-fill" style="color:#ffc107;"></i>' : ''} ${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)}
${item.type === 'todo' && item.status === 'completed' ? '' : ''} ${item.type === 'todo' && item.status === 'completed' ? '' : ''}
@@ -1958,7 +2067,7 @@ function renderItems(items) {
${item.type === 'todo' ? `${getStatusLabelShort(item.status)} ${getPriorityLabelShort(item.priority)} ${item.due_date ? '📅' + formatDueDate(item.due_date) : ''}` : ''} ${item.type === 'todo' ? `${getStatusLabelShort(item.status)} ${getPriorityLabelShort(item.priority)} ${item.due_date ? '📅' + formatDueDate(item.due_date) : ''}` : ''}
</p> </p>
</div> </div>
<div class="d-flex align-items-center gap-1 flex-wrap ms-2" onclick="event.stopPropagation();"> <div class="card-actions" onclick="event.stopPropagation();">
${item.tags.slice(0, 2).map(t => `<span class="badge bg-secondary" style="font-size:10px;">${t}</span>`).join('')} ${item.tags.slice(0, 2).map(t => `<span class="badge bg-secondary" style="font-size:10px;">${t}</span>`).join('')}
<button class="btn btn-sm btn-outline-warning py-0 px-1 star-btn" onclick="toggleStar(${item.id})" title="${item.is_starred ? '取消重点关注' : '设为重点关注'}"> <button class="btn btn-sm btn-outline-warning py-0 px-1 star-btn" onclick="toggleStar(${item.id})" title="${item.is_starred ? '取消重点关注' : '设为重点关注'}">
<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>
@@ -2023,6 +2132,28 @@ function renderPagination(total, page) {
container.innerHTML = html; container.innerHTML = html;
} }
// 显示模式single单行或 double双行
let displayMode = localStorage.getItem('displayMode') || 'double';
// 切换显示模式
function toggleDisplayMode() {
displayMode = displayMode === 'single' ? 'double' : 'single';
localStorage.setItem('displayMode', displayMode);
updateDisplayMode();
renderItems(currentItems);
}
// 更新显示模式按钮图标
function updateDisplayMode() {
const btn = document.getElementById('displayModeBtn');
if (btn) {
btn.innerHTML = displayMode === 'single'
? '<i class="bi bi-layout-text-sidebar-reverse"></i>'
: '<i class="bi bi-layout-text-sidebar"></i>';
btn.title = displayMode === 'single' ? '当前:单行(点击切换双行)' : '当前:双行(点击切换单行)';
}
}
// 加载统计 // 加载统计
async function loadStats() { async function loadStats() {
const res = await fetch(`${API_BASE}/stats`); const res = await fetch(`${API_BASE}/stats`);
@@ -2110,8 +2241,8 @@ function renderDrafts(drafts, total) {
container.innerHTML = header + drafts.map(draft => ` container.innerHTML = header + drafts.map(draft => `
<div class="card type-${draft.type} item-card" style="opacity: 0.85;"> <div class="card type-${draft.type} item-card" style="opacity: 0.85;">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="card-content">
<div style="flex: 1; min-width: 0;"> <div class="card-main">
<h6 class="card-title text-truncate mb-1"> <h6 class="card-title text-truncate mb-1">
${getTypeIcon(draft.type)} ${draft.title || '(无标题)'} ${getTypeIcon(draft.type)} ${draft.title || '(无标题)'}
</h6> </h6>
@@ -2120,14 +2251,14 @@ function renderDrafts(drafts, total) {
</p> </p>
<small class="text-muted">保存于: ${formatShortDate(draft.updated_at)}</small> <small class="text-muted">保存于: ${formatShortDate(draft.updated_at)}</small>
</div> </div>
<div class="d-flex gap-1"> <div class="card-actions">
<button class="btn btn-sm btn-outline-primary" onclick="editDraft(${draft.id})" title="编辑"> <button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="editDraft(${draft.id})" title="编辑">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-outline-success" onclick="publishDraft(${draft.id})" title="发布"> <button class="btn btn-sm btn-outline-success py-0 px-1" onclick="publishDraft(${draft.id})" title="发布">
<i class="bi bi-send"></i> <i class="bi bi-send"></i>
</button> </button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDraft(${draft.id})" title="删除"> <button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteDraft(${draft.id})" title="删除">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@@ -3269,8 +3400,8 @@ function renderTrash(items, total) {
container.innerHTML = header + items.map(item => ` container.innerHTML = header + items.map(item => `
<div class="card type-${item.type} item-card" style="opacity: 0.7;"> <div class="card type-${item.type} item-card" style="opacity: 0.7;">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="card-content">
<div style="flex: 1; min-width: 0;"> <div class="card-main">
<h6 class="card-title text-truncate mb-1"> <h6 class="card-title text-truncate mb-1">
${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)} ${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)}
</h6> </h6>
@@ -3278,11 +3409,11 @@ function renderTrash(items, total) {
删除时间: ${formatShortDate(item.deleted_at)} 删除时间: ${formatShortDate(item.deleted_at)}
</p> </p>
</div> </div>
<div class="d-flex gap-1"> <div class="card-actions">
<button class="btn btn-sm btn-outline-success" onclick="restoreItem(${item.id})" title="恢复"> <button class="btn btn-sm btn-outline-success py-0 px-1" onclick="restoreItem(${item.id})" title="恢复">
<i class="bi bi-arrow-counterclockwise"></i> 恢复 <i class="bi bi-arrow-counterclockwise"></i>
</button> </button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePermanently(${item.id})" title="彻底删除"> <button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deletePermanently(${item.id})" title="彻底删除">
<i class="bi bi-trash-fill"></i> <i class="bi bi-trash-fill"></i>
</button> </button>
</div> </div>
@@ -3375,15 +3506,20 @@ async function loadFolders() {
function renderFolderList(type) { function renderFolderList(type) {
const container = document.getElementById(`folderList-${type}`); const container = document.getElementById(`folderList-${type}`);
const section = document.getElementById(`section-${type}`);
if (!container) return; if (!container) return;
const folders = allFolders[type] || []; const folders = allFolders[type] || [];
if (folders.length === 0) { if (folders.length === 0) {
container.innerHTML = ''; container.innerHTML = '';
if (section) section.classList.remove('expanded');
return; return;
} }
// 默认不展开,保持折叠状态
// if (section) section.classList.add('expanded'); // 已移除
container.innerHTML = folders.map(f => ` container.innerHTML = folders.map(f => `
<a href="#" data-folder="${f.id}" onclick="filterByFolder('${type}', ${f.id}); return false;"> <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> <i class="bi bi-folder"></i> ${f.name} <small class="text-muted">(${f.item_count || 0})</small>
@@ -3391,6 +3527,24 @@ function renderFolderList(type) {
`).join(''); `).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) { async function showNewFolderModal(type) {
// 离线检查 // 离线检查
if (!checkOnlineBeforeAction('新建文件夹')) return; if (!checkOnlineBeforeAction('新建文件夹')) return;

View File

@@ -326,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()
@@ -358,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)