Browse Source
- 新增 `BookmarkManager` 类,处理收藏网站的增删改查逻辑 - 实现收藏列表、分类和标签的动态加载 - 添加收藏的模态框和搜索功能 - 新增 `BookmarkController`,提供收藏相关的API接口 - 创建数据库模型和迁移文件,支持收藏、分类和标签的管理 - 更新前端样式,优化用户体验feat/collect
14 changed files with 3930 additions and 1 deletions
Binary file not shown.
Binary file not shown.
@ -0,0 +1,804 @@ |
|||
// 收藏网站管理类
|
|||
class BookmarkManager { |
|||
constructor() { |
|||
this.currentView = 'grid'; |
|||
this.currentPage = 1; |
|||
this.itemsPerPage = 12; |
|||
this.currentCategory = null; |
|||
this.currentTag = null; |
|||
this.searchQuery = ''; |
|||
this.bookmarks = []; |
|||
this.categories = []; |
|||
this.tags = []; |
|||
this.editingBookmark = null; |
|||
this.totalBookmarks = 0; |
|||
this.pagination = null; |
|||
|
|||
this.init(); |
|||
} |
|||
|
|||
async init() { |
|||
this.bindEvents(); |
|||
await this.loadInitialData(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
bindEvents() { |
|||
// 搜索功能
|
|||
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch()); |
|||
document.getElementById('searchInput').addEventListener('keypress', (e) => { |
|||
if (e.key === 'Enter') this.handleSearch(); |
|||
}); |
|||
|
|||
// 添加收藏
|
|||
document.getElementById('addBookmarkBtn').addEventListener('click', () => this.showAddModal()); |
|||
|
|||
// 视图切换
|
|||
document.getElementById('gridViewBtn').addEventListener('click', () => this.switchView('grid')); |
|||
document.getElementById('listViewBtn').addEventListener('click', () => this.switchView('list')); |
|||
|
|||
// 模态框
|
|||
document.getElementById('closeModal').addEventListener('click', () => this.closeModal()); |
|||
document.getElementById('bookmarkForm').addEventListener('submit', (e) => this.handleFormSubmit(e)); |
|||
document.getElementById('addLinkBtn').addEventListener('click', () => this.addExtraLink()); |
|||
|
|||
// 点击模态框外部关闭
|
|||
document.querySelector('.modal-overlay').addEventListener('click', (e) => { |
|||
if (e.target.classList.contains('modal-overlay')) { |
|||
this.closeModal(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
async loadInitialData() { |
|||
try { |
|||
// 调用后端API加载数据
|
|||
await this.loadCategories(); |
|||
await this.loadTags(); |
|||
await this.loadBookmarks(); |
|||
} catch (error) { |
|||
console.error('加载数据失败:', error); |
|||
this.showError('加载数据失败,请刷新页面重试'); |
|||
} |
|||
} |
|||
|
|||
async loadCategories() { |
|||
try { |
|||
const response = await fetch('/categories', { |
|||
method: 'GET', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('获取分类失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
this.categories = result.data; |
|||
} else { |
|||
throw new Error(result.message || '获取分类失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('加载分类失败:', error); |
|||
// 如果API调用失败,使用默认分类
|
|||
this.categories = [ |
|||
{ id: 1, name: '技术开发', color: '#3B82F6', icon: 'code' }, |
|||
{ id: 2, name: '学习资源', color: '#10B981', icon: 'graduation-cap' }, |
|||
{ id: 3, name: '设计工具', color: '#F59E0B', icon: 'palette' }, |
|||
{ id: 4, name: '效率工具', color: '#8B5CF6', icon: 'zap' }, |
|||
{ id: 5, name: '娱乐休闲', color: '#EC4899', icon: 'heart' } |
|||
]; |
|||
} |
|||
} |
|||
|
|||
async loadTags() { |
|||
try { |
|||
const response = await fetch('/tags/popular', { |
|||
method: 'GET', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('获取标签失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
this.tags = result.data; |
|||
} else { |
|||
throw new Error(result.message || '获取标签失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('加载标签失败:', error); |
|||
// 如果API调用失败,使用默认标签
|
|||
this.tags = [ |
|||
{ id: 1, name: 'JavaScript', color: '#F7DF1E' }, |
|||
{ id: 2, name: 'React', color: '#61DAFB' }, |
|||
{ id: 3, name: 'Node.js', color: '#339933' }, |
|||
{ id: 4, name: 'CSS', color: '#1572B6' }, |
|||
{ id: 5, name: '设计灵感', color: '#FF6B6B' }, |
|||
{ id: 6, name: '免费资源', color: '#4ECDC4' }, |
|||
{ id: 7, name: 'API', color: '#45B7D1' }, |
|||
{ id: 8, name: '数据库', color: '#FFA500' } |
|||
]; |
|||
} |
|||
} |
|||
|
|||
async loadBookmarks() { |
|||
try { |
|||
const params = new URLSearchParams({ |
|||
page: this.currentPage, |
|||
limit: this.itemsPerPage |
|||
}); |
|||
|
|||
if (this.currentCategory) { |
|||
params.append('category_id', this.currentCategory); |
|||
} |
|||
if (this.currentTag) { |
|||
params.append('tag_id', this.currentTag); |
|||
} |
|||
if (this.searchQuery) { |
|||
params.append('search', this.searchQuery); |
|||
} |
|||
|
|||
const response = await fetch(`/bookmarks?${params}`, { |
|||
method: 'GET', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('获取收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
this.bookmarks = result.data; |
|||
this.totalBookmarks = result.total; |
|||
this.pagination = result.pagination; |
|||
} else { |
|||
throw new Error(result.message || '获取收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('加载收藏失败:', error); |
|||
// 如果API调用失败,使用默认数据
|
|||
this.bookmarks = [ |
|||
{ |
|||
id: 1, |
|||
title: 'MDN Web Docs', |
|||
description: 'Mozilla开发者网络,提供Web技术文档和教程', |
|||
url: 'https://developer.mozilla.org/', |
|||
favicon: 'https://developer.mozilla.org/favicon-48x48.cbbd161b.png', |
|||
category_id: 1, |
|||
category_name: '技术开发', |
|||
category_color: '#3B82F6', |
|||
is_favorite: true, |
|||
click_count: 15, |
|||
tags: ['JavaScript', 'Node.js', 'CSS'], |
|||
links: [ |
|||
{ title: 'JavaScript 教程', url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript', type: 'tutorial' }, |
|||
{ title: 'CSS 参考', url: 'https://developer.mozilla.org/zh-CN/docs/Web/CSS', type: 'reference' } |
|||
], |
|||
created_at: '2024-01-15T10:30:00Z' |
|||
} |
|||
]; |
|||
} |
|||
} |
|||
|
|||
renderUI() { |
|||
this.renderCategories(); |
|||
this.renderTags(); |
|||
this.renderBookmarks(); |
|||
this.renderPagination(); |
|||
} |
|||
|
|||
renderCategories() { |
|||
const categoryList = document.getElementById('categoryList'); |
|||
const allCategories = [{ id: null, name: '全部', color: '#6B7280', icon: 'grid' }, ...this.categories]; |
|||
|
|||
categoryList.innerHTML = allCategories.map(category => ` |
|||
<div class="category-item ${category.id === this.currentCategory ? 'active' : ''}" |
|||
data-id="${category.id}" |
|||
style="border-color: ${category.color}"> |
|||
${category.name} |
|||
</div> |
|||
`).join('');
|
|||
|
|||
// 绑定分类点击事件
|
|||
categoryList.querySelectorAll('.category-item').forEach(item => { |
|||
item.addEventListener('click', () => { |
|||
const categoryId = item.dataset.id ? parseInt(item.dataset.id) : null; |
|||
this.filterByCategory(categoryId); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
renderTags() { |
|||
const tagList = document.getElementById('tagList'); |
|||
const allTags = [{ id: null, name: '全部', color: '#6B7280' }, ...this.tags]; |
|||
|
|||
tagList.innerHTML = allTags.map(tag => ` |
|||
<div class="tag-item ${tag.id === this.currentTag ? 'active' : ''}" |
|||
data-id="${tag.id}" |
|||
style="border-color: ${tag.color}"> |
|||
${tag.name} |
|||
</div> |
|||
`).join('');
|
|||
|
|||
// 绑定标签点击事件
|
|||
tagList.querySelectorAll('.tag-item').forEach(item => { |
|||
item.addEventListener('click', () => { |
|||
const tagId = item.dataset.id ? parseInt(item.dataset.id) : null; |
|||
this.filterByTag(tagId); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
renderBookmarks() { |
|||
if (this.bookmarks.length === 0) { |
|||
this.showEmptyState(); |
|||
return; |
|||
} |
|||
|
|||
if (this.currentView === 'grid') { |
|||
this.renderGridView(this.bookmarks); |
|||
} else { |
|||
this.renderListView(this.bookmarks); |
|||
} |
|||
} |
|||
|
|||
renderGridView(bookmarks) { |
|||
const gridContainer = document.getElementById('bookmarksGrid'); |
|||
const listContainer = document.getElementById('bookmarksList'); |
|||
|
|||
gridContainer.style.display = 'grid'; |
|||
listContainer.style.display = 'none'; |
|||
|
|||
gridContainer.innerHTML = bookmarks.map(bookmark => this.createBookmarkCard(bookmark)).join(''); |
|||
this.bindBookmarkEvents(); |
|||
} |
|||
|
|||
renderListView(bookmarks) { |
|||
const gridContainer = document.getElementById('bookmarksGrid'); |
|||
const listContainer = document.getElementById('bookmarksList'); |
|||
|
|||
gridContainer.style.display = 'none'; |
|||
listContainer.style.display = 'block'; |
|||
|
|||
listContainer.innerHTML = bookmarks.map(bookmark => this.createBookmarkListItem(bookmark)).join(''); |
|||
this.bindBookmarkEvents(); |
|||
} |
|||
|
|||
createBookmarkCard(bookmark) { |
|||
const tagsHtml = bookmark.tags.map(tag => |
|||
`<span class="bookmark-tag">${tag}</span>` |
|||
).join(''); |
|||
|
|||
const linksHtml = bookmark.links.length > 0 ? |
|||
`<div class="bookmark-links">
|
|||
<small>额外链接: ${bookmark.links.length}个</small> |
|||
</div>` : ''; |
|||
|
|||
return ` |
|||
<div class="bookmark-card" data-id="${bookmark.id}"> |
|||
<div class="bookmark-header"> |
|||
<img src="${bookmark.favicon}" alt="favicon" class="bookmark-favicon" |
|||
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect width=%2232%22 height=%2232%22 fill=%22%23666%22 rx=%224%22/></svg>'"> |
|||
<h3 class="bookmark-title">${bookmark.title}</h3> |
|||
<button class="bookmark-favorite ${bookmark.is_favorite ? 'active' : ''}" |
|||
data-id="${bookmark.id}" title="特别收藏"> |
|||
${bookmark.is_favorite ? '★' : '☆'} |
|||
</button> |
|||
</div> |
|||
|
|||
<p class="bookmark-description">${bookmark.description}</p> |
|||
|
|||
<div class="bookmark-meta"> |
|||
<span class="bookmark-category" style="background: ${bookmark.category_color}20; color: ${bookmark.category_color}"> |
|||
${bookmark.category_name} |
|||
</span> |
|||
<div class="bookmark-stats"> |
|||
<span>👁 ${bookmark.click_count}</span> |
|||
<span>📅 ${this.formatDate(bookmark.created_at)}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
${tagsHtml ? `<div class="bookmark-tags">${tagsHtml}</div>` : ''} |
|||
${linksHtml} |
|||
|
|||
<div class="bookmark-actions"> |
|||
<button class="bookmark-btn primary" onclick="window.open('${bookmark.url}', '_blank')"> |
|||
访问网站 |
|||
</button> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.editBookmark(${bookmark.id})"> |
|||
编辑 |
|||
</button> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.deleteBookmark(${bookmark.id})"> |
|||
删除 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
} |
|||
|
|||
createBookmarkListItem(bookmark) { |
|||
const tagsHtml = bookmark.tags.map(tag => |
|||
`<span class="bookmark-tag">${tag}</span>` |
|||
).join(''); |
|||
|
|||
return ` |
|||
<div class="bookmark-list-item" data-id="${bookmark.id}"> |
|||
<img src="${bookmark.favicon}" alt="favicon" class="bookmark-list-favicon" |
|||
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 fill=%22%23666%22 rx=%223%22/></svg>'"> |
|||
|
|||
<div class="bookmark-list-content"> |
|||
<h4 class="bookmark-list-title">${bookmark.title}</h4> |
|||
<p class="bookmark-list-description">${bookmark.description}</p> |
|||
${tagsHtml ? `<div class="bookmark-tags">${tagsHtml}</div>` : ''} |
|||
</div> |
|||
|
|||
<div class="bookmark-list-meta"> |
|||
<span class="bookmark-category" style="background: ${bookmark.category_color}20; color: ${bookmark.category_color}"> |
|||
${bookmark.category_name} |
|||
</span> |
|||
<span>👁 ${bookmark.click_count}</span> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.editBookmark(${bookmark.id})">编辑</button> |
|||
<button class="bookmark-btn" onclick="bookmarkManager.deleteBookmark(${bookmark.id})">删除</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
} |
|||
|
|||
bindBookmarkEvents() { |
|||
// 绑定收藏按钮事件
|
|||
document.querySelectorAll('.bookmark-favorite').forEach(btn => { |
|||
btn.addEventListener('click', (e) => { |
|||
e.stopPropagation(); |
|||
const bookmarkId = parseInt(btn.dataset.id); |
|||
this.toggleFavorite(bookmarkId); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
renderPagination() { |
|||
if (!this.pagination || this.pagination.totalPages <= 1) { |
|||
document.getElementById('pagination').innerHTML = ''; |
|||
return; |
|||
} |
|||
|
|||
let paginationHtml = ''; |
|||
|
|||
// 上一页
|
|||
paginationHtml += ` |
|||
<button class="pagination-btn" ${!this.pagination.hasPrev ? 'disabled' : ''} |
|||
onclick="bookmarkManager.goToPage(${this.currentPage - 1})"> |
|||
上一页 |
|||
</button> |
|||
`;
|
|||
|
|||
// 页码
|
|||
for (let i = 1; i <= this.pagination.totalPages; i++) { |
|||
if (i === 1 || i === this.pagination.totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) { |
|||
paginationHtml += ` |
|||
<button class="pagination-btn ${i === this.currentPage ? 'active' : ''}" |
|||
onclick="bookmarkManager.goToPage(${i})"> |
|||
${i} |
|||
</button> |
|||
`;
|
|||
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) { |
|||
paginationHtml += '<span class="pagination-btn">...</span>'; |
|||
} |
|||
} |
|||
|
|||
// 下一页
|
|||
paginationHtml += ` |
|||
<button class="pagination-btn" ${!this.pagination.hasNext ? 'disabled' : ''} |
|||
onclick="bookmarkManager.goToPage(${this.currentPage + 1})"> |
|||
下一页 |
|||
</button> |
|||
`;
|
|||
|
|||
document.getElementById('pagination').innerHTML = paginationHtml; |
|||
} |
|||
|
|||
getFilteredBookmarks() { |
|||
// 现在数据直接从后端获取,已经过滤过了
|
|||
return this.bookmarks; |
|||
} |
|||
|
|||
async handleSearch() { |
|||
this.searchQuery = document.getElementById('searchInput').value.trim(); |
|||
this.currentPage = 1; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
async filterByCategory(categoryId) { |
|||
this.currentCategory = categoryId; |
|||
this.currentPage = 1; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
async filterByTag(tagId) { |
|||
this.currentTag = tagId; |
|||
this.currentPage = 1; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
} |
|||
|
|||
switchView(view) { |
|||
this.currentView = view; |
|||
|
|||
// 更新按钮状态
|
|||
document.querySelectorAll('.view-btn').forEach(btn => { |
|||
btn.classList.toggle('active', btn.dataset.view === view); |
|||
}); |
|||
|
|||
this.renderBookmarks(); |
|||
} |
|||
|
|||
async goToPage(page) { |
|||
this.currentPage = page; |
|||
await this.loadBookmarks(); |
|||
this.renderUI(); |
|||
|
|||
// 滚动到顶部
|
|||
document.querySelector('.bookmarks-section').scrollIntoView({ behavior: 'smooth' }); |
|||
} |
|||
|
|||
showAddModal() { |
|||
this.editingBookmark = null; |
|||
document.getElementById('modalTitle').textContent = '添加收藏'; |
|||
document.getElementById('bookmarkForm').reset(); |
|||
this.populateCategorySelect(); |
|||
this.showModal(); |
|||
} |
|||
|
|||
editBookmark(bookmarkId) { |
|||
const bookmark = this.bookmarks.find(b => b.id === bookmarkId); |
|||
if (!bookmark) return; |
|||
|
|||
this.editingBookmark = bookmark; |
|||
document.getElementById('modalTitle').textContent = '编辑收藏'; |
|||
|
|||
// 填充表单
|
|||
document.getElementById('bookmarkTitle').value = bookmark.title; |
|||
document.getElementById('bookmarkUrl').value = bookmark.url; |
|||
document.getElementById('bookmarkDescription').value = bookmark.description; |
|||
document.getElementById('bookmarkCategory').value = bookmark.category_id; |
|||
document.getElementById('bookmarkTags').value = bookmark.tags.join(', '); |
|||
|
|||
this.populateCategorySelect(); |
|||
this.populateExtraLinks(bookmark.links); |
|||
this.showModal(); |
|||
} |
|||
|
|||
async handleFormSubmit(e) { |
|||
e.preventDefault(); |
|||
|
|||
const formData = new FormData(e.target); |
|||
const bookmarkData = { |
|||
title: formData.get('bookmarkTitle'), |
|||
url: formData.get('bookmarkUrl'), |
|||
description: formData.get('bookmarkDescription'), |
|||
category_id: formData.get('bookmarkCategory') ? parseInt(formData.get('bookmarkCategory')) : null, |
|||
tags: formData.get('bookmarkTags').split(',').map(tag => tag.trim()).filter(tag => tag), |
|||
links: this.getExtraLinksData() |
|||
}; |
|||
|
|||
try { |
|||
if (this.editingBookmark) { |
|||
await this.updateBookmark(this.editingBookmark.id, bookmarkData); |
|||
} else { |
|||
await this.createBookmark(bookmarkData); |
|||
} |
|||
|
|||
this.closeModal(); |
|||
this.renderUI(); |
|||
this.showSuccess(this.editingBookmark ? '收藏更新成功' : '收藏添加成功'); |
|||
} catch (error) { |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
async createBookmark(data) { |
|||
try { |
|||
const response = await fetch('/bookmarks', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include', |
|||
body: JSON.stringify(data) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('创建收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const newBookmark = result.data; |
|||
this.bookmarks.unshift(newBookmark); |
|||
return newBookmark; |
|||
} else { |
|||
throw new Error(result.message || '创建收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('创建收藏失败:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async updateBookmark(id, data) { |
|||
try { |
|||
const response = await fetch(`/bookmarks/${id}`, { |
|||
method: 'PUT', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include', |
|||
body: JSON.stringify(data) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('更新收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const updatedBookmark = result.data; |
|||
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id); |
|||
if (bookmarkIndex !== -1) { |
|||
this.bookmarks[bookmarkIndex] = updatedBookmark; |
|||
} |
|||
return updatedBookmark; |
|||
} else { |
|||
throw new Error(result.message || '更新收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('更新收藏失败:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async deleteBookmark(id) { |
|||
if (!confirm('确定要删除这个收藏吗?')) return; |
|||
|
|||
try { |
|||
const response = await fetch(`/bookmarks/${id}`, { |
|||
method: 'DELETE', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('删除收藏失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id); |
|||
if (bookmarkIndex !== -1) { |
|||
this.bookmarks.splice(bookmarkIndex, 1); |
|||
} |
|||
this.renderUI(); |
|||
this.showSuccess('收藏删除成功'); |
|||
} else { |
|||
throw new Error(result.message || '删除收藏失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('删除收藏失败:', error); |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
async toggleFavorite(id) { |
|||
try { |
|||
const response = await fetch(`/bookmarks/${id}/favorite`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
credentials: 'include' |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error('切换收藏状态失败'); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
if (result.success) { |
|||
const bookmark = this.bookmarks.find(b => b.id === id); |
|||
if (bookmark) { |
|||
bookmark.is_favorite = result.data.is_favorite; |
|||
} |
|||
this.renderUI(); |
|||
this.showSuccess(result.message); |
|||
} else { |
|||
throw new Error(result.message || '切换收藏状态失败'); |
|||
} |
|||
} catch (error) { |
|||
console.error('切换收藏状态失败:', error); |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
populateCategorySelect() { |
|||
const select = document.getElementById('bookmarkCategory'); |
|||
select.innerHTML = '<option value="">选择分类</option>' + |
|||
this.categories.map(category => |
|||
`<option value="${category.id}">${category.name}</option>` |
|||
).join(''); |
|||
} |
|||
|
|||
populateExtraLinks(links) { |
|||
const container = document.getElementById('extraLinks'); |
|||
container.innerHTML = ''; |
|||
|
|||
if (links && links.length > 0) { |
|||
links.forEach(link => this.addExtraLink(link.title, link.url)); |
|||
} else { |
|||
this.addExtraLink(); |
|||
} |
|||
} |
|||
|
|||
addExtraLink(title = '', url = '') { |
|||
const container = document.getElementById('extraLinks'); |
|||
const linkItem = document.createElement('div'); |
|||
linkItem.className = 'extra-link-item'; |
|||
linkItem.innerHTML = ` |
|||
<input type="text" placeholder="链接标题" class="link-title" value="${title}"> |
|||
<input type="url" placeholder="链接URL" class="link-url" value="${url}"> |
|||
<button type="button" class="remove-link">删除</button> |
|||
`;
|
|||
|
|||
linkItem.querySelector('.remove-link').addEventListener('click', () => { |
|||
if (container.children.length > 1) { |
|||
container.removeChild(linkItem); |
|||
} |
|||
}); |
|||
|
|||
container.appendChild(linkItem); |
|||
} |
|||
|
|||
getExtraLinksData() { |
|||
const links = []; |
|||
document.querySelectorAll('.extra-link-item').forEach(item => { |
|||
const title = item.querySelector('.link-title').value.trim(); |
|||
const url = item.querySelector('.link-url').value.trim(); |
|||
if (title && url) { |
|||
links.push({ title, url, type: 'link' }); |
|||
} |
|||
}); |
|||
return links; |
|||
} |
|||
|
|||
showModal() { |
|||
document.getElementById('bookmarkModal').style.display = 'block'; |
|||
document.body.style.overflow = 'hidden'; |
|||
} |
|||
|
|||
closeModal() { |
|||
document.getElementById('bookmarkModal').style.display = 'none'; |
|||
document.body.style.overflow = ''; |
|||
this.editingBookmark = null; |
|||
} |
|||
|
|||
showEmptyState() { |
|||
const gridContainer = document.getElementById('bookmarksGrid'); |
|||
const listContainer = document.getElementById('bookmarksList'); |
|||
|
|||
gridContainer.style.display = 'none'; |
|||
listContainer.style.display = 'none'; |
|||
|
|||
const emptyHtml = ` |
|||
<div class="empty-state"> |
|||
<h3>暂无收藏</h3> |
|||
<p>开始添加你的第一个收藏吧!</p> |
|||
<button class="empty-btn" onclick="bookmarkManager.showAddModal()"> |
|||
添加收藏 |
|||
</button> |
|||
</div> |
|||
`;
|
|||
|
|||
if (this.currentView === 'grid') { |
|||
gridContainer.innerHTML = emptyHtml; |
|||
gridContainer.style.display = 'block'; |
|||
} else { |
|||
listContainer.innerHTML = emptyHtml; |
|||
listContainer.style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
showSuccess(message) { |
|||
this.showToast(message, 'success'); |
|||
} |
|||
|
|||
showError(message) { |
|||
this.showToast(message, 'error'); |
|||
} |
|||
|
|||
showToast(message, type = 'info') { |
|||
// 创建toast元素
|
|||
const toast = document.createElement('div'); |
|||
toast.className = `toast toast-${type}`; |
|||
toast.textContent = message; |
|||
toast.style.cssText = ` |
|||
position: fixed; |
|||
top: 20px; |
|||
right: 20px; |
|||
background: ${type === 'success' ? '#10B981' : type === 'error' ? '#EF4444' : '#3B82F6'}; |
|||
color: white; |
|||
padding: 12px 20px; |
|||
border-radius: 8px; |
|||
z-index: 10000; |
|||
animation: slideIn 0.3s ease; |
|||
`;
|
|||
|
|||
document.body.appendChild(toast); |
|||
|
|||
// 3秒后自动移除
|
|||
setTimeout(() => { |
|||
toast.style.animation = 'slideOut 0.3s ease'; |
|||
setTimeout(() => { |
|||
if (toast.parentNode) { |
|||
toast.parentNode.removeChild(toast); |
|||
} |
|||
}, 300); |
|||
}, 3000); |
|||
} |
|||
|
|||
formatDate(dateString) { |
|||
const date = new Date(dateString); |
|||
const now = new Date(); |
|||
const diffTime = Math.abs(now - date); |
|||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); |
|||
|
|||
if (diffDays === 1) return '今天'; |
|||
if (diffDays === 2) return '昨天'; |
|||
if (diffDays <= 7) return `${diffDays - 1}天前`; |
|||
if (diffDays <= 30) return `${Math.floor(diffDays / 7)}周前`; |
|||
if (diffDays <= 365) return `${Math.floor(diffDays / 30)}个月前`; |
|||
return `${Math.floor(diffDays / 365)}年前`; |
|||
} |
|||
} |
|||
|
|||
// 添加CSS动画
|
|||
const style = document.createElement('style'); |
|||
style.textContent = ` |
|||
@keyframes slideIn { |
|||
from { transform: translateX(100%); opacity: 0; } |
|||
to { transform: translateX(0); opacity: 1; } |
|||
} |
|||
@keyframes slideOut { |
|||
from { transform: translateX(0); opacity: 1; } |
|||
to { transform: translateX(100%); opacity: 0; } |
|||
} |
|||
`;
|
|||
document.head.appendChild(style); |
|||
|
|||
// 初始化收藏管理器
|
|||
let bookmarkManager; |
|||
document.addEventListener('DOMContentLoaded', () => { |
|||
bookmarkManager = new BookmarkManager(); |
|||
}); |
|||
|
|||
// 全局函数
|
|||
function closeModal() { |
|||
if (bookmarkManager) { |
|||
bookmarkManager.closeModal(); |
|||
} |
|||
} |
@ -0,0 +1,516 @@ |
|||
import Router from "utils/router.js" |
|||
import BookmarkService from "services/BookmarkService.js" |
|||
import CommonError from "@/utils/error/CommonError" |
|||
|
|||
class BookmarkController { |
|||
|
|||
/** |
|||
* 获取收藏列表 |
|||
*/ |
|||
async getBookmarks(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { |
|||
page = 1, |
|||
limit = 12, |
|||
category_id, |
|||
tag_id, |
|||
search, |
|||
orderBy = "created_at", |
|||
orderDirection = "desc" |
|||
} = ctx.query |
|||
|
|||
const options = { |
|||
page: parseInt(page), |
|||
limit: parseInt(limit), |
|||
categoryId: category_id ? parseInt(category_id) : null, |
|||
tagId: tag_id ? parseInt(tag_id) : null, |
|||
search: search || null, |
|||
orderBy, |
|||
orderDirection |
|||
} |
|||
|
|||
const result = await BookmarkService.getBookmarks(userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: result.bookmarks, |
|||
pagination: result.pagination, |
|||
total: result.total |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取收藏列表失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取单个收藏详情 |
|||
*/ |
|||
async getBookmarkById(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
const bookmark = await BookmarkService.getBookmarkById(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: bookmark |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取收藏详情失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建新收藏 |
|||
*/ |
|||
async createBookmark(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const bookmarkData = { |
|||
...ctx.request.body, |
|||
user_id: userId |
|||
} |
|||
|
|||
const bookmark = await BookmarkService.createBookmark(bookmarkData) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "收藏创建成功", |
|||
data: bookmark |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "创建收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新收藏 |
|||
*/ |
|||
async updateBookmark(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
const updateData = ctx.request.body |
|||
|
|||
const bookmark = await BookmarkService.updateBookmark(parseInt(id), updateData, userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "收藏更新成功", |
|||
data: bookmark |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "更新收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除收藏 |
|||
*/ |
|||
async deleteBookmark(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
await BookmarkService.deleteBookmark(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "收藏删除成功" |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "删除收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 搜索收藏 |
|||
*/ |
|||
async searchBookmarks(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { |
|||
query, |
|||
limit = 50, |
|||
includeTags = false |
|||
} = ctx.query |
|||
|
|||
if (!query) { |
|||
throw new CommonError("搜索关键词不能为空") |
|||
} |
|||
|
|||
const options = { |
|||
limit: parseInt(limit), |
|||
includeTags: includeTags === 'true' |
|||
} |
|||
|
|||
const bookmarks = await BookmarkService.searchBookmarks(query, userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: bookmarks, |
|||
total: bookmarks.length |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "搜索收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按分类获取收藏 |
|||
*/ |
|||
async getBookmarksByCategory(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { categoryId } = ctx.params |
|||
const { limit = 20 } = ctx.query |
|||
|
|||
const options = { limit: parseInt(limit) } |
|||
const result = await BookmarkService.getBookmarksByCategory(parseInt(categoryId), userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: result.bookmarks, |
|||
category: result.category, |
|||
total: result.bookmarks.length |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取分类收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按标签获取收藏 |
|||
*/ |
|||
async getBookmarksByTag(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { tagId } = ctx.params |
|||
const { limit = 20 } = ctx.query |
|||
|
|||
const options = { limit: parseInt(limit) } |
|||
const result = await BookmarkService.getBookmarksByTag(parseInt(tagId), userId, options) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: result.bookmarks, |
|||
tag: result.tag, |
|||
total: result.bookmarks.length |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取标签收藏失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏统计信息 |
|||
*/ |
|||
async getBookmarkStats(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const stats = await BookmarkService.getBookmarkStats(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: stats |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取统计信息失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取快捷访问数据 |
|||
*/ |
|||
async getQuickAccess(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const quickAccess = await BookmarkService.getQuickAccess(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: quickAccess |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取快捷访问失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加点击次数 |
|||
*/ |
|||
async incrementClickCount(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
await BookmarkService.incrementClickCount(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "点击次数已更新" |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "更新点击次数失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 切换收藏状态 |
|||
*/ |
|||
async toggleFavorite(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { id } = ctx.params |
|||
const result = await BookmarkService.toggleFavorite(parseInt(id), userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: result.message, |
|||
data: { is_favorite: result.is_favorite } |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "切换收藏状态失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 批量操作 |
|||
*/ |
|||
async batchOperation(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { operation, bookmarkIds, ...data } = ctx.request.body |
|||
|
|||
if (!operation || !bookmarkIds || !Array.isArray(bookmarkIds)) { |
|||
throw new CommonError("操作类型和收藏ID列表是必填项") |
|||
} |
|||
|
|||
const results = await BookmarkService.batchOperation(operation, bookmarkIds, userId, data) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
message: "批量操作完成", |
|||
data: results |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "批量操作失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有分类 |
|||
*/ |
|||
async getCategories(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const categories = await BookmarkService.getCategories(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: categories |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取分类失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有标签 |
|||
*/ |
|||
async getTags(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const tags = await BookmarkService.getTags(userId) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: tags |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取标签失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取热门标签 |
|||
*/ |
|||
async getPopularTags(ctx) { |
|||
try { |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
|
|||
const { limit = 20 } = ctx.query |
|||
const tags = await BookmarkService.getPopularTags(userId, parseInt(limit)) |
|||
|
|||
ctx.body = { |
|||
success: true, |
|||
data: tags |
|||
} |
|||
} catch (error) { |
|||
ctx.status = 400 |
|||
ctx.body = { |
|||
success: false, |
|||
message: error.message || "获取热门标签失败" |
|||
} |
|||
} |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new BookmarkController() |
|||
const router = new Router({ auth: "try" }) |
|||
|
|||
// 收藏相关API
|
|||
router.get("/bookmarks", controller.getBookmarks.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/:id", controller.getBookmarkById.bind(controller), { auth: true }) |
|||
router.post("/bookmarks", controller.createBookmark.bind(controller), { auth: true }) |
|||
router.put("/bookmarks/:id", controller.updateBookmark.bind(controller), { auth: true }) |
|||
router.delete("/bookmarks/:id", controller.deleteBookmark.bind(controller), { auth: true }) |
|||
|
|||
// 搜索和筛选
|
|||
router.get("/bookmarks/search", controller.searchBookmarks.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/category/:categoryId", controller.getBookmarksByCategory.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/tag/:tagId", controller.getBookmarksByTag.bind(controller), { auth: true }) |
|||
|
|||
// 统计和快捷访问
|
|||
router.get("/bookmarks/stats", controller.getBookmarkStats.bind(controller), { auth: true }) |
|||
router.get("/bookmarks/quick-access", controller.getQuickAccess.bind(controller), { auth: true }) |
|||
|
|||
// 收藏操作
|
|||
router.post("/bookmarks/:id/click", controller.incrementClickCount.bind(controller), { auth: true }) |
|||
router.post("/bookmarks/:id/favorite", controller.toggleFavorite.bind(controller), { auth: true }) |
|||
|
|||
// 批量操作
|
|||
router.post("/bookmarks/batch", controller.batchOperation.bind(controller), { auth: true }) |
|||
|
|||
// 分类和标签
|
|||
router.get("/categories", controller.getCategories.bind(controller), { auth: true }) |
|||
router.get("/tags", controller.getTags.bind(controller), { auth: true }) |
|||
router.get("/tags/popular", controller.getPopularTags.bind(controller), { auth: true }) |
|||
|
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default BookmarkController |
@ -0,0 +1,258 @@ |
|||
# BookmarkController 使用说明 |
|||
|
|||
## 概述 |
|||
|
|||
`BookmarkController` 是收藏网站的后端API控制器,提供了完整的收藏管理功能,包括CRUD操作、搜索、筛选、统计等。 |
|||
|
|||
## API 接口 |
|||
|
|||
### 1. 收藏管理 |
|||
|
|||
#### 获取收藏列表 |
|||
```http |
|||
GET /api/bookmarks?page=1&limit=12&category_id=1&tag_id=2&search=关键词&orderBy=created_at&orderDirection=desc |
|||
``` |
|||
|
|||
**查询参数:** |
|||
- `page`: 页码(默认1) |
|||
- `limit`: 每页数量(默认12) |
|||
- `category_id`: 分类ID(可选) |
|||
- `tag_id`: 标签ID(可选) |
|||
- `search`: 搜索关键词(可选) |
|||
- `orderBy`: 排序字段(默认created_at) |
|||
- `orderDirection`: 排序方向(默认desc) |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"data": [...], |
|||
"pagination": { |
|||
"page": 1, |
|||
"limit": 12, |
|||
"total": 50, |
|||
"totalPages": 5, |
|||
"hasNext": true, |
|||
"hasPrev": false |
|||
}, |
|||
"total": 50 |
|||
} |
|||
``` |
|||
|
|||
#### 获取单个收藏 |
|||
```http |
|||
GET /api/bookmarks/:id |
|||
``` |
|||
|
|||
#### 创建收藏 |
|||
```http |
|||
POST /api/bookmarks |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"title": "网站标题", |
|||
"url": "https://example.com", |
|||
"description": "网站描述", |
|||
"category_id": 1, |
|||
"tags": ["JavaScript", "React"], |
|||
"links": [ |
|||
{ |
|||
"title": "链接标题", |
|||
"url": "https://example.com/link", |
|||
"type": "tutorial" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
#### 更新收藏 |
|||
```http |
|||
PUT /api/bookmarks/:id |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"title": "更新后的标题", |
|||
"description": "更新后的描述" |
|||
} |
|||
``` |
|||
|
|||
#### 删除收藏 |
|||
```http |
|||
DELETE /api/bookmarks/:id |
|||
``` |
|||
|
|||
### 2. 搜索和筛选 |
|||
|
|||
#### 搜索收藏 |
|||
```http |
|||
GET /api/bookmarks/search?query=关键词&limit=50&includeTags=true |
|||
``` |
|||
|
|||
#### 按分类获取收藏 |
|||
```http |
|||
GET /api/bookmarks/category/:categoryId?limit=20 |
|||
``` |
|||
|
|||
#### 按标签获取收藏 |
|||
```http |
|||
GET /api/bookmarks/tag/:tagId?limit=20 |
|||
``` |
|||
|
|||
### 3. 统计和快捷访问 |
|||
|
|||
#### 获取统计信息 |
|||
```http |
|||
GET /api/bookmarks/stats |
|||
``` |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"data": { |
|||
"total": 50, |
|||
"favorites": 10, |
|||
"totalClicks": 150, |
|||
"categories": [...], |
|||
"popularTags": [...] |
|||
} |
|||
} |
|||
``` |
|||
|
|||
#### 获取快捷访问数据 |
|||
```http |
|||
GET /api/bookmarks/quick-access |
|||
``` |
|||
|
|||
**响应示例:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"data": { |
|||
"favorites": [...], |
|||
"recent": [...], |
|||
"popular": [...] |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 4. 收藏操作 |
|||
|
|||
#### 增加点击次数 |
|||
```http |
|||
POST /api/bookmarks/:id/click |
|||
``` |
|||
|
|||
#### 切换收藏状态 |
|||
```http |
|||
POST /api/bookmarks/:id/favorite |
|||
``` |
|||
|
|||
### 5. 批量操作 |
|||
|
|||
#### 批量操作 |
|||
```http |
|||
POST /api/bookmarks/batch |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"operation": "delete", |
|||
"bookmarkIds": [1, 2, 3], |
|||
"data": {} |
|||
} |
|||
``` |
|||
|
|||
**支持的操作类型:** |
|||
- `delete`: 批量删除 |
|||
- `move`: 批量移动分类 |
|||
- `tag`: 批量更新标签 |
|||
|
|||
### 6. 分类和标签 |
|||
|
|||
#### 获取所有分类 |
|||
```http |
|||
GET /api/categories |
|||
``` |
|||
|
|||
#### 获取所有标签 |
|||
```http |
|||
GET /api/tags |
|||
``` |
|||
|
|||
#### 获取热门标签 |
|||
```http |
|||
GET /api/tags/popular?limit=20 |
|||
``` |
|||
|
|||
## 认证要求 |
|||
|
|||
所有API接口都需要用户登录认证。系统会自动从session中获取用户ID: |
|||
|
|||
```javascript |
|||
const userId = ctx.state.user?.id |
|||
if (!userId) { |
|||
throw new CommonError("用户未登录") |
|||
} |
|||
``` |
|||
|
|||
## 错误处理 |
|||
|
|||
所有API都使用统一的错误处理格式: |
|||
|
|||
```json |
|||
{ |
|||
"success": false, |
|||
"message": "错误描述信息" |
|||
} |
|||
``` |
|||
|
|||
**常见HTTP状态码:** |
|||
- `200`: 请求成功 |
|||
- `400`: 请求参数错误或业务逻辑错误 |
|||
- `401`: 未认证(用户未登录) |
|||
- `500`: 服务器内部错误 |
|||
|
|||
## 前端集成 |
|||
|
|||
前端JavaScript代码已经更新为使用这些API接口。主要变化: |
|||
|
|||
1. **数据加载**: 从模拟数据改为API调用 |
|||
2. **分页处理**: 使用后端返回的分页信息 |
|||
3. **错误处理**: 统一的错误处理和用户提示 |
|||
4. **认证支持**: 自动包含用户认证信息 |
|||
|
|||
## 使用示例 |
|||
|
|||
### 启动服务器 |
|||
```bash |
|||
npm start |
|||
``` |
|||
|
|||
### 测试API |
|||
```bash |
|||
# 获取收藏列表 |
|||
curl -X GET "http://localhost:3000/api/bookmarks" \ |
|||
-H "Cookie: your-session-cookie" |
|||
|
|||
# 创建收藏 |
|||
curl -X POST "http://localhost:3000/api/bookmarks" \ |
|||
-H "Content-Type: application/json" \ |
|||
-H "Cookie: your-session-cookie" \ |
|||
-d '{"title":"测试收藏","url":"https://example.com"}' |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **用户认证**: 确保用户已登录才能访问API |
|||
2. **数据验证**: 所有输入数据都会进行验证 |
|||
3. **错误处理**: 前端需要处理API调用失败的情况 |
|||
4. **分页**: 大量数据使用分页加载,避免性能问题 |
|||
5. **缓存**: 考虑对分类和标签等静态数据进行缓存 |
|||
|
|||
## 扩展建议 |
|||
|
|||
1. **API版本控制**: 添加版本号支持(如 `/api/v1/bookmarks`) |
|||
2. **速率限制**: 添加API调用频率限制 |
|||
3. **缓存策略**: 实现Redis缓存提升性能 |
|||
4. **日志记录**: 记录API调用日志用于监控 |
|||
5. **API文档**: 使用Swagger等工具生成API文档 |
@ -0,0 +1,217 @@ |
|||
# 收藏网站数据模型 |
|||
|
|||
这是一个完整的收藏网站数据模型实现,支持网站收藏、分类管理、标签系统、多链接等功能。 |
|||
|
|||
## 数据库表结构 |
|||
|
|||
### 1. categories (分类表) |
|||
- `id`: 主键 |
|||
- `name`: 分类名称 (唯一) |
|||
- `description`: 分类描述 |
|||
- `color`: 分类颜色 (十六进制) |
|||
- `icon`: 分类图标 |
|||
- `sort_order`: 排序顺序 |
|||
- `is_active`: 是否激活 |
|||
- `user_id`: 用户ID (外键) |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 2. tags (标签表) |
|||
- `id`: 主键 |
|||
- `name`: 标签名称 |
|||
- `description`: 标签描述 |
|||
- `color`: 标签颜色 |
|||
- `user_id`: 用户ID (外键) |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 3. bookmarks (收藏主表) |
|||
- `id`: 主键 |
|||
- `title`: 收藏标题 |
|||
- `description`: 收藏描述 |
|||
- `url`: 网站URL |
|||
- `favicon`: 网站图标 |
|||
- `screenshot`: 截图路径 |
|||
- `category_id`: 分类ID (外键) |
|||
- `user_id`: 用户ID (外键) |
|||
- `is_public`: 是否公开 |
|||
- `is_favorite`: 是否特别收藏 |
|||
- `click_count`: 点击次数 |
|||
- `sort_order`: 排序顺序 |
|||
- `metadata`: 额外元数据 (JSON) |
|||
- `last_visited`: 最后访问时间 |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 4. bookmark_tags (收藏标签关联表) |
|||
- `id`: 主键 |
|||
- `bookmark_id`: 收藏ID (外键) |
|||
- `tag_id`: 标签ID (外键) |
|||
- `created_at`: 创建时间 |
|||
|
|||
### 5. bookmark_links (收藏多链接表) |
|||
- `id`: 主键 |
|||
- `bookmark_id`: 收藏ID (外键) |
|||
- `title`: 链接标题 |
|||
- `url`: 链接URL |
|||
- `description`: 链接描述 |
|||
- `type`: 链接类型 (link, download, api等) |
|||
- `is_active`: 是否激活 |
|||
- `sort_order`: 排序顺序 |
|||
- `created_at`: 创建时间 |
|||
- `updated_at`: 更新时间 |
|||
|
|||
### 6. bookmark_history (收藏历史表) |
|||
- `id`: 主键 |
|||
- `bookmark_id`: 收藏ID (外键) |
|||
- `user_id`: 用户ID (外键) |
|||
- `action`: 操作类型 (visit, favorite, share等) |
|||
- `context`: 上下文信息 (JSON) |
|||
- `created_at`: 创建时间 |
|||
|
|||
## 使用方法 |
|||
|
|||
### 1. 运行数据库迁移 |
|||
|
|||
```bash |
|||
# 运行迁移 |
|||
npx knex migrate:latest |
|||
|
|||
# 回滚迁移 |
|||
npx knex migrate:rollback |
|||
``` |
|||
|
|||
### 2. 插入种子数据 |
|||
|
|||
```bash |
|||
# 插入种子数据 |
|||
npx knex seed:run |
|||
``` |
|||
|
|||
### 3. 基本操作示例 |
|||
|
|||
#### 创建收藏 |
|||
```javascript |
|||
import BookmarkService from '../services/BookmarkService.js' |
|||
|
|||
const bookmarkData = { |
|||
title: "MDN Web Docs", |
|||
description: "Mozilla开发者网络", |
|||
url: "https://developer.mozilla.org/", |
|||
category_id: 1, |
|||
user_id: 1, |
|||
tags: ["JavaScript", "Web开发"], |
|||
links: [ |
|||
{ |
|||
title: "JavaScript教程", |
|||
url: "https://developer.mozilla.org/zh-CN/docs/Web/JavaScript", |
|||
description: "JavaScript完整教程", |
|||
type: "tutorial" |
|||
} |
|||
] |
|||
} |
|||
|
|||
const bookmark = await BookmarkService.createBookmark(bookmarkData) |
|||
``` |
|||
|
|||
#### 获取收藏列表 |
|||
```javascript |
|||
// 获取所有收藏 |
|||
const bookmarks = await BookmarkService.getBookmarks(userId) |
|||
|
|||
// 按分类获取 |
|||
const categoryBookmarks = await BookmarkService.getBookmarksByCategory(categoryId, userId) |
|||
|
|||
// 按标签获取 |
|||
const tagBookmarks = await BookmarkService.getBookmarksByTag(tagId, userId) |
|||
|
|||
// 搜索收藏 |
|||
const searchResults = await BookmarkService.searchBookmarks("JavaScript", userId) |
|||
``` |
|||
|
|||
#### 更新收藏 |
|||
```javascript |
|||
const updateData = { |
|||
title: "更新后的标题", |
|||
description: "更新后的描述", |
|||
tags: ["JavaScript", "React", "Node.js"] |
|||
} |
|||
|
|||
const updatedBookmark = await BookmarkService.updateBookmark(bookmarkId, updateData, userId) |
|||
``` |
|||
|
|||
#### 删除收藏 |
|||
```javascript |
|||
await BookmarkService.deleteBookmark(bookmarkId, userId) |
|||
``` |
|||
|
|||
### 4. 高级功能 |
|||
|
|||
#### 批量操作 |
|||
```javascript |
|||
// 批量删除 |
|||
const results = await BookmarkService.batchOperation('delete', [1, 2, 3], userId) |
|||
|
|||
// 批量移动分类 |
|||
const results = await BookmarkService.batchOperation('move', [1, 2, 3], userId, { category_id: 2 }) |
|||
|
|||
// 批量更新标签 |
|||
const results = await BookmarkService.batchOperation('tag', [1, 2, 3], userId, { tags: ["新标签"] }) |
|||
``` |
|||
|
|||
#### 统计信息 |
|||
```javascript |
|||
const stats = await BookmarkService.getBookmarkStats(userId) |
|||
console.log(`总收藏数: ${stats.total}`) |
|||
console.log(`特别收藏: ${stats.favorites}`) |
|||
console.log(`总点击数: ${stats.totalClicks}`) |
|||
``` |
|||
|
|||
#### 快捷访问 |
|||
```javascript |
|||
const quickAccess = await BookmarkService.getQuickAccess(userId) |
|||
console.log('特别收藏:', quickAccess.favorites) |
|||
console.log('最近访问:', quickAccess.recent) |
|||
console.log('热门收藏:', quickAccess.popular) |
|||
``` |
|||
|
|||
## 模型特性 |
|||
|
|||
### 1. 数据完整性 |
|||
- 外键约束确保数据一致性 |
|||
- 用户权限验证防止越权访问 |
|||
- 必填字段验证 |
|||
|
|||
### 2. 性能优化 |
|||
- 合理的索引设计 |
|||
- 分页查询支持 |
|||
- 关联查询优化 |
|||
|
|||
### 3. 扩展性 |
|||
- JSON字段支持元数据存储 |
|||
- 灵活的标签系统 |
|||
- 多链接支持 |
|||
- 历史记录追踪 |
|||
|
|||
### 4. 用户体验 |
|||
- 点击统计 |
|||
- 收藏状态管理 |
|||
- 搜索功能 |
|||
- 分类和标签管理 |
|||
|
|||
## 注意事项 |
|||
|
|||
1. 确保在运行迁移前数据库连接正常 |
|||
2. 种子数据需要先有用户数据 (user_id = 1) |
|||
3. 所有操作都需要验证用户权限 |
|||
4. URL格式会自动验证 |
|||
5. 标签和分类名称在同一用户下唯一 |
|||
|
|||
## 扩展建议 |
|||
|
|||
1. 添加收藏导入/导出功能 |
|||
2. 实现收藏分享功能 |
|||
3. 添加收藏推荐算法 |
|||
4. 支持收藏文件夹功能 |
|||
5. 添加收藏同步功能 |
|||
6. 实现收藏备份和恢复 |
@ -0,0 +1,115 @@ |
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const up = async knex => { |
|||
// 创建分类表
|
|||
await knex.schema.createTable("categories", function (table) { |
|||
table.increments("id").primary() |
|||
table.string("name", 100).notNullable().unique() |
|||
table.string("description", 500) |
|||
table.string("color", 7).defaultTo("#3B82F6") // 十六进制颜色值
|
|||
table.string("icon", 50) // 图标类名或路径
|
|||
table.integer("sort_order").defaultTo(0) |
|||
table.boolean("is_active").defaultTo(true) |
|||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
}) |
|||
|
|||
// 创建标签表
|
|||
await knex.schema.createTable("tags", function (table) { |
|||
table.increments("id").primary() |
|||
table.string("name", 100).notNullable() |
|||
table.string("description", 500) |
|||
table.string("color", 7).defaultTo("#6B7280") |
|||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
|
|||
// 同一用户下标签名唯一
|
|||
table.unique(["name", "user_id"]) |
|||
}) |
|||
|
|||
// 创建收藏主表
|
|||
await knex.schema.createTable("bookmarks", function (table) { |
|||
table.increments("id").primary() |
|||
table.string("title", 200).notNullable() |
|||
table.text("description") |
|||
table.string("url", 1000).notNullable() |
|||
table.string("favicon", 500) // 网站图标
|
|||
table.string("screenshot", 500) // 截图路径
|
|||
table.integer("category_id").unsigned().references("id").inTable("categories").onDelete("SET NULL") |
|||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|||
table.boolean("is_public").defaultTo(false) // 是否公开
|
|||
table.boolean("is_favorite").defaultTo(false) // 是否特别收藏
|
|||
table.integer("click_count").defaultTo(0) // 点击次数
|
|||
table.integer("sort_order").defaultTo(0) |
|||
table.json("metadata") // 存储额外的元数据,如网站标题、描述等
|
|||
table.timestamp("last_visited").defaultTo(knex.fn.now()) // 最后访问时间
|
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
|
|||
// 索引
|
|||
table.index(["user_id", "category_id"]) |
|||
table.index(["user_id", "is_favorite"]) |
|||
table.index(["user_id", "created_at"]) |
|||
}) |
|||
|
|||
// 创建收藏与标签关联表
|
|||
await knex.schema.createTable("bookmark_tags", function (table) { |
|||
table.increments("id").primary() |
|||
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE") |
|||
table.integer("tag_id").unsigned().notNullable().references("id").inTable("tags").onDelete("CASCADE") |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
|
|||
// 唯一约束,防止重复关联
|
|||
table.unique(["bookmark_id", "tag_id"]) |
|||
table.index(["bookmark_id"]) |
|||
table.index(["tag_id"]) |
|||
}) |
|||
|
|||
// 创建收藏的多链接表
|
|||
await knex.schema.createTable("bookmark_links", function (table) { |
|||
table.increments("id").primary() |
|||
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE") |
|||
table.string("title", 200).notNullable() |
|||
table.string("url", 1000).notNullable() |
|||
table.string("description", 500) |
|||
table.string("type", 50).defaultTo("link") // link, download, api, etc.
|
|||
table.boolean("is_active").defaultTo(true) |
|||
table.integer("sort_order").defaultTo(0) |
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|||
|
|||
table.index(["bookmark_id"]) |
|||
table.index(["type"]) |
|||
}) |
|||
|
|||
// 创建收藏历史表(可选,用于统计和分析)
|
|||
await knex.schema.createTable("bookmark_history", function (table) { |
|||
table.increments("id").primary() |
|||
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE") |
|||
table.integer("user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE") |
|||
table.string("action", 50).notNullable() // visit, favorite, share, etc.
|
|||
table.json("context") // 存储上下文信息
|
|||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|||
|
|||
table.index(["bookmark_id"]) |
|||
table.index(["user_id"]) |
|||
table.index(["created_at"]) |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const down = async knex => { |
|||
await knex.schema.dropTableIfExists("bookmark_history") |
|||
await knex.schema.dropTableIfExists("bookmark_links") |
|||
await knex.schema.dropTableIfExists("bookmark_tags") |
|||
await knex.schema.dropTableIfExists("bookmarks") |
|||
await knex.schema.dropTableIfExists("tags") |
|||
await knex.schema.dropTableIfExists("categories") |
|||
} |
@ -0,0 +1,349 @@ |
|||
import db from "../index.js" |
|||
|
|||
class BookmarkModel { |
|||
static async findAll(userId = null, options = {}) { |
|||
let query = db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.select("categories.color as category_color") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
|
|||
if (userId) { |
|||
query = query.where("bookmarks.user_id", userId) |
|||
} |
|||
|
|||
// 分类过滤
|
|||
if (options.categoryId) { |
|||
query = query.where("bookmarks.category_id", options.categoryId) |
|||
} |
|||
|
|||
// 标签过滤
|
|||
if (options.tagIds && options.tagIds.length > 0) { |
|||
query = query |
|||
.join("bookmark_tags", "bookmarks.id", "bookmark_tags.bookmark_id") |
|||
.whereIn("bookmark_tags.tag_id", options.tagIds) |
|||
} |
|||
|
|||
// 搜索过滤
|
|||
if (options.search) { |
|||
const searchTerm = `%${options.search}%` |
|||
query = query.where(function() { |
|||
this.where("bookmarks.title", "like", searchTerm) |
|||
.orWhere("bookmarks.description", "like", searchTerm) |
|||
.orWhere("bookmarks.url", "like", searchTerm) |
|||
}) |
|||
} |
|||
|
|||
// 排序
|
|||
const orderBy = options.orderBy || "created_at" |
|||
const orderDirection = options.orderDirection || "desc" |
|||
query = query.orderBy(`bookmarks.${orderBy}`, orderDirection) |
|||
|
|||
// 分页
|
|||
if (options.limit) { |
|||
query = query.limit(options.limit) |
|||
} |
|||
if (options.offset) { |
|||
query = query.offset(options.offset) |
|||
} |
|||
|
|||
return query |
|||
} |
|||
|
|||
static async findById(id, userId = null) { |
|||
let query = db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.select("categories.color as category_color") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.id", id) |
|||
|
|||
if (userId) { |
|||
query = query.where("bookmarks.user_id", userId) |
|||
} |
|||
|
|||
return query.first() |
|||
} |
|||
|
|||
static async create(data) { |
|||
// 提取 tags 和 links,避免插入到 bookmarks 表
|
|||
const { tags, links, ...bookmarkData } = data |
|||
|
|||
const bookmark = await db("bookmarks").insert({ |
|||
...bookmarkData, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
last_visited: db.fn.now(), |
|||
}).returning("*") |
|||
|
|||
// 如果有标签,创建标签关联
|
|||
if (tags && tags.length > 0) { |
|||
await this.addTags(bookmark[0].id, tags, bookmarkData.user_id) |
|||
} |
|||
|
|||
// 如果有多链接,创建链接记录
|
|||
if (links && links.length > 0) { |
|||
await this.addLinks(bookmark[0].id, links) |
|||
} |
|||
|
|||
return bookmark[0] |
|||
} |
|||
|
|||
static async update(id, data, userId = null) { |
|||
// 提取 tags 和 links,避免更新到 bookmarks 表
|
|||
const { tags, links, ...bookmarkData } = data |
|||
|
|||
let query = db("bookmarks").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
|
|||
const bookmark = await query.update({ |
|||
...bookmarkData, |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
|
|||
// 更新标签
|
|||
if (tags !== undefined) { |
|||
await this.updateTags(id, tags, userId) |
|||
} |
|||
|
|||
// 更新链接
|
|||
if (links !== undefined) { |
|||
await this.updateLinks(id, links) |
|||
} |
|||
|
|||
return bookmark[0] |
|||
} |
|||
|
|||
static async delete(id, userId = null) { |
|||
let query = db("bookmarks").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.del() |
|||
} |
|||
|
|||
static async incrementClickCount(id) { |
|||
return db("bookmarks") |
|||
.where("id", id) |
|||
.increment("click_count", 1) |
|||
.update({ |
|||
last_visited: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}) |
|||
} |
|||
|
|||
static async toggleFavorite(id, userId) { |
|||
const bookmark = await this.findById(id, userId) |
|||
if (!bookmark) return null |
|||
|
|||
return db("bookmarks") |
|||
.where("id", id) |
|||
.where("user_id", userId) |
|||
.update({ |
|||
is_favorite: !bookmark.is_favorite, |
|||
updated_at: db.fn.now(), |
|||
}) |
|||
.returning("*") |
|||
} |
|||
|
|||
static async getFavorites(userId, limit = 20) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.where("bookmarks.is_favorite", true) |
|||
.orderBy("bookmarks.updated_at", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getRecentBookmarks(userId, limit = 10) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.last_visited", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getPopularBookmarks(userId, limit = 10) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.click_count", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getBookmarksByCategory(categoryId, userId, limit = 20) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.category_id", categoryId) |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.sort_order", "asc") |
|||
.orderBy("bookmarks.created_at", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
static async getBookmarksByTag(tagId, userId, limit = 20) { |
|||
return db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.join("bookmark_tags", "bookmarks.id", "bookmark_tags.bookmark_id") |
|||
.where("bookmark_tags.tag_id", tagId) |
|||
.where("bookmarks.user_id", userId) |
|||
.orderBy("bookmarks.created_at", "desc") |
|||
.limit(limit) |
|||
} |
|||
|
|||
// 标签相关方法
|
|||
static async addTags(bookmarkId, tagNames, userId) { |
|||
const tags = [] |
|||
for (const tagName of tagNames) { |
|||
// 使用 findOrCreate 自动创建不存在的标签
|
|||
const tag = await db("tags") |
|||
.where("name", tagName.trim()) |
|||
.where("user_id", userId) |
|||
.first() |
|||
|
|||
if (tag) { |
|||
tags.push(tag.id) |
|||
} else { |
|||
// 如果标签不存在,创建新标签
|
|||
const newTag = await db("tags").insert({ |
|||
name: tagName.trim(), |
|||
user_id: userId, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
tags.push(newTag[0].id) |
|||
} |
|||
} |
|||
|
|||
if (tags.length > 0) { |
|||
const bookmarkTags = tags.map(tagId => ({ |
|||
bookmark_id: bookmarkId, |
|||
tag_id: tagId, |
|||
created_at: db.fn.now(), |
|||
})) |
|||
await db("bookmark_tags").insert(bookmarkTags) |
|||
} |
|||
} |
|||
|
|||
static async updateTags(bookmarkId, tagNames, userId) { |
|||
// 删除现有标签关联
|
|||
await db("bookmark_tags").where("bookmark_id", bookmarkId).del() |
|||
|
|||
// 添加新标签关联
|
|||
if (tagNames && tagNames.length > 0) { |
|||
await this.addTags(bookmarkId, tagNames, userId) |
|||
} |
|||
} |
|||
|
|||
static async getTags(bookmarkId) { |
|||
const tags = await db("tags") |
|||
.select("tags.name") |
|||
.join("bookmark_tags", "tags.id", "bookmark_tags.tag_id") |
|||
.where("bookmark_tags.bookmark_id", bookmarkId) |
|||
.orderBy("tags.name", "asc") |
|||
|
|||
// 返回标签名称数组
|
|||
return tags.map(tag => tag.name) |
|||
} |
|||
|
|||
// 链接相关方法
|
|||
static async addLinks(bookmarkId, links) { |
|||
if (links && links.length > 0) { |
|||
const bookmarkLinks = links.map((link, index) => ({ |
|||
bookmark_id: bookmarkId, |
|||
title: link.title, |
|||
url: link.url, |
|||
description: link.description, |
|||
type: link.type || "link", |
|||
sort_order: link.sort_order || index, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
})) |
|||
await db("bookmark_links").insert(bookmarkLinks) |
|||
} |
|||
} |
|||
|
|||
static async updateLinks(bookmarkId, links) { |
|||
// 删除现有链接
|
|||
await db("bookmark_links").where("bookmark_id", bookmarkId).del() |
|||
|
|||
// 添加新链接
|
|||
if (links && links.length > 0) { |
|||
await this.addLinks(bookmarkId, links) |
|||
} |
|||
} |
|||
|
|||
static async getLinks(bookmarkId) { |
|||
const links = await db("bookmark_links") |
|||
.select("title", "url", "type", "description") |
|||
.where("bookmark_id", bookmarkId) |
|||
.where("is_active", true) |
|||
.orderBy("sort_order", "asc") |
|||
.orderBy("created_at", "asc") |
|||
|
|||
return links |
|||
} |
|||
|
|||
// 统计方法
|
|||
static async getStats(userId) { |
|||
const [totalBookmarks] = await db("bookmarks") |
|||
.where("user_id", userId) |
|||
.count("* as count") |
|||
|
|||
const [favoriteBookmarks] = await db("bookmarks") |
|||
.where("user_id", userId) |
|||
.where("is_favorite", true) |
|||
.count("* as count") |
|||
|
|||
const [totalClicks] = await db("bookmarks") |
|||
.where("user_id", userId) |
|||
.sum("click_count as total") |
|||
|
|||
return { |
|||
total: parseInt(totalBookmarks.count), |
|||
favorites: parseInt(favoriteBookmarks.count), |
|||
totalClicks: parseInt(totalClicks.total) || 0, |
|||
} |
|||
} |
|||
|
|||
static async searchBookmarks(query, userId, options = {}) { |
|||
const searchTerm = `%${query}%` |
|||
let searchQuery = db("bookmarks") |
|||
.select("bookmarks.*") |
|||
.select("categories.name as category_name") |
|||
.leftJoin("categories", "bookmarks.category_id", "categories.id") |
|||
.where("bookmarks.user_id", userId) |
|||
.where(function() { |
|||
this.where("bookmarks.title", "like", searchTerm) |
|||
.orWhere("bookmarks.description", "like", searchTerm) |
|||
.orWhere("bookmarks.url", "like", searchTerm) |
|||
}) |
|||
|
|||
// 标签搜索
|
|||
if (options.includeTags) { |
|||
searchQuery = searchQuery |
|||
.leftJoin("bookmark_tags", "bookmarks.id", "bookmark_tags.bookmark_id") |
|||
.leftJoin("tags", "bookmark_tags.tag_id", "tags.id") |
|||
.orWhere("tags.name", "like", searchTerm) |
|||
} |
|||
|
|||
return searchQuery |
|||
.orderBy("bookmarks.updated_at", "desc") |
|||
.limit(options.limit || 50) |
|||
} |
|||
} |
|||
|
|||
export default BookmarkModel |
|||
export { BookmarkModel } |
@ -0,0 +1,85 @@ |
|||
import db from "../index.js" |
|||
|
|||
class CategoryModel { |
|||
static async findAll(userId = null) { |
|||
let query = db("categories").select("*") |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.orderBy("sort_order", "asc").orderBy("name", "asc") |
|||
} |
|||
|
|||
static async findById(id, userId = null) { |
|||
let query = db("categories").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.first() |
|||
} |
|||
|
|||
static async create(data) { |
|||
return db("categories").insert({ |
|||
...data, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async update(id, data, userId = null) { |
|||
let query = db("categories").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.update({ |
|||
...data, |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async delete(id, userId = null) { |
|||
let query = db("categories").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.del() |
|||
} |
|||
|
|||
static async findByName(name, userId) { |
|||
return db("categories") |
|||
.where("name", name) |
|||
.where("user_id", userId) |
|||
.first() |
|||
} |
|||
|
|||
static async getActiveCategories(userId) { |
|||
return db("categories") |
|||
.where("user_id", userId) |
|||
.where("is_active", true) |
|||
.orderBy("sort_order", "asc") |
|||
.orderBy("name", "asc") |
|||
} |
|||
|
|||
static async updateSortOrder(id, sortOrder, userId) { |
|||
return db("categories") |
|||
.where("id", id) |
|||
.where("user_id", userId) |
|||
.update({ |
|||
sort_order: sortOrder, |
|||
updated_at: db.fn.now(), |
|||
}) |
|||
.returning("*") |
|||
} |
|||
|
|||
static async getCategoryStats(userId) { |
|||
return db("categories") |
|||
.select("categories.*") |
|||
.select(db.raw("COUNT(bookmarks.id) as bookmark_count")) |
|||
.leftJoin("bookmarks", "categories.id", "bookmarks.category_id") |
|||
.where("categories.user_id", userId) |
|||
.groupBy("categories.id") |
|||
.orderBy("categories.sort_order", "asc") |
|||
} |
|||
} |
|||
|
|||
export default CategoryModel |
|||
export { CategoryModel } |
@ -0,0 +1,99 @@ |
|||
import db from "../index.js" |
|||
|
|||
class TagModel { |
|||
static async findAll(userId = null) { |
|||
let query = db("tags").select("*") |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.orderBy("name", "asc") |
|||
} |
|||
|
|||
static async findById(id, userId = null) { |
|||
let query = db("tags").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.first() |
|||
} |
|||
|
|||
static async create(data) { |
|||
return db("tags").insert({ |
|||
...data, |
|||
created_at: db.fn.now(), |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async update(id, data, userId = null) { |
|||
let query = db("tags").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.update({ |
|||
...data, |
|||
updated_at: db.fn.now(), |
|||
}).returning("*") |
|||
} |
|||
|
|||
static async delete(id, userId = null) { |
|||
let query = db("tags").where("id", id) |
|||
if (userId) { |
|||
query = query.where("user_id", userId) |
|||
} |
|||
return query.del() |
|||
} |
|||
|
|||
static async findByName(name, userId) { |
|||
return db("tags") |
|||
.where("name", name) |
|||
.where("user_id", userId) |
|||
.first() |
|||
} |
|||
|
|||
static async findOrCreate(name, userId, description = null) { |
|||
let tag = await this.findByName(name, userId) |
|||
if (!tag) { |
|||
tag = await this.create({ |
|||
name, |
|||
description, |
|||
user_id: userId, |
|||
}) |
|||
} |
|||
return tag |
|||
} |
|||
|
|||
static async getTagsWithBookmarkCount(userId) { |
|||
return db("tags") |
|||
.select("tags.*") |
|||
.select(db.raw("COUNT(DISTINCT bookmark_tags.bookmark_id) as bookmark_count")) |
|||
.leftJoin("bookmark_tags", "tags.id", "bookmark_tags.tag_id") |
|||
.where("tags.user_id", userId) |
|||
.groupBy("tags.id") |
|||
.orderBy("bookmark_count", "desc") |
|||
.orderBy("tags.name", "asc") |
|||
} |
|||
|
|||
static async searchTags(query, userId, limit = 10) { |
|||
return db("tags") |
|||
.where("user_id", userId) |
|||
.where("name", "like", `%${query}%`) |
|||
.limit(limit) |
|||
.orderBy("name", "asc") |
|||
} |
|||
|
|||
static async getPopularTags(userId, limit = 20) { |
|||
return db("tags") |
|||
.select("tags.*") |
|||
.select(db.raw("COUNT(bookmark_tags.bookmark_id) as usage_count")) |
|||
.leftJoin("bookmark_tags", "tags.id", "bookmark_tags.tag_id") |
|||
.where("tags.user_id", userId) |
|||
.groupBy("tags.id") |
|||
.orderBy("usage_count", "desc") |
|||
.orderBy("tags.name", "asc") |
|||
.limit(limit) |
|||
} |
|||
} |
|||
|
|||
export default TagModel |
|||
export { TagModel } |
@ -0,0 +1,310 @@ |
|||
/** |
|||
* @param { import("knex").Knex } knex |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
export const seed = async knex => { |
|||
// 清空现有数据
|
|||
await knex("bookmark_history").del() |
|||
await knex("bookmark_links").del() |
|||
await knex("bookmark_tags").del() |
|||
await knex("bookmarks").del() |
|||
await knex("tags").del() |
|||
await knex("categories").del() |
|||
|
|||
// 插入分类数据
|
|||
const categories = await knex("categories").insert([ |
|||
{ |
|||
name: "技术开发", |
|||
description: "编程、开发工具、技术文档等", |
|||
color: "#3B82F6", |
|||
icon: "code", |
|||
sort_order: 1, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "学习资源", |
|||
description: "在线课程、教程、学习平台等", |
|||
color: "#10B981", |
|||
icon: "graduation-cap", |
|||
sort_order: 2, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "设计工具", |
|||
description: "UI/UX设计、图标、配色等工具", |
|||
color: "#F59E0B", |
|||
icon: "palette", |
|||
sort_order: 3, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "效率工具", |
|||
description: "生产力工具、时间管理、项目管理等", |
|||
color: "#8B5CF6", |
|||
icon: "zap", |
|||
sort_order: 4, |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "娱乐休闲", |
|||
description: "游戏、视频、音乐等娱乐内容", |
|||
color: "#EC4899", |
|||
icon: "heart", |
|||
sort_order: 5, |
|||
user_id: 1, |
|||
}, |
|||
]).returning("*") |
|||
|
|||
// 插入标签数据
|
|||
const tags = await knex("tags").insert([ |
|||
{ |
|||
name: "JavaScript", |
|||
description: "JavaScript相关资源", |
|||
color: "#F7DF1E", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "React", |
|||
description: "React框架相关", |
|||
color: "#61DAFB", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "Node.js", |
|||
description: "Node.js相关资源", |
|||
color: "#339933", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "CSS", |
|||
description: "CSS样式相关", |
|||
color: "#1572B6", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "设计灵感", |
|||
description: "设计灵感和参考", |
|||
color: "#FF6B6B", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "免费资源", |
|||
description: "免费的设计和开发资源", |
|||
color: "#4ECDC4", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "API", |
|||
description: "各种API接口", |
|||
color: "#45B7D1", |
|||
user_id: 1, |
|||
}, |
|||
{ |
|||
name: "数据库", |
|||
description: "数据库相关资源", |
|||
color: "#FFA500", |
|||
user_id: 1, |
|||
}, |
|||
]).returning("*") |
|||
|
|||
// 插入收藏数据
|
|||
const bookmarks = await knex("bookmarks").insert([ |
|||
{ |
|||
title: "MDN Web Docs", |
|||
description: "Mozilla开发者网络,提供Web技术文档和教程", |
|||
url: "https://developer.mozilla.org/", |
|||
favicon: "https://developer.mozilla.org/favicon-48x48.cbbd161b.png", |
|||
category_id: categories[0].id, // 技术开发
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 15, |
|||
sort_order: 1, |
|||
metadata: JSON.stringify({ |
|||
siteTitle: "MDN Web Docs", |
|||
siteDescription: "Learn web development", |
|||
keywords: ["web", "development", "documentation"] |
|||
}), |
|||
}, |
|||
{ |
|||
title: "GitHub", |
|||
description: "代码托管平台,全球最大的开源社区", |
|||
url: "https://github.com/", |
|||
favicon: "https://github.com/favicon.ico", |
|||
category_id: categories[0].id, // 技术开发
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 42, |
|||
sort_order: 2, |
|||
metadata: JSON.stringify({ |
|||
siteTitle: "GitHub: Let's build from here", |
|||
siteDescription: "GitHub is where over 100 million developers shape the future of software", |
|||
keywords: ["git", "code", "open source"] |
|||
}), |
|||
}, |
|||
{ |
|||
title: "Stack Overflow", |
|||
description: "程序员问答社区,解决编程问题的最佳平台", |
|||
url: "https://stackoverflow.com/", |
|||
favicon: "https://cdn.sstatic.net/Sites/stackoverflow/Img/favicon.ico", |
|||
category_id: categories[0].id, // 技术开发
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: false, |
|||
click_count: 28, |
|||
sort_order: 3, |
|||
}, |
|||
{ |
|||
title: "Udemy", |
|||
description: "在线学习平台,提供各种技能课程", |
|||
url: "https://www.udemy.com/", |
|||
favicon: "https://www.udemy.com/favicon-32x32.png", |
|||
category_id: categories[1].id, // 学习资源
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: false, |
|||
click_count: 8, |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
title: "Figma", |
|||
description: "在线设计工具,支持团队协作的UI/UX设计平台", |
|||
url: "https://www.figma.com/", |
|||
favicon: "https://www.figma.com/favicon.ico", |
|||
category_id: categories[2].id, // 设计工具
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 35, |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
title: "Notion", |
|||
description: "全能型工作台,笔记、文档、项目管理一体化", |
|||
url: "https://www.notion.so/", |
|||
favicon: "https://www.notion.so/images/favicon.ico", |
|||
category_id: categories[3].id, // 效率工具
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: true, |
|||
click_count: 22, |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
title: "YouTube", |
|||
description: "全球最大的视频分享平台", |
|||
url: "https://www.youtube.com/", |
|||
favicon: "https://www.youtube.com/s/desktop/0d2c4a3b/img/favicon.ico", |
|||
category_id: categories[4].id, // 娱乐休闲
|
|||
user_id: 1, |
|||
is_public: true, |
|||
is_favorite: false, |
|||
click_count: 67, |
|||
sort_order: 1, |
|||
}, |
|||
]).returning("*") |
|||
|
|||
// 插入标签关联
|
|||
const bookmarkTags = [] |
|||
bookmarks.forEach((bookmark, index) => { |
|||
// 为每个收藏添加一些标签
|
|||
if (index === 0) { // MDN
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[0].id }, // JavaScript
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[2].id }, // Node.js
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[3].id } // CSS
|
|||
) |
|||
} else if (index === 1) { // GitHub
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[2].id }, // Node.js
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[6].id } // API
|
|||
) |
|||
} else if (index === 2) { // Stack Overflow
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[0].id }, // JavaScript
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[2].id } // Node.js
|
|||
) |
|||
} else if (index === 4) { // Figma
|
|||
bookmarkTags.push( |
|||
{ bookmark_id: bookmark.id, tag_id: tags[4].id }, // 设计灵感
|
|||
{ bookmark_id: bookmark.id, tag_id: tags[5].id } // 免费资源
|
|||
) |
|||
} |
|||
}) |
|||
|
|||
if (bookmarkTags.length > 0) { |
|||
await knex("bookmark_tags").insert(bookmarkTags) |
|||
} |
|||
|
|||
// 插入多链接数据
|
|||
const bookmarkLinks = [] |
|||
bookmarks.forEach((bookmark, index) => { |
|||
if (index === 0) { // MDN
|
|||
bookmarkLinks.push( |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "JavaScript 教程", |
|||
url: "https://developer.mozilla.org/zh-CN/docs/Web/JavaScript", |
|||
description: "JavaScript 完整教程", |
|||
type: "tutorial", |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "CSS 参考", |
|||
url: "https://developer.mozilla.org/zh-CN/docs/Web/CSS", |
|||
description: "CSS 属性参考手册", |
|||
type: "reference", |
|||
sort_order: 2, |
|||
} |
|||
) |
|||
} else if (index === 1) { // GitHub
|
|||
bookmarkLinks.push( |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "GitHub Pages", |
|||
url: "https://pages.github.com/", |
|||
description: "免费托管静态网站", |
|||
type: "service", |
|||
sort_order: 1, |
|||
}, |
|||
{ |
|||
bookmark_id: bookmark.id, |
|||
title: "GitHub Actions", |
|||
url: "https://github.com/features/actions", |
|||
description: "自动化工作流", |
|||
type: "service", |
|||
sort_order: 2, |
|||
} |
|||
) |
|||
} |
|||
}) |
|||
|
|||
if (bookmarkLinks.length > 0) { |
|||
await knex("bookmark_links").insert(bookmarkLinks) |
|||
} |
|||
|
|||
// 插入一些访问历史
|
|||
const history = [] |
|||
bookmarks.forEach((bookmark) => { |
|||
// 为每个收藏添加一些访问记录
|
|||
for (let i = 0; i < Math.floor(Math.random() * 5) + 1; i++) { |
|||
history.push({ |
|||
bookmark_id: bookmark.id, |
|||
user_id: 1, |
|||
action: "visit", |
|||
context: JSON.stringify({ |
|||
referrer: "direct", |
|||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" |
|||
}), |
|||
}) |
|||
} |
|||
}) |
|||
|
|||
if (history.length > 0) { |
|||
await knex("bookmark_history").insert(history) |
|||
} |
|||
|
|||
console.log("收藏网站种子数据插入完成!") |
|||
} |
@ -0,0 +1,415 @@ |
|||
import BookmarkModel from "../db/models/BookmarkModel.js" |
|||
import CategoryModel from "../db/models/CategoryModel.js" |
|||
import TagModel from "../db/models/TagModel.js" |
|||
|
|||
class BookmarkService { |
|||
/** |
|||
* 创建新收藏 |
|||
*/ |
|||
static async createBookmark(data) { |
|||
try { |
|||
// 验证必填字段
|
|||
if (!data.title || !data.url || !data.user_id) { |
|||
throw new Error("标题、URL和用户ID是必填字段") |
|||
} |
|||
|
|||
// 验证URL格式
|
|||
try { |
|||
new URL(data.url) |
|||
} catch (error) { |
|||
throw new Error("无效的URL格式") |
|||
} |
|||
|
|||
// 如果指定了分类,验证分类是否存在
|
|||
if (data.category_id) { |
|||
const category = await CategoryModel.findById(data.category_id, data.user_id) |
|||
if (!category) { |
|||
throw new Error("指定的分类不存在") |
|||
} |
|||
} |
|||
|
|||
// 创建收藏
|
|||
const bookmark = await BookmarkModel.create(data) |
|||
|
|||
// 获取完整的收藏信息(包含分类和标签)
|
|||
const fullBookmark = await BookmarkModel.findById(bookmark.id, data.user_id) |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
|
|||
return { |
|||
...fullBookmark, |
|||
tags, |
|||
links, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`创建收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新收藏 |
|||
*/ |
|||
static async updateBookmark(id, data, userId) { |
|||
try { |
|||
// 验证收藏是否存在
|
|||
const existingBookmark = await BookmarkModel.findById(id, userId) |
|||
if (!existingBookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
// 如果更新分类,验证分类是否存在
|
|||
if (data.category_id && data.category_id !== existingBookmark.category_id) { |
|||
const category = await CategoryModel.findById(data.category_id, userId) |
|||
if (!category) { |
|||
throw new Error("指定的分类不存在") |
|||
} |
|||
} |
|||
|
|||
// 更新收藏
|
|||
const updatedBookmark = await BookmarkModel.update(id, data, userId) |
|||
|
|||
// 获取完整的更新后信息
|
|||
const fullBookmark = await BookmarkModel.findById(id, userId) |
|||
const tags = await BookmarkModel.getTags(id) |
|||
const links = await BookmarkModel.getLinks(id) |
|||
|
|||
return { |
|||
...fullBookmark, |
|||
tags, |
|||
links, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`更新收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除收藏 |
|||
*/ |
|||
static async deleteBookmark(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
await BookmarkModel.delete(id, userId) |
|||
return { success: true, message: "收藏删除成功" } |
|||
} catch (error) { |
|||
throw new Error(`删除收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏列表 |
|||
*/ |
|||
static async getBookmarks(userId, options = {}) { |
|||
try { |
|||
const { page = 1, limit = 12, ...filterOptions } = options |
|||
|
|||
// 计算分页参数
|
|||
const offset = (page - 1) * limit |
|||
const paginationOptions = { ...filterOptions, limit, offset } |
|||
|
|||
const bookmarks = await BookmarkModel.findAll(userId, paginationOptions) |
|||
|
|||
// 为每个收藏添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
// 获取总数用于分页
|
|||
const totalBookmarks = await BookmarkModel.findAll(userId, filterOptions) |
|||
const total = totalBookmarks.length |
|||
|
|||
return { |
|||
bookmarks: enrichedBookmarks, |
|||
pagination: { |
|||
page, |
|||
limit, |
|||
total, |
|||
totalPages: Math.ceil(total / limit), |
|||
hasNext: page * limit < total, |
|||
hasPrev: page > 1 |
|||
}, |
|||
total |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取收藏列表失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取单个收藏详情 |
|||
*/ |
|||
static async getBookmarkById(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
const tags = await BookmarkModel.getTags(id) |
|||
const links = await BookmarkModel.getLinks(id) |
|||
|
|||
return { |
|||
...bookmark, |
|||
tags, |
|||
links, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取收藏详情失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 搜索收藏 |
|||
*/ |
|||
static async searchBookmarks(query, userId, options = {}) { |
|||
try { |
|||
if (!query || query.trim().length === 0) { |
|||
throw new Error("搜索关键词不能为空") |
|||
} |
|||
|
|||
const bookmarks = await BookmarkModel.searchBookmarks(query, userId, options) |
|||
|
|||
// 为搜索结果添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
return enrichedBookmarks |
|||
} catch (error) { |
|||
throw new Error(`搜索收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按分类获取收藏 |
|||
*/ |
|||
static async getBookmarksByCategory(categoryId, userId, options = {}) { |
|||
try { |
|||
// 验证分类是否存在
|
|||
const category = await CategoryModel.findById(categoryId, userId) |
|||
if (!category) { |
|||
throw new Error("分类不存在") |
|||
} |
|||
|
|||
const bookmarks = await BookmarkModel.getBookmarksByCategory(categoryId, userId, options.limit) |
|||
|
|||
// 为每个收藏添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
return { |
|||
category, |
|||
bookmarks: enrichedBookmarks, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取分类收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 按标签获取收藏 |
|||
*/ |
|||
static async getBookmarksByTag(tagId, userId, options = {}) { |
|||
try { |
|||
// 验证标签是否存在
|
|||
const tag = await TagModel.findById(tagId, userId) |
|||
if (!tag) { |
|||
throw new Error("标签不存在") |
|||
} |
|||
|
|||
const bookmarks = await BookmarkModel.getBookmarksByTag(tagId, userId, options.limit) |
|||
|
|||
// 为每个收藏添加标签和链接信息
|
|||
const enrichedBookmarks = await Promise.all( |
|||
bookmarks.map(async (bookmark) => { |
|||
const tags = await BookmarkModel.getTags(bookmark.id) |
|||
const links = await BookmarkModel.getLinks(bookmark.id) |
|||
return { ...bookmark, tags, links } |
|||
}) |
|||
) |
|||
|
|||
return { |
|||
tag, |
|||
bookmarks: enrichedBookmarks, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取标签收藏失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏统计信息 |
|||
*/ |
|||
static async getBookmarkStats(userId) { |
|||
try { |
|||
const stats = await BookmarkModel.getStats(userId) |
|||
const categories = await CategoryModel.getCategoryStats(userId) |
|||
const popularTags = await TagModel.getPopularTags(userId, 10) |
|||
|
|||
return { |
|||
...stats, |
|||
categories, |
|||
popularTags, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取统计信息失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有分类 |
|||
*/ |
|||
static async getCategories(userId) { |
|||
try { |
|||
return await CategoryModel.findAll(userId) |
|||
} catch (error) { |
|||
throw new Error(`获取分类失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有标签 |
|||
*/ |
|||
static async getTags(userId) { |
|||
try { |
|||
return await TagModel.findAll(userId) |
|||
} catch (error) { |
|||
throw new Error(`获取标签失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取热门标签 |
|||
*/ |
|||
static async getPopularTags(userId, limit = 20) { |
|||
try { |
|||
return await TagModel.getPopularTags(userId, limit) |
|||
} catch (error) { |
|||
throw new Error(`获取热门标签失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取收藏的快捷方式 |
|||
*/ |
|||
static async getQuickAccess(userId) { |
|||
try { |
|||
const [favorites, recent, popular] = await Promise.all([ |
|||
BookmarkModel.getFavorites(userId, 5), |
|||
BookmarkModel.getRecentBookmarks(userId, 5), |
|||
BookmarkModel.getPopularBookmarks(userId, 5), |
|||
]) |
|||
|
|||
return { |
|||
favorites, |
|||
recent, |
|||
popular, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`获取快捷访问失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加点击次数 |
|||
*/ |
|||
static async incrementClickCount(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
await BookmarkModel.incrementClickCount(id) |
|||
return { success: true, message: "点击次数已更新" } |
|||
} catch (error) { |
|||
throw new Error(`更新点击次数失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 切换收藏状态 |
|||
*/ |
|||
static async toggleFavorite(id, userId) { |
|||
try { |
|||
const bookmark = await BookmarkModel.findById(id, userId) |
|||
if (!bookmark) { |
|||
throw new Error("收藏不存在或无权限访问") |
|||
} |
|||
|
|||
const updatedBookmark = await BookmarkModel.toggleFavorite(id, userId) |
|||
return { |
|||
success: true, |
|||
message: `已${updatedBookmark.is_favorite ? '添加到' : '从'}特别收藏`, |
|||
is_favorite: updatedBookmark.is_favorite, |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`切换收藏状态失败: ${error.message}`) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 批量操作 |
|||
*/ |
|||
static async batchOperation(operation, bookmarkIds, userId, data = {}) { |
|||
try { |
|||
if (!Array.isArray(bookmarkIds) || bookmarkIds.length === 0) { |
|||
throw new Error("请选择要操作的收藏") |
|||
} |
|||
|
|||
const results = [] |
|||
for (const id of bookmarkIds) { |
|||
try { |
|||
switch (operation) { |
|||
case 'delete': |
|||
await this.deleteBookmark(id, userId) |
|||
results.push({ id, success: true, message: "删除成功" }) |
|||
break |
|||
case 'move': |
|||
if (!data.category_id) { |
|||
throw new Error("移动操作需要指定目标分类") |
|||
} |
|||
await this.updateBookmark(id, { category_id: data.category_id }, userId) |
|||
results.push({ id, success: true, message: "移动成功" }) |
|||
break |
|||
case 'tag': |
|||
if (!data.tags) { |
|||
throw new Error("标签操作需要指定标签") |
|||
} |
|||
await this.updateBookmark(id, { tags: data.tags }, userId) |
|||
results.push({ id, success: true, message: "标签更新成功" }) |
|||
break |
|||
default: |
|||
throw new Error(`不支持的操作类型: ${operation}`) |
|||
} |
|||
} catch (error) { |
|||
results.push({ id, success: false, message: error.message }) |
|||
} |
|||
} |
|||
|
|||
return results |
|||
} catch (error) { |
|||
throw new Error(`批量操作失败: ${error.message}`) |
|||
} |
|||
} |
|||
} |
|||
|
|||
export default BookmarkService |
|||
export { BookmarkService } |
Loading…
Reference in new issue