From e4f366988f3375e62003071b8c8002bc8de1cb4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Tue, 26 Aug 2025 14:44:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=94=B6=E8=97=8F=E7=BD=91?= =?UTF-8?q?=E7=AB=99=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E6=8B=AC=E5=89=8D=E7=AB=AF=E6=A0=B7=E5=BC=8F=E3=80=81=E5=90=8E?= =?UTF-8?q?=E7=AB=AFAPI=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `BookmarkManager` 类,处理收藏网站的增删改查逻辑 - 实现收藏列表、分类和标签的动态加载 - 添加收藏的模态框和搜索功能 - 新增 `BookmarkController`,提供收藏相关的API接口 - 创建数据库模型和迁移文件,支持收藏、分类和标签的管理 - 更新前端样式,优化用户体验 --- database/development.sqlite3-shm | Bin 32768 -> 32768 bytes database/development.sqlite3-wal | Bin 243112 -> 1590352 bytes public/css/page/index.css | 674 +++++++++++++++++ public/js/bookmarks.js | 804 +++++++++++++++++++++ src/controllers/Page/BookmarkController.js | 516 +++++++++++++ src/controllers/Page/README.md | 258 +++++++ src/db/README.md | 217 ++++++ .../20250101000001_create_bookmarks_tables.mjs | 115 +++ src/db/models/BookmarkModel.js | 349 +++++++++ src/db/models/CategoryModel.js | 85 +++ src/db/models/TagModel.js | 99 +++ src/db/seeds/20250101000001_bookmarks_seed.mjs | 310 ++++++++ src/services/BookmarkService.js | 415 +++++++++++ src/views/page/index/index.pug | 89 ++- 14 files changed, 3930 insertions(+), 1 deletion(-) create mode 100644 public/js/bookmarks.js create mode 100644 src/controllers/Page/BookmarkController.js create mode 100644 src/controllers/Page/README.md create mode 100644 src/db/README.md create mode 100644 src/db/migrations/20250101000001_create_bookmarks_tables.mjs create mode 100644 src/db/models/BookmarkModel.js create mode 100644 src/db/models/CategoryModel.js create mode 100644 src/db/models/TagModel.js create mode 100644 src/db/seeds/20250101000001_bookmarks_seed.mjs create mode 100644 src/services/BookmarkService.js diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 1ea89d8f3fe9bb7e865db34e58349babdb3c872e..4d1a716fa8ca5ff29bcb530de493aa60e99b14a2 100644 GIT binary patch literal 32768 zcmeI4Wsn|45Qd*O?k*Q2?zyk$QD?=R6?ay` zSxIL}&Pq8e?W~Nmvd;YZMc>0~1HC{e>Mw{Rh|A{`?g#UJJi6D9MfLqy*uQ^|#uuDX z_}qfNXC7T2){FX!#uUUG&LP|n=8sf9-cOKkYCgj;h5M=Hn%a1jMOyqtwfvqi??5xU zE*STB*IV!&Ji{8PX&!2g|0cX%^y&S1%`mIf`q8)#<A1=!uwK+>;K^7QnO|RjDQg^0!F|H7y%<-1dMzz6rCN2c=0b)_ zI$g<#Y_5Vt>vZk4O^HdMFC$pN>D{J}qDRZdk<6*W{-b=M$`)=bUSYOT-oUqKdE z)uT;f9->8+P6GWH$x6=PR=(s<4vDN zq^m7cjtbPEK8ZU##sG%CADVn2YT9@gGr}Ii5#9VijAsjj#C26^_cT+^VE% zYOLn!sUaGx*_yAl^3|v^cgyZ7WaXtYwQ0x6nHxoh$$kJsSi>pYo?$YIE0KJzu3D0- zmv$T#sX`qZ(~OpE;4U7D%cbeYOkf~GS<9*15wo9TUp~L9Tvp0e3M@}0s*+3-n$wDn z+|ABoKez75opg;W^f^w#H4SsMj&wsbYL1Yxrj>> zH$;of2*gC7Ba@lURxampp5jA_s)R~q`u_vR1lwd2j6f%*Fo$hi!4o{qK8mTNO55#E zuv4;d;vvwP@yz2~u3{H2@)~dOE?@HlzwjGU9_3R36;gGzP+Rp{KU`v%I{=RVbxGewNpP0*LW?on}38)$lS$8peqwu z!1-LmqrAml@+!Xys+HQSxB6>@-Szm;oAOa7(2YqftO zFtb7C{zn2(ab^Z03MfZ{KhCVvTpFnIYGgcw8_)EG1w^cW0*ias(;{t(Uw IRK%nK0PK=W)&Kwi diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal index 7ebc8c553bd13d4dcff1478839797c8d346d433b..4d3725da00197edbfa69db65138875808eb1da20 100644 GIT binary patch literal 1590352 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 X0w0P%+KIrhe}Qy~D}D1p{0sblf6Z(N delta 15 Wcmcc6k+|YDUqcIH3)2?n6 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 => ` +
+ ${category.name} +
+ `).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 => ` +
+ ${tag.name} +
+ `).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 => + `${tag}` + ).join(''); + + const linksHtml = bookmark.links.length > 0 ? + `` : ''; + + return ` +
+
+ favicon +

${bookmark.title}

+ +
+ +

${bookmark.description}

+ +
+ + ${bookmark.category_name} + +
+ 👁 ${bookmark.click_count} + 📅 ${this.formatDate(bookmark.created_at)} +
+
+ + ${tagsHtml ? `
${tagsHtml}
` : ''} + ${linksHtml} + +
+ + + +
+
+ `; + } + + createBookmarkListItem(bookmark) { + const tagsHtml = bookmark.tags.map(tag => + `${tag}` + ).join(''); + + return ` +
+ favicon + +
+

${bookmark.title}

+

${bookmark.description}

+ ${tagsHtml ? `
${tagsHtml}
` : ''} +
+ +
+ + ${bookmark.category_name} + + 👁 ${bookmark.click_count} + + +
+
+ `; + } + + 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 += ` + + `; + + // 页码 + 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 += ` + + `; + } else if (i === this.currentPage - 3 || i === this.currentPage + 3) { + paginationHtml += '...'; + } + } + + // 下一页 + paginationHtml += ` + + `; + + 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 = '' + + this.categories.map(category => + `` + ).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 = ` + + + + `; + + 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 = ` +
+

暂无收藏

+

开始添加你的第一个收藏吧!

+ +
+ `; + + 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(); + } +} diff --git a/src/controllers/Page/BookmarkController.js b/src/controllers/Page/BookmarkController.js new file mode 100644 index 0000000..96fc40c --- /dev/null +++ b/src/controllers/Page/BookmarkController.js @@ -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 diff --git a/src/controllers/Page/README.md b/src/controllers/Page/README.md new file mode 100644 index 0000000..bb67db8 --- /dev/null +++ b/src/controllers/Page/README.md @@ -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文档 diff --git a/src/db/README.md b/src/db/README.md new file mode 100644 index 0000000..9065b63 --- /dev/null +++ b/src/db/README.md @@ -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. 实现收藏备份和恢复 diff --git a/src/db/migrations/20250101000001_create_bookmarks_tables.mjs b/src/db/migrations/20250101000001_create_bookmarks_tables.mjs new file mode 100644 index 0000000..673f338 --- /dev/null +++ b/src/db/migrations/20250101000001_create_bookmarks_tables.mjs @@ -0,0 +1,115 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +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") +} diff --git a/src/db/models/BookmarkModel.js b/src/db/models/BookmarkModel.js new file mode 100644 index 0000000..b8786fc --- /dev/null +++ b/src/db/models/BookmarkModel.js @@ -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 } diff --git a/src/db/models/CategoryModel.js b/src/db/models/CategoryModel.js new file mode 100644 index 0000000..b6de49f --- /dev/null +++ b/src/db/models/CategoryModel.js @@ -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 } diff --git a/src/db/models/TagModel.js b/src/db/models/TagModel.js new file mode 100644 index 0000000..bab67ff --- /dev/null +++ b/src/db/models/TagModel.js @@ -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 } diff --git a/src/db/seeds/20250101000001_bookmarks_seed.mjs b/src/db/seeds/20250101000001_bookmarks_seed.mjs new file mode 100644 index 0000000..3ede8ea --- /dev/null +++ b/src/db/seeds/20250101000001_bookmarks_seed.mjs @@ -0,0 +1,310 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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("收藏网站种子数据插入完成!") +} diff --git a/src/services/BookmarkService.js b/src/services/BookmarkService.js new file mode 100644 index 0000000..1d7b2f9 --- /dev/null +++ b/src/services/BookmarkService.js @@ -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 } diff --git a/src/views/page/index/index.pug b/src/views/page/index/index.pug index 12cdb2d..6987eae 100644 --- a/src/views/page/index/index.pug +++ b/src/views/page/index/index.pug @@ -6,4 +6,91 @@ block pageHead block pageContent div(class="mt-[50px]") +include() - include /htmx/navbar.pug \ No newline at end of file + include /htmx/navbar.pug + +//- // 收藏网站主界面 +//- .bookmarks-container +//- // 搜索和添加区域 +//- .search-section +//- .search-box +//- input#searchInput(type="text" placeholder="搜索收藏..." class="search-input") +//- button#searchBtn(class="search-btn") 搜索 +//- button#addBookmarkBtn(class="add-btn") + 添加收藏 + +//- // 分类和标签导航 +//- .navigation-section +//- .categories-nav +//- .nav-title 分类 +//- .category-list#categoryList +//- // 分类将通过JavaScript动态加载 + +//- .tags-cloud +//- .nav-title 热门标签 +//- .tag-list#tagList +//- // 标签将通过JavaScript动态加载 + +//- // 收藏列表区域 +//- .bookmarks-section +//- .section-header +//- h2#sectionTitle 我的收藏 +//- .view-controls +//- button#gridViewBtn(class="view-btn active" data-view="grid") 网格 +//- button#listViewBtn(class="view-btn" data-view="list") 列表 + +//- .bookmarks-grid#bookmarksGrid +//- // 收藏卡片将通过JavaScript动态加载 + +//- .bookmarks-list#bookmarksList(style="display: none;") +//- // 列表视图将通过JavaScript动态加载 + +//- // 分页控制 +//- .pagination-section +//- .pagination#pagination +//- // 分页将通过JavaScript动态加载 + +//- // 添加/编辑收藏模态框 +//- .modal#bookmarkModal(style="display: none;") +//- .modal-overlay +//- .modal-content +//- .modal-header +//- h3#modalTitle 添加收藏 +//- button.modal-close#closeModal × + +//- .modal-body +//- form#bookmarkForm +//- .form-group +//- label(for="bookmarkTitle") 标题 * +//- input#bookmarkTitle(name="bookmarkTitle" type="text" required placeholder="输入网站标题") + +//- .form-group +//- label(for="bookmarkUrl") URL * +//- input#bookmarkUrl(name="bookmarkUrl" type="url" required placeholder="https://example.com") + +//- .form-group +//- label(for="bookmarkDescription") 描述 +//- textarea#bookmarkDescription(name="bookmarkDescription" placeholder="描述这个网站...") + +//- .form-group +//- label(for="bookmarkCategory") 分类 +//- select#bookmarkCategory(name="bookmarkCategory") +//- option(value="") 选择分类 + +//- .form-group +//- label(for="bookmarkTags") 标签 +//- input#bookmarkTags(name="bookmarkTags" type="text" placeholder="用逗号分隔多个标签") + +//- .form-group +//- label 额外链接 +//- .extra-links#extraLinks +//- .extra-link-item +//- input(type="text" placeholder="链接标题" class="link-title") +//- input(type="url" placeholder="链接URL" class="link-url") +//- button(type="button" class="remove-link") 删除 +//- button#addLinkBtn(type="button" class="add-link-btn") + 添加链接 + +//- .form-actions +//- button(type="submit" class="submit-btn") 保存 +//- button(type="button" class="cancel-btn" onclick="closeModal()") 取消 + +//- block pageScripts +//- script(src="/js/bookmarks.js") \ No newline at end of file