Compare commits

...

4 Commits

Author SHA1 Message Date
22c32a9f3d fix: 编辑保存失败时显示错误提示;修复只修改标签时返回False的问题 2026-04-19 09:25:59 +08:00
c3791ce961 fix: 重点关注图标样式优化,只有五角星变实心,按钮保持outline样式 2026-04-19 09:03:44 +08:00
27e24e2a86 fix: 修复数据库初始化索引创建顺序 2026-04-19 00:13:35 +08:00
0864c99b75 feat: 新增重点关注功能
- 数据库新增 is_starred 字段,兼容旧数据库自动添加
- 所有类别数据支持一键设置/取消重点关注
- 侧边栏新增"重点关注"过滤选项,重点关注数据优先显示
- 新增数据时可直接勾选"设为重点关注"开关
- 编辑时可切换重点关注状态
- 卡片显示重点关注标记(星标图标)和特殊样式
- API新增 /api/items/<id>/star 接口用于切换重点关注状态
- 重点关注数据按创建时间倒序排列并优先显示
2026-04-19 00:11:24 +08:00
2 changed files with 159 additions and 28 deletions

View File

@@ -17,11 +17,19 @@ CORS(app)
@app.route('/api/items', methods=['GET'])
def list_items():
"""列出条目"""
starred_param = request.args.get('starred')
starred = None
if starred_param == 'true' or starred_param == '1':
starred = True
elif starred_param == 'false' or starred_param == '0':
starred = False
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,
limit=int(request.args.get('limit', 50)),
offset=int(request.args.get('offset', 0))
)
@@ -35,7 +43,8 @@ def list_items():
type=request.args.get('type'),
status=request.args.get('status'),
tag=request.args.get('tag'),
keyword=request.args.get('keyword')
keyword=request.args.get('keyword'),
starred=starred
)
return jsonify({'success': True, 'data': items, 'total': total})
@@ -64,7 +73,8 @@ def create_item():
priority=data.get('priority', 'medium'),
due_date=data.get('due_date'),
note=data.get('note'),
tags=data.get('tags', [])
tags=data.get('tags', []),
is_starred=data.get('is_starred', False)
)
item = db.get_item(item_id)
return jsonify({'success': True, 'data': item}), 201
@@ -134,6 +144,25 @@ def delete_item(item_id):
return jsonify({'success': False, 'error': '条目不存在'}), 404
@app.route('/api/items/<int:item_id>/star', methods=['POST'])
def toggle_star_item(item_id):
"""切换重点关注状态"""
if db.toggle_star(item_id):
item = db.get_item(item_id)
return jsonify({'success': True, 'data': item})
return jsonify({'success': False, 'error': '条目不存在'}), 404
@app.route('/api/items/<int:item_id>/star/<int:status>', methods=['POST'])
def set_star_item(item_id, status):
"""设置重点关注状态 (status: 1=重点关注, 0=取消重点关注)"""
starred = status == 1
if db.set_star(item_id, starred):
item = db.get_item(item_id)
return jsonify({'success': True, 'data': item})
return jsonify({'success': False, 'error': '条目不存在'}), 404
@app.route('/api/items/<int:item_id>/done', methods=['POST'])
def complete_item(item_id):
"""完成待办"""
@@ -624,6 +653,9 @@ INDEX_TEMPLATE = '''
.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; }
.star-btn { font-size: 11px; }
.status-pending { color: #ffc107; }
.status-in_progress { color: #17a2b8; }
.status-completed { color: #28a745; text-decoration: line-through; }
@@ -643,6 +675,7 @@ INDEX_TEMPLATE = '''
</div>
<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>
@@ -812,6 +845,14 @@ INDEX_TEMPLATE = '''
<label class="form-label">详情/备注</label>
<textarea id="addNote" class="form-control" rows="5"></textarea>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="addStarred">
<label class="form-check-label" for="addStarred">
<i class="bi bi-star-fill" style="color:#ffc107;"></i> 设为重点关注
</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
@@ -918,6 +959,14 @@ INDEX_TEMPLATE = '''
<label class="form-label">详情/备注</label>
<textarea id="editNote" class="form-control" rows="5"></textarea>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editStarred">
<label class="form-check-label" for="editStarred">
<i class="bi bi-star-fill" style="color:#ffc107;"></i> 重点关注
</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
@@ -1200,7 +1249,7 @@ INDEX_TEMPLATE = '''
<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: '' };
let currentFilter = { type: '', status: '', starred: null };
let currentPage = 1;
const pageSize = 20;
function debounce(fn, delay) {
@@ -1216,7 +1265,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// 确保初始状态清空
document.getElementById('searchInput').value = '';
document.getElementById('typeFilter').value = '';
currentFilter = { type: '', status: '' };
currentFilter = { type: '', status: '', starred: null };
await loadStats(); // 先加载统计,确保总数可用
loadItems();
@@ -1259,12 +1308,14 @@ document.addEventListener('DOMContentLoaded', async () => {
a.classList.add('active');
const filter = a.dataset.filter;
if (['text', 'link', 'column', 'todo'].includes(filter)) {
currentFilter = { type: filter, status: '' };
if (filter === 'starred') {
currentFilter = { type: '', status: '', starred: true };
} else if (['text', 'link', 'column', 'todo'].includes(filter)) {
currentFilter = { type: filter, status: '', starred: null };
} else if (['pending', 'in_progress', 'completed'].includes(filter)) {
currentFilter = { type: 'todo', status: filter };
currentFilter = { type: 'todo', status: filter, starred: null };
} else {
currentFilter = { type: '', status: '' };
currentFilter = { type: '', status: '', starred: null };
}
loadItems();
});
@@ -1278,6 +1329,7 @@ async function loadItems(page = 1) {
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 (currentFilter.starred !== null) url += `&starred=${currentFilter.starred ? 'true' : 'false'}`;
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
const res = await fetch(url);
@@ -1298,12 +1350,12 @@ function renderItems(items) {
}
container.innerHTML = items.map(item => `
<div class="card type-${item.type} item-card" style="cursor: pointer;" onclick="showDetail(${item.id})">
<div class="card type-${item.type} item-card ${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-start">
<div style="flex: 1; min-width: 0;">
<h6 class="card-title text-truncate mb-1 ${item.type === 'todo' && item.status === 'completed' ? 'text-muted' : ''}">
${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' ? '' : ''}
<span style="font-size:10px; opacity:0.5; margin-left:8px;">
${formatShortDate(item.created_at)}${item.updated_at && item.updated_at !== item.created_at ? '' + formatShortDate(item.updated_at) : ''}
@@ -1318,6 +1370,9 @@ function renderItems(items) {
</div>
<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-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>
</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-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>
@@ -1439,7 +1494,8 @@ async function addItem() {
priority: type === 'todo' ? document.getElementById('addPriority').value : null,
due_date: type === 'todo' ? document.getElementById('addDueDate').value : null,
note: document.getElementById('addNote').value,
tags: document.getElementById('addTags').value.split(',').map(t => t.trim()).filter(t => t)
tags: document.getElementById('addTags').value.split(',').map(t => t.trim()).filter(t => t),
is_starred: document.getElementById('addStarred').checked
};
const res = await fetch(`${API_BASE}/items`, {
@@ -1577,6 +1633,11 @@ async function showDetail(id) {
let html = `<div class="mb-3"><strong>类型:</strong> ${getTypeLabel(item.type)}</div>`;
// 显示重点关注状态
if (item.is_starred) {
html += `<div class="mb-3"><strong>状态:</strong> <span class="badge bg-warning text-dark"><i class="bi bi-star-fill"></i> 重点关注</span></div>`;
}
// 显示内容统计
if (item.content_stats && (item.content_stats.lines > 0 || item.content_stats.chars > 0)) {
html += `<div class="mb-3"><strong>统计:</strong> <span class="badge bg-info">${item.content_stats.lines} 有效行 / ${item.content_stats.chars} 字</span></div>`;
@@ -1687,6 +1748,9 @@ async function openEditModal(id) {
}
}
// 设置重点关注状态
document.getElementById('editStarred').checked = item.is_starred === 1;
new bootstrap.Modal(document.getElementById('editModal')).show();
}
@@ -1713,17 +1777,34 @@ async function saveEdit() {
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)
tags: document.getElementById('editTags').value.split(',').map(t => t.trim()).filter(t => t),
is_starred: document.getElementById('editStarred').checked ? 1 : 0
};
const res = await fetch(`${API_BASE}/items/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
try {
const res = await fetch(`${API_BASE}/items/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (res.ok && result.success) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
refreshData();
} else {
alert('保存失败: ' + (result.error || '未知错误'));
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
// 切换重点关注状态
async function toggleStar(id) {
const res = await fetch(`${API_BASE}/items/${id}/star`, { method: 'POST' });
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
refreshData();
}
}

View File

@@ -59,6 +59,7 @@ class Database:
priority TEXT DEFAULT 'medium',
due_date TEXT,
note TEXT,
is_starred INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
@@ -114,6 +115,15 @@ class Database:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_tags_item ON item_tags(item_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_tags_tag ON item_tags(tag_id)")
# 检查并添加 is_starred 字段(兼容旧数据库)
try:
cursor.execute("SELECT is_starred FROM items LIMIT 1")
except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN is_starred INTEGER DEFAULT 0")
# 创建 is_starred 索引(字段添加后再创建)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_starred ON items(is_starred)")
conn.commit()
# ============ Item 操作 ============
@@ -121,7 +131,7 @@ 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) -> int:
tags: List[str] = None, is_starred: bool = False) -> int:
"""创建新条目"""
self._ensure_init()
now = datetime.now().isoformat()
@@ -135,9 +145,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, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, now, now))
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))
item_id = cursor.lastrowid
# 添加标签
@@ -161,7 +171,7 @@ class Database:
return item
def list_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
keyword: str = None, starred: bool = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出条目"""
with self.get_conn() as conn:
cursor = conn.cursor()
@@ -184,6 +194,10 @@ class Database:
conditions.append("i.status = ?")
params.append(status)
if starred is not None:
conditions.append("i.is_starred = ?")
params.append(1 if starred else 0)
if keyword:
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
keyword_pattern = f"%{keyword}%"
@@ -192,7 +206,8 @@ class Database:
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY i.created_at DESC LIMIT ? OFFSET ?"
# 重点关注优先显示
query += " ORDER BY i.is_starred DESC, i.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
@@ -205,7 +220,7 @@ class Database:
return items
def count_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None) -> int:
keyword: str = None, starred: bool = None) -> int:
"""计算符合条件的条目总数"""
with self.get_conn() as conn:
cursor = conn.cursor()
@@ -228,6 +243,10 @@ class Database:
conditions.append("i.status = ?")
params.append(status)
if starred is not None:
conditions.append("i.is_starred = ?")
params.append(1 if starred else 0)
if keyword:
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
keyword_pattern = f"%{keyword}%"
@@ -241,9 +260,10 @@ class Database:
def update_item(self, item_id: int, **kwargs) -> bool:
"""更新条目"""
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note']
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'is_starred']
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
# 只有 tags 变化也算有效更新
if not update_fields and 'tags' not in kwargs:
return False
@@ -252,6 +272,11 @@ class Database:
with self.get_conn() as conn:
cursor = conn.cursor()
# 检查条目是否存在
cursor.execute("SELECT id FROM items WHERE id = ?", (item_id,))
if not cursor.fetchone():
return False
if update_fields:
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
set_clause += ", updated_at = ?"
@@ -266,7 +291,7 @@ class Database:
self._add_tags_to_item(conn, item_id, kwargs['tags'])
conn.commit()
return cursor.rowcount > 0
return True
def delete_item(self, item_id: int) -> bool:
"""删除条目"""
@@ -276,6 +301,31 @@ class Database:
conn.commit()
return cursor.rowcount > 0
def toggle_star(self, item_id: int) -> bool:
"""切换重点关注状态"""
with self.get_conn() as conn:
cursor = conn.cursor()
# 先获取当前状态
cursor.execute("SELECT is_starred FROM items WHERE id = ?", (item_id,))
row = cursor.fetchone()
if not row:
return False
new_status = 0 if row['is_starred'] else 1
now = datetime.now().isoformat()
cursor.execute("UPDATE items SET is_starred = ?, updated_at = ? WHERE id = ?", (new_status, now, item_id))
conn.commit()
return True
def set_star(self, item_id: int, starred: bool = True) -> bool:
"""设置重点关注状态"""
with self.get_conn() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute("UPDATE items SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if starred else 0, now, item_id))
conn.commit()
return cursor.rowcount > 0
# ============ Tag 操作 ============
def create_tag(self, name: str, color: str = "#3498db") -> int: