@@ -55,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 } )
@@ -872,6 +873,8 @@ INDEX_TEMPLATE = '''
margin-left: 200px;
padding: 20px;
padding-top: 0; /* 顶部按钮栏有 sticky, 这里去掉顶部 padding */
width: -webkit-fill-available;
width: fill-available;
}
/* 顶部操作栏固定在主内容区顶部 */
@@ -1032,12 +1035,63 @@ INDEX_TEMPLATE = '''
.item-card h6 { font-size: 14px; margin-bottom: 2px; }
.item-card p { margin-bottom: 2px; }
.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-link { border-left: 4px solid #28a745; }
.type-column { border-left: 4px solid #6f42c1; }
.type-todo { border-left: 4px solid #ffc107; }
.is-starred { border-left: 4px solid #ffc107; background: #fffbe6; }
.is-starred:hover { background: #fff9e0; }
/* Markdown内容样式 */
.markdown-content { line-height: 1.6; }
.markdown-content h3 { font-size: 1.2em; margin-bottom: 0.5em; }
.markdown-content h4 { font-size: 1.1em; margin-bottom: 0.4em; }
.markdown-content h5 { font-size: 1.0em; margin-bottom: 0.3em; }
.markdown-content h6 { font-size: 0.9em; margin-bottom: 0.2em; }
.markdown-content code { font-size: 0.9em; }
.markdown-content pre { font-size: 0.85em; overflow-x: auto; }
.markdown-content ul, .markdown-content ol { margin-bottom: 0.5em; }
.markdown-content a { color: #0d6efd; }
/* 快速插入区域样式 */
.quick-insert-area:hover {
background: #f0f8ff;
border-color: #0d6efd;
}
.quick-insert-area:active {
background: #e8f4ff;
}
.star-btn { font-size: 11px; }
.status-pending { color: #ffc107; }
.status-in_progress { color: #17a2b8; }
@@ -1182,6 +1236,9 @@ INDEX_TEMPLATE = '''
<button class= " btn btn-outline-info me-2 " onclick= " showAIAddModal() " title= " AI自动添加 " >
<i class= " bi bi-robot " ></i> AI添加
</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= " 添加文本 " >
<i class= " bi bi-file-text " ></i>
</button>
@@ -1740,6 +1797,55 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 快速插入模态框 -->
<div class= " modal fade " id= " quickInsertModal " tabindex= " -1 " >
<div class= " modal-dialog " >
<div class= " modal-content " >
<div class= " modal-header " >
<h5 class= " modal-title " ><i class= " bi bi-plus-circle " ></i> 快速插入内容</h5>
<button type= " button " class= " btn-close " data-bs-dismiss= " modal " ></button>
</div>
<div class= " modal-body " >
<input type= " hidden " id= " quickInsertItemId " >
<input type= " hidden " id= " quickInsertField " >
<div class= " mb-3 " >
<label class= " form-label " >插入位置</label>
<select id= " quickInsertPosition " class= " form-select " >
<option value= " start " >开头</option>
<option value= " end " selected>末尾</option>
<option value= " selection_before " >选中内容之前</option>
<option value= " selection_after " >选中内容之后</option>
<option value= " selection_replace " >替换选中内容</option>
</select>
<input type= " hidden " id= " quickInsertSelection " >
<div id= " quickInsertSelectionInfo " class= " form-text " style= " display:none; " >
选中内容: <code id= " quickInsertSelectionPreview " ></code>
</div>
</div>
<div class= " mb-3 " >
<label class= " form-label " >要插入的内容</label>
<textarea id= " quickInsertContent " class= " form-control " rows= " 5 " placeholder= " 输入要插入的内容... " ></textarea>
</div>
<div class= " mb-3 " >
<label class= " form-label " >插入格式</label>
<select id= " quickInsertFormat " class= " form-select " >
<option value= " raw " >原文本</option>
<option value= " newline " >独立一行</option>
<option value= " bullet " >列表项 (- item)</option>
<option value= " heading4 " >标题 ####</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= " executeQuickInsert() " >
<i class= " bi bi-check " ></i> 插入并保存
</button>
</div>
</div>
</div>
</div>
<!-- 移动到文件夹模态框 -->
<div class= " modal fade " id= " moveToFolderModal " tabindex= " -1 " >
<div class= " modal-dialog " >
@@ -1775,6 +1881,7 @@ INDEX_TEMPLATE = '''
const API_BASE = ' /api ' ;
let currentFilter = { type: ' ' , status: ' ' , starred: null, folder_id: null };
let currentSort = { sort_by: ' ' , sort_order: ' ' };
let currentItems = []; // 保存当前显示的数据,用于切换显示模式时重新渲染
let currentPage = 1;
const pageSize = 20;
let allFolders = {} ; // 按类型存储文件夹
@@ -1906,6 +2013,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// 启动连接状态检测
startConnectionCheck();
// 初始化显示模式
updateDisplayMode();
// 确保初始状态清空
document.getElementById( ' searchInput ' ).value = ' ' ;
document.getElementById( ' typeFilter ' ).value = ' ' ;
@@ -1990,6 +2100,7 @@ async function loadItems(page = 1) {
const data = await res.json();
if (data.success) {
currentItems = data.data; // 保存数据用于切换显示模式
renderItems(data.data);
renderPagination(data.total, page); // 使用API返回的total
}
@@ -2003,11 +2114,13 @@ function renderItems(items) {
return;
}
const modeClass = displayMode === ' single ' ? ' compact ' : ' double-line ' ;
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= " d-flex justify-content-between align-items-star t" >
<div style = " flex: 1; min-width: 0; " >
<div class= " card-conten t" >
<div class = " card-main " >
<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.type === ' todo ' && item.status === ' completed ' ? ' ✓ ' : ' ' }
@@ -2023,7 +2136,7 @@ function renderItems(items) {
$ { item.type === ' todo ' ? `$ { getStatusLabelShort(item.status)} $ { getPriorityLabelShort(item.priority)} $ { item.due_date ? ' 📅 ' + formatDueDate(item.due_date) : ' ' }` : ' ' }
</p>
</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( ' ' )}
<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>
@@ -2088,6 +2201,28 @@ function renderPagination(total, page) {
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() {
const res = await fetch(`$ {API_BASE} /stats`);
@@ -2175,8 +2310,8 @@ function renderDrafts(drafts, total) {
container.innerHTML = header + drafts.map(draft => `
<div class= " card type-$ {draft.type} item-card " style= " opacity: 0.85; " >
<div class= " card-body " >
<div class= " d-flex justify-content-between align-items-star t" >
<div style = " flex: 1; min-width: 0; " >
<div class= " card-conten t" >
<div class = " card-main " >
<h6 class= " card-title text-truncate mb-1 " >
$ { getTypeIcon(draft.type)} $ { draft.title || ' (无标题) ' }
</h6>
@@ -2185,14 +2320,14 @@ function renderDrafts(drafts, total) {
</p>
<small class= " text-muted " >保存于: $ { formatShortDate(draft.updated_at)}</small>
</div>
<div class= " d-flex gap-1 " >
<button class= " btn btn-sm btn-outline-primary " onclick= " editDraft($ {draft.id} ) " title= " 编辑 " >
<div class= " card-actions " >
<button class= " btn btn-sm btn-outline-primary py-0 px-1 " onclick= " editDraft($ {draft.id} ) " title= " 编辑 " >
<i class= " bi bi-pencil " ></i>
</button>
<button class= " btn btn-sm btn-outline-success " onclick= " publishDraft($ {draft.id} ) " title= " 发布 " >
<button class= " btn btn-sm btn-outline-success py-0 px-1 " onclick= " publishDraft($ {draft.id} ) " title= " 发布 " >
<i class= " bi bi-send " ></i>
</button>
<button class= " btn btn-sm btn-outline-danger " onclick= " deleteDraft($ {draft.id} ) " title= " 删除 " >
<button class= " btn btn-sm btn-outline-danger py-0 px-1 " onclick= " deleteDraft($ {draft.id} ) " title= " 删除 " >
<i class= " bi bi-trash " ></i>
</button>
</div>
@@ -2613,7 +2748,19 @@ async function showDetail(id) {
}
if (item.content) {
html += `<div class= " mb-3 " ><strong>内容:</strong><br><div class= " border rounded p-3 bg-light " style= " white-space: pre-wrap; word-break: break-all; " >$ { escapeHtml(item.content)}</div></div>`;
html += `<div class= " mb-3 " >
<div class= " d-flex justify-content-between align-items-center " >
<strong>内容:</strong>
<small class= " text-muted " ><i class= " bi bi-hand-index " ></i> 可先选中部分文本,再双击在此处插入</small>
</div>
<div class= " border rounded p-3 bg-light markdown-content quick-insert-area "
data-field= " content "
data-item-id= " $ {item.id} "
ondblclick= " showQuickInsert($ {item.id} , ' content ' ) "
style= " cursor: pointer; " >
$ { renderMarkdown(item.content)}
</div>
</div>`;
}
if (item.source) {
@@ -2633,7 +2780,19 @@ async function showDetail(id) {
}
if (item.note) {
html += `<div class= " mb-3 " ><strong>详情/备注:</strong><br><div class= " border rounded p-3 bg-light " style= " white-space: pre-wrap; word-break: break-all; " >$ { escapeHtml(item.note)}</div></div>`;
html += `<div class= " mb-3 " >
<div class= " d-flex justify-content-between align-items-center " >
<strong>详情/备注:</strong>
<small class= " text-muted " ><i class= " bi bi-hand-index " ></i> 可先选中部分文本,再双击在此处插入</small>
</div>
<div class= " border rounded p-3 bg-light markdown-content quick-insert-area "
data-field= " note "
data-item-id= " $ {item.id} "
ondblclick= " showQuickInsert($ {item.id} , ' note ' ) "
style= " cursor: pointer; " >
$ { renderMarkdown(item.note)}
</div>
</div>`;
}
html += `<div class= " text-muted small " ><strong>创建时间:</strong> $ { formatDate(item.created_at)}<br><strong>更新时间:</strong> $ { formatDate(item.updated_at)}</div>`;
@@ -2964,6 +3123,55 @@ function escapeHtml(str) {
return str.replace(/&/g, ' & ' ).replace(/</g, ' < ' ).replace(/>/g, ' > ' );
}
// 简单Markdown渲染( 支持标题、粗体、斜体、代码、链接、列表)
function renderMarkdown(text) {
if (!text) return ' ' ;
// 先转义HTML
let html = escapeHtml(text);
// 代码块 ```code```
html = html.replace(/```( \\ w*) \\ n([ \\ s \\ S]*?)```/g, ' <pre class= " bg-dark text-light p-2 rounded " ><code>$2</code></pre> ' );
// 行内代码 `code`
html = html.replace(/`([^`]+)`/g, ' <code class= " bg-light px-1 rounded " >$1</code> ' );
// 标题 # ## ### ####
html = html.replace(/^#### (.+)$/gm, ' <h6 class= " mt-2 " >$1</h6> ' );
html = html.replace(/^### (.+)$/gm, ' <h5 class= " mt-2 " >$1</h5> ' );
html = html.replace(/^## (.+)$/gm, ' <h4 class= " mt-2 " >$1</h4> ' );
html = html.replace(/^# (.+)$/gm, ' <h3 class= " mt-2 " >$1</h3> ' );
// 粗体 **text**
html = html.replace(/ \ * \ *([^*]+) \ * \ */g, ' <strong>$1</strong> ' );
// 斜体 *text*
html = html.replace(/ \ *([^*]+) \ */g, ' <em>$1</em> ' );
// 链接 [text](url)
html = html.replace(/ \ [([^ \ ]]+) \ ] \ (([^)]+) \ )/g, ' <a href= " $2 " target= " _blank " >$1</a> ' );
// 无序列表 - item
html = html.replace(/^- (.+)$/gm, ' <li class= " ms-3 " >$1</li> ' );
html = html.replace(/(<li.*< \\ /li> \\ n?)+/g, ' <ul class= " mb-2 " >$&</ul> ' );
// 有序列表 1. item
html = html.replace(/^ \\ d+ \\ . (.+)$/gm, ' <li class= " ms-3 " >$1</li> ' );
// 段落分隔(两个换行)
html = html.replace(/ \\ n \\ n/g, ' </p><p class= " mb-2 " > ' );
// 单换行
html = html.replace(/ \\ n/g, ' <br> ' );
// 包装在段落中
if (!html.startsWith( ' < ' )) {
html = ' <p class= " mb-2 " > ' + html + ' </p> ' ;
}
return html;
}
// 标签管理
let allTags = [];
@@ -3334,8 +3542,8 @@ function renderTrash(items, total) {
container.innerHTML = header + items.map(item => `
<div class= " card type-$ {item.type} item-card " style= " opacity: 0.7; " >
<div class= " card-body " >
<div class= " d-flex justify-content-between align-items-star t" >
<div style = " flex: 1; min-width: 0; " >
<div class= " card-conten t" >
<div class = " card-main " >
<h6 class= " card-title text-truncate mb-1 " >
$ { getTypeIcon(item.type)} $ { item.title || truncate(item.content || item.url, 30)}
</h6>
@@ -3343,11 +3551,11 @@ function renderTrash(items, total) {
删除时间: $ { formatShortDate(item.deleted_at)}
</p>
</div>
<div class= " d-flex gap-1 " >
<button class= " btn btn-sm btn-outline-success " onclick= " restoreItem($ {item.id} ) " title= " 恢复 " >
<i class= " bi bi-arrow-counterclockwise " ></i> 恢复
<div class= " card-actions " >
<button class= " btn btn-sm btn-outline-success py-0 px-1 " onclick= " restoreItem($ {item.id} ) " title= " 恢复 " >
<i class= " bi bi-arrow-counterclockwise " ></i>
</button>
<button class= " btn btn-sm btn-outline-danger " onclick= " deletePermanently($ {item.id} ) " title= " 彻底删除 " >
<button class= " btn btn-sm btn-outline-danger py-0 px-1 " onclick= " deletePermanently($ {item.id} ) " title= " 彻底删除 " >
<i class= " bi bi-trash-fill " ></i>
</button>
</div>
@@ -3419,6 +3627,137 @@ async function emptyTrash() {
// ============ Folder 文件夹管理 ============
// ============ 快速插入功能 ============
// 保存当前详情数据,用于快速插入
let currentDetailItem = null;
// 显示快速插入模态框
function showQuickInsert(itemId, field) {
document.getElementById( ' quickInsertItemId ' ).value = itemId;
document.getElementById( ' quickInsertField ' ).value = field;
document.getElementById( ' quickInsertContent ' ).value = ' ' ;
document.getElementById( ' quickInsertPosition ' ).value = ' end ' ;
document.getElementById( ' quickInsertFormat ' ).value = ' newline ' ;
// 获取当前选中的文本
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText) {
document.getElementById( ' quickInsertSelection ' ).value = selectedText;
document.getElementById( ' quickInsertSelectionInfo ' ).style.display = ' block ' ;
document.getElementById( ' quickInsertSelectionPreview ' ).textContent = selectedText.substring(0, 50) + (selectedText.length > 50 ? ' ... ' : ' ' );
document.getElementById( ' quickInsertPosition ' ).value = ' selection_after ' ; // 默认在选中内容之后插入
} else {
document.getElementById( ' quickInsertSelection ' ).value = ' ' ;
document.getElementById( ' quickInsertSelectionInfo ' ).style.display = ' none ' ;
}
new bootstrap.Modal(document.getElementById( ' quickInsertModal ' )).show();
}
// 执行快速插入
async function executeQuickInsert() {
const itemId = document.getElementById( ' quickInsertItemId ' ).value;
const field = document.getElementById( ' quickInsertField ' ).value;
const insertContent = document.getElementById( ' quickInsertContent ' ).value.trim();
const position = document.getElementById( ' quickInsertPosition ' ).value;
const format = document.getElementById( ' quickInsertFormat ' ).value;
const selectedText = document.getElementById( ' quickInsertSelection ' ).value;
if (!insertContent) {
alert( ' 请输入要插入的内容 ' );
return;
}
// 离线检查
if (!checkOnlineBeforeAction( ' 快速插入 ' )) return;
// 获取当前数据
const res = await fetch(`$ {API_BASE} /items/$ {itemId} `);
const data = await res.json();
if (!data.success) {
alert( ' 获取数据失败 ' );
return;
}
const item = data.data;
let originalContent = item[field] || ' ' ;
// 根据格式处理插入内容
let formattedContent = insertContent;
switch (format) {
case ' newline ' :
formattedContent = ' \\ n ' + insertContent;
break;
case ' bullet ' :
formattedContent = ' \\ n- ' + insertContent;
break;
case ' heading4 ' :
formattedContent = ' \\ n#### ' + insertContent;
break;
default:
formattedContent = insertContent;
}
// 根据位置插入
let newContent;
if (position === ' start ' ) {
newContent = formattedContent + originalContent;
} else if (position === ' end ' ) {
newContent = originalContent + formattedContent;
} else if (position.startsWith( ' selection_ ' ) && selectedText) {
// 在选中内容位置插入
const selectionIndex = originalContent.indexOf(selectedText);
if (selectionIndex === -1) {
alert( ' 未在原内容中找到选中的文本, 可能是因为Markdown渲染后的文本与原文本不完全一致。 \\ n请尝试手动编辑。 ' );
return;
}
const before = originalContent.substring(0, selectionIndex);
const selected = originalContent.substring(selectionIndex, selectionIndex + selectedText.length);
const after = originalContent.substring(selectionIndex + selectedText.length);
if (position === ' selection_before ' ) {
newContent = before + formattedContent + selected + after;
} else if (position === ' selection_after ' ) {
newContent = before + selected + formattedContent + after;
} else if (position === ' selection_replace ' ) {
newContent = before + formattedContent + after;
}
} else {
// 没有选中内容但选择了选中位置选项,默认末尾
newContent = originalContent + formattedContent;
}
// 保存更新
const updateData = {} ;
updateData[field] = newContent;
const updateRes = await fetch(`$ {API_BASE} /items/$ {itemId} `, {
method: ' PUT ' ,
headers: { ' Content-Type ' : ' application/json ' },
body: JSON.stringify(updateData)
});
const updateResult = await updateRes.json();
if (updateResult.success) {
bootstrap.Modal.getInstance(document.getElementById( ' quickInsertModal ' )).hide();
// 刷新详情显示
showDetail(itemId);
// 刷新列表
refreshData();
} else {
alert( ' 保存失败: ' + updateResult.error);
}
}
async function loadFolders() {
const res = await fetch(`$ {API_BASE} /folders`);
const data = await res.json();
@@ -3462,11 +3801,21 @@ function renderFolderList(type) {
}
// 切换文件夹区域展开/折叠
// 切换文件夹区域展开/折叠,并过滤显示该类别数据
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) {