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 ?
+ `
+ 额外链接: ${bookmark.links.length}个
+
` : '';
+
+ return `
+
+
+
+
${bookmark.description}
+
+
+
+ ${tagsHtml ? `
${tagsHtml}
` : ''}
+ ${linksHtml}
+
+
+
+
+
+
+
+ `;
+ }
+
+ createBookmarkListItem(bookmark) {
+ const tagsHtml = bookmark.tags.map(tag =>
+ `${tag}`
+ ).join('');
+
+ return `
+
+

+
+
+
${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