Compare commits

..

18 Commits

Author SHA1 Message Date
94efc524c6 fix: 修复 JavaScript 语法错误(多余的闭合括号导致页面无法加载) 2026-04-19 23:15:17 +08:00
436f897711 fix: 修复编辑弹框关闭确认只提示一次的问题
- 定义命名函数作为监听器
- 每次打开编辑弹框时移除旧监听器再添加新监听器
- 确保每次关闭时都能检查是否有修改
2026-04-19 23:09:15 +08:00
9d6cea0453 feat: 编辑弹框关闭时检查是否有修改并提示确认
- 打开编辑弹框时保存原始数据状态
- 关闭时比较当前内容和原始内容
- 如果有修改,弹出确认对话框询问是否放弃修改
- 保存成功后清除原始数据标记
2026-04-19 18:49:35 +08:00
c9433a5e98 fix: 修复编辑草稿时重复创建新草稿的问题
- showAddModal 不再重置 currentDraftId
- 编辑已有草稿时会更新该草稿而不是创建新草稿
2026-04-19 18:08:00 +08:00
7d3c5c2ae1 feat: 重构草稿箱功能
- 数据库添加 drafts 表存储草稿数据
- 草稿箱独立页面,侧边栏添加入口
- 自动保存间隔可配置(2/5/10/30/60秒)
- 草稿可编辑、发布为正式条目、删除
- 编辑时自动保存到服务器数据库
2026-04-19 17:55:29 +08:00
51c76ebd24 feat: 新增草稿箱自动保存功能
- 编辑时自动保存到 localStorage(每5秒或输入后2秒)
- 打开添加弹框时检查是否有草稿并提示恢复
- 成功添加后清除草稿
- 弹框标题显示“已自动保存”指示器
2026-04-19 17:31:08 +08:00
facf39e778 fix: 修复回收站显示问题
- 修改提示文字:回收站数据可随时恢复,无30天限制
- 清空分页组件:避免显示错误的分页
2026-04-19 17:09:58 +08:00
51cecf1f4e fix: 修复回收站点击事件被侧边栏通用处理器捕获的问题
- 只为有 data-filter 属性的链接添加过滤事件处理器
- 回收站链接使用 onclick 内联事件,不会被通用处理器干扰
- 从回收站返回时正确重置 trashView 状态
2026-04-19 17:07:48 +08:00
79e4eb4de0 feat: 新增回收站功能
- 数据库添加 is_deleted 和 deleted_at 字段
- 删除数据改为移动到回收站(软删除)
- 回收站支持查看、恢复、彻底删除
- 支持一键清空回收站
- 侧边栏添加回收站入口
2026-04-19 16:57:47 +08:00
70b40cb90b feat: 新增阅读数功能
- 数据库添加 views 字段,兼容旧数据库自动添加
- API 新增 /api/items/<id>/view 接口增加阅读数
- 列表显示阅读数(👁图标)
- 详情页显示阅读数,点击详情时自动增加
2026-04-19 10:44:05 +08:00
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
0c6057de28 feat: 内容统计信息显示(有效行数/总字数) 2026-04-17 10:51:58 +08:00
6f20e5978d fix: 分页正确显示当前筛选的总数
- API返回 total 字段(筛选后的实际总数)
- 新增 count_items 函数计算筛选条件下的总数
- 分页使用API返回的total而不是全局统计
- 解决筛选后分页显示不正确的问题
2026-04-16 14:08:12 +08:00
56ff1e8163 fix: 搜索输入实时响应修复
- 改用直接 setTimeout 方式,不用 debounce 函数
- 避免函数绑定问题导致搜索不触发
2026-04-16 14:03:05 +08:00
9ec479415a docs: 更新版本历史 v2.4.0 2026-04-16 13:55:07 +08:00
3 changed files with 1138 additions and 47 deletions

View File

@@ -141,6 +141,13 @@ xian-favor/
## 版本历史
- **v2.4.0** (2026-04-16): 数据库备份机制
- 自动备份:每天 04:00 执行
- 手动备份:页面一键操作
- 备份清理规则保留30天 + 每月第一天永久保留
- 手动备份最多保留10个
- 支持恢复备份和删除备份
- 备份管理页面入口在侧边栏
- **v2.3.2** (2026-04-16): 搜索功能修复
- 修复 debounce 函数定义顺序问题
- 搜索框输入后可正常过滤列表

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,10 @@ class Database:
priority TEXT DEFAULT 'medium',
due_date TEXT,
note TEXT,
is_starred INTEGER DEFAULT 0,
views INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
deleted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
@@ -95,6 +99,26 @@ class Database:
)
""")
# 草稿表
cursor.execute("""
CREATE TABLE IF NOT EXISTS drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL DEFAULT 'text',
title TEXT,
content TEXT,
url TEXT,
source TEXT,
status TEXT DEFAULT 'pending',
priority TEXT DEFAULT 'medium',
due_date TEXT,
note TEXT,
tags TEXT,
is_starred INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
# 邮件发送记录表
cursor.execute("""
CREATE TABLE IF NOT EXISTS email_logs (
@@ -114,6 +138,31 @@ 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)")
# 检查并添加 views 字段(兼容旧数据库)
try:
cursor.execute("SELECT views FROM items LIMIT 1")
except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN views INTEGER DEFAULT 0")
# 检查并添加 is_deleted 和 deleted_at 字段(兼容旧数据库)
try:
cursor.execute("SELECT is_deleted FROM items LIMIT 1")
except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN is_deleted INTEGER DEFAULT 0")
try:
cursor.execute("SELECT deleted_at FROM items LIMIT 1")
except sqlite3.OperationalError:
cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT")
conn.commit()
# ============ Item 操作 ============
@@ -121,7 +170,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 +184,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,8 +210,13 @@ 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, 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
"""
with self.get_conn() as conn:
cursor = conn.cursor()
@@ -170,6 +224,81 @@ class Database:
params = []
conditions = []
# 只显示未删除的数据
conditions.append("i.is_deleted = 0")
# 标签过滤需要JOIN
if tag:
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
conditions.append("t.name = ?")
params.append(tag)
if type:
conditions.append("i.type = ?")
params.append(type)
if status:
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}%"
params.extend([keyword_pattern, keyword_pattern, keyword_pattern])
if conditions:
query += " WHERE " + " AND ".join(conditions)
# 排序逻辑
if sort_by == 'updated_at':
order_field = 'i.updated_at'
elif sort_by == 'created_at':
order_field = 'i.created_at'
else:
# 默认:重点关注优先 + 创建时间降序
order_field = 'i.created_at'
order_dir = 'DESC' if (sort_order == 'asc' or sort_order is None) else 'ASC'
# 这里反转逻辑:用户选择"降序"时用DESC选择"升序"时用ASC
if sort_order == 'asc':
order_dir = 'ASC'
elif sort_order == 'desc':
order_dir = 'DESC'
else:
order_dir = 'DESC' # 默认降序
# 如果有指定排序字段,按该字段排序;否则默认重点关注优先
if sort_by:
query += f" ORDER BY {order_field} {order_dir} LIMIT ? OFFSET ?"
else:
# 默认:重点关注优先,然后创建时间降序
query += f" ORDER BY i.is_starred DESC, i.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
items = []
for row in cursor.fetchall():
item = dict(row)
item['tags'] = self._get_item_tags(conn, item['id'])
items.append(item)
return items
def count_items(self, type: str = None, status: str = None, tag: str = None,
keyword: str = None, starred: bool = None) -> int:
"""计算符合条件的条目总数"""
with self.get_conn() as conn:
cursor = conn.cursor()
query = "SELECT COUNT(DISTINCT i.id) as count FROM items i"
params = []
conditions = []
# 标签过滤需要JOIN
if tag:
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
@@ -184,6 +313,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,23 +325,15 @@ class Database:
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY i.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
items = []
for row in cursor.fetchall():
item = dict(row)
item['tags'] = self._get_item_tags(conn, item['id'])
items.append(item)
return items
return cursor.fetchone()['count']
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
@@ -217,6 +342,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 = ?"
@@ -231,16 +361,212 @@ 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:
"""删除条目"""
"""删除条目(移动到回收站)"""
with self.get_conn() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute("UPDATE items SET is_deleted = 1, deleted_at = ? WHERE id = ?", (now, item_id))
conn.commit()
return cursor.rowcount > 0
def list_trash(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出回收站数据"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM items WHERE is_deleted = 1 ORDER BY deleted_at DESC LIMIT ? OFFSET ?", (limit, offset))
items = []
for row in cursor.fetchall():
item = dict(row)
item['tags'] = self._get_item_tags(conn, item['id'])
items.append(item)
return items
def count_trash(self) -> int:
"""计算回收站数据总数"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM items WHERE is_deleted = 1")
return cursor.fetchone()['count']
def restore_item(self, item_id: int) -> bool:
"""从回收站恢复数据"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE items SET is_deleted = 0, deleted_at = NULL WHERE id = ?", (item_id,))
conn.commit()
return cursor.rowcount > 0
def delete_permanently(self, item_id: int) -> bool:
"""彻底删除数据(从数据库中删除)"""
with self.get_conn() as conn:
cursor = conn.cursor()
# 删除标签关联
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
# 删除邮件发送记录
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
# 删除数据
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
conn.commit()
return cursor.rowcount > 0
def empty_trash(self) -> int:
"""清空回收站"""
with self.get_conn() as conn:
cursor = conn.cursor()
# 获取所有回收站数据ID
cursor.execute("SELECT id FROM items WHERE is_deleted = 1")
ids = [row['id'] for row in cursor.fetchall()]
# 删除所有关联数据
for item_id in ids:
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
# 删除所有回收站数据
cursor.execute("DELETE FROM items WHERE is_deleted = 1")
deleted_count = cursor.rowcount
conn.commit()
return deleted_count
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
def increment_views(self, item_id: int) -> bool:
"""增加阅读数"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE items SET views = views + 1 WHERE id = ?", (item_id,))
conn.commit()
return cursor.rowcount > 0
# ============ Draft 草稿操作 ============
def save_draft(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: str = None, is_starred: bool = False) -> int:
"""保存草稿"""
self._ensure_init()
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO drafts (type, title, content, url, source, status, priority, due_date, note, tags, is_starred, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (type, title, content, url, source, status, priority, due_date, note, tags, 1 if is_starred else 0, now, now))
draft_id = cursor.lastrowid
conn.commit()
return draft_id
def update_draft(self, draft_id: int, **kwargs) -> bool:
"""更新草稿"""
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'tags', 'is_starred']
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
if not update_fields:
return False
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id FROM drafts WHERE id = ?", (draft_id,))
if not cursor.fetchone():
return False
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
set_clause += ", updated_at = ?"
values = list(update_fields.values()) + [now, draft_id]
cursor.execute(f"UPDATE drafts SET {set_clause} WHERE id = ?", values)
conn.commit()
return True
def list_drafts(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""列出草稿"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM drafts ORDER BY updated_at DESC LIMIT ? OFFSET ?", (limit, offset))
return [dict(row) for row in cursor.fetchall()]
def count_drafts(self) -> int:
"""计算草稿总数"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM drafts")
return cursor.fetchone()['count']
def get_draft(self, draft_id: int) -> Optional[Dict[str, Any]]:
"""获取单个草稿"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM drafts WHERE id = ?", (draft_id,))
row = cursor.fetchone()
return dict(row) if row else None
def delete_draft(self, draft_id: int) -> bool:
"""删除草稿"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM drafts WHERE id = ?", (draft_id,))
conn.commit()
return cursor.rowcount > 0
def draft_to_item(self, draft_id: int) -> Optional[int]:
"""将草稿转为正式条目"""
draft = self.get_draft(draft_id)
if not draft:
return None
# 创建条目
tags_list = draft['tags'].split(',') if draft['tags'] else []
tags_list = [t.strip() for t in tags_list if t.strip()]
item_id = self.create_item(
type=draft['type'],
title=draft['title'],
content=draft['content'],
url=draft['url'],
source=draft['source'],
status=draft['status'],
priority=draft['priority'],
due_date=draft['due_date'],
note=draft['note'],
tags=tags_list,
is_starred=draft['is_starred']
)
# 删除草稿
if item_id:
self.delete_draft(draft_id)
return item_id
# ============ Tag 操作 ============
def create_tag(self, name: str, color: str = "#3498db") -> int: