Skip to content

Commit 4655d4b

Browse files
committed
feat: 更新版本至 1.3.0,添加批量操作和清理空分类功能
✨ 新功能 - 更新版本号至 1.3.0,反映新功能和改进 - 添加批量操作功能,支持批量移动、编辑和删除书签及分类 - 引入清理空分类功能,自动检测并清理未使用的分类 🔧 技术改进 - 更新相关组件以支持批量操作和清理空分类 - 优化 API 逻辑,确保批量操作的高效性 - 改进用户界面,提升操作的可用性和反馈
1 parent b9ae9e6 commit 4655d4b

15 files changed

+1800
-108
lines changed

README.md

Lines changed: 7 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@
99
- 📑 **分类管理**:创建、编辑、删除书签分类
1010
- 🔖 **书签管理**:添加、编辑、删除书签,支持拖拽排序
1111
- 🔒 **私密书签**:支持设置私密书签,仅登录后可见
12-
- 🔍 **实时搜索**:按名称或URL快速搜索书签
13-
- 📥 **导入导出**:支持导出为 JSON/HTML 格式,导入浏览器书签
12+
- 🔍 **实时搜索**:按名称或URL快速搜索书签,支持防抖优化
13+
- 📥 **导入导出**:支持导出为 JSON/HTML 格式,导入浏览器书签(支持进度显示)
14+
-**批量操作**:批量移动、批量编辑属性(私密/公开)、批量删除书签和分类
15+
- 🧹 **清理空分类**:自动检测并清理空分类
16+
- 📊 **数据统计**:显示书签总数和私密书签统计
1417
- 🎨 **主题切换**:支持明暗主题切换
1518
- 🔐 **登录保护**:管理功能需要登录认证
1619
- 📱 **响应式设计**:完美适配桌面端和移动端
20+
- 🔔 **更新通知**:版本更新提示功能
21+
- ⬆️ **回到顶部**:滚动时显示回到顶部按钮
1722
-**边缘部署**:基于 Cloudflare Pages,全球加速
1823

1924
## 🛠️ 技术栈
@@ -69,58 +74,6 @@ JWT_SECRET = 至少32位的随机字符串
6974

7075
完成!访问你的 Pages URL 即可使用。
7176

72-
## 💡 使用说明
73-
74-
### 访客模式
75-
- 浏览所有公开书签
76-
- 使用搜索功能
77-
- 切换主题
78-
79-
### 登录后
80-
1. 点击"登录"输入用户名和密码
81-
2. 点击"设置"按钮可以:
82-
- 添加书签(可设置为私密)
83-
- 输入URL后点击"自动获取"可自动抓取网页标题和描述
84-
- 添加分类
85-
- 导入/导出书签
86-
- 查看统计信息
87-
- 调整界面设置(搜索栏、空分类显示)
88-
3. 点击"编辑"按钮可以:
89-
- 拖拽排序书签
90-
- 拖拽移动到其他分类
91-
- 编辑/删除书签
92-
- 编辑/删除分类
93-
94-
### 私密书签
95-
- 创建书签时勾选"私密书签"
96-
- 私密书签带有🔒锁图标标识
97-
- 仅登录后可见私密书签
98-
- 退出登录后自动隐藏
99-
100-
## 🎯 已实现功能
101-
102-
- [x] 私密书签(登录可见)
103-
- [x] 书签导入/导出(支持 JSON/HTML 格式,支持Chrome嵌套书签)
104-
- [x] 独立设置页面
105-
- [x] 拖拽排序(跨分类移动)
106-
- [x] 实时搜索(可开关,带防抖优化)
107-
- [x] 主题切换
108-
- [x] 批量导入(自动去重)
109-
- [x] Toast 通知系统
110-
- [x] 数据库索引优化
111-
- [x] 图片懒加载
112-
- [x] Token 过期自动处理
113-
- [x] 安全响应头(CSP、XSS 防护)
114-
- [x] 隐藏空分类选项
115-
- [x] 自动获取网页标题和描述
116-
117-
## 🚧 待实现功能
118-
119-
- [ ] 批量编辑
120-
- [ ] 书签标签系统
121-
- [ ] 多用户支持
122-
- [ ] 书签分享功能
123-
- [ ] 浏览器扩展
12477

12578
## 📝 许可证
12679

functions/api/batch-operations.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// POST batch operations
2+
export async function onRequestPost(context) {
3+
const { request, env } = context;
4+
5+
try {
6+
const { operation, bookmarkIds, categoryIds, data } = await request.json();
7+
8+
switch (operation) {
9+
case 'delete':
10+
// Batch delete bookmarks
11+
if (!bookmarkIds || !Array.isArray(bookmarkIds) || bookmarkIds.length === 0) {
12+
return new Response(JSON.stringify({ error: 'Invalid bookmark IDs' }), {
13+
status: 400,
14+
headers: { 'Content-Type': 'application/json' }
15+
});
16+
}
17+
const placeholders = bookmarkIds.map(() => '?').join(',');
18+
await env.DB.prepare(
19+
`DELETE FROM bookmarks WHERE id IN (${placeholders})`
20+
).bind(...bookmarkIds).run();
21+
break;
22+
23+
case 'delete-categories':
24+
// Batch delete categories (cascade delete bookmarks)
25+
if (!categoryIds || !Array.isArray(categoryIds) || categoryIds.length === 0) {
26+
return new Response(JSON.stringify({ error: 'Invalid category IDs' }), {
27+
status: 400,
28+
headers: { 'Content-Type': 'application/json' }
29+
});
30+
}
31+
const categoryPlaceholders = categoryIds.map(() => '?').join(',');
32+
await env.DB.prepare(
33+
`DELETE FROM categories WHERE id IN (${categoryPlaceholders})`
34+
).bind(...categoryIds).run();
35+
break;
36+
37+
case 'move':
38+
// Batch move to category
39+
if (!bookmarkIds || !Array.isArray(bookmarkIds) || bookmarkIds.length === 0) {
40+
return new Response(JSON.stringify({ error: 'Invalid bookmark IDs' }), {
41+
status: 400,
42+
headers: { 'Content-Type': 'application/json' }
43+
});
44+
}
45+
if (!data || !data.categoryId) {
46+
return new Response(JSON.stringify({ error: 'Category ID required' }), {
47+
status: 400,
48+
headers: { 'Content-Type': 'application/json' }
49+
});
50+
}
51+
52+
// Get the max position in the target category
53+
const maxPositionResult = await env.DB.prepare(
54+
'SELECT COALESCE(MAX(position), -1) as max_pos FROM bookmarks WHERE category_id = ?'
55+
).bind(data.categoryId).first();
56+
57+
let nextPosition = (maxPositionResult?.max_pos ?? -1) + 1;
58+
59+
// Update each bookmark
60+
for (const bookmarkId of bookmarkIds) {
61+
await env.DB.prepare(
62+
'UPDATE bookmarks SET category_id = ?, position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
63+
).bind(data.categoryId, nextPosition++, bookmarkId).run();
64+
}
65+
break;
66+
67+
case 'edit':
68+
// Batch edit properties (privacy)
69+
if (!bookmarkIds || !Array.isArray(bookmarkIds) || bookmarkIds.length === 0) {
70+
return new Response(JSON.stringify({ error: 'Invalid bookmark IDs' }), {
71+
status: 400,
72+
headers: { 'Content-Type': 'application/json' }
73+
});
74+
}
75+
if (data && typeof data.isPrivate !== 'undefined') {
76+
const isPrivate = data.isPrivate ? 1 : 0;
77+
const placeholders = bookmarkIds.map(() => '?').join(',');
78+
await env.DB.prepare(
79+
`UPDATE bookmarks SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`
80+
).bind(isPrivate, ...bookmarkIds).run();
81+
}
82+
break;
83+
84+
default:
85+
return new Response(JSON.stringify({ error: 'Invalid operation' }), {
86+
status: 400,
87+
headers: { 'Content-Type': 'application/json' }
88+
});
89+
}
90+
91+
return new Response(JSON.stringify({ success: true }), {
92+
status: 200,
93+
headers: { 'Content-Type': 'application/json' }
94+
});
95+
} catch (error) {
96+
console.error('Batch operation error:', error);
97+
return new Response(JSON.stringify({ error: 'Failed to perform batch operation' }), {
98+
status: 500,
99+
headers: { 'Content-Type': 'application/json' }
100+
});
101+
}
102+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// GET: 获取所有空分类列表
2+
// POST: 删除所有空分类
3+
4+
export async function onRequestGet(context) {
5+
const { env, request } = context;
6+
7+
try {
8+
// 检查是否已登录
9+
const authHeader = request.headers.get('Authorization');
10+
const isAuthenticated = authHeader && authHeader.startsWith('Bearer ');
11+
12+
if (!isAuthenticated) {
13+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
14+
status: 401,
15+
headers: { 'Content-Type': 'application/json' }
16+
});
17+
}
18+
19+
// 查找所有空分类(没有书签的分类)
20+
// 使用 LEFT JOIN 找出没有书签的分类
21+
const { results } = await env.DB.prepare(`
22+
SELECT c.id, c.name
23+
FROM categories c
24+
LEFT JOIN bookmarks b ON c.id = b.category_id
25+
WHERE b.id IS NULL
26+
ORDER BY c.name
27+
`).all();
28+
29+
return new Response(JSON.stringify({
30+
success: true,
31+
emptyCategories: results || [],
32+
count: results?.length || 0
33+
}), {
34+
status: 200,
35+
headers: { 'Content-Type': 'application/json' }
36+
});
37+
} catch (error) {
38+
console.error('Get empty categories error:', error);
39+
return new Response(JSON.stringify({ error: 'Failed to fetch empty categories' }), {
40+
status: 500,
41+
headers: { 'Content-Type': 'application/json' }
42+
});
43+
}
44+
}
45+
46+
export async function onRequestPost(context) {
47+
const { env, request } = context;
48+
49+
try {
50+
// 检查是否已登录
51+
const authHeader = request.headers.get('Authorization');
52+
const isAuthenticated = authHeader && authHeader.startsWith('Bearer ');
53+
54+
if (!isAuthenticated) {
55+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
56+
status: 401,
57+
headers: { 'Content-Type': 'application/json' }
58+
});
59+
}
60+
61+
// 查找所有空分类的 ID
62+
const { results } = await env.DB.prepare(`
63+
SELECT c.id, c.name
64+
FROM categories c
65+
LEFT JOIN bookmarks b ON c.id = b.category_id
66+
WHERE b.id IS NULL
67+
`).all();
68+
69+
if (!results || results.length === 0) {
70+
return new Response(JSON.stringify({
71+
success: true,
72+
deletedCount: 0,
73+
message: '没有空分类需要清理'
74+
}), {
75+
status: 200,
76+
headers: { 'Content-Type': 'application/json' }
77+
});
78+
}
79+
80+
// 提取分类 ID
81+
const categoryIds = results.map(cat => cat.id);
82+
83+
// 批量删除空分类(级联删除由数据库外键处理)
84+
const placeholders = categoryIds.map(() => '?').join(',');
85+
await env.DB.prepare(
86+
`DELETE FROM categories WHERE id IN (${placeholders})`
87+
).bind(...categoryIds).run();
88+
89+
return new Response(JSON.stringify({
90+
success: true,
91+
deletedCount: categoryIds.length,
92+
deletedCategories: results.map(cat => ({ id: cat.id, name: cat.name }))
93+
}), {
94+
status: 200,
95+
headers: { 'Content-Type': 'application/json' }
96+
});
97+
} catch (error) {
98+
console.error('Cleanup empty categories error:', error);
99+
return new Response(JSON.stringify({ error: 'Failed to cleanup empty categories' }), {
100+
status: 500,
101+
headers: { 'Content-Type': 'application/json' }
102+
});
103+
}
104+
}
105+

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bookmark-manager",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"type": "module",
55
"scripts": {
66
"dev": "vite",

0 commit comments

Comments
 (0)