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:
@ -27,7 +27,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
270
internal/controller/favorite_controller.go
Normal file
270
internal/controller/favorite_controller.go
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// favoriteUseCase FavoriteController 对 FavoriteService 的最小依赖(ISP)
|
||||||
|
type favoriteUseCase interface {
|
||||||
|
ListFolders(userID uint) (*service.FavoriteListResult, error)
|
||||||
|
CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error)
|
||||||
|
UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error
|
||||||
|
DeleteFolder(userID, folderID uint) error
|
||||||
|
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
|
||||||
|
AddFavorite(userID, postID uint, folderID *uint) error
|
||||||
|
RemoveFavorite(userID, postID uint) error
|
||||||
|
ToggleFavorite(userID, postID uint) (bool, error)
|
||||||
|
GetPostFavoriteStatus(userID, postID uint) (*service.FavoriteStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoriteController 收藏夹 API 控制器
|
||||||
|
type FavoriteController struct {
|
||||||
|
svc favoriteUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFavoriteController 构造函数
|
||||||
|
func NewFavoriteController(svc favoriteUseCase) *FavoriteController {
|
||||||
|
return &FavoriteController{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFolders 我的收藏夹列表 GET /api/folders
|
||||||
|
func (fc *FavoriteController) ListFolders(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fc.svc.ListFolders(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取收藏夹失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder 创建收藏夹 POST /api/folders
|
||||||
|
func (fc *FavoriteController) CreateFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := fc.svc.CreateFolder(uid, req.Name, req.Description, req.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "收藏夹名称已存在" || err.Error() == "收藏夹名称不能为空" || err.Error() == "收藏夹名称不能超过 50 个字符" {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusInternalServerError, "创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkWithMessage(c, f, "收藏夹创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFolder 修改收藏夹 PUT /api/folders/:id
|
||||||
|
func (fc *FavoriteController) UpdateFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.UpdateFolder(uid, uint(folderID), req.Name, req.Description, req.IsPublic); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "收藏夹已更新")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder 删除收藏夹 DELETE /api/folders/:id
|
||||||
|
func (fc *FavoriteController) DeleteFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.DeleteFolder(uid, uint(folderID)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "收藏夹已删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFolderItems 获取收藏夹内文章列表 GET /api/folders/:id/posts
|
||||||
|
func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
|
||||||
|
uid, _, _ := common.GetGinUser(c) // 允许未登录查看公开收藏夹
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
if err == common.ErrPermissionDenied {
|
||||||
|
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToFolder 收藏文章 POST /api/folders/:id/posts
|
||||||
|
func (fc *FavoriteController) AddToFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PostID uint `json:"post_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.PostID == 0 {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.AddFavorite(uid, req.PostID, func() *uint { v := uint(folderID); return &v }()); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "收藏成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFromFolder 取消收藏 DELETE /api/folders/:id/posts/:postId
|
||||||
|
func (fc *FavoriteController) RemoveFromFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("postId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.RemoveFavorite(uid, uint(postID)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = folderID // 移除不依赖具体收藏夹
|
||||||
|
common.OkMessage(c, "已取消收藏")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostStatus 查询文章收藏状态 GET /api/posts/:id/folder-status
|
||||||
|
func (fc *FavoriteController) GetPostStatus(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Ok(c, gin.H{"favorited": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := fc.svc.GetPostFavoriteStatus(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleFavorite 切换收藏 POST /api/posts/:id/favorite
|
||||||
|
func (fc *FavoriteController) ToggleFavorite(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isFavored, err := fc.svc.ToggleFavorite(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"favorited": isFavored,
|
||||||
|
})
|
||||||
|
}
|
||||||
85
internal/controller/settings_api_notify.go
Normal file
85
internal/controller/settings_api_notify.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifyPrefReader 通知偏好读写的接口(ISP)
|
||||||
|
type notifyPrefReader interface {
|
||||||
|
GetNotifyPrefs(userID uint) (map[string]bool, error)
|
||||||
|
UpdateNotifyPref(userID uint, key string, enabled bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNotifyPrefReader 注入通知偏好读写器
|
||||||
|
func (sc *SettingsController) SetNotifyPrefReader(reader notifyPrefReader) {
|
||||||
|
sc.notifyPrefReader = reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotifyPrefs 获取通知偏好 GET /api/settings/notify-prefs
|
||||||
|
func (sc *SettingsController) GetNotifyPrefs(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc.notifyPrefReader == nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "服务不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs, err := sc.notifyPrefReader.GetNotifyPrefs(uid.(uint))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotifyPref 更新通知偏好 PUT /api/settings/notify-prefs
|
||||||
|
func (sc *SettingsController) UpdateNotifyPref(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc.notifyPrefReader == nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "服务不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 key 是否合法
|
||||||
|
allowedKeys := map[string]bool{
|
||||||
|
model.NotifyComment: true,
|
||||||
|
model.NotifyCommentReply: true,
|
||||||
|
model.NotifyLikeAggregated: true,
|
||||||
|
model.NotifyFollow: true,
|
||||||
|
}
|
||||||
|
if !allowedKeys[req.Key] {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的通知类型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.notifyPrefReader.UpdateNotifyPref(uid.(uint), req.Key, req.Enabled); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "通知偏好已更新")
|
||||||
|
}
|
||||||
@ -55,6 +55,7 @@ type SettingsController struct {
|
|||||||
sessionCfg sessionConfig
|
sessionCfg sessionConfig
|
||||||
levelSvc taskCompleter
|
levelSvc taskCompleter
|
||||||
energySvc energyRenameHandler
|
energySvc energyRenameHandler
|
||||||
|
notifyPrefReader notifyPrefReader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsController 构造函数
|
// NewSettingsController 构造函数
|
||||||
@ -78,7 +79,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
uid := uidVal.(uint)
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
tab := c.Param("tab")
|
tab := c.Param("tab")
|
||||||
if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" {
|
if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" && tab != "favorites" && tab != "notify" {
|
||||||
c.String(http.StatusNotFound, "页面不存在")
|
c.String(http.StatusNotFound, "页面不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,14 +8,21 @@ import (
|
|||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// trendsUseCase StudioController 对趋势服务的最小依赖(ISP)
|
||||||
|
type trendsUseCase interface {
|
||||||
|
GetTrends(userID uint, since string) (*service.TrendResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
// StudioController 创作中心控制器(SSR 页面 + API)
|
// StudioController 创作中心控制器(SSR 页面 + API)
|
||||||
type StudioController struct {
|
type StudioController struct {
|
||||||
postService studioUseCase
|
postService studioUseCase
|
||||||
energySvc energyDeducter
|
energySvc energyDeducter
|
||||||
|
trendsSvc trendsUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStudioController 构造函数
|
// NewStudioController 构造函数
|
||||||
@ -29,6 +36,12 @@ func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioContr
|
|||||||
return ctrl
|
return ctrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTrendsService 链式注入趋势服务
|
||||||
|
func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioController {
|
||||||
|
ctrl.trendsSvc = svc
|
||||||
|
return ctrl
|
||||||
|
}
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
// SSR 页面方法
|
// SSR 页面方法
|
||||||
// ==============================
|
// ==============================
|
||||||
@ -402,3 +415,41 @@ func (ctrl *StudioController) Submit(c *gin.Context) {
|
|||||||
|
|
||||||
common.OkMessage(c, "已提交审核")
|
common.OkMessage(c, "已提交审核")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrendsAPI 创作中心趋势数据 API GET /api/studio/trends?period=7d|30d|90d
|
||||||
|
func (ctrl *StudioController) TrendsAPI(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctrl.trendsSvc == nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "趋势服务不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
period := c.DefaultQuery("period", "7d")
|
||||||
|
var days int
|
||||||
|
switch period {
|
||||||
|
case "7d":
|
||||||
|
days = 7
|
||||||
|
case "30d":
|
||||||
|
days = 30
|
||||||
|
case "90d":
|
||||||
|
days = 90
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的时间范围,支持 7d/30d/90d")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
|
|
||||||
|
result, err := ctrl.trendsSvc.GetTrends(uid, since)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取趋势数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|||||||
30
internal/model/folder.go
Normal file
30
internal/model/folder.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Folder 收藏夹
|
||||||
|
type Folder struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
|
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
||||||
|
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||||
|
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 联表查询填充(非数据库字段)
|
||||||
|
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderItem 收藏记录
|
||||||
|
type FolderItem struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
||||||
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// 联表查询填充
|
||||||
|
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ type Post struct {
|
|||||||
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
||||||
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
||||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||||
|
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -258,6 +259,22 @@ func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
|
|||||||
return authorID, err
|
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 后台评论列表(支持关键词搜索、分页)
|
// ListAllComments 后台评论列表(支持关键词搜索、分页)
|
||||||
func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) {
|
func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@ -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
|
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
return logs, total, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
157
internal/repository/favorite_repo.go
Normal file
157
internal/repository/favorite_repo.go
Normal 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
|
||||||
|
}
|
||||||
@ -105,3 +105,43 @@ func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) {
|
|||||||
err := r.db.Select("username").First(&user, userID).Error
|
err := r.db.Select("username").First(&user, userID).Error
|
||||||
return user.Username, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -119,3 +119,19 @@ func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSum
|
|||||||
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
|
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
|
||||||
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).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
|
||||||
|
}
|
||||||
|
|||||||
@ -25,6 +25,9 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
|
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
|
||||||
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
|
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
|
||||||
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
|
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
|
||||||
|
// 通知偏好
|
||||||
|
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
|
||||||
|
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 消息中心 API(需登录) ---
|
// --- 消息中心 API(需登录) ---
|
||||||
@ -145,4 +148,38 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
{
|
{
|
||||||
followAPI.POST("/follow", d.followCtrl.Toggle)
|
followAPI.POST("/follow", d.followCtrl.Toggle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 收藏夹 API ---
|
||||||
|
// 公开:文章收藏状态(未登录返回 false)
|
||||||
|
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
|
||||||
|
// 公开:查看公开收藏夹内容
|
||||||
|
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
|
||||||
|
// 需登录:收藏操作
|
||||||
|
folderAPI := r.Group("/api/folders")
|
||||||
|
folderAPI.Use(d.authMdw.Required())
|
||||||
|
folderAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
folderAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
folderAPI.GET("", d.favoriteCtrl.ListFolders)
|
||||||
|
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
|
||||||
|
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
|
||||||
|
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
|
||||||
|
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
|
||||||
|
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
|
||||||
|
}
|
||||||
|
// 需登录:切换收藏(Toggle)
|
||||||
|
favToggleAPI := r.Group("/api/posts/:id")
|
||||||
|
favToggleAPI.Use(d.authMdw.Required())
|
||||||
|
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
favToggleAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Studio 趋势 API(需登录) ---
|
||||||
|
trendsAPI := r.Group("/api/studio")
|
||||||
|
trendsAPI.Use(d.authMdw.Required())
|
||||||
|
{
|
||||||
|
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,9 @@ type dependencies struct {
|
|||||||
reactionCtrl *controller.ReactionController
|
reactionCtrl *controller.ReactionController
|
||||||
followCtrl *controller.FollowController
|
followCtrl *controller.FollowController
|
||||||
followPageCtrl *controller.FollowPageController
|
followPageCtrl *controller.FollowPageController
|
||||||
|
favoriteCtrl *controller.FavoriteController
|
||||||
|
favoriteService *service.FavoriteService
|
||||||
|
trendsService *service.TrendsService
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
|
|||||||
@ -40,6 +40,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
|
|
||||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
|
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
|
||||||
settingsCtrl.WithEnergyService(energyService)
|
settingsCtrl.WithEnergyService(energyService)
|
||||||
|
settingsCtrl.SetNotifyPrefReader(authService)
|
||||||
siteSettingController := adminCtrl.NewSiteSettingController(
|
siteSettingController := adminCtrl.NewSiteSettingController(
|
||||||
service.NewSiteSettingService(siteSettings),
|
service.NewSiteSettingService(siteSettings),
|
||||||
&service.AuditDefaults{
|
&service.AuditDefaults{
|
||||||
@ -85,6 +86,15 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||||
spaceCtrl := controller.NewSpaceController(spaceService)
|
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||||
|
|
||||||
|
// 收藏夹系统
|
||||||
|
favoriteRepo := repository.NewFavoriteRepo(db)
|
||||||
|
favoriteService := service.NewFavoriteService(db, favoriteRepo)
|
||||||
|
favoriteCtrl := controller.NewFavoriteController(favoriteService)
|
||||||
|
|
||||||
|
// Studio 趋势图
|
||||||
|
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo)
|
||||||
|
studioCtrl.WithTrendsService(trendsService)
|
||||||
|
|
||||||
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
||||||
|
|
||||||
return &dependencies{
|
return &dependencies{
|
||||||
@ -105,5 +115,8 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
reactionCtrl: reactionCtrl,
|
reactionCtrl: reactionCtrl,
|
||||||
followCtrl: followCtrl,
|
followCtrl: followCtrl,
|
||||||
followPageCtrl: followPageCtrl,
|
followPageCtrl: followPageCtrl,
|
||||||
|
favoriteCtrl: favoriteCtrl,
|
||||||
|
favoriteService: favoriteService,
|
||||||
|
trendsService: trendsService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@ -309,3 +310,29 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
|
|||||||
}
|
}
|
||||||
return s.userRepo.Update(user)
|
return s.userRepo.Update(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNotifyPrefs 获取用户通知偏好
|
||||||
|
func (s *AuthService) GetNotifyPrefs(userID uint) (map[string]bool, error) {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return ParseNotifyPrefs(user.NotifyPrefs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotifyPref 更新单个通知偏好项
|
||||||
|
func (s *AuthService) UpdateNotifyPref(userID uint, key string, enabled bool) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := ParseNotifyPrefs(user.NotifyPrefs)
|
||||||
|
prefs[key] = enabled
|
||||||
|
data, err := json.Marshal(prefs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.NotifyPrefs = string(data)
|
||||||
|
return s.userRepo.Update(user)
|
||||||
|
}
|
||||||
|
|||||||
363
internal/service/favorite_service.go
Normal file
363
internal/service/favorite_service.go
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FavoriteStore FavoriteService 所需的最小仓储接口(ISP)
|
||||||
|
type FavoriteStore interface {
|
||||||
|
// Folder
|
||||||
|
CreateFolder(folder *model.Folder) error
|
||||||
|
FindFolderByID(id uint) (*model.Folder, error)
|
||||||
|
ListFoldersByUser(userID uint) ([]model.Folder, error)
|
||||||
|
UpdateFolder(folder *model.Folder) error
|
||||||
|
DeleteFolder(id uint) error
|
||||||
|
DeleteItemsByFolder(folderID uint) error
|
||||||
|
CountByNameAndUser(userID uint, name string, excludeID uint) (int64, error)
|
||||||
|
HasDefaultFolder(userID uint) (bool, error)
|
||||||
|
// FolderItem
|
||||||
|
CreateItem(item *model.FolderItem) error
|
||||||
|
DeleteItem(folderID, postID uint) error
|
||||||
|
FindItemByUserAndPost(userID, postID uint) (*model.FolderItem, error)
|
||||||
|
ListItemsByFolder(folderID uint, offset, limit int) ([]model.FolderItem, int64, error)
|
||||||
|
CountItemsByFolder(folderID uint) (int64, error)
|
||||||
|
// Post counter
|
||||||
|
IncrPostFavoritesCount(postID uint, delta int) error
|
||||||
|
// Trend
|
||||||
|
AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||||
|
// Tx
|
||||||
|
WithTx(tx *gorm.DB) FavoriteStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoriteListResult 收藏夹列表 + 收藏内容
|
||||||
|
type FavoriteListResult struct {
|
||||||
|
Folders []model.Folder `json:"folders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderItemsResult 收藏夹内容
|
||||||
|
type FolderItemsResult struct {
|
||||||
|
Items []model.FolderItem `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoriteStatus 某文章的收藏状态
|
||||||
|
type FavoriteStatus struct {
|
||||||
|
Favorited bool `json:"favorited"`
|
||||||
|
FolderID *uint `json:"folder_id,omitempty"`
|
||||||
|
FolderName *string `json:"folder_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoriteService 收藏夹业务逻辑
|
||||||
|
type FavoriteService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
repo FavoriteStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFavoriteService 构造函数
|
||||||
|
func NewFavoriteService(db *gorm.DB, repo FavoriteStore) *FavoriteService {
|
||||||
|
return &FavoriteService{db: db, repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDefaultFolder 确保用户有默认收藏夹(不存在则创建)
|
||||||
|
func (s *FavoriteService) ensureDefaultFolder(userID uint) (*model.Folder, error) {
|
||||||
|
has, err := s.repo.HasDefaultFolder(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询默认收藏夹: %w", err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
folders, err := s.repo.ListFoldersByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询收藏夹列表: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range folders {
|
||||||
|
if f.IsDefault {
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认收藏夹
|
||||||
|
f := &model.Folder{
|
||||||
|
UserID: userID,
|
||||||
|
Name: "默认",
|
||||||
|
Description: "自动创建的默认收藏夹",
|
||||||
|
IsPublic: false,
|
||||||
|
IsDefault: true,
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateFolder(f); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建默认收藏夹: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFolders 获取用户的所有收藏夹
|
||||||
|
func (s *FavoriteService) ListFolders(userID uint) (*FavoriteListResult, error) {
|
||||||
|
folders, err := s.repo.ListFoldersByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询收藏夹: %w", err)
|
||||||
|
}
|
||||||
|
if folders == nil {
|
||||||
|
folders = []model.Folder{}
|
||||||
|
}
|
||||||
|
return &FavoriteListResult{Folders: folders}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder 创建收藏夹
|
||||||
|
func (s *FavoriteService) CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("收藏夹名称不能为空")
|
||||||
|
}
|
||||||
|
if len(name) > 50 {
|
||||||
|
return nil, fmt.Errorf("收藏夹名称不能超过 50 个字符")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查名称是否重复
|
||||||
|
count, err := s.repo.CountByNameAndUser(userID, name, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("检查名称: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil, fmt.Errorf("收藏夹名称已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &model.Folder{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
IsPublic: isPublic,
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateFolder(f); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建收藏夹: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFolder 修改收藏夹(默认收藏夹不可改名/改描述)
|
||||||
|
func (s *FavoriteService) UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error {
|
||||||
|
f, err := s.repo.FindFolderByID(folderID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrPostNotFound // 复用错误哨兵
|
||||||
|
}
|
||||||
|
if f.UserID != userID {
|
||||||
|
return common.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.IsDefault {
|
||||||
|
// 默认收藏夹只允许修改公开性
|
||||||
|
f.IsPublic = isPublic
|
||||||
|
return s.repo.UpdateFolder(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("收藏夹名称不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查名称重复
|
||||||
|
count, err := s.repo.CountByNameAndUser(userID, name, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("检查名称: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("收藏夹名称已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Name = name
|
||||||
|
f.Description = description
|
||||||
|
f.IsPublic = isPublic
|
||||||
|
return s.repo.UpdateFolder(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder 删除收藏夹(默认不可删)
|
||||||
|
func (s *FavoriteService) DeleteFolder(userID, folderID uint) error {
|
||||||
|
f, err := s.repo.FindFolderByID(folderID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrPostNotFound
|
||||||
|
}
|
||||||
|
if f.UserID != userID {
|
||||||
|
return common.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if f.IsDefault {
|
||||||
|
return fmt.Errorf("默认收藏夹不可删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
|
||||||
|
// 先清理 items 的 fans 计数
|
||||||
|
items, _, _ := txRepo.ListItemsByFolder(folderID, 0, 10000)
|
||||||
|
for _, item := range items {
|
||||||
|
if err := txRepo.IncrPostFavoritesCount(item.PostID, -1); err != nil {
|
||||||
|
return fmt.Errorf("更新文章收藏数: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := txRepo.DeleteItemsByFolder(folderID); err != nil {
|
||||||
|
return fmt.Errorf("删除收藏内容: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txRepo.DeleteFolder(folderID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToFolder 将文章加入收藏夹(会先移除该文章在其他收藏夹的收藏)
|
||||||
|
func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error {
|
||||||
|
// 检查收藏夹归属
|
||||||
|
f, err := s.repo.FindFolderByID(folderID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrPostNotFound
|
||||||
|
}
|
||||||
|
if f.UserID != userID {
|
||||||
|
return common.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
|
||||||
|
// 如果该文章已在其他收藏夹中,先移除
|
||||||
|
existing, err := txRepo.FindItemByUserAndPost(userID, postID)
|
||||||
|
if err == nil && existing.FolderID != folderID {
|
||||||
|
if err := txRepo.DeleteItem(existing.FolderID, postID); err != nil {
|
||||||
|
return fmt.Errorf("移除旧收藏: %w", err)
|
||||||
|
}
|
||||||
|
// 旧收藏夹没有增加计数,所以不需要减
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已在目标收藏夹中
|
||||||
|
if err == nil && existing.FolderID == folderID {
|
||||||
|
return fmt.Errorf("文章已在此收藏夹中")
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &model.FolderItem{
|
||||||
|
FolderID: folderID,
|
||||||
|
PostID: postID,
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
if err := txRepo.CreateItem(item); err != nil {
|
||||||
|
return fmt.Errorf("添加收藏: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txRepo.IncrPostFavoritesCount(postID, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFromFolder 从收藏夹移除文章
|
||||||
|
func (s *FavoriteService) RemoveFromFolder(userID, folderID, postID uint) error {
|
||||||
|
f, err := s.repo.FindFolderByID(folderID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrPostNotFound
|
||||||
|
}
|
||||||
|
if f.UserID != userID {
|
||||||
|
return common.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
if err := txRepo.DeleteItem(folderID, postID); err != nil {
|
||||||
|
return fmt.Errorf("移除收藏: %w", err)
|
||||||
|
}
|
||||||
|
return txRepo.IncrPostFavoritesCount(postID, -1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFavorite 收藏文章(自动选择或创建默认收藏夹)
|
||||||
|
func (s *FavoriteService) AddFavorite(userID, postID uint, folderID *uint) error {
|
||||||
|
var targetFolderID uint
|
||||||
|
|
||||||
|
if folderID != nil && *folderID > 0 {
|
||||||
|
targetFolderID = *folderID
|
||||||
|
} else {
|
||||||
|
// 使用或创建默认收藏夹
|
||||||
|
f, err := s.ensureDefaultFolder(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targetFolderID = f.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.AddToFolder(userID, targetFolderID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFavorite 取消收藏文章
|
||||||
|
func (s *FavoriteService) RemoveFavorite(userID, postID uint) error {
|
||||||
|
existing, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("未收藏该文章")
|
||||||
|
}
|
||||||
|
return s.RemoveFromFolder(userID, existing.FolderID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFolderItems 列收藏夹内的文章
|
||||||
|
func (s *FavoriteService) ListFolderItems(userID, folderID uint, page, pageSize int) (*FolderItemsResult, error) {
|
||||||
|
f, err := s.repo.FindFolderByID(folderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, common.ErrPostNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限检查:非本人 + 非公开 → 拒绝
|
||||||
|
if f.UserID != userID && !f.IsPublic {
|
||||||
|
return nil, common.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
|
||||||
|
items, total, err := s.repo.ListItemsByFolder(folderID, p.Offset(), p.PageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询收藏内容: %w", err)
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []model.FolderItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FolderItemsResult{
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Page: p.Page,
|
||||||
|
TotalPages: common.PageCount(total, p.PageSize),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostFavoriteStatus 查询文章收藏状态
|
||||||
|
func (s *FavoriteService) GetPostFavoriteStatus(userID, postID uint) (*FavoriteStatus, error) {
|
||||||
|
item, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||||
|
if err != nil {
|
||||||
|
return &FavoriteStatus{Favorited: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.repo.FindFolderByID(item.FolderID)
|
||||||
|
var folderName *string
|
||||||
|
if err == nil {
|
||||||
|
folderName = &f.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FavoriteStatus{
|
||||||
|
Favorited: true,
|
||||||
|
FolderID: &item.FolderID,
|
||||||
|
FolderName: folderName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleFavorite 切换收藏(有则取消,无则加入默认收藏夹)
|
||||||
|
func (s *FavoriteService) ToggleFavorite(userID, postID uint) (bool, error) {
|
||||||
|
existing, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||||
|
if err == nil {
|
||||||
|
// 已收藏,取消
|
||||||
|
if err := s.RemoveFromFolder(userID, existing.FolderID, postID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未收藏,加入默认
|
||||||
|
if err := s.AddFavorite(userID, postID, nil); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@ -8,20 +9,45 @@ import (
|
|||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ParseNotifyPrefs 解析用户通知偏好 JSON 字符串
|
||||||
|
func ParseNotifyPrefs(raw string) map[string]bool {
|
||||||
|
prefs := map[string]bool{
|
||||||
|
model.NotifyComment: true,
|
||||||
|
model.NotifyCommentReply: true,
|
||||||
|
model.NotifyLikeAggregated: true,
|
||||||
|
model.NotifyFollow: true,
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
var parsed map[string]bool
|
||||||
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
for k, v := range parsed {
|
||||||
|
prefs[k] = v
|
||||||
|
}
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
|
||||||
// notifRepo 通知服务所需的最小仓储接口(ISP)
|
// notifRepo 通知服务所需的最小仓储接口(ISP)
|
||||||
type notifRepo interface {
|
type notifRepo interface {
|
||||||
Create(n *model.Notification) error
|
Create(n *model.Notification) error
|
||||||
ListByUser(userID uint, offset, limit int) ([]model.Notification, error)
|
ListByUser(userID uint, offset, limit int) ([]model.Notification, error)
|
||||||
ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error)
|
ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error)
|
||||||
|
ListByUserExcludeTypes(userID uint, excludeTypes []string, offset, limit int) ([]model.Notification, error)
|
||||||
CountByUser(userID uint) (int64, error)
|
CountByUser(userID uint) (int64, error)
|
||||||
|
CountByUserExcludeTypes(userID uint, excludeTypes []string) (int64, error)
|
||||||
CountByUserAndType(userID uint, notifyType string) (int64, error)
|
CountByUserAndType(userID uint, notifyType string) (int64, error)
|
||||||
CountUnread(userID uint) (int64, error)
|
CountUnread(userID uint) (int64, error)
|
||||||
|
CountUnreadExcludeTypes(userID uint, excludeTypes []string) (int64, error)
|
||||||
CountUnreadByType(userID uint, notifyType string) (int64, error)
|
CountUnreadByType(userID uint, notifyType string) (int64, error)
|
||||||
MarkRead(id, userID uint) error
|
MarkRead(id, userID uint) error
|
||||||
MarkAllRead(userID uint) error
|
MarkAllRead(userID uint) error
|
||||||
GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error)
|
GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error)
|
||||||
MarkDailyLikeSummaryNotified(id uint) error
|
MarkDailyLikeSummaryNotified(id uint) error
|
||||||
GetUsernameByID(userID uint) (string, error)
|
GetUsernameByID(userID uint) (string, error)
|
||||||
|
GetNotifyPrefs(userID uint) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationService 消息/通知业务逻辑
|
// NotificationService 消息/通知业务逻辑
|
||||||
@ -56,12 +82,14 @@ func (s *NotificationService) List(userID uint, page, pageSize int) (*model.Noti
|
|||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
p.DefaultPagination()
|
p.DefaultPagination()
|
||||||
|
|
||||||
total, err := s.repo.CountByUser(userID)
|
excludeTypes := s.getDisabledTypes(userID)
|
||||||
|
|
||||||
|
total, err := s.repo.CountByUserExcludeTypes(userID, excludeTypes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := s.repo.ListByUser(userID, p.Offset(), p.PageSize)
|
items, err := s.repo.ListByUserExcludeTypes(userID, excludeTypes, p.Offset(), p.PageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -198,9 +226,31 @@ func sortNotifications(items []model.Notification) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountUnread 统计未读消息数
|
// CountUnread 统计未读消息数(排除用户禁用的通知类型)
|
||||||
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
|
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
|
||||||
return s.repo.CountUnread(userID)
|
excludeTypes := s.getDisabledTypes(userID)
|
||||||
|
return s.repo.CountUnreadExcludeTypes(userID, excludeTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDisabledTypes 获取用户禁用的通知类型列表
|
||||||
|
func (s *NotificationService) getDisabledTypes(userID uint) []string {
|
||||||
|
raw, err := s.repo.GetNotifyPrefs(userID)
|
||||||
|
if err != nil || raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefs := ParseNotifyPrefs(raw)
|
||||||
|
var disabled []string
|
||||||
|
for _, t := range []string{
|
||||||
|
model.NotifyComment,
|
||||||
|
model.NotifyCommentReply,
|
||||||
|
model.NotifyLikeAggregated,
|
||||||
|
model.NotifyFollow,
|
||||||
|
} {
|
||||||
|
if !prefs[t] {
|
||||||
|
disabled = append(disabled, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkRead 标记单条消息为已读
|
// MarkRead 标记单条消息为已读
|
||||||
|
|||||||
@ -185,3 +185,29 @@ type FollowStore interface {
|
|||||||
GetFollowListPublic(userID uint) (bool, error)
|
GetFollowListPublic(userID uint) (bool, error)
|
||||||
WithTx(tx *gorm.DB) FollowStore
|
WithTx(tx *gorm.DB) FollowStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrendPoint 趋势数据点
|
||||||
|
type TrendPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnergyTrendStore 赋能趋势聚合(ISP)
|
||||||
|
type EnergyTrendStore interface {
|
||||||
|
AggregateEnergyByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentTrendStore 评论趋势聚合(ISP)
|
||||||
|
type CommentTrendStore interface {
|
||||||
|
AggregateCommentsByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikeTrendStore 点赞趋势聚合(ISP)
|
||||||
|
type LikeTrendStore interface {
|
||||||
|
AggregateLikesByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoriteTrendStore 收藏趋势聚合(ISP)
|
||||||
|
type FavoriteTrendStore interface {
|
||||||
|
AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||||
|
}
|
||||||
|
|||||||
59
internal/service/trends_service.go
Normal file
59
internal/service/trends_service.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// TrendResult 趋势返回结构
|
||||||
|
type TrendResult struct {
|
||||||
|
Energy []TrendPoint `json:"energy"`
|
||||||
|
Comments []TrendPoint `json:"comments"`
|
||||||
|
Likes []TrendPoint `json:"likes"`
|
||||||
|
Favorites []TrendPoint `json:"favorites"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendsService Studio 趋势分析服务
|
||||||
|
type TrendsService struct {
|
||||||
|
energyRepo EnergyTrendStore
|
||||||
|
commentRepo CommentTrendStore
|
||||||
|
likeRepo LikeTrendStore
|
||||||
|
favoriteRepo FavoriteTrendStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTrendsService 构造函数
|
||||||
|
func NewTrendsService(energyRepo EnergyTrendStore, commentRepo CommentTrendStore, likeRepo LikeTrendStore, favoriteRepo FavoriteTrendStore) *TrendsService {
|
||||||
|
return &TrendsService{
|
||||||
|
energyRepo: energyRepo,
|
||||||
|
commentRepo: commentRepo,
|
||||||
|
likeRepo: likeRepo,
|
||||||
|
favoriteRepo: favoriteRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrends 获取指定时间范围的趋势数据
|
||||||
|
func (s *TrendsService) GetTrends(userID uint, since string) (*TrendResult, error) {
|
||||||
|
energy, err := s.energyRepo.AggregateEnergyByAuthor(userID, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询赋能趋势: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comments, err := s.commentRepo.AggregateCommentsByAuthor(userID, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询评论趋势: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
likes, err := s.likeRepo.AggregateLikesByAuthor(userID, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询点赞趋势: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
favorites, err := s.favoriteRepo.AggregateFavoritesByAuthor(userID, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询收藏趋势: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrendResult{
|
||||||
|
Energy: energy,
|
||||||
|
Comments: comments,
|
||||||
|
Likes: likes,
|
||||||
|
Favorites: favorites,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@ -31,7 +31,7 @@
|
|||||||
<div class="post-detail-body" id="postContent"></div>
|
<div class="post-detail-body" id="postContent"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- 赞/踩按钮 -->
|
<!-- 赞/踩/收藏按钮 -->
|
||||||
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}">
|
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}">
|
||||||
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞">
|
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
|
||||||
@ -40,6 +40,10 @@
|
|||||||
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩">
|
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="reaction-btn reaction-fav-btn" id="favBtn" title="收藏">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
<span class="reaction-like-count" id="favCount">{{.Post.FavoritesCount}}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
@ -340,6 +344,63 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ========== 收藏按钮 ==========
|
||||||
|
(function() {
|
||||||
|
var favBtn = document.getElementById('favBtn');
|
||||||
|
var favCountEl = document.getElementById('favCount');
|
||||||
|
var container = document.getElementById('postReactions');
|
||||||
|
if (!favBtn || !container) return;
|
||||||
|
|
||||||
|
var postId = container.dataset.postId;
|
||||||
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||||
|
|
||||||
|
// 页面加载时获取收藏状态
|
||||||
|
fetch('/api/posts/' + postId + '/folder-status')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.success && d.data && d.data.favorited) {
|
||||||
|
favBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
|
||||||
|
favBtn.addEventListener('click', function() {
|
||||||
|
favBtn.disabled = true;
|
||||||
|
fetch('/api/posts/' + postId + '/favorite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 401) { window.location.href = '/login?redirect=/posts/' + postId; return null; }
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(d) {
|
||||||
|
favBtn.disabled = false;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.success) {
|
||||||
|
var isFav = d.data && d.data.favorited;
|
||||||
|
if (isFav) {
|
||||||
|
favBtn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
favBtn.classList.remove('active');
|
||||||
|
}
|
||||||
|
if (favCountEl && d.data && typeof d.data.favorites_count === 'number') {
|
||||||
|
favCountEl.textContent = d.data.favorites_count;
|
||||||
|
} else if (favCountEl) {
|
||||||
|
var cur = parseInt(favCountEl.textContent) || 0;
|
||||||
|
favCountEl.textContent = isFav ? cur + 1 : Math.max(0, cur - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(d.message || '操作失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { favBtn.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ========== 评论系统 ==========
|
// ========== 评论系统 ==========
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@ -40,6 +40,20 @@
|
|||||||
</svg>
|
</svg>
|
||||||
我的域能
|
我的域能
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/settings/favorites"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "favorites"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
我的收藏夹
|
||||||
|
</a>
|
||||||
|
<a href="/settings/notify"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "notify"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
通知偏好
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -181,6 +195,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{else if eq .ActiveTab "favorites"}}
|
||||||
|
<!-- 我的收藏夹 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>我的收藏夹</h2>
|
||||||
|
<p class="settings-card-desc">管理和查看你的收藏夹</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="fav-toolbar">
|
||||||
|
<button class="settings-save-btn" id="createFolderBtn">+ 新建收藏夹</button>
|
||||||
|
</div>
|
||||||
|
<div class="fav-list" id="favFolderList">
|
||||||
|
<div class="energy-log-empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if eq .ActiveTab "notify"}}
|
||||||
|
<!-- 通知偏好 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>通知偏好</h2>
|
||||||
|
<p class="settings-card-desc">管理你的通知推送偏好,关闭后不影响通知记录存储</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body" id="notifyPrefsBody">
|
||||||
|
<div class="energy-log-empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- 账号信息 -->
|
<!-- 账号信息 -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
@ -981,10 +1022,172 @@
|
|||||||
|
|
||||||
function formatTime(isoStr) {
|
function formatTime(isoStr) {
|
||||||
if (!isoStr) return '';
|
if (!isoStr) return '';
|
||||||
// '2026-06-01T12:39:41+08:00' → '2026-06-01 12:39:41'
|
|
||||||
return isoStr.replace('T', ' ').replace(/\+.*/, '');
|
return isoStr.replace('T', ' ').replace(/\+.*/, '');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ===== 收藏夹 Tab =====
|
||||||
|
(function () {
|
||||||
|
var listEl = document.getElementById('favFolderList');
|
||||||
|
var createBtn = document.getElementById('createFolderBtn');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
function loadFolders() {
|
||||||
|
fetch('/api/folders')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.success || !d.data || !d.data.folders || d.data.folders.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="energy-log-empty">暂无收藏夹</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
d.data.folders.forEach(function (f) {
|
||||||
|
var isDefault = f.is_default ? ' (默认)' : '';
|
||||||
|
var pub = f.is_public ? '公开' : '私密';
|
||||||
|
html += '<div class="fav-folder-card">'
|
||||||
|
+ '<div class="fav-folder-info">'
|
||||||
|
+ '<span class="fav-folder-name">' + escapeHTML(f.name) + isDefault + '</span>'
|
||||||
|
+ '<span class="fav-folder-meta">' + (f.item_count || 0) + ' 篇文章 · ' + pub + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="fav-folder-actions">'
|
||||||
|
+ (f.is_default ? '' : '<button class="fav-folder-del" data-id="' + f.id + '">删除</button>')
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
// 绑定删除按钮
|
||||||
|
listEl.querySelectorAll('.fav-folder-del').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
if (!confirm('确定要删除此收藏夹?其中的收藏内容也将被清除。')) return;
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('DELETE', '/api/folders/' + id, true);
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (r.success) {
|
||||||
|
showSettingsToast('收藏夹已删除');
|
||||||
|
loadFolders();
|
||||||
|
} else {
|
||||||
|
showSettingsToast(r.message || '删除失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('删除失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
listEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.addEventListener('click', function () {
|
||||||
|
var name = prompt('请输入收藏夹名称(最多50个字符):');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
if (name.trim().length > 50) {
|
||||||
|
showSettingsToast('收藏夹名称不能超过 50 个字符', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/folders', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (r.success) {
|
||||||
|
showSettingsToast('收藏夹已创建');
|
||||||
|
loadFolders();
|
||||||
|
} else {
|
||||||
|
showSettingsToast(r.message || '创建失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('创建失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({ name: name.trim(), description: '', is_public: false }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFolders();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ===== 通知偏好 Tab =====
|
||||||
|
(function () {
|
||||||
|
var bodyEl = document.getElementById('notifyPrefsBody');
|
||||||
|
if (!bodyEl) return;
|
||||||
|
|
||||||
|
function loadPrefs() {
|
||||||
|
fetch('/api/settings/notify-prefs')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.success || !d.data) {
|
||||||
|
bodyEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var prefs = d.data;
|
||||||
|
var items = [
|
||||||
|
{ key: 'comment', label: '评论通知', desc: '有人评论我的文章时通知我' },
|
||||||
|
{ key: 'comment_reply', label: '回复通知', desc: '有人回复我的评论时通知我' },
|
||||||
|
{ key: 'like_aggregated', label: '点赞通知', desc: '每日汇总的点赞通知' },
|
||||||
|
{ key: 'follow', label: '关注通知', desc: '有人关注我时通知我' }
|
||||||
|
];
|
||||||
|
var html = '';
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var checked = prefs[item.key] ? 'checked' : '';
|
||||||
|
html += '<div class="notify-pref-row">'
|
||||||
|
+ '<div class="notify-pref-info">'
|
||||||
|
+ '<span class="notify-pref-label">' + item.label + '</span>'
|
||||||
|
+ '<span class="notify-pref-desc">' + item.desc + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<label class="notify-pref-toggle">'
|
||||||
|
+ '<input type="checkbox" class="notify-pref-cb" data-key="' + item.key + '" ' + checked + '>'
|
||||||
|
+ '<span class="notify-pref-slider"></span>'
|
||||||
|
+ '</label>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
bodyEl.innerHTML = html;
|
||||||
|
|
||||||
|
// 绑定切换事件
|
||||||
|
bodyEl.querySelectorAll('.notify-pref-cb').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
var key = cb.getAttribute('data-key');
|
||||||
|
var enabled = cb.checked;
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', '/api/settings/notify-prefs', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (r.success) {
|
||||||
|
showSettingsToast('通知偏好已更新');
|
||||||
|
} else {
|
||||||
|
showSettingsToast(r.message || '更新失败', true);
|
||||||
|
cb.checked = !enabled;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('更新失败', true);
|
||||||
|
cb.checked = !enabled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({ key: key, enabled: enabled }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
bodyEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrefs();
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -40,26 +40,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="studio-section">
|
<div class="studio-section">
|
||||||
<h2 class="section-title">状态分布</h2>
|
<div class="trends-header">
|
||||||
<div class="status-bar">
|
<h2 class="section-title">交互趋势</h2>
|
||||||
{{if gt .Overview.ApprovedCount 0}}
|
<div class="trends-period">
|
||||||
<div class="status-bar-segment status-bar-approved" style="flex:{{.Overview.ApprovedCount}}">已发布 {{.Overview.ApprovedCount}}</div>
|
<button class="trends-period-btn active" data-period="7d">7 天</button>
|
||||||
{{end}}
|
<button class="trends-period-btn" data-period="30d">30 天</button>
|
||||||
{{if gt .Overview.DraftCount 0}}
|
<button class="trends-period-btn" data-period="90d">90 天</button>
|
||||||
<div class="status-bar-segment status-bar-draft" style="flex:{{.Overview.DraftCount}}">草稿 {{.Overview.DraftCount}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if gt .Overview.PendingCount 0}}
|
|
||||||
<div class="status-bar-segment status-bar-pending" style="flex:{{.Overview.PendingCount}}">审核中 {{.Overview.PendingCount}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if gt .Overview.RejectedCount 0}}
|
|
||||||
<div class="status-bar-segment status-bar-rejected" style="flex:{{.Overview.RejectedCount}}">已退回 {{.Overview.RejectedCount}}</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="studio-section">
|
<div class="trends-grid">
|
||||||
<div class="studio-placeholder">
|
<div class="trends-card">
|
||||||
<p>📈 更详细的阅读趋势、互动数据将在后续版本中推出</p>
|
<h3 class="trends-card-title">赋能值</h3>
|
||||||
|
<div class="trends-chart-wrap">
|
||||||
|
<canvas id="energyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="trends-loading" id="energyLoading">加载中...</div>
|
||||||
|
</div>
|
||||||
|
<div class="trends-card">
|
||||||
|
<h3 class="trends-card-title">评论量</h3>
|
||||||
|
<div class="trends-chart-wrap">
|
||||||
|
<canvas id="commentsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="trends-loading" id="commentsLoading">加载中...</div>
|
||||||
|
</div>
|
||||||
|
<div class="trends-card">
|
||||||
|
<h3 class="trends-card-title">点赞量</h3>
|
||||||
|
<div class="trends-chart-wrap">
|
||||||
|
<canvas id="likesChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="trends-loading" id="likesLoading">加载中...</div>
|
||||||
|
</div>
|
||||||
|
<div class="trends-card">
|
||||||
|
<h3 class="trends-card-title">收藏量</h3>
|
||||||
|
<div class="trends-chart-wrap">
|
||||||
|
<canvas id="favoritesChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="trends-loading" id="favoritesLoading">加载中...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -68,5 +86,182 @@
|
|||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<script src="/static/js/chart.umd.min.js?v={{assetV "/static/js/chart.umd.min.js"}}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var charts = {};
|
||||||
|
var currentPeriod = '7d';
|
||||||
|
|
||||||
|
var chartColors = {
|
||||||
|
energy: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
||||||
|
comments: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
||||||
|
likes: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||||
|
favorites: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function fillDates(points, days) {
|
||||||
|
var now = new Date();
|
||||||
|
var map = {};
|
||||||
|
points.forEach(function (p) {
|
||||||
|
map[p.date] = p.value;
|
||||||
|
});
|
||||||
|
var result = [];
|
||||||
|
var labels = [];
|
||||||
|
for (var i = days - 1; i >= 0; i--) {
|
||||||
|
var d = new Date(now);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
var key = d.toISOString().slice(0, 10);
|
||||||
|
labels.push(key.slice(5)); // MM-DD
|
||||||
|
result.push(map[key] || 0);
|
||||||
|
}
|
||||||
|
var valueSum = 0;
|
||||||
|
for (var j = 0; j < result.length; j++) { valueSum += result[j]; }
|
||||||
|
return { labels: labels, values: result, hasData: valueSum > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart(canvasId, color, label) {
|
||||||
|
var ctx = document.getElementById(canvasId).getContext('2d');
|
||||||
|
return new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: label,
|
||||||
|
data: [],
|
||||||
|
borderColor: color.border,
|
||||||
|
backgroundColor: color.bg,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 5
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { font: { size: 10 }, maxRotation: 45, minRotation: 45 },
|
||||||
|
grid: { display: false }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
font: { size: 10 },
|
||||||
|
precision: 0,
|
||||||
|
callback: function (v) { return v === Math.floor(v) ? v : ''; }
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(0,0,0,0.05)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyCharts() {
|
||||||
|
Object.keys(charts).forEach(function (key) {
|
||||||
|
if (charts[key]) {
|
||||||
|
charts[key].destroy();
|
||||||
|
delete charts[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(loadingId) {
|
||||||
|
var el = document.getElementById(loadingId);
|
||||||
|
if (el) el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading(loadingId) {
|
||||||
|
var el = document.getElementById(loadingId);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTrends(period) {
|
||||||
|
currentPeriod = period;
|
||||||
|
|
||||||
|
destroyCharts();
|
||||||
|
|
||||||
|
var days = period === '30d' ? 30 : period === '90d' ? 90 : 7;
|
||||||
|
|
||||||
|
showLoading('energyLoading');
|
||||||
|
showLoading('commentsLoading');
|
||||||
|
showLoading('likesLoading');
|
||||||
|
showLoading('favoritesLoading');
|
||||||
|
|
||||||
|
var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart'];
|
||||||
|
canvasIds.forEach(function (id) {
|
||||||
|
var canvas = document.getElementById(id);
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('/api/studio/trends?period=' + period)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.success || !d.data) {
|
||||||
|
canvasIds.forEach(function (id) {
|
||||||
|
hideLoading(id.replace('Chart', 'Loading').replace('Chart', 'Loading'));
|
||||||
|
});
|
||||||
|
canvasIds.forEach(function (id) {
|
||||||
|
document.getElementById(id).style.display = 'block';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maps = [
|
||||||
|
{ id: 'energyChart', key: 'energy', color: chartColors.energy, label: '赋能值', loading: 'energyLoading' },
|
||||||
|
{ id: 'commentsChart', key: 'comments', color: chartColors.comments, label: '评论', loading: 'commentsLoading' },
|
||||||
|
{ id: 'likesChart', key: 'likes', color: chartColors.likes, label: '点赞', loading: 'likesLoading' },
|
||||||
|
{ id: 'favoritesChart', key: 'favorites', color: chartColors.favorites, label: '收藏', loading: 'favoritesLoading' }
|
||||||
|
];
|
||||||
|
|
||||||
|
maps.forEach(function (m) {
|
||||||
|
var fd = fillDates(d.data[m.key] || [], days);
|
||||||
|
hideLoading(m.loading);
|
||||||
|
var canvas = document.getElementById(m.id);
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
|
||||||
|
if (!fd.hasData && fd.values.every(function (v) { return v === 0; })) {
|
||||||
|
canvas.parentElement.innerHTML = '<div class="trends-no-data">暂无数据</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
charts[m.id] = createChart(m.id, m.color, m.label);
|
||||||
|
charts[m.id].data.labels = fd.labels;
|
||||||
|
charts[m.id].data.datasets[0].data = fd.values;
|
||||||
|
charts[m.id].update();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
canvasIds.forEach(function (id) {
|
||||||
|
hideLoading(id.replace('Chart', 'Loading'));
|
||||||
|
document.getElementById(id).style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定期间按钮
|
||||||
|
document.querySelectorAll('.trends-period-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.trends-period-btn').forEach(function (b) {
|
||||||
|
b.classList.remove('active');
|
||||||
|
});
|
||||||
|
btn.classList.add('active');
|
||||||
|
loadTrends(btn.getAttribute('data-period'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
if (document.getElementById('energyChart')) {
|
||||||
|
loadTrends('7d');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -255,6 +255,17 @@
|
|||||||
stroke: #f87171;
|
stroke: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active.reaction-fav-btn {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #f59e0b;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active.reaction-fav-btn svg {
|
||||||
|
fill: #f59e0b;
|
||||||
|
stroke: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.reaction-like-count {
|
.reaction-like-count {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 12px;
|
min-width: 12px;
|
||||||
|
|||||||
@ -877,3 +877,139 @@ body {
|
|||||||
.energy-log-minus .energy-log-amount {
|
.energy-log-minus .energy-log-amount {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 收藏夹 Tab ---------- */
|
||||||
|
.fav-toolbar {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-name {
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-meta {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-del {
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #e74c3c;
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-folder-del:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
border-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 通知偏好 Tab ---------- */
|
||||||
|
.notify-pref-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-label {
|
||||||
|
font-size: .92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-desc {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-toggle input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: background .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform .2s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-toggle input:checked + .notify-pref-slider {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pref-toggle input:checked + .notify-pref-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|||||||
@ -621,6 +621,90 @@
|
|||||||
padding: 8px 24px;
|
padding: 8px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 趋势图 ---------- */
|
||||||
|
.trends-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-period {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-period-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-period-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-chart-wrap canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-loading {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends-no-data {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 220px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 响应式 ---------- */
|
/* ---------- 响应式 ---------- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.studio-wrapper {
|
.studio-wrapper {
|
||||||
|
|||||||
20
templates/MetaLab-2026/static/js/chart.umd.min.js
vendored
Normal file
20
templates/MetaLab-2026/static/js/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user