diff --git a/backend/handler/auth.go b/backend/handler/auth.go
index a919f70..8aa4b78 100644
--- a/backend/handler/auth.go
+++ b/backend/handler/auth.go
@@ -96,6 +96,13 @@ type LoginRequest struct {
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
func (h *Handlers) Register(c *gin.Context) {
+ if allow, err := h.Setting.AllowRegister(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "注册暂时不可用"})
+ return
+ } else if !allow {
+ c.JSON(http.StatusForbidden, gin.H{"error": "站点已关闭注册"})
+ return
+ }
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go
index 640f940..3deb4cc 100644
--- a/backend/handler/moderation.go
+++ b/backend/handler/moderation.go
@@ -87,16 +87,91 @@ func (h *Handlers) AdminRejectComment(c *gin.Context) {
h.execModeration(c, h.Moderation.RejectComment)
}
-// writeModerationError 映射审核业务错误为 HTTP 响应
+// ===== 内容库(帖子/评论 + 已删除回收站) =====
+
+func (h *Handlers) AdminContentCounts(c *gin.Context) {
+ c.JSON(http.StatusOK, h.Moderation.AdminContentCounts(middleware.CurrentActor(c)))
+}
+
+func (h *Handlers) AdminListPosts(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ items, total, err := h.Moderation.ListAdminPosts(
+ middleware.CurrentActor(c), c.Query("status"), c.Query("q"), page,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取帖子列表失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"posts": nonNilSlice(items), "total": total, "page": page})
+}
+
+func (h *Handlers) AdminListComments(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ items, total, err := h.Moderation.ListAdminComments(
+ middleware.CurrentActor(c), c.Query("status"), c.Query("q"), page,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取评论列表失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"comments": nonNilSlice(items), "total": total, "page": page})
+}
+
+func (h *Handlers) AdminSoftDeletePost(c *gin.Context) {
+ h.execContentAction(c, h.Moderation.SoftDeletePost)
+}
+
+func (h *Handlers) AdminRestorePost(c *gin.Context) {
+ h.execContentAction(c, h.Moderation.RestorePost)
+}
+
+func (h *Handlers) AdminPurgePost(c *gin.Context) {
+ h.execContentAction(c, h.Moderation.PurgePost)
+}
+
+func (h *Handlers) AdminSoftDeleteComment(c *gin.Context) {
+ h.execContentAction(c, h.Moderation.SoftDeleteComment)
+}
+
+func (h *Handlers) AdminRestoreComment(c *gin.Context) {
+ h.execContentAction(c, h.Moderation.RestoreComment)
+}
+
+func (h *Handlers) AdminPurgeComment(c *gin.Context) {
+ h.execContentAction(c, h.Moderation.PurgeComment)
+}
+
+func (h *Handlers) execContentAction(c *gin.Context, fn func(*service.Actor, uint) error) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
+ return
+ }
+ if err := fn(middleware.CurrentActor(c), uint(id)); err != nil {
+ h.writeModerationError(c, err)
+ return
+ }
+ h.broadcastModerationChanged()
+ c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
+}
+
+// writeModerationError 映射审核/内容库业务错误为 HTTP 响应
func (h *Handlers) writeModerationError(c *gin.Context, err error) {
switch {
- case errors.Is(err, gorm.ErrRecordNotFound):
+ case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, service.ErrContentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"})
case errors.Is(err, service.ErrModerationForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- case errors.Is(err, service.ErrNotPending):
+ case errors.Is(err, service.ErrNotPending),
+ errors.Is(err, service.ErrContentNotDeleted):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
+ msg := err.Error()
+ if msg == "帖子已在回收站" || msg == "帖子未被删除" ||
+ msg == "评论已在回收站" || msg == "评论未被删除" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": msg})
+ return
+ }
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
}
}
diff --git a/backend/handler/setting.go b/backend/handler/setting.go
index cc892f4..b2eb090 100644
--- a/backend/handler/setting.go
+++ b/backend/handler/setting.go
@@ -3,41 +3,32 @@ package handler
import (
"errors"
"net/http"
- "strings"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
-// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量等
// GET /api/settings
func (h *Handlers) PublicSettings(c *gin.Context) {
- accent, err := h.Setting.AccentColor()
+ s, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
- trust, err := h.Setting.TrustReviewedPublish()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
- return
- }
- c.JSON(http.StatusOK, gin.H{
- "accent": accent,
- "trust_reviewed_publish": trust,
- })
+ c.JSON(http.StatusOK, s)
}
-// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖审核策略
+// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
type updateSettingsRequest struct {
- // Accent 主题色 #RRGGBB;空串恢复默认
- Accent *string `json:"accent"`
- // TrustReviewedPublish 已过审用户后续内容免审
- TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
+ Accent *string `json:"accent"`
+ TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
+ SiteName *string `json:"site_name"`
+ SiteDescription *string `json:"site_description"`
+ AllowRegister *bool `json:"allow_register"`
+ PostCooldownHours *int `json:"post_cooldown_hours"`
}
-// UpdateSettings 更新站点设置(管理员)
// PUT /api/admin/settings
func (h *Handlers) UpdateSettings(c *gin.Context) {
var req updateSettingsRequest
@@ -45,12 +36,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
- if req.Accent == nil && req.TrustReviewedPublish == nil {
+ if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil &&
+ req.SiteDescription == nil && req.AllowRegister == nil && req.PostCooldownHours == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
return
}
- savedAccent := ""
if req.Accent != nil {
if err := h.Setting.SetAccent(*req.Accent); err != nil {
if errors.Is(err, service.ErrInvalidAccent) {
@@ -60,43 +51,74 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
- savedAccent = strings.ToLower(strings.TrimSpace(*req.Accent))
- } else {
- var err error
- savedAccent, err = h.Setting.AccentColor()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
- return
- }
}
-
- trustOn := true
if req.TrustReviewedPublish != nil {
if err := h.Setting.SetTrustReviewedPublish(*req.TrustReviewedPublish); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
- trustOn = *req.TrustReviewedPublish
- } else {
- var err error
- trustOn, err = h.Setting.TrustReviewedPublish()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ }
+ if req.SiteName != nil {
+ if err := h.Setting.SetSiteName(*req.SiteName); err != nil {
+ if errors.Is(err, service.ErrInvalidSiteSetting) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称过长或无效"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ }
+ if req.SiteDescription != nil {
+ if err := h.Setting.SetSiteDescription(*req.SiteDescription); err != nil {
+ if errors.Is(err, service.ErrInvalidSiteSetting) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "站点简介过长"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ }
+ if req.AllowRegister != nil {
+ if err := h.Setting.SetAllowRegister(*req.AllowRegister); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ }
+ if req.PostCooldownHours != nil {
+ if err := h.Setting.SetPostCooldownHours(*req.PostCooldownHours); err != nil {
+ if errors.Is(err, service.ErrInvalidSiteSetting) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "冷静期须为 0–168 小时"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
- // 归一化后广播:主题色客户端热换肤;免审开关由下次发帖/发评读库生效
+ saved, err := h.Setting.Public()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ return
+ }
+
h.Hub.BroadcastAll(realtime.Envelope{
Type: realtime.EventSettingsChanged,
Data: gin.H{
- "accent": savedAccent,
- "trust_reviewed_publish": trustOn,
+ "accent": saved.Accent,
+ "trust_reviewed_publish": saved.TrustReviewedPublish,
+ "site_name": saved.SiteName,
+ "site_description": saved.SiteDescription,
+ "allow_register": saved.AllowRegister,
+ "post_cooldown_hours": saved.PostCooldownHours,
},
})
c.JSON(http.StatusOK, gin.H{
"ok": true,
- "accent": savedAccent,
- "trust_reviewed_publish": trustOn,
+ "accent": saved.Accent,
+ "trust_reviewed_publish": saved.TrustReviewedPublish,
+ "site_name": saved.SiteName,
+ "site_description": saved.SiteDescription,
+ "allow_register": saved.AllowRegister,
+ "post_cooldown_hours": saved.PostCooldownHours,
})
}
diff --git a/backend/router/router.go b/backend/router/router.go
index 16a3b80..f52305b 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -41,7 +41,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 服务
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
boardSvc := service.NewBoardService(model.DB)
- postSvc := service.NewPostService(model.DB)
+ settingSvc := service.NewSettingService(model.DB)
+ postSvc := service.NewPostService(model.DB).WithSetting(settingSvc)
commentSvc := service.NewCommentService(model.DB)
likeSvc := service.NewLikeService(model.DB)
notifSvc := service.NewNotificationService(model.DB)
@@ -51,7 +52,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private"))
pointsSvc := service.NewPointsService(model.DB)
- settingSvc := service.NewSettingService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB)
moderationSvc := service.NewModerationService(model.DB, notifSvc)
chatSvc := service.NewChatService(model.DB, notifSvc)
@@ -205,6 +205,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.PUT("/moderation/comments/:id/approve", h.AdminApproveComment)
staffAPI.PUT("/moderation/comments/:id/reject", h.AdminRejectComment)
+ // 内容库:帖子/评论全量 + 回收站(恢复 / 彻底删除)
+ staffAPI.GET("/content/counts", h.AdminContentCounts)
+ staffAPI.GET("/content/posts", h.AdminListPosts)
+ staffAPI.GET("/content/comments", h.AdminListComments)
+ staffAPI.DELETE("/content/posts/:id", h.AdminSoftDeletePost)
+ staffAPI.PUT("/content/posts/:id/restore", h.AdminRestorePost)
+ staffAPI.DELETE("/content/posts/:id/purge", h.AdminPurgePost)
+ staffAPI.DELETE("/content/comments/:id", h.AdminSoftDeleteComment)
+ staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment)
+ staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment)
+
// 积分与类型帖概览(管理员及以上)
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go
new file mode 100644
index 0000000..e4cb1d9
--- /dev/null
+++ b/backend/service/admin_content.go
@@ -0,0 +1,395 @@
+package service
+
+import (
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+// 内容库管理错误
+var (
+ ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
+ ErrContentNotFound = errors.New("内容不存在")
+)
+
+const adminContentPageSize = 15
+
+// AdminContentStatus 列表筛选:live=未删除全部状态;其余为具体状态或已删除
+type AdminContentStatus string
+
+const (
+ AdminContentLive AdminContentStatus = "live"
+ AdminContentPending AdminContentStatus = "pending"
+ AdminContentRejected AdminContentStatus = "rejected"
+ AdminContentPublished AdminContentStatus = "published"
+ AdminContentDeleted AdminContentStatus = "deleted"
+)
+
+// AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab)
+type AdminContentCounts struct {
+ PostsLive int64 `json:"posts_live"`
+ PostsPending int64 `json:"posts_pending"`
+ PostsRejected int64 `json:"posts_rejected"`
+ PostsDeleted int64 `json:"posts_deleted"`
+ CommentsLive int64 `json:"comments_live"`
+ CommentsPending int64 `json:"comments_pending"`
+ CommentsRejected int64 `json:"comments_rejected"`
+ CommentsDeleted int64 `json:"comments_deleted"`
+}
+
+// AdminContentPost 后台帖子列表项
+type AdminContentPost struct {
+ ID uint `json:"id"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Deleted bool `json:"deleted"`
+ DeletedAt *time.Time `json:"deleted_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ CommentCount int `json:"comment_count"`
+ BoardID uint `json:"board_id"`
+ Board model.Board `json:"board"`
+ User model.User `json:"user"`
+}
+
+// AdminContentComment 后台评论列表项
+type AdminContentComment struct {
+ ID uint `json:"id"`
+ PostID uint `json:"post_id"`
+ PostTitle string `json:"post_title"`
+ BoardID uint `json:"board_id"`
+ Content string `json:"content"`
+ Status string `json:"status"`
+ Deleted bool `json:"deleted"`
+ DeletedAt *time.Time `json:"deleted_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ Board model.Board `json:"board"`
+ User model.User `json:"user"`
+}
+
+func normalizeAdminStatus(raw string) AdminContentStatus {
+ switch AdminContentStatus(raw) {
+ case AdminContentPending, AdminContentRejected, AdminContentPublished, AdminContentDeleted:
+ return AdminContentStatus(raw)
+ default:
+ return AdminContentLive
+ }
+}
+
+func applyPostStatusFilter(q *gorm.DB, st AdminContentStatus) *gorm.DB {
+ switch st {
+ case AdminContentDeleted:
+ return q.Where("deleted_at IS NOT NULL")
+ case AdminContentPending, AdminContentRejected, AdminContentPublished:
+ return q.Where("deleted_at IS NULL AND status = ?", string(st))
+ default:
+ return q.Where("deleted_at IS NULL")
+ }
+}
+
+func applyCommentStatusFilter(q *gorm.DB, st AdminContentStatus) *gorm.DB {
+ switch st {
+ case AdminContentDeleted:
+ return q.Where("comments.deleted_at IS NOT NULL")
+ case AdminContentPending, AdminContentRejected, AdminContentPublished:
+ return q.Where("comments.deleted_at IS NULL AND comments.status = ?", string(st))
+ default:
+ return q.Where("comments.deleted_at IS NULL")
+ }
+}
+
+func (s *ModerationService) scopedPosts(actor *Actor) *gorm.DB {
+ q := s.db.Unscoped().Model(&model.Post{})
+ if actor != nil && actor.Role == model.RoleBoardAdmin {
+ if len(actor.BoardIDs) == 0 {
+ return q.Where("1 = 0")
+ }
+ q = q.Where("board_id IN ?", actor.BoardIDs)
+ }
+ return q
+}
+
+func (s *ModerationService) scopedComments(actor *Actor) *gorm.DB {
+ q := s.db.Unscoped().Table("comments").
+ Joins("JOIN posts ON posts.id = comments.post_id")
+ if actor != nil && actor.Role == model.RoleBoardAdmin {
+ if len(actor.BoardIDs) == 0 {
+ return q.Where("1 = 0")
+ }
+ q = q.Where("posts.board_id IN ?", actor.BoardIDs)
+ }
+ return q
+}
+
+// AdminContentCounts 各筛选桶数量(每次独立起查询,避免 Where 叠加)
+func (s *ModerationService) AdminContentCounts(actor *Actor) AdminContentCounts {
+ var c AdminContentCounts
+ s.scopedPosts(actor).Where("deleted_at IS NULL").Count(&c.PostsLive)
+ s.scopedPosts(actor).Where("deleted_at IS NULL AND status = ?", model.ContentStatusPending).Count(&c.PostsPending)
+ s.scopedPosts(actor).Where("deleted_at IS NULL AND status = ?", model.ContentStatusRejected).Count(&c.PostsRejected)
+ s.scopedPosts(actor).Where("deleted_at IS NOT NULL").Count(&c.PostsDeleted)
+
+ s.scopedComments(actor).Where("comments.deleted_at IS NULL").Count(&c.CommentsLive)
+ s.scopedComments(actor).Where("comments.deleted_at IS NULL AND comments.status = ?", model.ContentStatusPending).Count(&c.CommentsPending)
+ s.scopedComments(actor).Where("comments.deleted_at IS NULL AND comments.status = ?", model.ContentStatusRejected).Count(&c.CommentsRejected)
+ s.scopedComments(actor).Where("comments.deleted_at IS NOT NULL").Count(&c.CommentsDeleted)
+ return c
+}
+
+// ListAdminPosts 后台帖子库(含已删除)
+func (s *ModerationService) ListAdminPosts(actor *Actor, status, keyword string, page int) ([]AdminContentPost, int64, error) {
+ if page < 1 {
+ page = 1
+ }
+ st := normalizeAdminStatus(status)
+ q := applyPostStatusFilter(s.scopedPosts(actor), st)
+ if kw := strings.TrimSpace(keyword); kw != "" {
+ like := "%" + kw + "%"
+ q = q.Where("title ILIKE ?", like)
+ }
+ var total int64
+ if err := q.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var posts []model.Post
+ if err := q.Order("id DESC").
+ Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize).
+ Preload("Board").Preload("User").
+ Find(&posts).Error; err != nil {
+ return nil, 0, err
+ }
+ items := make([]AdminContentPost, 0, len(posts))
+ for _, p := range posts {
+ it := AdminContentPost{
+ ID: p.ID,
+ Title: p.Title,
+ Status: p.Status,
+ CreatedAt: p.CreatedAt,
+ CommentCount: p.CommentCount,
+ BoardID: p.BoardID,
+ Board: p.Board,
+ User: p.User,
+ }
+ if p.DeletedAt.Valid {
+ it.Deleted = true
+ t := p.DeletedAt.Time
+ it.DeletedAt = &t
+ }
+ items = append(items, it)
+ }
+ return items, total, nil
+}
+
+// ListAdminComments 后台评论库(含已删除;所属帖即使已删仍可见标题)
+func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword string, page int) ([]AdminContentComment, int64, error) {
+ if page < 1 {
+ page = 1
+ }
+ st := normalizeAdminStatus(status)
+ q := applyCommentStatusFilter(s.scopedComments(actor), st)
+ if kw := strings.TrimSpace(keyword); kw != "" {
+ like := "%" + kw + "%"
+ q = q.Where("comments.content ILIKE ?", like)
+ }
+ var total int64
+ if err := q.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ type row struct {
+ ID uint
+ PostID uint
+ Content string
+ Status string
+ CreatedAt time.Time
+ DeletedAt gorm.DeletedAt
+ BoardID uint
+ PostTitle string
+ UserID uint
+ }
+ var rows []row
+ if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id").
+ Order("comments.id DESC").
+ Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize).
+ Scan(&rows).Error; err != nil {
+ return nil, 0, err
+ }
+
+ boardIDs := make([]uint, 0)
+ userIDs := make([]uint, 0)
+ seenB := map[uint]struct{}{}
+ seenU := map[uint]struct{}{}
+ for _, r := range rows {
+ if _, ok := seenB[r.BoardID]; !ok {
+ seenB[r.BoardID] = struct{}{}
+ boardIDs = append(boardIDs, r.BoardID)
+ }
+ if _, ok := seenU[r.UserID]; !ok {
+ seenU[r.UserID] = struct{}{}
+ userIDs = append(userIDs, r.UserID)
+ }
+ }
+ boards := map[uint]model.Board{}
+ if len(boardIDs) > 0 {
+ var list []model.Board
+ s.db.Where("id IN ?", boardIDs).Find(&list)
+ for _, b := range list {
+ boards[b.ID] = b
+ }
+ }
+ users := map[uint]model.User{}
+ if len(userIDs) > 0 {
+ var list []model.User
+ s.db.Where("id IN ?", userIDs).Find(&list)
+ for _, u := range list {
+ users[u.ID] = u
+ }
+ }
+
+ items := make([]AdminContentComment, 0, len(rows))
+ for _, r := range rows {
+ it := AdminContentComment{
+ ID: r.ID,
+ PostID: r.PostID,
+ PostTitle: r.PostTitle,
+ BoardID: r.BoardID,
+ Content: r.Content,
+ Status: r.Status,
+ CreatedAt: r.CreatedAt,
+ Board: boards[r.BoardID],
+ User: users[r.UserID],
+ }
+ if r.DeletedAt.Valid {
+ it.Deleted = true
+ t := r.DeletedAt.Time
+ it.DeletedAt = &t
+ }
+ items = append(items, it)
+ }
+ return items, total, nil
+}
+
+func (s *ModerationService) loadPostUnscoped(actor *Actor, id uint) (*model.Post, error) {
+ var post model.Post
+ if err := s.db.Unscoped().First(&post, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrContentNotFound
+ }
+ return nil, err
+ }
+ if !actor.CanModerateBoard(post.BoardID) {
+ return nil, ErrModerationForbidden
+ }
+ return &post, nil
+}
+
+func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.Comment, uint, error) {
+ var cm model.Comment
+ if err := s.db.Unscoped().First(&cm, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, 0, ErrContentNotFound
+ }
+ return nil, 0, err
+ }
+ var post model.Post
+ if err := s.db.Unscoped().Select("id", "board_id").First(&post, cm.PostID).Error; err != nil {
+ return nil, 0, ErrContentNotFound
+ }
+ if !actor.CanModerateBoard(post.BoardID) {
+ return nil, 0, ErrModerationForbidden
+ }
+ return &cm, post.BoardID, nil
+}
+
+// SoftDeletePost 软删帖子(未删除时)
+func (s *ModerationService) SoftDeletePost(actor *Actor, id uint) error {
+ post, err := s.loadPostUnscoped(actor, id)
+ if err != nil {
+ return err
+ }
+ if post.DeletedAt.Valid {
+ return errors.New("帖子已在回收站")
+ }
+ return s.db.Delete(post).Error
+}
+
+// RestorePost 从回收站恢复帖子
+func (s *ModerationService) RestorePost(actor *Actor, id uint) error {
+ post, err := s.loadPostUnscoped(actor, id)
+ if err != nil {
+ return err
+ }
+ if !post.DeletedAt.Valid {
+ return errors.New("帖子未被删除")
+ }
+ return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
+}
+
+// PurgePost 彻底删除已软删帖子及其评论
+func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
+ post, err := s.loadPostUnscoped(actor, id)
+ if err != nil {
+ return err
+ }
+ if !post.DeletedAt.Valid {
+ return ErrContentNotDeleted
+ }
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil {
+ return err
+ }
+ return tx.Unscoped().Delete(&model.Post{}, id).Error
+ })
+}
+
+// SoftDeleteComment 软删单条评论(不级联子树,后台点对点清理)
+func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
+ cm, _, err := s.loadCommentUnscoped(actor, id)
+ if err != nil {
+ return err
+ }
+ if cm.DeletedAt.Valid {
+ return errors.New("评论已在回收站")
+ }
+ if err := s.db.Delete(cm).Error; err != nil {
+ return err
+ }
+ if cm.Status == model.ContentStatusPublished {
+ s.db.Model(&model.Post{}).Where("id = ?", cm.PostID).
+ UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
+ }
+ return nil
+}
+
+// RestoreComment 恢复单条评论
+func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
+ cm, _, err := s.loadCommentUnscoped(actor, id)
+ if err != nil {
+ return err
+ }
+ if !cm.DeletedAt.Valid {
+ return errors.New("评论未被删除")
+ }
+ if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
+ return err
+ }
+ if cm.Status == model.ContentStatusPublished {
+ s.db.Model(&model.Post{}).Where("id = ?", cm.PostID).
+ UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
+ }
+ return nil
+}
+
+// PurgeComment 彻底删除已软删评论
+func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
+ cm, _, err := s.loadCommentUnscoped(actor, id)
+ if err != nil {
+ return err
+ }
+ if !cm.DeletedAt.Valid {
+ return ErrContentNotDeleted
+ }
+ return s.db.Unscoped().Delete(&model.Comment{}, id).Error
+}
diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go
index a826862..a02bcb6 100644
--- a/backend/service/admin_user.go
+++ b/backend/service/admin_user.go
@@ -59,12 +59,17 @@ type AdminUserItem struct {
LastLoginAt *time.Time `json:"last_login_at"`
}
-// AdminUserSummary 列表顶部汇总(总数/管理团队/封禁/今日新增)
+// AdminUserSummary 列表顶部汇总
+// - online / in_cooldown / active_7d / failed_logins_24h:用户页独有运营指标
+// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
type AdminUserSummary struct {
- Total int64 `json:"total"`
- Admins int64 `json:"admins"`
- Banned int64 `json:"banned"`
- TodayNew int64 `json:"today_new"`
+ Online int64 `json:"online"`
+ InCooldown int64 `json:"in_cooldown"`
+ Active7d int64 `json:"active_7d"`
+ FailedLogins24h int64 `json:"failed_logins_24h"`
+ Total int64 `json:"total"`
+ Admins int64 `json:"admins"`
+ Banned int64 `json:"banned"`
}
// staffRoles 管理团队角色集合
@@ -131,14 +136,31 @@ func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, err
items := s.toItems(users)
- // 汇总数据(不受筛选条件影响,始终反映全站)
+ // 汇总:用户页独有指标(避免与仪表盘站点总量重复)
now := time.Now()
- dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ onlineSince := now.Add(-onlineThreshold)
+ hours, _ := (&SettingService{db: s.db}).PostCooldownHours()
+ activeSince := now.Add(-7 * 24 * time.Hour)
+ failSince := now.Add(-24 * time.Hour)
summary := AdminUserSummary{}
+ s.db.Model(&model.User{}).
+ Where("banned = ? AND last_seen_at IS NOT NULL AND last_seen_at >= ?", false, onlineSince).
+ Count(&summary.Online)
+ if hours > 0 {
+ cooldownSince := now.Add(-time.Duration(hours) * time.Hour)
+ s.db.Model(&model.User{}).
+ Where("banned = ? AND role = ? AND created_at >= ?", false, model.RoleUser, cooldownSince).
+ Count(&summary.InCooldown)
+ }
+ s.db.Model(&model.User{}).
+ Where("last_seen_at IS NOT NULL AND last_seen_at >= ?", activeSince).
+ Count(&summary.Active7d)
+ s.db.Model(&model.LoginLog{}).
+ Where("success = ? AND created_at >= ?", false, failSince).
+ Count(&summary.FailedLogins24h)
s.db.Model(&model.User{}).Count(&summary.Total)
s.db.Model(&model.User{}).Where("role IN ?", staffRoles).Count(&summary.Admins)
s.db.Model(&model.User{}).Where("banned = ?", true).Count(&summary.Banned)
- s.db.Model(&model.User{}).Where("created_at >= ?", dayStart).Count(&summary.TodayNew)
return &AdminUserListResult{
Users: items,
diff --git a/backend/service/post.go b/backend/service/post.go
index ff997f6..75a619c 100644
--- a/backend/service/post.go
+++ b/backend/service/post.go
@@ -2,6 +2,7 @@ package service
import (
"errors"
+ "fmt"
"strings"
"time"
@@ -17,13 +18,20 @@ var (
// PostService 帖子服务
type PostService struct {
- db *gorm.DB
+ db *gorm.DB
+ setting *SettingService
}
func NewPostService(db *gorm.DB) *PostService {
return &PostService{db: db}
}
+// WithSetting 注入站点设置(发帖冷静期可读配置);可链式调用
+func (s *PostService) WithSetting(setting *SettingService) *PostService {
+ s.setting = setting
+ return s
+}
+
// PostListQuery 帖子列表查询参数
type PostListQuery struct {
BoardID uint
@@ -925,14 +933,23 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
return nil
}
-// checkNewUserCooldown 新用户发帖 24h 冷静期
+// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭)
func (s *PostService) checkNewUserCooldown(userID uint) error {
+ hours := DefaultCooldownHours
+ if s.setting != nil {
+ if h, err := s.setting.PostCooldownHours(); err == nil {
+ hours = h
+ }
+ }
+ if hours <= 0 {
+ return nil
+ }
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return err
}
- if time.Since(user.CreatedAt) < 24*time.Hour {
- return errors.New("新用户注册 24 小时后才能发帖")
+ if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour {
+ return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours)
}
return nil
}
diff --git a/backend/service/setting.go b/backend/service/setting.go
index 6793b0b..91ca6b6 100644
--- a/backend/service/setting.go
+++ b/backend/service/setting.go
@@ -3,7 +3,9 @@ package service
import (
"errors"
"regexp"
+ "strconv"
"strings"
+ "unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
@@ -15,14 +17,40 @@ const (
SettingKeyAccent = "theme_accent"
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
+ SettingKeySiteName = "site_name"
+ SettingKeySiteDescription = "site_description"
+ SettingKeyAllowRegister = "allow_register"
+ SettingKeyPostCooldownHours = "post_cooldown_hours"
+)
+
+const (
+ DefaultSiteName = "姜十三论坛"
+ DefaultSiteDescription = "姜十三论坛 - 技术分享与讨论社区"
+ DefaultCooldownHours = 24
+ MaxSiteNameRunes = 32
+ MaxSiteDescRunes = 160
+ MaxCooldownHours = 168
)
// ErrInvalidAccent 主题色格式非法
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
+// ErrInvalidSiteSetting 站点设置字段校验失败
+var ErrInvalidSiteSetting = errors.New("站点设置参数无效")
+
var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
-// SettingService 站点级键值设置(主题色等外观配置)
+// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
+type PublicSiteSettings struct {
+ Accent string `json:"accent"`
+ TrustReviewedPublish bool `json:"trust_reviewed_publish"`
+ SiteName string `json:"site_name"`
+ SiteDescription string `json:"site_description"`
+ AllowRegister bool `json:"allow_register"`
+ PostCooldownHours int `json:"post_cooldown_hours"`
+}
+
+// SettingService 站点级键值设置
type SettingService struct {
db *gorm.DB
}
@@ -31,59 +59,195 @@ func NewSettingService(db *gorm.DB) *SettingService {
return &SettingService{db: db}
}
-// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
-func (s *SettingService) AccentColor() (string, error) {
+func (s *SettingService) getValue(key string) (string, bool, error) {
var st model.SiteSetting
- err := s.db.Where("key = ?", SettingKeyAccent).First(&st).Error
+ err := s.db.Where("key = ?", key).First(&st).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
- return "", nil
+ return "", false, nil
}
if err != nil {
+ return "", false, err
+ }
+ return st.Value, true, nil
+}
+
+func (s *SettingService) putValue(key, value string) error {
+ st := model.SiteSetting{Key: key, Value: value}
+ return s.db.Save(&st).Error
+}
+
+func (s *SettingService) deleteKey(key string) error {
+ return s.db.Where("key = ?", key).Delete(&model.SiteSetting{}).Error
+}
+
+// 缺行或空值视为 on;仅显式 false/0/off/no 为关。不用 GORM bool default:true,避免零值省略。
+func parseBoolDefaultTrue(v string, found bool) bool {
+ if !found {
+ return true
+ }
+ switch strings.TrimSpace(strings.ToLower(v)) {
+ case "false", "0", "off", "no":
+ return false
+ default:
+ return true
+ }
+}
+
+func (s *SettingService) setBoolDefaultTrue(key string, on bool) error {
+ if on {
+ return s.deleteKey(key)
+ }
+ return s.putValue(key, "false")
+}
+
+// Public 返回解析后的公开站点设置(缺省已填默认值)
+func (s *SettingService) Public() (PublicSiteSettings, error) {
+ out := PublicSiteSettings{
+ SiteName: DefaultSiteName,
+ SiteDescription: DefaultSiteDescription,
+ TrustReviewedPublish: true,
+ AllowRegister: true,
+ PostCooldownHours: DefaultCooldownHours,
+ }
+ accent, err := s.AccentColor()
+ if err != nil {
+ return out, err
+ }
+ out.Accent = accent
+
+ trust, err := s.TrustReviewedPublish()
+ if err != nil {
+ return out, err
+ }
+ out.TrustReviewedPublish = trust
+
+ name, found, err := s.getValue(SettingKeySiteName)
+ if err != nil {
+ return out, err
+ }
+ if found {
+ name = strings.TrimSpace(name)
+ if name != "" {
+ out.SiteName = name
+ }
+ }
+
+ desc, found, err := s.getValue(SettingKeySiteDescription)
+ if err != nil {
+ return out, err
+ }
+ if found {
+ out.SiteDescription = strings.TrimSpace(desc)
+ }
+
+ reg, found, err := s.getValue(SettingKeyAllowRegister)
+ if err != nil {
+ return out, err
+ }
+ out.AllowRegister = parseBoolDefaultTrue(reg, found)
+
+ hours, err := s.PostCooldownHours()
+ if err != nil {
+ return out, err
+ }
+ out.PostCooldownHours = hours
+ return out, nil
+}
+
+// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
+func (s *SettingService) AccentColor() (string, error) {
+ v, found, err := s.getValue(SettingKeyAccent)
+ if err != nil || !found {
return "", err
}
- return strings.ToLower(st.Value), nil
+ return strings.ToLower(v), nil
}
// SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键)
func (s *SettingService) SetAccent(hex string) error {
hex = strings.TrimSpace(strings.ToLower(hex))
if hex == "" {
- return s.db.Where("key = ?", SettingKeyAccent).Delete(&model.SiteSetting{}).Error
+ return s.deleteKey(SettingKeyAccent)
}
if !accentHexRe.MatchString(hex) {
return ErrInvalidAccent
}
- // UPSERT:键冲突时更新值与时间戳
- st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
- return s.db.Save(&st).Error
+ return s.putValue(SettingKeyAccent, hex)
}
// TrustReviewedPublish 是否对「已有过审内容」的用户免审。
-// KV 缺行 / 空值视为开启(默认开);仅显式写入 false/0/off/no 为关闭。
-// 不用 GORM bool default:true,避免零值省略陷阱。
func (s *SettingService) TrustReviewedPublish() (bool, error) {
- var st model.SiteSetting
- err := s.db.Where("key = ?", SettingKeyTrustReviewedPublish).First(&st).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return true, nil
- }
+ v, found, err := s.getValue(SettingKeyTrustReviewedPublish)
if err != nil {
return true, err
}
- v := strings.TrimSpace(strings.ToLower(st.Value))
- switch v {
- case "false", "0", "off", "no":
- return false, nil
- default:
- return true, nil
- }
+ return parseBoolDefaultTrue(v, found), nil
}
// SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义
func (s *SettingService) SetTrustReviewedPublish(on bool) error {
- if on {
- return s.db.Where("key = ?", SettingKeyTrustReviewedPublish).Delete(&model.SiteSetting{}).Error
- }
- st := model.SiteSetting{Key: SettingKeyTrustReviewedPublish, Value: "false"}
- return s.db.Save(&st).Error
+ return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on)
+}
+
+func (s *SettingService) SetSiteName(name string) error {
+ name = strings.TrimSpace(name)
+ if name == "" || name == DefaultSiteName {
+ return s.deleteKey(SettingKeySiteName)
+ }
+ if utf8.RuneCountInString(name) > MaxSiteNameRunes {
+ return ErrInvalidSiteSetting
+ }
+ return s.putValue(SettingKeySiteName, name)
+}
+
+func (s *SettingService) SetSiteDescription(desc string) error {
+ desc = strings.TrimSpace(desc)
+ if utf8.RuneCountInString(desc) > MaxSiteDescRunes {
+ return ErrInvalidSiteSetting
+ }
+ if desc == "" || desc == DefaultSiteDescription {
+ return s.deleteKey(SettingKeySiteDescription)
+ }
+ return s.putValue(SettingKeySiteDescription, desc)
+}
+
+func (s *SettingService) AllowRegister() (bool, error) {
+ v, found, err := s.getValue(SettingKeyAllowRegister)
+ if err != nil {
+ return true, err
+ }
+ return parseBoolDefaultTrue(v, found), nil
+}
+
+func (s *SettingService) SetAllowRegister(on bool) error {
+ return s.setBoolDefaultTrue(SettingKeyAllowRegister, on)
+}
+
+// PostCooldownHours 新用户发帖冷静期(小时)。缺行=24;0 表示关闭。
+func (s *SettingService) PostCooldownHours() (int, error) {
+ v, found, err := s.getValue(SettingKeyPostCooldownHours)
+ if err != nil {
+ return DefaultCooldownHours, err
+ }
+ if !found {
+ return DefaultCooldownHours, nil
+ }
+ n, convErr := strconv.Atoi(strings.TrimSpace(v))
+ if convErr != nil || n < 0 {
+ return DefaultCooldownHours, nil
+ }
+ if n > MaxCooldownHours {
+ return MaxCooldownHours, nil
+ }
+ return n, nil
+}
+
+func (s *SettingService) SetPostCooldownHours(hours int) error {
+ if hours < 0 || hours > MaxCooldownHours {
+ return ErrInvalidSiteSetting
+ }
+ if hours == DefaultCooldownHours {
+ return s.deleteKey(SettingKeyPostCooldownHours)
+ }
+ return s.putValue(SettingKeyPostCooldownHours, strconv.Itoa(hours))
}
diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx
index b27ee3e..cd6fdfc 100644
--- a/frontend/app/admin/AdminNav.tsx
+++ b/frontend/app/admin/AdminNav.tsx
@@ -13,6 +13,8 @@ import {
ArrowLeft,
ClipboardCheck,
Coins,
+ Globe,
+ FolderOpen,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
@@ -32,45 +34,80 @@ interface NavItem {
label: string;
icon: LucideIcon;
exact?: boolean;
- /** 按 /me 角色过滤可见性;最终权限以后端路由守卫为准 */
visible: (u: RoleUserLike) => boolean;
- /** 是否携带待审核角标 */
pendingBadge?: boolean;
}
-// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
-const NAV_ITEMS: NavItem[] = [
- { href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
+interface NavGroup {
+ title: string;
+ items: NavItem[];
+}
+
+// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
+const NAV_GROUPS: NavGroup[] = [
{
- href: "/admin/moderation",
- label: "内容审核",
- icon: ClipboardCheck,
- visible: (u) => canModerateAny(u),
- pendingBadge: true,
+ title: "概览",
+ items: [
+ { href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
+ ],
},
{
- href: "/admin/economy",
- label: "积分看板",
- icon: Coins,
- visible: (u) => isAdminOrAbove(u.role),
+ title: "内容",
+ items: [
+ {
+ href: "/admin/moderation",
+ label: "内容审核",
+ icon: ClipboardCheck,
+ visible: (u) => canModerateAny(u),
+ pendingBadge: true,
+ },
+ {
+ href: "/admin/content",
+ label: "内容管理",
+ icon: FolderOpen,
+ visible: (u) => canModerateAny(u),
+ },
+ {
+ href: "/admin/announcements",
+ label: "公告管理",
+ icon: Megaphone,
+ visible: (u) => isAdminOrAbove(u.role),
+ },
+ ],
},
{
- href: "/admin/announcements",
- label: "公告管理",
- icon: Megaphone,
- visible: (u) => isAdminOrAbove(u.role),
+ title: "成员与经济",
+ items: [
+ {
+ href: "/admin/users",
+ label: "用户权限",
+ icon: Users,
+ visible: (u) => isSuperOrOwner(u.role),
+ },
+ {
+ href: "/admin/economy",
+ label: "积分看板",
+ icon: Coins,
+ visible: (u) => isAdminOrAbove(u.role),
+ },
+ ],
},
{
- href: "/admin/users",
- label: "用户权限",
- icon: Users,
- visible: (u) => isSuperOrOwner(u.role),
- },
- {
- href: "/admin/appearance",
- label: "外观设置",
- icon: Palette,
- visible: (u) => isSuperOrOwner(u.role),
+ title: "站点",
+ items: [
+ {
+ href: "/admin/settings",
+ label: "站点设置",
+ icon: Globe,
+ visible: (u) => isSuperOrOwner(u.role),
+ },
+ {
+ href: "/admin/appearance",
+ label: "外观",
+ icon: Palette,
+ visible: (u) => isSuperOrOwner(u.role),
+ },
+ ],
},
];
@@ -78,7 +115,6 @@ function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
-/** 待审核数量:首屏 + WS 推送 + 回前台校准(无定时轮询) */
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
@@ -116,19 +152,24 @@ function PendingBadge({ count }: { count: number }) {
);
}
-/**
- * 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
- * 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
- * 与 j13-bcard / 顶部 pill 保持一致。
- */
+function visibleGroups(user: RoleUserLike) {
+ return NAV_GROUPS.map((g) => ({
+ ...g,
+ items: g.items.filter((i) => i.visible(user)),
+ })).filter((g) => g.items.length > 0);
+}
+
+function flatItems(user: RoleUserLike) {
+ return visibleGroups(user).flatMap((g) => g.items);
+}
+
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
- const items = NAV_ITEMS.filter((i) => i.visible(user));
- const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
+ const groups = visibleGroups(user);
+ const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
return (
- {/* 品牌头 */}
- {/* 导航 */}
-
- {canConfigureSettings && (
-
-
-
- 已过审用户后续免审
-
-
- 开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
-
-
-
-
- )}
-
{/* 队列切换 */}
{(
diff --git a/frontend/app/admin/moderation/page.tsx b/frontend/app/admin/moderation/page.tsx
index 3031cfb..427b96c 100644
--- a/frontend/app/admin/moderation/page.tsx
+++ b/frontend/app/admin/moderation/page.tsx
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
-import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
-import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
+import { getMeCached } from "@/lib/serverData";
+import { canModerateAny } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ModerationAdmin from "./ModerationAdmin";
@@ -17,7 +17,6 @@ export default async function ModerationPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
- // 板块管理员及以上且(对板块管理员)拥有授权板块
if (!me.user || !canModerateAny(me.user)) {
return
;
}
@@ -33,16 +32,11 @@ export default async function ModerationPage() {
initError = e instanceof Error ? e.message : "获取待审核内容失败";
}
- const canConfigureSettings = isSuperOrOwner(me.user.role);
- const settings = await getPublicSettingsCached();
-
return (
);
}
diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx
new file mode 100644
index 0000000..dc0d1c2
--- /dev/null
+++ b/frontend/app/admin/settings/SettingsAdmin.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Globe,
+ Loader2,
+ Save,
+ UserPlus,
+} from "lucide-react";
+import {
+ apiUpdateSiteSettings,
+ type PublicSettings,
+} from "@/lib/api";
+import { toast } from "@/lib/toast";
+
+function Switch({
+ checked,
+ disabled,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ disabled?: boolean;
+ onChange: () => void;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
+ const [siteName, setSiteName] = useState(initial.site_name);
+ const [siteDescription, setSiteDescription] = useState(initial.site_description);
+ const [allowRegister, setAllowRegister] = useState(initial.allow_register);
+ const [savedName, setSavedName] = useState(initial.site_name);
+ const [savedDesc, setSavedDesc] = useState(initial.site_description);
+ const [saving, setSaving] = useState(false);
+ const [togglingReg, setTogglingReg] = useState(false);
+
+ const dirty =
+ siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
+
+ const saveIdentity = async () => {
+ if (saving) return;
+ const name = siteName.trim();
+ if (!name) {
+ toast("站点名称不能为空");
+ return;
+ }
+ setSaving(true);
+ try {
+ const res = await apiUpdateSiteSettings({
+ site_name: name,
+ site_description: siteDescription.trim(),
+ });
+ setSiteName(res.site_name);
+ setSiteDescription(res.site_description);
+ setSavedName(res.site_name);
+ setSavedDesc(res.site_description);
+ toast("站点信息已保存", "ok");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const toggleRegister = async () => {
+ if (togglingReg) return;
+ const next = !allowRegister;
+ setTogglingReg(true);
+ try {
+ const res = await apiUpdateSiteSettings({ allow_register: next });
+ setAllowRegister(res.allow_register);
+ toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败");
+ } finally {
+ setTogglingReg(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ 站点设置
+
+
+ 配置站点名称、简介与注册开关;主题色请到「外观」页调整
+
+
+
+
+
+
+
+
+ 开放注册
+
+
+ 关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
+
+
+ void toggleRegister()}
+ label="开放注册"
+ />
+
+
+ );
+}
diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx
new file mode 100644
index 0000000..ac7ac65
--- /dev/null
+++ b/frontend/app/admin/settings/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import { cookies } from "next/headers";
+import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
+import { authCookieHeader } from "@/lib/cookies";
+import { isSuperOrOwner } from "@/lib/roles";
+import Forbidden from "../Forbidden";
+import SettingsAdmin from "./SettingsAdmin";
+
+export const metadata: Metadata = {
+ title: "站点设置",
+};
+
+export default async function AdminSettingsPage() {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+ if (!me.user || !isSuperOrOwner(me.user.role)) {
+ return
;
+ }
+ const settings = await getPublicSettingsCached();
+ return
;
+}
diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx
index a561e4c..8ee2057 100644
--- a/frontend/app/admin/users/UsersAdmin.tsx
+++ b/frontend/app/admin/users/UsersAdmin.tsx
@@ -15,9 +15,11 @@ import {
Monitor,
RotateCcw,
Search,
+ ShieldAlert,
ShieldCheck,
Users as UsersIcon,
- UserPlus,
+ Activity,
+ Hourglass,
X,
} from "lucide-react";
import {
@@ -121,7 +123,15 @@ export default function UsersAdmin({
}) {
const [users, setUsers] = useState
(initial?.users ?? []);
const [summary, setSummary] = useState(
- initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
+ initial?.summary ?? {
+ online: 0,
+ in_cooldown: 0,
+ active_7d: 0,
+ failed_logins_24h: 0,
+ total: 0,
+ admins: 0,
+ banned: 0,
+ }
);
const [total, setTotal] = useState(initial?.total ?? 0);
const [page, setPage] = useState(initialQuery.page);
@@ -468,37 +478,38 @@ export default function UsersAdmin({
- {/* 汇总 */}
+ {/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
0 ? `今日 +${summary.today_new}` : undefined}
- icon={}
- color="var(--accent)"
- soft="var(--accent-soft)"
+ label="当前在线"
+ value={String(summary.online)}
+ sub="5 分钟内活跃"
+ icon={}
+ color="var(--ok)"
+ soft="var(--ok-soft)"
/>
}
+ label="发帖冷静期中"
+ value={String(summary.in_cooldown)}
+ sub="普通用户"
+ icon={}
color="var(--gold)"
soft="var(--gold-soft)"
/>
}
- color="var(--danger)"
- soft="var(--danger-soft)"
+ label="近 7 日活跃"
+ value={String(summary.active_7d)}
+ icon={}
+ color="var(--accent)"
+ soft="var(--accent-soft)"
/>
}
- color="var(--ok)"
- soft="var(--ok-soft)"
+ label="24h 登录失败"
+ value={String(summary.failed_logins_24h)}
+ sub="次"
+ icon={}
+ color="var(--danger)"
+ soft="var(--danger-soft)"
/>
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index d10ee25..79f8b3b 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -37,18 +37,23 @@ const jetbrainsMono = JetBrains_Mono({
display: "swap",
});
-export const metadata: Metadata = {
- title: {
- default: "姜十三论坛 · J13 FORUM",
- template: "%s · 姜十三论坛",
- },
- description: "姜十三论坛 - 技术分享与讨论社区",
- openGraph: {
- title: "姜十三论坛 · J13 FORUM",
- description: "姜十三论坛 - 技术分享与讨论社区",
- type: "website",
- },
-};
+export async function generateMetadata(): Promise {
+ const settings = await getPublicSettingsCached();
+ const name = settings.site_name || "姜十三论坛";
+ const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
+ return {
+ title: {
+ default: `${name} · J13 FORUM`,
+ template: `%s · ${name}`,
+ },
+ description: desc,
+ openGraph: {
+ title: `${name} · J13 FORUM`,
+ description: desc,
+ type: "website",
+ },
+ };
+}
export default async function RootLayout({
children,
@@ -64,7 +69,8 @@ export default async function RootLayout({
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
- const { accent } = await getPublicSettingsCached();
+ const settings = await getPublicSettingsCached();
+ const accent = settings.accent;
const accentCSS = accentStyleCSS(accent || "");
let user: User | null = null;
@@ -101,6 +107,8 @@ export default async function RootLayout({
initialUnread={unread}
initialChatUnread={chatUnread}
initialTheme={theme}
+ siteName={settings.site_name}
+ allowRegister={settings.allow_register}
/>
{children}
-
+
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
index b72b07e..1388cf6 100644
--- a/frontend/app/login/page.tsx
+++ b/frontend/app/login/page.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { MessagesSquare } from "lucide-react";
-import { apiLogin, apiMe } from "@/lib/api";
+import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
@@ -15,6 +15,28 @@ function safeRedirect(to: string | null): string {
return to;
}
+function RegisterHint() {
+ const [allow, setAllow] = useState(true);
+ useEffect(() => {
+ apiGetSettings()
+ .then((s) => setAllow(s.allow_register))
+ .catch(() => {});
+ }, []);
+ if (!allow) {
+ return (
+ 当前站点暂不开放注册
+ );
+ }
+ return (
+
+ 还没有账号?
+
+ 立即注册
+
+
+ );
+}
+
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -107,12 +129,7 @@ export default function LoginPage() {
-
- 还没有账号?
-
- 立即注册
-
-
+
);
}
diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx
index 5688176..b5b7b88 100644
--- a/frontend/app/register/page.tsx
+++ b/frontend/app/register/page.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
-import { apiRegister, apiMe } from "@/lib/api";
+import { apiRegister, apiMe, apiGetSettings } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
export default function RegisterPage() {
@@ -14,6 +14,7 @@ export default function RegisterPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
+ const [registerClosed, setRegisterClosed] = useState(false);
// 已登录用户不必再注册,直接回首页
useEffect(() => {
@@ -25,6 +26,14 @@ export default function RegisterPage() {
.catch(() => {});
}, [router]);
+ useEffect(() => {
+ apiGetSettings()
+ .then((s) => {
+ if (!s.allow_register) setRegisterClosed(true);
+ })
+ .catch(() => {});
+ }, []);
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
@@ -39,13 +48,31 @@ export default function RegisterPage() {
// 极端情况:注册成功但自动签发失败,引导去登录
router.push("/login");
} else {
- setError(res.error || "注册失败");
+ const msg = res.error || "注册失败";
+ if (msg.includes("关闭注册")) setRegisterClosed(true);
+ setError(msg);
}
} finally {
setLoading(false);
}
};
+ if (registerClosed) {
+ return (
+
+
+
+ 暂不开放注册
+
+
站点当前已关闭新用户注册,已有账号可直接登录。
+
+ 去登录
+
+
+
+ );
+ }
+
return (
@@ -59,7 +86,7 @@ export default function RegisterPage() {
创建账号
- 加入姜十三,开始你的第一帖
+ 加入社区,开始你的第一帖
diff --git a/frontend/components/Footer.tsx b/frontend/components/Footer.tsx
index 753f515..ca54533 100644
--- a/frontend/components/Footer.tsx
+++ b/frontend/components/Footer.tsx
@@ -7,13 +7,14 @@ function realVersion(v: string): string {
}
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
-export default function Footer() {
+export default function Footer({ siteName = "姜十三社区" }: { siteName?: string }) {
const year = new Date().getFullYear();
+ const brand = siteName.trim() || "姜十三社区";
return (
diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx
index ce4e543..b320d34 100644
--- a/frontend/components/Header.tsx
+++ b/frontend/components/Header.tsx
@@ -36,11 +36,15 @@ export default function Header({
initialUnread = 0,
initialChatUnread = 0,
initialTheme = "light",
+ siteName = "姜十三",
+ allowRegister = true,
}: {
initialUser: User | null;
initialUnread?: number;
initialChatUnread?: number;
initialTheme?: "light" | "dark";
+ siteName?: string;
+ allowRegister?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -226,7 +230,7 @@ export default function Header({
- 姜十三
+ {siteName}
J13 社区
@@ -332,9 +336,11 @@ export default function Header({
登录
-
- 注册
-
+ {allowRegister && (
+
+ 注册
+
+ )}
)}