Browse Source

新增收藏网站管理功能,包括前端样式、后端API和数据库模型

- 新增 `BookmarkManager` 类,处理收藏网站的增删改查逻辑
- 实现收藏列表、分类和标签的动态加载
- 添加收藏的模态框和搜索功能
- 新增 `BookmarkController`,提供收藏相关的API接口
- 创建数据库模型和迁移文件,支持收藏、分类和标签的管理
- 更新前端样式,优化用户体验
feat/collect
谢亚昕 2 days ago
parent
commit
e4f366988f
  1. BIN
      database/development.sqlite3-shm
  2. BIN
      database/development.sqlite3-wal
  3. 674
      public/css/page/index.css
  4. 804
      public/js/bookmarks.js
  5. 516
      src/controllers/Page/BookmarkController.js
  6. 258
      src/controllers/Page/README.md
  7. 217
      src/db/README.md
  8. 115
      src/db/migrations/20250101000001_create_bookmarks_tables.mjs
  9. 349
      src/db/models/BookmarkModel.js
  10. 85
      src/db/models/CategoryModel.js
  11. 99
      src/db/models/TagModel.js
  12. 310
      src/db/seeds/20250101000001_bookmarks_seed.mjs
  13. 415
      src/services/BookmarkService.js
  14. 89
      src/views/page/index/index.pug

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

674
public/css/page/index.css

@ -45,9 +45,683 @@
text-align: center; 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) { @media screen and (max-width: 768px) {
.home-hero { .home-hero {
margin: 0; margin: 0;
margin-top: 20px; 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);
} }

804
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 => `
<div class="category-item ${category.id === this.currentCategory ? 'active' : ''}"
data-id="${category.id}"
style="border-color: ${category.color}">
${category.name}
</div>
`).join('');
// 绑定分类点击事件
categoryList.querySelectorAll('.category-item').forEach(item => {
item.addEventListener('click', () => {
const categoryId = item.dataset.id ? parseInt(item.dataset.id) : null;
this.filterByCategory(categoryId);
});
});
}
renderTags() {
const tagList = document.getElementById('tagList');
const allTags = [{ id: null, name: '全部', color: '#6B7280' }, ...this.tags];
tagList.innerHTML = allTags.map(tag => `
<div class="tag-item ${tag.id === this.currentTag ? 'active' : ''}"
data-id="${tag.id}"
style="border-color: ${tag.color}">
${tag.name}
</div>
`).join('');
// 绑定标签点击事件
tagList.querySelectorAll('.tag-item').forEach(item => {
item.addEventListener('click', () => {
const tagId = item.dataset.id ? parseInt(item.dataset.id) : null;
this.filterByTag(tagId);
});
});
}
renderBookmarks() {
if (this.bookmarks.length === 0) {
this.showEmptyState();
return;
}
if (this.currentView === 'grid') {
this.renderGridView(this.bookmarks);
} else {
this.renderListView(this.bookmarks);
}
}
renderGridView(bookmarks) {
const gridContainer = document.getElementById('bookmarksGrid');
const listContainer = document.getElementById('bookmarksList');
gridContainer.style.display = 'grid';
listContainer.style.display = 'none';
gridContainer.innerHTML = bookmarks.map(bookmark => this.createBookmarkCard(bookmark)).join('');
this.bindBookmarkEvents();
}
renderListView(bookmarks) {
const gridContainer = document.getElementById('bookmarksGrid');
const listContainer = document.getElementById('bookmarksList');
gridContainer.style.display = 'none';
listContainer.style.display = 'block';
listContainer.innerHTML = bookmarks.map(bookmark => this.createBookmarkListItem(bookmark)).join('');
this.bindBookmarkEvents();
}
createBookmarkCard(bookmark) {
const tagsHtml = bookmark.tags.map(tag =>
`<span class="bookmark-tag">${tag}</span>`
).join('');
const linksHtml = bookmark.links.length > 0 ?
`<div class="bookmark-links">
<small>额外链接: ${bookmark.links.length}</small>
</div>` : '';
return `
<div class="bookmark-card" data-id="${bookmark.id}">
<div class="bookmark-header">
<img src="${bookmark.favicon}" alt="favicon" class="bookmark-favicon"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect width=%2232%22 height=%2232%22 fill=%22%23666%22 rx=%224%22/></svg>'">
<h3 class="bookmark-title">${bookmark.title}</h3>
<button class="bookmark-favorite ${bookmark.is_favorite ? 'active' : ''}"
data-id="${bookmark.id}" title="特别收藏">
${bookmark.is_favorite ? '★' : '☆'}
</button>
</div>
<p class="bookmark-description">${bookmark.description}</p>
<div class="bookmark-meta">
<span class="bookmark-category" style="background: ${bookmark.category_color}20; color: ${bookmark.category_color}">
${bookmark.category_name}
</span>
<div class="bookmark-stats">
<span>👁 ${bookmark.click_count}</span>
<span>📅 ${this.formatDate(bookmark.created_at)}</span>
</div>
</div>
${tagsHtml ? `<div class="bookmark-tags">${tagsHtml}</div>` : ''}
${linksHtml}
<div class="bookmark-actions">
<button class="bookmark-btn primary" onclick="window.open('${bookmark.url}', '_blank')">
访问网站
</button>
<button class="bookmark-btn" onclick="bookmarkManager.editBookmark(${bookmark.id})">
编辑
</button>
<button class="bookmark-btn" onclick="bookmarkManager.deleteBookmark(${bookmark.id})">
删除
</button>
</div>
</div>
`;
}
createBookmarkListItem(bookmark) {
const tagsHtml = bookmark.tags.map(tag =>
`<span class="bookmark-tag">${tag}</span>`
).join('');
return `
<div class="bookmark-list-item" data-id="${bookmark.id}">
<img src="${bookmark.favicon}" alt="favicon" class="bookmark-list-favicon"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 fill=%22%23666%22 rx=%223%22/></svg>'">
<div class="bookmark-list-content">
<h4 class="bookmark-list-title">${bookmark.title}</h4>
<p class="bookmark-list-description">${bookmark.description}</p>
${tagsHtml ? `<div class="bookmark-tags">${tagsHtml}</div>` : ''}
</div>
<div class="bookmark-list-meta">
<span class="bookmark-category" style="background: ${bookmark.category_color}20; color: ${bookmark.category_color}">
${bookmark.category_name}
</span>
<span>👁 ${bookmark.click_count}</span>
<button class="bookmark-btn" onclick="bookmarkManager.editBookmark(${bookmark.id})">编辑</button>
<button class="bookmark-btn" onclick="bookmarkManager.deleteBookmark(${bookmark.id})">删除</button>
</div>
</div>
`;
}
bindBookmarkEvents() {
// 绑定收藏按钮事件
document.querySelectorAll('.bookmark-favorite').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const bookmarkId = parseInt(btn.dataset.id);
this.toggleFavorite(bookmarkId);
});
});
}
renderPagination() {
if (!this.pagination || this.pagination.totalPages <= 1) {
document.getElementById('pagination').innerHTML = '';
return;
}
let paginationHtml = '';
// 上一页
paginationHtml += `
<button class="pagination-btn" ${!this.pagination.hasPrev ? 'disabled' : ''}
onclick="bookmarkManager.goToPage(${this.currentPage - 1})">
上一页
</button>
`;
// 页码
for (let i = 1; i <= this.pagination.totalPages; i++) {
if (i === 1 || i === this.pagination.totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
paginationHtml += `
<button class="pagination-btn ${i === this.currentPage ? 'active' : ''}"
onclick="bookmarkManager.goToPage(${i})">
${i}
</button>
`;
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
paginationHtml += '<span class="pagination-btn">...</span>';
}
}
// 下一页
paginationHtml += `
<button class="pagination-btn" ${!this.pagination.hasNext ? 'disabled' : ''}
onclick="bookmarkManager.goToPage(${this.currentPage + 1})">
下一页
</button>
`;
document.getElementById('pagination').innerHTML = paginationHtml;
}
getFilteredBookmarks() {
// 现在数据直接从后端获取,已经过滤过了
return this.bookmarks;
}
async handleSearch() {
this.searchQuery = document.getElementById('searchInput').value.trim();
this.currentPage = 1;
await this.loadBookmarks();
this.renderUI();
}
async filterByCategory(categoryId) {
this.currentCategory = categoryId;
this.currentPage = 1;
await this.loadBookmarks();
this.renderUI();
}
async filterByTag(tagId) {
this.currentTag = tagId;
this.currentPage = 1;
await this.loadBookmarks();
this.renderUI();
}
switchView(view) {
this.currentView = view;
// 更新按钮状态
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
this.renderBookmarks();
}
async goToPage(page) {
this.currentPage = page;
await this.loadBookmarks();
this.renderUI();
// 滚动到顶部
document.querySelector('.bookmarks-section').scrollIntoView({ behavior: 'smooth' });
}
showAddModal() {
this.editingBookmark = null;
document.getElementById('modalTitle').textContent = '添加收藏';
document.getElementById('bookmarkForm').reset();
this.populateCategorySelect();
this.showModal();
}
editBookmark(bookmarkId) {
const bookmark = this.bookmarks.find(b => b.id === bookmarkId);
if (!bookmark) return;
this.editingBookmark = bookmark;
document.getElementById('modalTitle').textContent = '编辑收藏';
// 填充表单
document.getElementById('bookmarkTitle').value = bookmark.title;
document.getElementById('bookmarkUrl').value = bookmark.url;
document.getElementById('bookmarkDescription').value = bookmark.description;
document.getElementById('bookmarkCategory').value = bookmark.category_id;
document.getElementById('bookmarkTags').value = bookmark.tags.join(', ');
this.populateCategorySelect();
this.populateExtraLinks(bookmark.links);
this.showModal();
}
async handleFormSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const bookmarkData = {
title: formData.get('bookmarkTitle'),
url: formData.get('bookmarkUrl'),
description: formData.get('bookmarkDescription'),
category_id: formData.get('bookmarkCategory') ? parseInt(formData.get('bookmarkCategory')) : null,
tags: formData.get('bookmarkTags').split(',').map(tag => tag.trim()).filter(tag => tag),
links: this.getExtraLinksData()
};
try {
if (this.editingBookmark) {
await this.updateBookmark(this.editingBookmark.id, bookmarkData);
} else {
await this.createBookmark(bookmarkData);
}
this.closeModal();
this.renderUI();
this.showSuccess(this.editingBookmark ? '收藏更新成功' : '收藏添加成功');
} catch (error) {
this.showError(error.message);
}
}
async createBookmark(data) {
try {
const response = await fetch('/bookmarks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('创建收藏失败');
}
const result = await response.json();
if (result.success) {
const newBookmark = result.data;
this.bookmarks.unshift(newBookmark);
return newBookmark;
} else {
throw new Error(result.message || '创建收藏失败');
}
} catch (error) {
console.error('创建收藏失败:', error);
throw error;
}
}
async updateBookmark(id, data) {
try {
const response = await fetch(`/bookmarks/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('更新收藏失败');
}
const result = await response.json();
if (result.success) {
const updatedBookmark = result.data;
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id);
if (bookmarkIndex !== -1) {
this.bookmarks[bookmarkIndex] = updatedBookmark;
}
return updatedBookmark;
} else {
throw new Error(result.message || '更新收藏失败');
}
} catch (error) {
console.error('更新收藏失败:', error);
throw error;
}
}
async deleteBookmark(id) {
if (!confirm('确定要删除这个收藏吗?')) return;
try {
const response = await fetch(`/bookmarks/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error('删除收藏失败');
}
const result = await response.json();
if (result.success) {
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id);
if (bookmarkIndex !== -1) {
this.bookmarks.splice(bookmarkIndex, 1);
}
this.renderUI();
this.showSuccess('收藏删除成功');
} else {
throw new Error(result.message || '删除收藏失败');
}
} catch (error) {
console.error('删除收藏失败:', error);
this.showError(error.message);
}
}
async toggleFavorite(id) {
try {
const response = await fetch(`/bookmarks/${id}/favorite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error('切换收藏状态失败');
}
const result = await response.json();
if (result.success) {
const bookmark = this.bookmarks.find(b => b.id === id);
if (bookmark) {
bookmark.is_favorite = result.data.is_favorite;
}
this.renderUI();
this.showSuccess(result.message);
} else {
throw new Error(result.message || '切换收藏状态失败');
}
} catch (error) {
console.error('切换收藏状态失败:', error);
this.showError(error.message);
}
}
populateCategorySelect() {
const select = document.getElementById('bookmarkCategory');
select.innerHTML = '<option value="">选择分类</option>' +
this.categories.map(category =>
`<option value="${category.id}">${category.name}</option>`
).join('');
}
populateExtraLinks(links) {
const container = document.getElementById('extraLinks');
container.innerHTML = '';
if (links && links.length > 0) {
links.forEach(link => this.addExtraLink(link.title, link.url));
} else {
this.addExtraLink();
}
}
addExtraLink(title = '', url = '') {
const container = document.getElementById('extraLinks');
const linkItem = document.createElement('div');
linkItem.className = 'extra-link-item';
linkItem.innerHTML = `
<input type="text" placeholder="链接标题" class="link-title" value="${title}">
<input type="url" placeholder="链接URL" class="link-url" value="${url}">
<button type="button" class="remove-link">删除</button>
`;
linkItem.querySelector('.remove-link').addEventListener('click', () => {
if (container.children.length > 1) {
container.removeChild(linkItem);
}
});
container.appendChild(linkItem);
}
getExtraLinksData() {
const links = [];
document.querySelectorAll('.extra-link-item').forEach(item => {
const title = item.querySelector('.link-title').value.trim();
const url = item.querySelector('.link-url').value.trim();
if (title && url) {
links.push({ title, url, type: 'link' });
}
});
return links;
}
showModal() {
document.getElementById('bookmarkModal').style.display = 'block';
document.body.style.overflow = 'hidden';
}
closeModal() {
document.getElementById('bookmarkModal').style.display = 'none';
document.body.style.overflow = '';
this.editingBookmark = null;
}
showEmptyState() {
const gridContainer = document.getElementById('bookmarksGrid');
const listContainer = document.getElementById('bookmarksList');
gridContainer.style.display = 'none';
listContainer.style.display = 'none';
const emptyHtml = `
<div class="empty-state">
<h3>暂无收藏</h3>
<p>开始添加你的第一个收藏吧</p>
<button class="empty-btn" onclick="bookmarkManager.showAddModal()">
添加收藏
</button>
</div>
`;
if (this.currentView === 'grid') {
gridContainer.innerHTML = emptyHtml;
gridContainer.style.display = 'block';
} else {
listContainer.innerHTML = emptyHtml;
listContainer.style.display = 'block';
}
}
showSuccess(message) {
this.showToast(message, 'success');
}
showError(message) {
this.showToast(message, 'error');
}
showToast(message, type = 'info') {
// 创建toast元素
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#10B981' : type === 'error' ? '#EF4444' : '#3B82F6'};
color: white;
padding: 12px 20px;
border-radius: 8px;
z-index: 10000;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
// 3秒后自动移除
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 3000);
}
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) return '今天';
if (diffDays === 2) return '昨天';
if (diffDays <= 7) return `${diffDays - 1}天前`;
if (diffDays <= 30) return `${Math.floor(diffDays / 7)}周前`;
if (diffDays <= 365) return `${Math.floor(diffDays / 30)}个月前`;
return `${Math.floor(diffDays / 365)}年前`;
}
}
// 添加CSS动画
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
// 初始化收藏管理器
let bookmarkManager;
document.addEventListener('DOMContentLoaded', () => {
bookmarkManager = new BookmarkManager();
});
// 全局函数
function closeModal() {
if (bookmarkManager) {
bookmarkManager.closeModal();
}
}

516
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

258
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文档

217
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. 实现收藏备份和恢复

115
src/db/migrations/20250101000001_create_bookmarks_tables.mjs

@ -0,0 +1,115 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async knex => {
// 创建分类表
await knex.schema.createTable("categories", function (table) {
table.increments("id").primary()
table.string("name", 100).notNullable().unique()
table.string("description", 500)
table.string("color", 7).defaultTo("#3B82F6") // 十六进制颜色值
table.string("icon", 50) // 图标类名或路径
table.integer("sort_order").defaultTo(0)
table.boolean("is_active").defaultTo(true)
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE")
table.timestamp("created_at").defaultTo(knex.fn.now())
table.timestamp("updated_at").defaultTo(knex.fn.now())
})
// 创建标签表
await knex.schema.createTable("tags", function (table) {
table.increments("id").primary()
table.string("name", 100).notNullable()
table.string("description", 500)
table.string("color", 7).defaultTo("#6B7280")
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE")
table.timestamp("created_at").defaultTo(knex.fn.now())
table.timestamp("updated_at").defaultTo(knex.fn.now())
// 同一用户下标签名唯一
table.unique(["name", "user_id"])
})
// 创建收藏主表
await knex.schema.createTable("bookmarks", function (table) {
table.increments("id").primary()
table.string("title", 200).notNullable()
table.text("description")
table.string("url", 1000).notNullable()
table.string("favicon", 500) // 网站图标
table.string("screenshot", 500) // 截图路径
table.integer("category_id").unsigned().references("id").inTable("categories").onDelete("SET NULL")
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE")
table.boolean("is_public").defaultTo(false) // 是否公开
table.boolean("is_favorite").defaultTo(false) // 是否特别收藏
table.integer("click_count").defaultTo(0) // 点击次数
table.integer("sort_order").defaultTo(0)
table.json("metadata") // 存储额外的元数据,如网站标题、描述等
table.timestamp("last_visited").defaultTo(knex.fn.now()) // 最后访问时间
table.timestamp("created_at").defaultTo(knex.fn.now())
table.timestamp("updated_at").defaultTo(knex.fn.now())
// 索引
table.index(["user_id", "category_id"])
table.index(["user_id", "is_favorite"])
table.index(["user_id", "created_at"])
})
// 创建收藏与标签关联表
await knex.schema.createTable("bookmark_tags", function (table) {
table.increments("id").primary()
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE")
table.integer("tag_id").unsigned().notNullable().references("id").inTable("tags").onDelete("CASCADE")
table.timestamp("created_at").defaultTo(knex.fn.now())
// 唯一约束,防止重复关联
table.unique(["bookmark_id", "tag_id"])
table.index(["bookmark_id"])
table.index(["tag_id"])
})
// 创建收藏的多链接表
await knex.schema.createTable("bookmark_links", function (table) {
table.increments("id").primary()
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE")
table.string("title", 200).notNullable()
table.string("url", 1000).notNullable()
table.string("description", 500)
table.string("type", 50).defaultTo("link") // link, download, api, etc.
table.boolean("is_active").defaultTo(true)
table.integer("sort_order").defaultTo(0)
table.timestamp("created_at").defaultTo(knex.fn.now())
table.timestamp("updated_at").defaultTo(knex.fn.now())
table.index(["bookmark_id"])
table.index(["type"])
})
// 创建收藏历史表(可选,用于统计和分析)
await knex.schema.createTable("bookmark_history", function (table) {
table.increments("id").primary()
table.integer("bookmark_id").unsigned().notNullable().references("id").inTable("bookmarks").onDelete("CASCADE")
table.integer("user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE")
table.string("action", 50).notNullable() // visit, favorite, share, etc.
table.json("context") // 存储上下文信息
table.timestamp("created_at").defaultTo(knex.fn.now())
table.index(["bookmark_id"])
table.index(["user_id"])
table.index(["created_at"])
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = async knex => {
await knex.schema.dropTableIfExists("bookmark_history")
await knex.schema.dropTableIfExists("bookmark_links")
await knex.schema.dropTableIfExists("bookmark_tags")
await knex.schema.dropTableIfExists("bookmarks")
await knex.schema.dropTableIfExists("tags")
await knex.schema.dropTableIfExists("categories")
}

349
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 }

85
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 }

99
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 }

310
src/db/seeds/20250101000001_bookmarks_seed.mjs

@ -0,0 +1,310 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const seed = async knex => {
// 清空现有数据
await knex("bookmark_history").del()
await knex("bookmark_links").del()
await knex("bookmark_tags").del()
await knex("bookmarks").del()
await knex("tags").del()
await knex("categories").del()
// 插入分类数据
const categories = await knex("categories").insert([
{
name: "技术开发",
description: "编程、开发工具、技术文档等",
color: "#3B82F6",
icon: "code",
sort_order: 1,
user_id: 1,
},
{
name: "学习资源",
description: "在线课程、教程、学习平台等",
color: "#10B981",
icon: "graduation-cap",
sort_order: 2,
user_id: 1,
},
{
name: "设计工具",
description: "UI/UX设计、图标、配色等工具",
color: "#F59E0B",
icon: "palette",
sort_order: 3,
user_id: 1,
},
{
name: "效率工具",
description: "生产力工具、时间管理、项目管理等",
color: "#8B5CF6",
icon: "zap",
sort_order: 4,
user_id: 1,
},
{
name: "娱乐休闲",
description: "游戏、视频、音乐等娱乐内容",
color: "#EC4899",
icon: "heart",
sort_order: 5,
user_id: 1,
},
]).returning("*")
// 插入标签数据
const tags = await knex("tags").insert([
{
name: "JavaScript",
description: "JavaScript相关资源",
color: "#F7DF1E",
user_id: 1,
},
{
name: "React",
description: "React框架相关",
color: "#61DAFB",
user_id: 1,
},
{
name: "Node.js",
description: "Node.js相关资源",
color: "#339933",
user_id: 1,
},
{
name: "CSS",
description: "CSS样式相关",
color: "#1572B6",
user_id: 1,
},
{
name: "设计灵感",
description: "设计灵感和参考",
color: "#FF6B6B",
user_id: 1,
},
{
name: "免费资源",
description: "免费的设计和开发资源",
color: "#4ECDC4",
user_id: 1,
},
{
name: "API",
description: "各种API接口",
color: "#45B7D1",
user_id: 1,
},
{
name: "数据库",
description: "数据库相关资源",
color: "#FFA500",
user_id: 1,
},
]).returning("*")
// 插入收藏数据
const bookmarks = await knex("bookmarks").insert([
{
title: "MDN Web Docs",
description: "Mozilla开发者网络,提供Web技术文档和教程",
url: "https://developer.mozilla.org/",
favicon: "https://developer.mozilla.org/favicon-48x48.cbbd161b.png",
category_id: categories[0].id, // 技术开发
user_id: 1,
is_public: true,
is_favorite: true,
click_count: 15,
sort_order: 1,
metadata: JSON.stringify({
siteTitle: "MDN Web Docs",
siteDescription: "Learn web development",
keywords: ["web", "development", "documentation"]
}),
},
{
title: "GitHub",
description: "代码托管平台,全球最大的开源社区",
url: "https://github.com/",
favicon: "https://github.com/favicon.ico",
category_id: categories[0].id, // 技术开发
user_id: 1,
is_public: true,
is_favorite: true,
click_count: 42,
sort_order: 2,
metadata: JSON.stringify({
siteTitle: "GitHub: Let's build from here",
siteDescription: "GitHub is where over 100 million developers shape the future of software",
keywords: ["git", "code", "open source"]
}),
},
{
title: "Stack Overflow",
description: "程序员问答社区,解决编程问题的最佳平台",
url: "https://stackoverflow.com/",
favicon: "https://cdn.sstatic.net/Sites/stackoverflow/Img/favicon.ico",
category_id: categories[0].id, // 技术开发
user_id: 1,
is_public: true,
is_favorite: false,
click_count: 28,
sort_order: 3,
},
{
title: "Udemy",
description: "在线学习平台,提供各种技能课程",
url: "https://www.udemy.com/",
favicon: "https://www.udemy.com/favicon-32x32.png",
category_id: categories[1].id, // 学习资源
user_id: 1,
is_public: true,
is_favorite: false,
click_count: 8,
sort_order: 1,
},
{
title: "Figma",
description: "在线设计工具,支持团队协作的UI/UX设计平台",
url: "https://www.figma.com/",
favicon: "https://www.figma.com/favicon.ico",
category_id: categories[2].id, // 设计工具
user_id: 1,
is_public: true,
is_favorite: true,
click_count: 35,
sort_order: 1,
},
{
title: "Notion",
description: "全能型工作台,笔记、文档、项目管理一体化",
url: "https://www.notion.so/",
favicon: "https://www.notion.so/images/favicon.ico",
category_id: categories[3].id, // 效率工具
user_id: 1,
is_public: true,
is_favorite: true,
click_count: 22,
sort_order: 1,
},
{
title: "YouTube",
description: "全球最大的视频分享平台",
url: "https://www.youtube.com/",
favicon: "https://www.youtube.com/s/desktop/0d2c4a3b/img/favicon.ico",
category_id: categories[4].id, // 娱乐休闲
user_id: 1,
is_public: true,
is_favorite: false,
click_count: 67,
sort_order: 1,
},
]).returning("*")
// 插入标签关联
const bookmarkTags = []
bookmarks.forEach((bookmark, index) => {
// 为每个收藏添加一些标签
if (index === 0) { // MDN
bookmarkTags.push(
{ bookmark_id: bookmark.id, tag_id: tags[0].id }, // JavaScript
{ bookmark_id: bookmark.id, tag_id: tags[2].id }, // Node.js
{ bookmark_id: bookmark.id, tag_id: tags[3].id } // CSS
)
} else if (index === 1) { // GitHub
bookmarkTags.push(
{ bookmark_id: bookmark.id, tag_id: tags[2].id }, // Node.js
{ bookmark_id: bookmark.id, tag_id: tags[6].id } // API
)
} else if (index === 2) { // Stack Overflow
bookmarkTags.push(
{ bookmark_id: bookmark.id, tag_id: tags[0].id }, // JavaScript
{ bookmark_id: bookmark.id, tag_id: tags[2].id } // Node.js
)
} else if (index === 4) { // Figma
bookmarkTags.push(
{ bookmark_id: bookmark.id, tag_id: tags[4].id }, // 设计灵感
{ bookmark_id: bookmark.id, tag_id: tags[5].id } // 免费资源
)
}
})
if (bookmarkTags.length > 0) {
await knex("bookmark_tags").insert(bookmarkTags)
}
// 插入多链接数据
const bookmarkLinks = []
bookmarks.forEach((bookmark, index) => {
if (index === 0) { // MDN
bookmarkLinks.push(
{
bookmark_id: bookmark.id,
title: "JavaScript 教程",
url: "https://developer.mozilla.org/zh-CN/docs/Web/JavaScript",
description: "JavaScript 完整教程",
type: "tutorial",
sort_order: 1,
},
{
bookmark_id: bookmark.id,
title: "CSS 参考",
url: "https://developer.mozilla.org/zh-CN/docs/Web/CSS",
description: "CSS 属性参考手册",
type: "reference",
sort_order: 2,
}
)
} else if (index === 1) { // GitHub
bookmarkLinks.push(
{
bookmark_id: bookmark.id,
title: "GitHub Pages",
url: "https://pages.github.com/",
description: "免费托管静态网站",
type: "service",
sort_order: 1,
},
{
bookmark_id: bookmark.id,
title: "GitHub Actions",
url: "https://github.com/features/actions",
description: "自动化工作流",
type: "service",
sort_order: 2,
}
)
}
})
if (bookmarkLinks.length > 0) {
await knex("bookmark_links").insert(bookmarkLinks)
}
// 插入一些访问历史
const history = []
bookmarks.forEach((bookmark) => {
// 为每个收藏添加一些访问记录
for (let i = 0; i < Math.floor(Math.random() * 5) + 1; i++) {
history.push({
bookmark_id: bookmark.id,
user_id: 1,
action: "visit",
context: JSON.stringify({
referrer: "direct",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}),
})
}
})
if (history.length > 0) {
await knex("bookmark_history").insert(history)
}
console.log("收藏网站种子数据插入完成!")
}

415
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 }

89
src/views/page/index/index.pug

@ -6,4 +6,91 @@ block pageHead
block pageContent block pageContent
div(class="mt-[50px]") div(class="mt-[50px]")
+include() +include()
include /htmx/navbar.pug 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 &times;
//- .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")
Loading…
Cancel
Save