feat: 收藏夹系统 + Studio 趋势图 + 通知偏好设置

- 新增收藏夹系统 (Folder/FolderItem 模型、CRUD API、前端管理页)
- 新增文章详情页收藏按钮 (书签图标、ToggleFavorite 一键收藏)
- 新增 Studio 趋势图 (赋能值/评论/点赞/收藏 4 线图, 7d/30d/90d)
- 新增通知偏好设置页 (评论/回复/点赞/关注 4 类开关)
- 后端通知列表支持按偏好过滤 (排除已关闭的通知类型)
- 下载 Chart.js 到本地静态资源 (static/js/chart.umd.min.js)
- 补充收藏夹卡片、开关滑块、趋势图网格等 CSS 样式
This commit is contained in:
2026-06-01 17:33:59 +08:00
parent 680df7371a
commit 4d212a8f8a
27 changed files with 2005 additions and 33 deletions

View File

@ -2,6 +2,7 @@ package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"strconv"
"gorm.io/gorm"
@ -258,6 +259,22 @@ func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
return authorID, err
}
// AggregateCommentsByAuthor 按日期聚合评论量(作者所有文章在时间段内的评论)
func (r *CommentRepo) AggregateCommentsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("comments").
Select("TO_CHAR(comments.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("posts.user_id = ? AND comments.created_at >= ?", userID, since).
Group("TO_CHAR(comments.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}
// ListAllComments 后台评论列表(支持关键词搜索、分页)
func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) {
var total int64

View File

@ -146,3 +146,19 @@ func (r *EnergyRepo) ListAllEnergyLogs(energyType string, offset, limit int) ([]
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
return logs, total, err
}
// AggregateEnergyByAuthor 按日期聚合赋能值(作者所有文章在时间段内的赋能记录)
func (r *EnergyRepo) AggregateEnergyByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("post_energize_logs").
Select("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD') AS date, COALESCE(SUM(post_energize_logs.amount), 0) AS value").
Joins("JOIN posts ON posts.id = post_energize_logs.post_id").
Where("posts.user_id = ? AND post_energize_logs.created_at >= ?", userID, since).
Group("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}

View File

@ -0,0 +1,157 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
)
// FavoriteRepo 收藏夹数据访问
type FavoriteRepo struct {
db *gorm.DB
}
// NewFavoriteRepo 构造函数
func NewFavoriteRepo(db *gorm.DB) *FavoriteRepo {
return &FavoriteRepo{db: db}
}
// WithTx 基于事务连接创建 FavoriteRepo
func (r *FavoriteRepo) WithTx(tx *gorm.DB) service.FavoriteStore {
return &FavoriteRepo{db: tx}
}
// ======================== Folder CRUD ========================
// CreateFolder 创建收藏夹
func (r *FavoriteRepo) CreateFolder(folder *model.Folder) error {
return r.db.Create(folder).Error
}
// FindFolderByID 按 ID 查找收藏夹
func (r *FavoriteRepo) FindFolderByID(id uint) (*model.Folder, error) {
var f model.Folder
err := r.db.First(&f, id).Error
if err != nil {
return nil, err
}
return &f, nil
}
// ListFoldersByUser 列用户的收藏夹(附项目数)
func (r *FavoriteRepo) ListFoldersByUser(userID uint) ([]model.Folder, error) {
var folders []model.Folder
err := r.db.Table("folders").
Select("folders.*, COUNT(folder_items.id) AS item_count").
Joins("LEFT JOIN folder_items ON folder_items.folder_id = folders.id").
Where("folders.user_id = ?", userID).
Group("folders.id").
Order("folders.is_default DESC, folders.created_at ASC").
Scan(&folders).Error
return folders, err
}
// UpdateFolder 更新收藏夹
func (r *FavoriteRepo) UpdateFolder(folder *model.Folder) error {
return r.db.Save(folder).Error
}
// DeleteFolder 删除收藏夹
func (r *FavoriteRepo) DeleteFolder(id uint) error {
return r.db.Where("id = ?", id).Delete(&model.Folder{}).Error
}
// DeleteItemsByFolder 删除收藏夹内所有记录
func (r *FavoriteRepo) DeleteItemsByFolder(folderID uint) error {
return r.db.Where("folder_id = ?", folderID).Delete(&model.FolderItem{}).Error
}
// CountByNameAndUser 检查同用户下名称是否重复(排除自身)
func (r *FavoriteRepo) CountByNameAndUser(userID uint, name string, excludeID uint) (int64, error) {
var count int64
q := r.db.Model(&model.Folder{}).Where("user_id = ? AND name = ?", userID, name)
if excludeID > 0 {
q = q.Where("id != ?", excludeID)
}
err := q.Count(&count).Error
return count, err
}
// HasDefaultFolder 检查用户是否有默认收藏夹
func (r *FavoriteRepo) HasDefaultFolder(userID uint) (bool, error) {
var count int64
err := r.db.Model(&model.Folder{}).Where("user_id = ? AND is_default = ?", userID, true).Count(&count).Error
return count > 0, err
}
// ======================== FolderItem CRUD ========================
// CreateItem 添加收藏
func (r *FavoriteRepo) CreateItem(item *model.FolderItem) error {
return r.db.Create(item).Error
}
// DeleteItem 取消收藏
func (r *FavoriteRepo) DeleteItem(folderID, postID uint) error {
return r.db.Where("folder_id = ? AND post_id = ?", folderID, postID).Delete(&model.FolderItem{}).Error
}
// FindItemByUserAndPost 查找用户对某文章的收藏记录
func (r *FavoriteRepo) FindItemByUserAndPost(userID, postID uint) (*model.FolderItem, error) {
var item model.FolderItem
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&item).Error
if err != nil {
return nil, err
}
return &item, nil
}
// ListItemsByFolder 列收藏夹内文章分页JOIN posts 获取标题)
func (r *FavoriteRepo) ListItemsByFolder(folderID uint, offset, limit int) ([]model.FolderItem, int64, error) {
var total int64
if err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.FolderItem
err := r.db.Table("folder_items").
Select("folder_items.*, posts.title AS post_title").
Joins("LEFT JOIN posts ON posts.id = folder_items.post_id").
Where("folder_items.folder_id = ?", folderID).
Order("folder_items.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// CountItemsByFolder 统计收藏夹内文章数
func (r *FavoriteRepo) CountItemsByFolder(folderID uint) (int64, error) {
var count int64
err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&count).Error
return count, err
}
// ======================== Post 计数器 ========================
// IncrPostFavoritesCount 原子更新文章收藏数
func (r *FavoriteRepo) IncrPostFavoritesCount(postID uint, delta int) error {
return r.db.Model(&model.Post{}).
Where("id = ?", postID).
UpdateColumn("favorites_count", gorm.Expr("favorites_count + ?", delta)).Error
}
// ======================== 趋势聚合 ========================
// AggregateFavoritesByAuthor 按日期聚合收藏量(对于某用户的文章)
func (r *FavoriteRepo) AggregateFavoritesByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("folder_items").
Select("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
Joins("JOIN posts ON posts.id = folder_items.post_id").
Where("posts.user_id = ? AND folder_items.created_at >= ?", userID, since).
Group("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
return results, err
}

View File

@ -105,3 +105,43 @@ func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) {
err := r.db.Select("username").First(&user, userID).Error
return user.Username, err
}
// GetNotifyPrefs 获取用户通知偏好NotifyPrefs 字段原始 JSON
func (r *NotificationRepo) GetNotifyPrefs(userID uint) (string, error) {
var prefs string
err := r.db.Table("users").Select("notify_prefs").Where("uid = ?", userID).Scan(&prefs).Error
return prefs, err
}
// ListByUserExcludeTypes 分页查询用户消息(排除指定通知类型)
func (r *NotificationRepo) ListByUserExcludeTypes(userID uint, excludeTypes []string, offset, limit int) ([]model.Notification, error) {
var list []model.Notification
q := r.db.Where("user_id = ?", userID)
if len(excludeTypes) > 0 {
q = q.Where("notify_type NOT IN ?", excludeTypes)
}
err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&list).Error
return list, err
}
// CountByUserExcludeTypes 统计用户消息总数(排除指定通知类型)
func (r *NotificationRepo) CountByUserExcludeTypes(userID uint, excludeTypes []string) (int64, error) {
var count int64
q := r.db.Model(&model.Notification{}).Where("user_id = ?", userID)
if len(excludeTypes) > 0 {
q = q.Where("notify_type NOT IN ?", excludeTypes)
}
err := q.Count(&count).Error
return count, err
}
// CountUnreadExcludeTypes 统计用户未读消息数(排除指定通知类型)
func (r *NotificationRepo) CountUnreadExcludeTypes(userID uint, excludeTypes []string) (int64, error) {
var count int64
q := r.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = false", userID)
if len(excludeTypes) > 0 {
q = q.Where("notify_type NOT IN ?", excludeTypes)
}
err := q.Count(&count).Error
return count, err
}

View File

@ -119,3 +119,19 @@ func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSum
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}
// AggregateLikesByAuthor 按日期聚合点赞量(作者所有文章在时间段内的点赞)
func (r *ReactionRepo) AggregateLikesByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("post_likes").
Select("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
Joins("JOIN posts ON posts.id = post_likes.post_id").
Where("posts.user_id = ? AND post_likes.created_at >= ?", userID, since).
Group("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}