diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 1ea89d8..4d1a716 100644 Binary files a/database/development.sqlite3-shm and b/database/development.sqlite3-shm differ diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal index 7ebc8c5..4d3725d 100644 Binary files a/database/development.sqlite3-wal and b/database/development.sqlite3-wal differ diff --git a/public/css/page/index.css b/public/css/page/index.css index 7498e1e..bdcd49b 100644 --- a/public/css/page/index.css +++ b/public/css/page/index.css @@ -45,9 +45,683 @@ text-align: center; } +/* 收藏网站样式 */ +.bookmarks-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + color: white; +} + +/* 搜索区域 */ +.search-section { + margin-bottom: 30px; +} + +.search-box { + display: flex; + gap: 15px; + align-items: center; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + border-radius: 12px; + padding: 20px; +} + +.search-input { + flex: 1; + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 8px; + padding: 12px 16px; + color: white; + font-size: 16px; + outline: none; +} + +.search-input::placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.search-btn, .add-btn { + background: rgba(59, 130, 246, 0.8); + border: none; + border-radius: 8px; + padding: 12px 20px; + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.search-btn:hover, .add-btn:hover { + background: rgba(59, 130, 246, 1); + transform: translateY(-2px); +} + +.add-btn { + background: rgba(16, 185, 129, 0.8); +} + +.add-btn:hover { + background: rgba(16, 185, 129, 1); +} + +/* 导航区域 */ +.navigation-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 30px; +} + +.categories-nav, .tags-cloud { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + border-radius: 12px; + padding: 20px; +} + +.nav-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 15px; + color: rgba(255, 255, 255, 0.9); +} + +.category-list, .tag-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.category-item, .tag-item { + background: rgba(255, 255, 255, 0.2); + border-radius: 20px; + padding: 8px 16px; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; + border: 2px solid transparent; +} + +.category-item:hover, .tag-item:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.category-item.active, .tag-item.active { + border-color: rgba(59, 130, 246, 0.8); + background: rgba(59, 130, 246, 0.3); +} + +/* 收藏区域 */ +.bookmarks-section { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + border-radius: 12px; + padding: 20px; + margin-bottom: 30px; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); +} + +.section-header h2 { + margin: 0; + font-size: 24px; + font-weight: 600; +} + +.view-controls { + display: flex; + gap: 10px; +} + +.view-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 6px; + padding: 8px 16px; + color: white; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; +} + +.view-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.view-btn.active { + background: rgba(59, 130, 246, 0.8); +} + +/* 网格视图 */ +.bookmarks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.bookmark-card { + background: rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 20px; + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.bookmark-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.3); +} + +.bookmark-header { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.bookmark-favicon { + width: 32px; + height: 32px; + border-radius: 6px; + margin-right: 12px; + object-fit: cover; +} + +.bookmark-title { + font-size: 18px; + font-weight: 600; + margin: 0; + color: white; + flex: 1; +} + +.bookmark-favorite { + background: none; + border: none; + color: #fbbf24; + font-size: 20px; + cursor: pointer; + padding: 5px; + border-radius: 50%; + transition: all 0.3s ease; +} + +.bookmark-favorite:hover { + background: rgba(251, 191, 36, 0.2); +} + +.bookmark-favorite.active { + color: #f59e0b; +} + +.bookmark-description { + color: rgba(255, 255, 255, 0.8); + margin-bottom: 15px; + line-height: 1.5; +} + +.bookmark-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.bookmark-category { + background: rgba(59, 130, 246, 0.3); + color: rgba(59, 130, 246, 1); + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.bookmark-stats { + display: flex; + gap: 15px; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; +} + +.bookmark-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 15px; +} + +.bookmark-tag { + background: rgba(255, 255, 255, 0.2); + color: white; + padding: 4px 10px; + border-radius: 10px; + font-size: 11px; +} + +.bookmark-actions { + display: flex; + gap: 10px; +} + +.bookmark-btn { + flex: 1; + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 6px; + padding: 8px 12px; + color: white; + font-size: 12px; + cursor: pointer; + transition: all 0.3s ease; +} + +.bookmark-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.bookmark-btn.primary { + background: rgba(59, 130, 246, 0.8); +} + +.bookmark-btn.primary:hover { + background: rgba(59, 130, 246, 1); +} + +/* 列表视图 */ +.bookmarks-list { + display: none; +} + +.bookmark-list-item { + display: flex; + align-items: center; + padding: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: background 0.3s ease; +} + +.bookmark-list-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.bookmark-list-item:last-child { + border-bottom: none; +} + +.bookmark-list-favicon { + width: 24px; + height: 24px; + border-radius: 4px; + margin-right: 15px; +} + +.bookmark-list-content { + flex: 1; +} + +.bookmark-list-title { + font-size: 16px; + font-weight: 600; + margin: 0 0 5px 0; + color: white; +} + +.bookmark-list-description { + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + margin: 0; +} + +.bookmark-list-meta { + display: flex; + gap: 15px; + margin-left: 20px; +} + +/* 分页 */ +.pagination-section { + display: flex; + justify-content: center; + margin-top: 30px; +} + +.pagination { + display: flex; + gap: 10px; + align-items: center; +} + +.pagination-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 6px; + padding: 8px 12px; + color: white; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; +} + +.pagination-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.pagination-btn.active { + background: rgba(59, 130, 246, 0.8); +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 模态框 */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(5px); +} + +.modal-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(30, 41, 59, 0.95); + backdrop-filter: blur(20px); + border-radius: 16px; + padding: 0; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 25px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.modal-header h3 { + margin: 0; + color: white; + font-size: 20px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + font-size: 24px; + cursor: pointer; + padding: 5px; + border-radius: 50%; + transition: all 0.3s ease; +} + +.modal-close:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +.modal-body { + padding: 25px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + color: white; + font-weight: 500; + font-size: 14px; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 12px 16px; + color: white; + font-size: 14px; + outline: none; + transition: all 0.3s ease; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + border-color: rgba(59, 130, 246, 0.8); + background: rgba(255, 255, 255, 0.15); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.extra-links { + margin-bottom: 15px; +} + +.extra-link-item { + display: flex; + gap: 10px; + margin-bottom: 10px; + align-items: center; +} + +.extra-link-item .link-title { + flex: 1; +} + +.extra-link-item .link-url { + flex: 2; +} + +.remove-link { + background: rgba(239, 68, 68, 0.8); + border: none; + border-radius: 6px; + padding: 8px 12px; + color: white; + font-size: 12px; + cursor: pointer; + transition: all 0.3s ease; +} + +.remove-link:hover { + background: rgba(239, 68, 68, 1); +} + +.add-link-btn { + background: rgba(16, 185, 129, 0.8); + border: none; + border-radius: 6px; + padding: 8px 16px; + color: white; + font-size: 14px; + cursor: pointer; + transition: all 0.3s ease; +} + +.add-link-btn:hover { + background: rgba(16, 185, 129, 1); +} + +.form-actions { + display: flex; + gap: 15px; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.submit-btn, .cancel-btn { + flex: 1; + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.submit-btn { + background: rgba(59, 130, 246, 0.8); + color: white; +} + +.submit-btn:hover { + background: rgba(59, 130, 246, 1); + transform: translateY(-2px); +} + +.cancel-btn { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.cancel-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* 响应式设计 */ @media screen and (max-width: 768px) { .home-hero { margin: 0; margin-top: 20px; } + + .bookmarks-container { + padding: 15px; + } + + .search-box { + flex-direction: column; + align-items: stretch; + } + + .navigation-section { + grid-template-columns: 1fr; + gap: 15px; + } + + .section-header { + flex-direction: column; + gap: 15px; + align-items: stretch; + } + + .bookmarks-grid { + grid-template-columns: 1fr; + } + + .modal-content { + width: 95%; + margin: 20px; + } + + .form-actions { + flex-direction: column; + } +} + +/* 加载状态 */ +.loading { + text-align: center; + padding: 40px; + color: rgba(255, 255, 255, 0.7); +} + +.loading::after { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 1s ease-in-out infinite; + margin-left: 10px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* 空状态 */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.7); +} + +.empty-state h3 { + margin: 0 0 15px 0; + font-size: 20px; + color: white; +} + +.empty-state p { + margin: 0 0 25px 0; + font-size: 16px; +} + +.empty-state .empty-btn { + background: rgba(59, 130, 246, 0.8); + border: none; + border-radius: 8px; + padding: 12px 24px; + color: white; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; +} + +.empty-state .empty-btn:hover { + background: rgba(59, 130, 246, 1); + transform: translateY(-2px); } diff --git a/public/js/bookmarks.js b/public/js/bookmarks.js new file mode 100644 index 0000000..1b634ca --- /dev/null +++ b/public/js/bookmarks.js @@ -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 => ` +
+ ${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