Compare commits

...

24 Commits

Author SHA1 Message Date
ffe7adcad2 feat: 编辑收藏时支持更改类型,动态显示对应字段 2026-04-13 16:45:48 +08:00
963dd1846b docs: 更新版本历史 2026-04-13 16:35:35 +08:00
ad6d369069 feat: 标签管理添加编辑功能,支持修改标签名称 2026-04-13 16:35:09 +08:00
68411813b3 docs: 更新版本历史 2026-04-13 16:27:56 +08:00
d8780b9000 fix: 标签管理添加搜索功能,实时过滤标签列表 2026-04-13 16:27:36 +08:00
196bfaf213 docs: 更新版本历史 2026-04-13 16:17:23 +08:00
2675edb695 feat: 首页添加分页功能,每页20条记录 2026-04-13 16:17:06 +08:00
1f9d0a7ea2 docs: 更新版本历史 2026-04-13 16:10:41 +08:00
4642a278a5 feat: 首页添加导出按钮,一键导出JSON格式 2026-04-13 16:10:27 +08:00
598a86b32d docs: 更新版本历史 2026-04-13 16:01:20 +08:00
ae27641b6d feat: 编辑和添加模态框改为大尺寸(modal-lg) 2026-04-13 16:01:06 +08:00
7ab6647c32 docs: 更新版本历史 2026-04-13 12:27:08 +08:00
4d51a680aa fix: 备注改名为详情,支持换行显示,扩大输入框 2026-04-13 12:21:37 +08:00
e2db77e93f docs: 更新版本历史 2026-04-13 12:14:25 +08:00
1c2c8bbebe feat: 标签管理功能和输入自动提示 2026-04-13 12:14:09 +08:00
f77fd667d9 docs: 更新版本历史 2026-04-13 12:03:35 +08:00
4c8dd35d53 feat: 卡片显示两行,第二行显示内容预览 2026-04-13 12:03:21 +08:00
a4f7606794 docs: 更新版本历史 2026-04-13 11:59:59 +08:00
f6674a2ad3 feat: 压缩首页卡片高度,更紧凑的列表布局 2026-04-13 11:59:39 +08:00
6441b3817c docs: 更新版本历史 2026-04-13 11:55:52 +08:00
807772b3e4 feat: 编辑备注改为多行输入框 2026-04-13 11:55:40 +08:00
d25435dc82 docs: 更新版本历史 2026-04-13 11:50:13 +08:00
67768feee0 feat: 网页端支持查看详情和编辑功能 2026-04-13 11:49:54 +08:00
a70666d79d feat: 添加浏览器标签图标 2026-04-13 10:34:30 +08:00
4 changed files with 648 additions and 37 deletions

View File

@@ -136,4 +136,19 @@ xian-favor/
## 版本历史
- v1.6.0 (2026-04-13): 标签管理添加编辑功能,支持修改标签名称
- v1.5.1 (2026-04-13): 标签管理添加搜索功能,实时过滤标签列表
- v1.5.0 (2026-04-13): 首页添加分页功能每页20条记录
- v1.4.0 (2026-04-13): 首页添加导出按钮一键导出所有收藏为JSON文件
- v1.3.2 (2026-04-13): 编辑和添加模态框改为大尺寸(modal-lg)
- v1.3.1 (2026-04-13): 备注改名为详情,支持换行显示,扩大输入框
- v1.3.0 (2026-04-13): 标签管理功能和输入自动提示
- 侧边栏新增"标签管理"入口
- 标签管理页面:显示所有标签、使用条目数、创建/删除标签
- 添加/编辑时标签输入支持自动提示(输入时显示匹配的已有标签)
- 点击提示标签快速添加
- v1.2.1 (2026-04-13): 卡片显示两行,第二行显示内容预览
- v1.2.0 (2026-04-13): 压缩首页卡片高度,更紧凑的列表布局
- v1.1.1 (2026-04-13): 编辑备注改为多行输入框
- v1.1.0 (2026-04-13): 网页端支持查看详情和编辑功能
- v1.0.0 (2026-04-12): 初始版本支持CLI/API/Web三种模式

View File

@@ -179,6 +179,7 @@ INDEX_TEMPLATE = '''
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xian Favor - 收藏系统</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⭐</text></svg>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>

View File

@@ -132,6 +132,23 @@ def create_tag():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/tags/<int:tag_id>', methods=['PUT'])
def update_tag(tag_id):
"""更新标签"""
data = request.get_json()
name = data.get('name', '').strip()
if not name:
return jsonify({'success': False, 'error': '标签名不能为空'}), 400
try:
if db.update_tag(tag_id, name):
return jsonify({'success': True, 'data': {'id': tag_id, 'name': name}})
return jsonify({'success': False, 'error': '标签不存在或名称已存在'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/tags/<int:tag_id>', methods=['DELETE'])
def delete_tag(tag_id):
"""删除标签"""
@@ -179,6 +196,7 @@ INDEX_TEMPLATE = '''
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xian Favor - 收藏系统</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⭐</text></svg>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
@@ -187,9 +205,14 @@ INDEX_TEMPLATE = '''
.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; }
.card { margin-bottom: 15px; transition: transform 0.2s; }
.card { margin-bottom: 8px; transition: transform 0.2s; }
.card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.card-body { padding: 10px 15px; }
.tag { margin-right: 5px; }
.item-card { font-size: 14px; }
.item-card h6 { font-size: 14px; margin-bottom: 4px; }
.item-card p { margin-bottom: 4px; }
.item-card .text-muted.small { font-size: 12px; }
.type-text { border-left: 4px solid #17a2b8; }
.type-link { border-left: 4px solid #28a745; }
.type-column { border-left: 4px solid #6f42c1; }
@@ -221,6 +244,8 @@ INDEX_TEMPLATE = '''
<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="completed"><i class="bi bi-check-circle"></i> 已完成</a>
<hr class="border-secondary">
<a href="#" onclick="showTagManager(); return false;"><i class="bi bi-tags"></i> 标签管理</a>
</nav>
</div>
@@ -238,6 +263,9 @@ INDEX_TEMPLATE = '''
<option value="todo">待办</option>
</select>
</div>
<button class="btn btn-outline-success me-2" onclick="exportData()" title="导出JSON">
<i class="bi bi-download"></i> 导出
</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
<i class="bi bi-plus-lg"></i> 添加
</button>
@@ -281,13 +309,16 @@ INDEX_TEMPLATE = '''
<!-- 列表 -->
<div id="itemList"></div>
<!-- 分页 -->
<div id="pagination" class="d-flex justify-content-center mt-3"></div>
</div>
</div>
</div>
<!-- 添加模态框 -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">添加条目</h5>
@@ -347,11 +378,13 @@ INDEX_TEMPLATE = '''
</div>
<div class="mb-3">
<label class="form-label">标签 (逗号分隔)</label>
<input type="text" id="addTags" class="form-control" placeholder="标签1, 标签2">
<input type="text" id="addTags" class="form-control" placeholder="标签1, 标签2" list="tagList">
<datalist id="tagList"></datalist>
<div id="addTagSuggestions" class="mt-1"></div>
</div>
<div class="mb-3">
<label class="form-label">备注</label>
<input type="text" id="addNote" class="form-control">
<label class="form-label">详情/备注</label>
<textarea id="addNote" class="form-control" rows="5"></textarea>
</div>
</form>
</div>
@@ -363,15 +396,160 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 详情模态框 -->
<div class="modal fade" id="detailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><span id="detailTypeIcon"></span> <span id="detailTitle"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="detailContent">
<!-- 动态填充 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary" onclick="openEditModalFromDetail()">
<i class="bi bi-pencil"></i> 编辑
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- 编辑模态框 -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑条目</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editForm">
<input type="hidden" id="editId">
<div class="mb-3">
<label class="form-label">类型</label>
<select id="editType" class="form-select">
<option value="text">📝 文本</option>
<option value="link">🔗 链接</option>
<option value="column">📰 专栏</option>
<option value="todo">✅ 待办</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">标题</label>
<input type="text" id="editTitle" class="form-control">
</div>
<div class="mb-3" id="editContentGroup">
<label class="form-label">内容</label>
<textarea id="editContent" class="form-control" rows="5"></textarea>
</div>
<div class="mb-3" id="editUrlGroup" style="display:none;">
<label class="form-label">URL</label>
<input type="url" id="editUrl" class="form-control">
</div>
<div class="mb-3" id="editSourceGroup" style="display:none;">
<label class="form-label">来源</label>
<input type="text" id="editSource" class="form-control">
</div>
<div class="mb-3" id="editTodoFields" style="display:none;">
<div class="row">
<div class="col">
<label class="form-label">状态</label>
<select id="editStatus" class="form-select">
<option value="pending">⏳ 待处理</option>
<option value="in_progress">🔄 进行中</option>
<option value="completed">✅ 已完成</option>
</select>
</div>
<div class="col">
<label class="form-label">优先级</label>
<select id="editPriority" class="form-select">
<option value="low">🟢 低</option>
<option value="medium">🟡 中</option>
<option value="high">🟠 高</option>
<option value="urgent">🔴 紧急</option>
</select>
</div>
</div>
<div class="mt-3">
<label class="form-label">截止日期</label>
<input type="date" id="editDueDate" class="form-control">
</div>
</div>
<div class="mb-3">
<label class="form-label">标签 (逗号分隔)</label>
<input type="text" id="editTags" class="form-control" placeholder="标签1, 标签2" list="tagList">
<div id="editTagSuggestions" class="mt-1"></div>
</div>
<div class="mb-3">
<label class="form-label">详情/备注</label>
<textarea id="editNote" class="form-control" rows="5"></textarea>
</div>
</form>
</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="saveEdit()">保存</button>
</div>
</div>
</div>
</div>
<!-- 标签管理模态框 -->
<div class="modal fade" id="tagManagerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-tags"></i> 标签管理</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col">
<div class="input-group">
<input type="text" id="tagSearch" class="form-control" placeholder="搜索标签...">
<button class="btn btn-outline-secondary" onclick="loadTagManagerList()"><i class="bi bi-search"></i></button>
</div>
</div>
<div class="col">
<div class="input-group">
<input type="text" id="newTagName" class="form-control" placeholder="新标签名称">
<button class="btn btn-primary" onclick="createTag()"><i class="bi bi-plus"></i> 创建</button>
</div>
</div>
</div>
<div id="tagListContainer">
<!-- 动态填充 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</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: '' };
// 初始化
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
await loadStats(); // 先加载统计,确保总数可用
loadItems();
loadStats();
loadTags();
// 标签输入自动提示
document.getElementById('addTags').addEventListener('input', showTagSuggestions);
document.getElementById('editTags').addEventListener('input', showTagSuggestionsEdit);
// 标签搜索实时过滤
document.getElementById('tagSearch')?.addEventListener('input', debounce(loadTagManagerList, 300));
// 类型切换时显示/隐藏字段
document.getElementById('addType').addEventListener('change', (e) => {
@@ -382,6 +560,11 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
});
// 编辑时类型切换
document.getElementById('editType').addEventListener('change', (e) => {
updateEditFieldsByType(e.target.value);
});
// 搜索
document.getElementById('searchInput').addEventListener('input', debounce(loadItems, 300));
@@ -412,9 +595,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
// 加载列表
async function loadItems() {
let currentPage = 1;
const pageSize = 20;
async function loadItems(page = 1) {
currentPage = page;
const keyword = document.getElementById('searchInput').value;
let url = `${API_BASE}/items?limit=100`;
let url = `${API_BASE}/items?limit=${pageSize}&offset=${(page-1)*pageSize}`;
if (currentFilter.type) url += `&type=${currentFilter.type}`;
if (currentFilter.status) url += `&status=${currentFilter.status}`;
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
@@ -424,6 +611,7 @@ async function loadItems() {
if (data.success) {
renderItems(data.data);
renderPagination(data.data.length, page);
}
}
@@ -436,38 +624,77 @@ function renderItems(items) {
}
container.innerHTML = items.map(item => `
<div class="card type-${item.type}">
<div class="card type-${item.type} item-card" style="cursor: pointer;" onclick="showDetail(${item.id})">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="card-title">
${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 50)}
<div style="flex: 1; min-width: 0;">
<h6 class="card-title text-truncate mb-1">
${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 30)}
</h6>
<p class="card-text text-muted small mb-2">
${item.url ? `<a href="${item.url}" target="_blank">${item.url}</a><br>` : ''}
${item.content ? truncate(item.content, 100) : ''}
<p class="card-text text-muted small mb-0 text-truncate" style="font-size:12px;">
${item.url ? truncate(item.url, 50) : item.content ? truncate(item.content, 50) : item.note ? truncate(item.note, 50) : ''}
${item.type === 'todo' ? `${getStatusLabelShort(item.status)} ${getPriorityLabelShort(item.priority)} ${item.due_date ? '📅' + item.due_date : ''}` : ''}
</p>
<div>
${item.tags.map(t => `<span class="badge bg-secondary tag">${t}</span>`).join('')}
${item.type === 'todo' ? `
<span class="badge status-${item.status}">${getStatusLabel(item.status)}</span>
<span class="badge priority-${item.priority}">${getPriorityLabel(item.priority)}</span>
${item.due_date ? `<span class="badge bg-light text-dark">📅 ${item.due_date}</span>` : ''}
` : ''}
</div>
</div>
<div class="btn-group btn-group-sm">
${item.type === 'todo' && item.status !== 'completed' ?
`<button class="btn btn-outline-success" onclick="completeItem(${item.id})" title="完成"><i class="bi bi-check-lg"></i></button>` : ''}
<button class="btn btn-outline-danger" onclick="deleteItem(${item.id})" title="删除"><i class="bi bi-trash"></i></button>
<div class="d-flex align-items-center gap-1 flex-wrap ms-2" 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-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>` : ''}
<button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteItem(${item.id})" title="删除"><i class="bi bi-trash" style="font-size:11px;"></i></button>
</div>
</div>
<div class="text-muted small mt-2">${formatDate(item.created_at)}</div>
</div>
</div>
`).join('');
}
// 渲染分页
function renderPagination(itemCount, page) {
const container = document.getElementById('pagination');
const total = parseInt(document.getElementById('statTotal').textContent);
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '<nav><ul class="pagination">';
// 上一页
html += `<li class="page-item ${page === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadItems(${page-1}); return false;">«</a>
</li>`;
// 页码最多显示5个
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
if (startPage > 1) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="loadItems(1); return false;">1</a></li>`;
if (startPage > 2) html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
}
for (let p = startPage; p <= endPage; p++) {
html += `<li class="page-item ${p === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadItems(${p}); return false;">${p}</a>
</li>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
html += `<li class="page-item"><a class="page-link" href="#" onclick="loadItems(${totalPages}); return false;">${totalPages}</a></li>`;
}
// 下一页
html += `<li class="page-item ${page === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadItems(${page+1}); return false;">»</a>
</li>`;
html += '</ul></nav>';
container.innerHTML = html;
}
// 加载统计
async function loadStats() {
const res = await fetch(`${API_BASE}/stats`);
@@ -480,6 +707,12 @@ async function loadStats() {
}
}
// 刷新数据(统计+列表)
async function refreshData() {
await loadStats();
loadItems(currentPage);
}
// 添加条目
async function addItem() {
const type = document.getElementById('addType').value;
@@ -505,24 +738,150 @@ async function addItem() {
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
document.getElementById('addForm').reset();
loadItems();
loadStats();
refreshData();
}
}
// 完成待办
async function completeItem(id) {
await fetch(`${API_BASE}/items/${id}/done`, { method: 'POST' });
loadItems();
loadStats();
refreshData();
}
// 删除条目
async function deleteItem(id) {
if (!confirm('确认删除?')) return;
await fetch(`${API_BASE}/items/${id}`, { method: 'DELETE' });
loadItems();
loadStats();
refreshData();
}
// 当前查看的条目ID
let currentDetailId = null;
// 显示详情
async function showDetail(id) {
currentDetailId = id;
const res = await fetch(`${API_BASE}/items/${id}`);
const data = await res.json();
if (!data.success) return;
const item = data.data;
document.getElementById('detailTypeIcon').textContent = getTypeIcon(item.type);
document.getElementById('detailTitle').textContent = item.title || '(无标题)';
let html = `<div class="mb-3"><strong>类型:</strong> ${getTypeLabel(item.type)}</div>`;
if (item.url) {
html += `<div class="mb-3"><strong>URL:</strong> <a href="${item.url}" target="_blank">${item.url}</a></div>`;
}
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>`;
}
if (item.source) {
html += `<div class="mb-3"><strong>来源:</strong> ${escapeHtml(item.source)}</div>`;
}
if (item.type === 'todo') {
html += `<div class="mb-3"><strong>状态:</strong> ${getStatusLabel(item.status)}</div>`;
html += `<div class="mb-3"><strong>优先级:</strong> ${getPriorityLabel(item.priority)}</div>`;
if (item.due_date) {
html += `<div class="mb-3"><strong>截止日期:</strong> ${item.due_date}</div>`;
}
}
if (item.tags.length) {
html += `<div class="mb-3"><strong>标签:</strong> ${item.tags.map(t => `<span class="badge bg-secondary">${escapeHtml(t)}</span>`).join(' ')}</div>`;
}
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="text-muted small"><strong>创建时间:</strong> ${formatDate(item.created_at)}<br><strong>更新时间:</strong> ${formatDate(item.updated_at)}</div>`;
document.getElementById('detailContent').innerHTML = html;
new bootstrap.Modal(document.getElementById('detailModal')).show();
}
// 从详情页打开编辑
function openEditModalFromDetail() {
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
setTimeout(() => openEditModal(currentDetailId), 300);
}
// 打开编辑模态框
async function openEditModal(id) {
currentDetailId = id;
const res = await fetch(`${API_BASE}/items/${id}`);
const data = await res.json();
if (!data.success) return;
const item = data.data;
const type = item.type;
document.getElementById('editId').value = id;
document.getElementById('editType').value = type;
document.getElementById('editTitle').value = item.title || '';
// 根据类型显示/隐藏字段
updateEditFieldsByType(type);
document.getElementById('editContent').value = item.content || '';
document.getElementById('editUrl').value = item.url || '';
document.getElementById('editSource').value = item.source || '';
document.getElementById('editTags').value = item.tags.join(', ');
document.getElementById('editNote').value = item.note || '';
if (type === 'todo') {
document.getElementById('editStatus').value = item.status;
document.getElementById('editPriority').value = item.priority;
document.getElementById('editDueDate').value = item.due_date || '';
}
new bootstrap.Modal(document.getElementById('editModal')).show();
}
// 根据类型更新编辑表单字段显示
function updateEditFieldsByType(type) {
document.getElementById('editContentGroup').style.display = type === 'text' ? 'block' : 'none';
document.getElementById('editUrlGroup').style.display = ['link', 'column'].includes(type) ? 'block' : 'none';
document.getElementById('editSourceGroup').style.display = type === 'column' ? 'block' : 'none';
document.getElementById('editTodoFields').style.display = type === 'todo' ? 'block' : 'none';
}
// 保存编辑
async function saveEdit() {
const id = document.getElementById('editId').value;
const type = document.getElementById('editType').value; // 从下拉框获取新类型
const data = {
type: type, // 包含类型变更
title: document.getElementById('editTitle').value,
content: type === 'text' ? document.getElementById('editContent').value : null,
url: ['link', 'column'].includes(type) ? document.getElementById('editUrl').value : null,
source: type === 'column' ? document.getElementById('editSource').value : null,
status: type === 'todo' ? document.getElementById('editStatus').value : null,
priority: type === 'todo' ? document.getElementById('editPriority').value : null,
due_date: type === 'todo' ? document.getElementById('editDueDate').value : null,
note: document.getElementById('editNote').value,
tags: document.getElementById('editTags').value.split(',').map(t => t.trim()).filter(t => t)
};
const res = await fetch(`${API_BASE}/items/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
refreshData();
}
}
// 工具函数
@@ -531,13 +890,28 @@ function getTypeIcon(type) {
return icons[type] || '📄';
}
function getTypeLabel(type) {
const labels = { text: '📝 文本', link: '🔗 链接', column: '📰 专栏', todo: '✅ 待办' };
return labels[type] || type;
}
function getStatusLabel(status) {
const labels = { pending: '待处理', in_progress: '进行中', completed: '已完成' };
const labels = { pending: '待处理', in_progress: '🔄 进行中', completed: '已完成' };
return labels[status] || status;
}
function getStatusLabelShort(status) {
const labels = { pending: '', in_progress: '🔄', completed: '' };
return labels[status] || status;
}
function getPriorityLabelShort(priority) {
const labels = { low: '🟢', medium: '🟡', high: '🟠', urgent: '🔴' };
return labels[priority] || '';
}
function getPriorityLabel(priority) {
const labels = { low: '', medium: '', high: '', urgent: '紧急' };
const labels = { low: '🟢 ', medium: '🟡 ', high: '🟠 ', urgent: '🔴 紧急' };
return labels[priority] || priority;
}
@@ -549,6 +923,215 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString('zh-CN');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 标签管理
let allTags = [];
async function loadTags() {
const res = await fetch(`${API_BASE}/tags`);
const data = await res.json();
if (data.success) {
allTags = data.data.map(t => t.name);
updateTagDatalist();
}
}
function updateTagDatalist() {
const datalist = document.getElementById('tagList');
datalist.innerHTML = allTags.map(t => `<option value="${t}">`).join('');
}
function showTagSuggestions(e) {
const input = e.target.value;
const parts = input.split(',');
const current = parts[parts.length - 1].trim().toLowerCase();
const container = document.getElementById('addTagSuggestions');
if (!current) {
container.innerHTML = '';
return;
}
const suggestions = allTags.filter(t => t.toLowerCase().includes(current) && !parts.slice(0, -1).includes(t));
container.innerHTML = suggestions.slice(0, 5).map(t =>
`<span class="badge bg-light text-dark me-1" style="cursor:pointer;" onclick="addTagToInput('addTags', '${t}')">${t}</span>`
).join('');
}
function showTagSuggestionsEdit(e) {
const input = e.target.value;
const parts = input.split(',');
const current = parts[parts.length - 1].trim().toLowerCase();
const container = document.getElementById('editTagSuggestions');
if (!current) {
container.innerHTML = '';
return;
}
const suggestions = allTags.filter(t => t.toLowerCase().includes(current) && !parts.slice(0, -1).includes(t));
container.innerHTML = suggestions.slice(0, 5).map(t =>
`<span class="badge bg-light text-dark me-1" style="cursor:pointer;" onclick="addTagToInput('editTags', '${t}')">${t}</span>`
).join('');
}
function addTagToInput(inputId, tag) {
const input = document.getElementById(inputId);
const parts = input.value.split(',');
parts[parts.length - 1] = tag;
input.value = parts.join(', ') + ', ';
// 清空提示
if (inputId === 'addTags') {
document.getElementById('addTagSuggestions').innerHTML = '';
} else {
document.getElementById('editTagSuggestions').innerHTML = '';
}
}
async function showTagManager() {
await loadTagManagerList();
new bootstrap.Modal(document.getElementById('tagManagerModal')).show();
}
async function loadTagManagerList() {
const res = await fetch(`${API_BASE}/tags`);
const data = await res.json();
if (!data.success) return;
const container = document.getElementById('tagListContainer');
// 搜索过滤
const searchKeyword = document.getElementById('tagSearch').value.trim().toLowerCase();
let tags = data.data;
if (searchKeyword) {
tags = tags.filter(t => t.name.toLowerCase().includes(searchKeyword));
}
if (!tags.length) {
container.innerHTML = '<div class="text-center text-muted py-3">暂无标签</div>';
return;
}
container.innerHTML = tags.map(tag => `
<div class="d-flex justify-content-between align-items-center p-2 border-bottom" id="tag-row-${tag.id}">
<div id="tag-display-${tag.id}">
<span class="badge bg-secondary">${tag.name}</span>
<span class="text-muted small ms-2">${tag.item_count || 0} 个条目</span>
</div>
<div id="tag-edit-${tag.id}" style="display:none;">
<input type="text" class="form-control form-control-sm" id="edit-tag-name-${tag.id}" value="${tag.name}" style="width:150px;">
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" id="tag-edit-btn-${tag.id}" onclick="showEditTag(${tag.id})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-success" id="tag-save-btn-${tag.id}" style="display:none;" onclick="saveEditTag(${tag.id})" title="保存">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-outline-secondary" id="tag-cancel-btn-${tag.id}" style="display:none;" onclick="cancelEditTag(${tag.id}, '${tag.name}')" title="取消">
<i class="bi bi-x"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteTagManager(${tag.id}, '${tag.name}')" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`).join('');
}
async function createTag() {
const name = document.getElementById('newTagName').value.trim();
if (!name) return;
const res = await fetch(`${API_BASE}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (res.ok) {
document.getElementById('newTagName').value = '';
loadTagManagerList();
loadTags();
}
}
async function deleteTagManager(id, name) {
if (!confirm(`确认删除标签 "${name}"?此操作将移除所有条目中的该标签。`)) return;
await fetch(`${API_BASE}/tags/${id}`, { method: 'DELETE' });
loadTagManagerList();
loadTags();
loadItems();
}
// 编辑标签
function showEditTag(id) {
document.getElementById(`tag-display-${id}`).style.display = 'none';
document.getElementById(`tag-edit-${id}`).style.display = 'block';
document.getElementById(`tag-edit-btn-${id}`).style.display = 'none';
document.getElementById(`tag-save-btn-${id}`).style.display = 'inline-block';
document.getElementById(`tag-cancel-btn-${id}`).style.display = 'inline-block';
document.getElementById(`edit-tag-name-${id}`).focus();
}
function cancelEditTag(id, oldName) {
document.getElementById(`edit-tag-name-${id}`).value = oldName;
document.getElementById(`tag-display-${id}`).style.display = 'block';
document.getElementById(`tag-edit-${id}`).style.display = 'none';
document.getElementById(`tag-edit-btn-${id}`).style.display = 'inline-block';
document.getElementById(`tag-save-btn-${id}`).style.display = 'none';
document.getElementById(`tag-cancel-btn-${id}`).style.display = 'none';
}
async function saveEditTag(id) {
const newName = document.getElementById(`edit-tag-name-${id}`).value.trim();
if (!newName) return;
const res = await fetch(`${API_BASE}/tags/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (res.ok) {
loadTagManagerList();
loadTags();
loadItems();
} else {
const data = await res.json();
alert(data.error || '更新失败');
}
}
// 导出数据
async function exportData() {
const res = await fetch(`${API_BASE}/items?limit=1000`);
const data = await res.json();
if (!data.success) {
alert('导出失败');
return;
}
// 格式化JSON
const jsonStr = JSON.stringify(data.data, null, 2);
// 创建下载
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `xian_favor_export_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
function debounce(fn, delay) {
let timer;
return function(...args) {

View File

@@ -244,6 +244,18 @@ class Database:
""")
return [dict(row) for row in cursor.fetchall()]
def update_tag(self, tag_id: int, name: str) -> bool:
"""更新标签名称"""
with self.get_conn() as conn:
cursor = conn.cursor()
# 检查名称是否已存在(排除自己)
cursor.execute("SELECT id FROM tags WHERE name = ? AND id != ?", (name, tag_id))
if cursor.fetchone():
return False # 名称已存在
cursor.execute("UPDATE tags SET name = ? WHERE id = ?", (name, tag_id))
conn.commit()
return cursor.rowcount > 0
def delete_tag(self, tag_id: int = None, name: str = None) -> bool:
"""删除标签"""
with self.get_conn() as conn: