feat(admin): 完善站点设置、内容回收站与后台菜单分组

新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:56:27 +08:00
parent be47b38370
commit dd7bd010b8
25 changed files with 2193 additions and 323 deletions

View File

@@ -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()})

View File

@@ -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": "操作失败"})
}
}

View File

@@ -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,
})
}