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 ( +
+
+

+ + + + 站点设置 +

+

+ 配置站点名称、简介与注册开关;主题色请到「外观」页调整 +

+
+ +
+

+ 基本信息 +

+

用于页眉品牌、页脚版权与 SEO 元信息

+ + + setSiteName(e.target.value)} + placeholder="姜十三论坛" + /> + + +