feat(admin): 完善站点设置、内容回收站与后台菜单分组
新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -96,6 +96,13 @@ type LoginRequest struct {
|
|||||||
|
|
||||||
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
||||||
func (h *Handlers) Register(c *gin.Context) {
|
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
|
var req RegisterRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -87,16 +87,91 @@ func (h *Handlers) AdminRejectComment(c *gin.Context) {
|
|||||||
h.execModeration(c, h.Moderation.RejectComment)
|
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) {
|
func (h *Handlers) writeModerationError(c *gin.Context, err error) {
|
||||||
switch {
|
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": "内容不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"})
|
||||||
case errors.Is(err, service.ErrModerationForbidden):
|
case errors.Is(err, service.ErrModerationForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
default:
|
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": "操作失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,32 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/realtime"
|
"github.com/freefire/jiang13-bbs/realtime"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量等
|
|
||||||
// GET /api/settings
|
// GET /api/settings
|
||||||
func (h *Handlers) PublicSettings(c *gin.Context) {
|
func (h *Handlers) PublicSettings(c *gin.Context) {
|
||||||
accent, err := h.Setting.AccentColor()
|
s, err := h.Setting.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trust, err := h.Setting.TrustReviewedPublish()
|
c.JSON(http.StatusOK, s)
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"accent": accent,
|
|
||||||
"trust_reviewed_publish": trust,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖审核策略
|
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
|
||||||
type updateSettingsRequest struct {
|
type updateSettingsRequest struct {
|
||||||
// Accent 主题色 #RRGGBB;空串恢复默认
|
|
||||||
Accent *string `json:"accent"`
|
Accent *string `json:"accent"`
|
||||||
// TrustReviewedPublish 已过审用户后续内容免审
|
|
||||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
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
|
// PUT /api/admin/settings
|
||||||
func (h *Handlers) UpdateSettings(c *gin.Context) {
|
func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||||
var req updateSettingsRequest
|
var req updateSettingsRequest
|
||||||
@@ -45,12 +36,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
return
|
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": "未提供任何可更新的设置项"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedAccent := ""
|
|
||||||
if req.Accent != nil {
|
if req.Accent != nil {
|
||||||
if err := h.Setting.SetAccent(*req.Accent); err != nil {
|
if err := h.Setting.SetAccent(*req.Accent); err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidAccent) {
|
if errors.Is(err, service.ErrInvalidAccent) {
|
||||||
@@ -60,43 +51,74 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
return
|
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 req.TrustReviewedPublish != nil {
|
||||||
if err := h.Setting.SetTrustReviewedPublish(*req.TrustReviewedPublish); err != nil {
|
if err := h.Setting.SetTrustReviewedPublish(*req.TrustReviewedPublish); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trustOn = *req.TrustReviewedPublish
|
}
|
||||||
} else {
|
if req.SiteName != nil {
|
||||||
var err error
|
if err := h.Setting.SetSiteName(*req.SiteName); err != nil {
|
||||||
trustOn, err = h.Setting.TrustReviewedPublish()
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
if err != nil {
|
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称过长或无效"})
|
||||||
c.JSON(http.StatusInternalServerError, 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 归一化后广播:主题色客户端热换肤;免审开关由下次发帖/发评读库生效
|
saved, err := h.Setting.Public()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.Hub.BroadcastAll(realtime.Envelope{
|
h.Hub.BroadcastAll(realtime.Envelope{
|
||||||
Type: realtime.EventSettingsChanged,
|
Type: realtime.EventSettingsChanged,
|
||||||
Data: gin.H{
|
Data: gin.H{
|
||||||
"accent": savedAccent,
|
"accent": saved.Accent,
|
||||||
"trust_reviewed_publish": trustOn,
|
"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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"accent": savedAccent,
|
"accent": saved.Accent,
|
||||||
"trust_reviewed_publish": trustOn,
|
"trust_reviewed_publish": saved.TrustReviewedPublish,
|
||||||
|
"site_name": saved.SiteName,
|
||||||
|
"site_description": saved.SiteDescription,
|
||||||
|
"allow_register": saved.AllowRegister,
|
||||||
|
"post_cooldown_hours": saved.PostCooldownHours,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 服务
|
// 服务
|
||||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||||
boardSvc := service.NewBoardService(model.DB)
|
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)
|
commentSvc := service.NewCommentService(model.DB)
|
||||||
likeSvc := service.NewLikeService(model.DB)
|
likeSvc := service.NewLikeService(model.DB)
|
||||||
notifSvc := service.NewNotificationService(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"))
|
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private"))
|
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private"))
|
||||||
pointsSvc := service.NewPointsService(model.DB)
|
pointsSvc := service.NewPointsService(model.DB)
|
||||||
settingSvc := service.NewSettingService(model.DB)
|
|
||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
chatSvc := service.NewChatService(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/approve", h.AdminApproveComment)
|
||||||
staffAPI.PUT("/moderation/comments/:id/reject", h.AdminRejectComment)
|
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)
|
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
|
||||||
|
|
||||||
|
|||||||
395
backend/service/admin_content.go
Normal file
395
backend/service/admin_content.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -59,12 +59,17 @@ type AdminUserItem struct {
|
|||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUserSummary 列表顶部汇总(总数/管理团队/封禁/今日新增)
|
// AdminUserSummary 列表顶部汇总
|
||||||
|
// - online / in_cooldown / active_7d / failed_logins_24h:用户页独有运营指标
|
||||||
|
// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
|
||||||
type AdminUserSummary struct {
|
type AdminUserSummary struct {
|
||||||
|
Online int64 `json:"online"`
|
||||||
|
InCooldown int64 `json:"in_cooldown"`
|
||||||
|
Active7d int64 `json:"active_7d"`
|
||||||
|
FailedLogins24h int64 `json:"failed_logins_24h"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Admins int64 `json:"admins"`
|
Admins int64 `json:"admins"`
|
||||||
Banned int64 `json:"banned"`
|
Banned int64 `json:"banned"`
|
||||||
TodayNew int64 `json:"today_new"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// staffRoles 管理团队角色集合
|
// staffRoles 管理团队角色集合
|
||||||
@@ -131,14 +136,31 @@ func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, err
|
|||||||
|
|
||||||
items := s.toItems(users)
|
items := s.toItems(users)
|
||||||
|
|
||||||
// 汇总数据(不受筛选条件影响,始终反映全站)
|
// 汇总:用户页独有指标(避免与仪表盘站点总量重复)
|
||||||
now := time.Now()
|
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{}
|
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{}).Count(&summary.Total)
|
||||||
s.db.Model(&model.User{}).Where("role IN ?", staffRoles).Count(&summary.Admins)
|
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("banned = ?", true).Count(&summary.Banned)
|
||||||
s.db.Model(&model.User{}).Where("created_at >= ?", dayStart).Count(&summary.TodayNew)
|
|
||||||
|
|
||||||
return &AdminUserListResult{
|
return &AdminUserListResult{
|
||||||
Users: items,
|
Users: items,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,12 +19,19 @@ var (
|
|||||||
// PostService 帖子服务
|
// PostService 帖子服务
|
||||||
type PostService struct {
|
type PostService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
setting *SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPostService(db *gorm.DB) *PostService {
|
func NewPostService(db *gorm.DB) *PostService {
|
||||||
return &PostService{db: db}
|
return &PostService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSetting 注入站点设置(发帖冷静期可读配置);可链式调用
|
||||||
|
func (s *PostService) WithSetting(setting *SettingService) *PostService {
|
||||||
|
s.setting = setting
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// PostListQuery 帖子列表查询参数
|
// PostListQuery 帖子列表查询参数
|
||||||
type PostListQuery struct {
|
type PostListQuery struct {
|
||||||
BoardID uint
|
BoardID uint
|
||||||
@@ -925,14 +933,23 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkNewUserCooldown 新用户发帖 24h 冷静期
|
// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭)
|
||||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
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
|
var user model.User
|
||||||
if err := s.db.First(&user, userID).Error; err != nil {
|
if err := s.db.First(&user, userID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if time.Since(user.CreatedAt) < 24*time.Hour {
|
if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour {
|
||||||
return errors.New("新用户注册 24 小时后才能发帖")
|
return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -15,14 +17,40 @@ const (
|
|||||||
SettingKeyAccent = "theme_accent"
|
SettingKeyAccent = "theme_accent"
|
||||||
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
|
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
|
||||||
SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
|
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 主题色格式非法
|
// ErrInvalidAccent 主题色格式非法
|
||||||
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
|
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
|
||||||
|
|
||||||
|
// ErrInvalidSiteSetting 站点设置字段校验失败
|
||||||
|
var ErrInvalidSiteSetting = errors.New("站点设置参数无效")
|
||||||
|
|
||||||
var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
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 {
|
type SettingService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -31,59 +59,195 @@ func NewSettingService(db *gorm.DB) *SettingService {
|
|||||||
return &SettingService{db: db}
|
return &SettingService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
|
func (s *SettingService) getValue(key string) (string, bool, error) {
|
||||||
func (s *SettingService) AccentColor() (string, error) {
|
|
||||||
var st model.SiteSetting
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return "", nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
if err != 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 "", err
|
||||||
}
|
}
|
||||||
return strings.ToLower(st.Value), nil
|
return strings.ToLower(v), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键)
|
// SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键)
|
||||||
func (s *SettingService) SetAccent(hex string) error {
|
func (s *SettingService) SetAccent(hex string) error {
|
||||||
hex = strings.TrimSpace(strings.ToLower(hex))
|
hex = strings.TrimSpace(strings.ToLower(hex))
|
||||||
if hex == "" {
|
if hex == "" {
|
||||||
return s.db.Where("key = ?", SettingKeyAccent).Delete(&model.SiteSetting{}).Error
|
return s.deleteKey(SettingKeyAccent)
|
||||||
}
|
}
|
||||||
if !accentHexRe.MatchString(hex) {
|
if !accentHexRe.MatchString(hex) {
|
||||||
return ErrInvalidAccent
|
return ErrInvalidAccent
|
||||||
}
|
}
|
||||||
// UPSERT:键冲突时更新值与时间戳
|
return s.putValue(SettingKeyAccent, hex)
|
||||||
st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
|
|
||||||
return s.db.Save(&st).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrustReviewedPublish 是否对「已有过审内容」的用户免审。
|
// TrustReviewedPublish 是否对「已有过审内容」的用户免审。
|
||||||
// KV 缺行 / 空值视为开启(默认开);仅显式写入 false/0/off/no 为关闭。
|
|
||||||
// 不用 GORM bool default:true,避免零值省略陷阱。
|
|
||||||
func (s *SettingService) TrustReviewedPublish() (bool, error) {
|
func (s *SettingService) TrustReviewedPublish() (bool, error) {
|
||||||
var st model.SiteSetting
|
v, found, err := s.getValue(SettingKeyTrustReviewedPublish)
|
||||||
err := s.db.Where("key = ?", SettingKeyTrustReviewedPublish).First(&st).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
v := strings.TrimSpace(strings.ToLower(st.Value))
|
return parseBoolDefaultTrue(v, found), nil
|
||||||
switch v {
|
|
||||||
case "false", "0", "off", "no":
|
|
||||||
return false, nil
|
|
||||||
default:
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义
|
// SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义
|
||||||
func (s *SettingService) SetTrustReviewedPublish(on bool) error {
|
func (s *SettingService) SetTrustReviewedPublish(on bool) error {
|
||||||
if on {
|
return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on)
|
||||||
return s.db.Where("key = ?", SettingKeyTrustReviewedPublish).Delete(&model.SiteSetting{}).Error
|
|
||||||
}
|
}
|
||||||
st := model.SiteSetting{Key: SettingKeyTrustReviewedPublish, Value: "false"}
|
|
||||||
return s.db.Save(&st).Error
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Coins,
|
Coins,
|
||||||
|
Globe,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { apiAdminPendingCounts } from "@/lib/api";
|
import { apiAdminPendingCounts } from "@/lib/api";
|
||||||
@@ -32,15 +34,26 @@ interface NavItem {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
/** 按 /me 角色过滤可见性;最终权限以后端路由守卫为准 */
|
|
||||||
visible: (u: RoleUserLike) => boolean;
|
visible: (u: RoleUserLike) => boolean;
|
||||||
/** 是否携带待审核角标 */
|
|
||||||
pendingBadge?: boolean;
|
pendingBadge?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
|
interface NavGroup {
|
||||||
const NAV_ITEMS: NavItem[] = [
|
title: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
title: "概览",
|
||||||
|
items: [
|
||||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "内容",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/moderation",
|
href: "/admin/moderation",
|
||||||
label: "内容审核",
|
label: "内容审核",
|
||||||
@@ -49,10 +62,10 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
pendingBadge: true,
|
pendingBadge: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/economy",
|
href: "/admin/content",
|
||||||
label: "积分看板",
|
label: "内容管理",
|
||||||
icon: Coins,
|
icon: FolderOpen,
|
||||||
visible: (u) => isAdminOrAbove(u.role),
|
visible: (u) => canModerateAny(u),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/announcements",
|
href: "/admin/announcements",
|
||||||
@@ -60,25 +73,48 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
icon: Megaphone,
|
icon: Megaphone,
|
||||||
visible: (u) => isAdminOrAbove(u.role),
|
visible: (u) => isAdminOrAbove(u.role),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "成员与经济",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/users",
|
href: "/admin/users",
|
||||||
label: "用户权限",
|
label: "用户权限",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
visible: (u) => isSuperOrOwner(u.role),
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/economy",
|
||||||
|
label: "积分看板",
|
||||||
|
icon: Coins,
|
||||||
|
visible: (u) => isAdminOrAbove(u.role),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "站点",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/admin/settings",
|
||||||
|
label: "站点设置",
|
||||||
|
icon: Globe,
|
||||||
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/appearance",
|
href: "/admin/appearance",
|
||||||
label: "外观设置",
|
label: "外观",
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
visible: (u) => isSuperOrOwner(u.role),
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActive(pathname: string, href: string, exact?: boolean) {
|
function isActive(pathname: string, href: string, exact?: boolean) {
|
||||||
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待审核数量:首屏 + WS 推送 + 回前台校准(无定时轮询) */
|
|
||||||
function usePendingCounts(enabled: boolean) {
|
function usePendingCounts(enabled: boolean) {
|
||||||
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
|
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,19 +152,24 @@ function PendingBadge({ count }: { count: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function visibleGroups(user: RoleUserLike) {
|
||||||
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
|
return NAV_GROUPS.map((g) => ({
|
||||||
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
|
...g,
|
||||||
* 与 j13-bcard / 顶部 pill 保持一致。
|
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 }) {
|
export function AdminSidebar({ user }: { user: User }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const items = NAV_ITEMS.filter((i) => i.visible(user));
|
const groups = visibleGroups(user);
|
||||||
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panel p-3">
|
<div className="panel p-3">
|
||||||
{/* 品牌头 */}
|
|
||||||
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
|
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||||
<span
|
<span
|
||||||
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
|
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
|
||||||
@@ -146,12 +187,14 @@ export function AdminSidebar({ user }: { user: User }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航 */}
|
<nav className="flex flex-col gap-3 pt-3 pb-2" aria-label="后台导航">
|
||||||
<nav className="flex flex-col gap-1 pt-3 pb-2" aria-label="后台导航">
|
{groups.map((group) => (
|
||||||
|
<div key={group.title}>
|
||||||
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
|
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
|
||||||
站点管理
|
{group.title}
|
||||||
</p>
|
</p>
|
||||||
{items.map((item) => {
|
<div className="flex flex-col gap-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
const active = isActive(pathname, item.href, item.exact);
|
const active = isActive(pathname, item.href, item.exact);
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
@@ -172,9 +215,11 @@ export function AdminSidebar({ user }: { user: User }) {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 返回前台 */}
|
|
||||||
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
|
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
@@ -186,7 +231,6 @@ export function AdminSidebar({ user }: { user: User }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 身份卡:按当前角色显示对应徽章(站长为皇冠标识) */}
|
|
||||||
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
|
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
|
||||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
|
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
|
||||||
<div className="min-w-0 leading-tight">
|
<div className="min-w-0 leading-tight">
|
||||||
@@ -205,13 +249,9 @@ export function AdminSidebar({ user }: { user: User }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
|
|
||||||
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
|
|
||||||
*/
|
|
||||||
export function AdminNavMobile({ user }: { user: User }) {
|
export function AdminNavMobile({ user }: { user: User }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const items = NAV_ITEMS.filter((i) => i.visible(user));
|
const items = flatItems(user);
|
||||||
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiAdminListAnnouncements,
|
apiAdminListAnnouncements,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||||
{ value: "blue", label: "蓝色" },
|
{ value: "blue", label: "蓝色" },
|
||||||
@@ -52,6 +52,7 @@ export default function AnnouncementAdmin() {
|
|||||||
const [form, setForm] = useState<FormState | null>(null);
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -113,7 +114,6 @@ export default function AnnouncementAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(a: Announcement) {
|
async function remove(a: Announcement) {
|
||||||
if (!window.confirm(`确定删除公告「${a.title}」吗?此操作不可撤销。`)) return;
|
|
||||||
setDeletingId(a.id);
|
setDeletingId(a.id);
|
||||||
try {
|
try {
|
||||||
await apiAdminDeleteAnnouncement(a.id);
|
await apiAdminDeleteAnnouncement(a.id);
|
||||||
@@ -124,6 +124,7 @@ export default function AnnouncementAdmin() {
|
|||||||
toast(e instanceof Error ? e.message : "删除失败");
|
toast(e instanceof Error ? e.message : "删除失败");
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +151,10 @@ export default function AnnouncementAdmin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-6 items-start">
|
<div className="grid grid-cols-1 gap-6 items-start">
|
||||||
{/* 编辑/新建表单 */}
|
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
|
||||||
{form ? (
|
{form && (
|
||||||
<div className="panel p-5 lg:sticky lg:top-24">
|
<div className="panel p-5">
|
||||||
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
||||||
{form.id == null ? "新建公告" : "编辑公告"}
|
{form.id == null ? "新建公告" : "编辑公告"}
|
||||||
</p>
|
</p>
|
||||||
@@ -257,26 +258,6 @@ export default function AnnouncementAdmin() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="panel p-6 text-center lg:sticky lg:top-24">
|
|
||||||
<span
|
|
||||||
className="inline-flex w-12 h-12 rounded-2xl items-center justify-center mb-3"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<FileText size={22} />
|
|
||||||
</span>
|
|
||||||
<p className="text-[14.5px]" style={{ color: "var(--ink)" }}>
|
|
||||||
公告编辑器
|
|
||||||
</p>
|
|
||||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
|
||||||
点击右上「新建公告」发布公告,
|
|
||||||
<br />
|
|
||||||
或在右侧列表中选择已有公告进行编辑。
|
|
||||||
</p>
|
|
||||||
<button onClick={startCreate} className="btn btn-primary mt-4">
|
|
||||||
<Plus size={15} /> 新建公告
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 公告列表 */}
|
{/* 公告列表 */}
|
||||||
@@ -350,7 +331,7 @@ export default function AnnouncementAdmin() {
|
|||||||
<Pencil size={15} />
|
<Pencil size={15} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => remove(a)}
|
onClick={() => setDeleteTarget(a)}
|
||||||
disabled={deletingId === a.id}
|
disabled={deletingId === a.id}
|
||||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{ color: "var(--ink-3)" }}
|
||||||
@@ -367,6 +348,19 @@ export default function AnnouncementAdmin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="删除公告"
|
||||||
|
message={deleteTarget ? `确定删除公告「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
||||||
|
danger
|
||||||
|
busy={deletingId != null}
|
||||||
|
confirmLabel="删除"
|
||||||
|
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) void remove(deleteTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
696
frontend/app/admin/content/ContentAdmin.tsx
Normal file
696
frontend/app/admin/content/ContentAdmin.tsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
MessagesSquare,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminContentCounts,
|
||||||
|
apiAdminContentPosts,
|
||||||
|
apiAdminContentComments,
|
||||||
|
apiAdminSoftDeletePost,
|
||||||
|
apiAdminRestorePost,
|
||||||
|
apiAdminPurgePost,
|
||||||
|
apiAdminSoftDeleteComment,
|
||||||
|
apiAdminRestoreComment,
|
||||||
|
apiAdminPurgeComment,
|
||||||
|
apiUpdateSiteSettings,
|
||||||
|
type AdminContentComment,
|
||||||
|
type AdminContentCounts,
|
||||||
|
type AdminContentPost,
|
||||||
|
type AdminContentStatus,
|
||||||
|
type PublicSettings,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
type Kind = "posts" | "comments";
|
||||||
|
|
||||||
|
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
|
||||||
|
{ key: "live", label: "全部" },
|
||||||
|
{ key: "published", label: "已发布" },
|
||||||
|
{ key: "pending", label: "待审" },
|
||||||
|
{ key: "rejected", label: "已拒绝" },
|
||||||
|
{ key: "deleted", label: "已删除" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusLabel(status: string, deleted: boolean) {
|
||||||
|
if (deleted) return "已删除";
|
||||||
|
if (status === "pending") return "待审";
|
||||||
|
if (status === "rejected") return "已拒绝";
|
||||||
|
if (status === "published") return "已发布";
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerpt(md: string, max = 120): string {
|
||||||
|
const text = md
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/[#>*_`~\-]/g, "")
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onChange}
|
||||||
|
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
||||||
|
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
||||||
|
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentAdmin({
|
||||||
|
initialPosts,
|
||||||
|
initialPostsTotal,
|
||||||
|
initialCounts,
|
||||||
|
initError,
|
||||||
|
canConfigureSettings,
|
||||||
|
initialSettings,
|
||||||
|
}: {
|
||||||
|
initialPosts: AdminContentPost[];
|
||||||
|
initialPostsTotal: number;
|
||||||
|
initialCounts: AdminContentCounts;
|
||||||
|
initError: string;
|
||||||
|
canConfigureSettings: boolean;
|
||||||
|
initialSettings: PublicSettings;
|
||||||
|
}) {
|
||||||
|
const [kind, setKind] = useState<Kind>("posts");
|
||||||
|
const [status, setStatus] = useState<AdminContentStatus>("live");
|
||||||
|
const [counts, setCounts] = useState(initialCounts);
|
||||||
|
|
||||||
|
const [posts, setPosts] = useState(initialPosts);
|
||||||
|
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
|
||||||
|
const [postsPage, setPostsPage] = useState(1);
|
||||||
|
|
||||||
|
const [comments, setComments] = useState<AdminContentComment[]>([]);
|
||||||
|
const [commentsTotal, setCommentsTotal] = useState(0);
|
||||||
|
const [commentsPage, setCommentsPage] = useState(1);
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [appliedQ, setAppliedQ] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(initError);
|
||||||
|
const [actingKey, setActingKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
|
||||||
|
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
const [confirm, setConfirm] = useState<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
danger?: boolean;
|
||||||
|
run: () => Promise<void>;
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
|
||||||
|
const refreshCounts = useCallback(() => {
|
||||||
|
apiAdminContentCounts().then(setCounts).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminContentPosts(page, st, q);
|
||||||
|
setPosts(res.posts);
|
||||||
|
setPostsTotal(res.total);
|
||||||
|
setPostsPage(page);
|
||||||
|
setError("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "获取帖子失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminContentComments(page, st, q);
|
||||||
|
setComments(res.comments);
|
||||||
|
setCommentsTotal(res.total);
|
||||||
|
setCommentsPage(page);
|
||||||
|
setError("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "获取评论失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = (page = 1) => {
|
||||||
|
if (kind === "posts") void loadPosts(page, status, appliedQ);
|
||||||
|
else void loadComments(page, status, appliedQ);
|
||||||
|
refreshCounts();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshCounts();
|
||||||
|
}, [refreshCounts]);
|
||||||
|
|
||||||
|
const switchKind = (k: Kind) => {
|
||||||
|
if (k === kind) return;
|
||||||
|
setKind(k);
|
||||||
|
setError("");
|
||||||
|
if (k === "posts") void loadPosts(1, status, appliedQ);
|
||||||
|
else void loadComments(1, status, appliedQ);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchStatus = (st: AdminContentStatus) => {
|
||||||
|
if (st === status) return;
|
||||||
|
setStatus(st);
|
||||||
|
setError("");
|
||||||
|
if (kind === "posts") void loadPosts(1, st, appliedQ);
|
||||||
|
else void loadComments(1, st, appliedQ);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = keyword.trim();
|
||||||
|
setAppliedQ(q);
|
||||||
|
if (kind === "posts") void loadPosts(1, status, q);
|
||||||
|
else void loadComments(1, status, q);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
|
||||||
|
setActingKey(key);
|
||||||
|
fn()
|
||||||
|
.then(() => {
|
||||||
|
toast(okMsg, "ok");
|
||||||
|
reload(kind === "posts" ? postsPage : commentsPage);
|
||||||
|
})
|
||||||
|
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||||
|
.finally(() => setActingKey(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const askConfirm = (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
run: () => Promise<void>,
|
||||||
|
danger = false
|
||||||
|
) => setConfirm({ title, message, run, danger });
|
||||||
|
|
||||||
|
const toggleTrust = async () => {
|
||||||
|
if (!canConfigureSettings || savingPolicy) return;
|
||||||
|
setSavingPolicy(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
|
||||||
|
setTrustOn(res.trust_reviewed_publish);
|
||||||
|
toast(res.trust_reviewed_publish ? "已开启过审免审" : "已关闭过审免审", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCooldown = async () => {
|
||||||
|
if (!canConfigureSettings || savingPolicy) return;
|
||||||
|
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
|
||||||
|
setSavingPolicy(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
|
||||||
|
setCooldown(res.post_cooldown_hours);
|
||||||
|
toast(
|
||||||
|
res.post_cooldown_hours === 0
|
||||||
|
? "已关闭新用户发帖冷静期"
|
||||||
|
: `冷静期已设为 ${res.post_cooldown_hours} 小时`,
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = kind === "posts" ? postsTotal : commentsTotal;
|
||||||
|
const page = kind === "posts" ? postsPage : commentsPage;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
const statusCount = (st: AdminContentStatus) => {
|
||||||
|
if (kind === "posts") {
|
||||||
|
if (st === "live") return counts.posts_live;
|
||||||
|
if (st === "pending") return counts.posts_pending;
|
||||||
|
if (st === "rejected") return counts.posts_rejected;
|
||||||
|
if (st === "deleted") return counts.posts_deleted;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (st === "live") return counts.comments_live;
|
||||||
|
if (st === "pending") return counts.comments_pending;
|
||||||
|
if (st === "rejected") return counts.comments_rejected;
|
||||||
|
if (st === "deleted") return counts.comments_deleted;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5">
|
||||||
|
<h1
|
||||||
|
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<FolderOpen size={18} />
|
||||||
|
</span>
|
||||||
|
内容管理
|
||||||
|
</h1>
|
||||||
|
<p className="meta mt-1.5 text-[12.5px]">
|
||||||
|
浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canConfigureSettings && (
|
||||||
|
<div className="grid gap-3 mb-5">
|
||||||
|
<section className="panel p-4 sm:p-5 flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
已过审用户后续免审
|
||||||
|
</h2>
|
||||||
|
<p className="meta mt-1 text-[12.5px] leading-relaxed">
|
||||||
|
开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={trustOn}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
onChange={() => void toggleTrust()}
|
||||||
|
label="已过审用户后续免审"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel p-4 sm:p-5">
|
||||||
|
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
新用户发帖冷静期
|
||||||
|
</h2>
|
||||||
|
<p className="meta mt-1 text-[12.5px] leading-relaxed mb-3">
|
||||||
|
注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={168}
|
||||||
|
className="field w-28"
|
||||||
|
value={cooldown}
|
||||||
|
onChange={(e) => setCooldown(Number(e.target.value))}
|
||||||
|
aria-label="冷静期小时数"
|
||||||
|
/>
|
||||||
|
<span className="meta text-[12.5px]">小时</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={savingPolicy}
|
||||||
|
onClick={() => void saveCooldown()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 mb-3 flex-wrap" role="tablist" aria-label="内容类型">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "posts" as Kind, label: "帖子", icon: <FileText size={14} />, count: counts.posts_live },
|
||||||
|
{
|
||||||
|
key: "comments" as Kind,
|
||||||
|
label: "评论",
|
||||||
|
icon: <MessagesSquare size={14} />,
|
||||||
|
count: counts.comments_live,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
).map((t) => {
|
||||||
|
const active = kind === t.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => switchKind(t.key)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
|
||||||
|
style={
|
||||||
|
active
|
||||||
|
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
|
||||||
|
: { color: "var(--ink-2)", background: "var(--panel-2)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.icon}
|
||||||
|
{t.label}
|
||||||
|
<span className="text-[11px] tabular-nums" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}>
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="状态筛选">
|
||||||
|
{STATUS_TABS.map((t) => {
|
||||||
|
const active = status === t.key;
|
||||||
|
const cnt = statusCount(t.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => switchStatus(t.key)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] transition-colors"
|
||||||
|
style={
|
||||||
|
active
|
||||||
|
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||||
|
: { color: "var(--ink-3)", background: "var(--panel)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{cnt != null && (
|
||||||
|
<span className="tabular-nums text-[11px] opacity-80">{cnt}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={applySearch} className="panel p-3 mb-4 flex gap-2 items-center" role="search">
|
||||||
|
<Search size={15} style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||||
|
<input
|
||||||
|
className="field flex-1 !border-0 !shadow-none !bg-transparent py-1.5"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
|
||||||
|
aria-label="搜索内容"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-line btn-sm">
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
|
||||||
|
<Loader2 size={16} className="animate-spin" /> 加载中…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<p className="alert-error m-4">{error}</p>
|
||||||
|
) : kind === "posts" && posts.length === 0 ? (
|
||||||
|
<p className="meta text-center py-16 text-[13px]">暂无帖子</p>
|
||||||
|
) : kind === "comments" && comments.length === 0 ? (
|
||||||
|
<p className="meta text-center py-16 text-[13px]">暂无评论</p>
|
||||||
|
) : kind === "posts" ? (
|
||||||
|
posts.map((p) => {
|
||||||
|
const key = `p-${p.id}`;
|
||||||
|
const busy = actingKey === key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<Avatar name={p.user?.nickname || p.user?.username || "?"} src={p.user?.avatar || undefined} size={34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: p.deleted ? "var(--danger-soft)" : "var(--tint)",
|
||||||
|
color: p.deleted ? "var(--danger)" : "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel(p.status, p.deleted)}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11.5px]">{p.board?.name}</span>
|
||||||
|
<span className="meta text-[11.5px] tabular-nums">{formatRelative(p.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{p.deleted ? (
|
||||||
|
<p className="mt-1 text-[14px]" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{p.title}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Link href={`/post/${p.id}`} className="mt-1 block text-[14px] hover:underline" style={{ color: "var(--ink)" }}>
|
||||||
|
{p.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<p className="meta text-[12px] mt-0.5">
|
||||||
|
{p.user?.nickname} · {p.comment_count} 评
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{p.deleted ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
|
||||||
|
恢复
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
askConfirm(
|
||||||
|
"彻底删除帖子",
|
||||||
|
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||||
|
() =>
|
||||||
|
apiAdminPurgePost(p.id).then(() => {
|
||||||
|
toast("帖子已彻底删除", "ok");
|
||||||
|
reload(postsPage);
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 清除
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
askConfirm(
|
||||||
|
"移入回收站",
|
||||||
|
`确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
|
||||||
|
() =>
|
||||||
|
apiAdminSoftDeletePost(p.id).then(() => {
|
||||||
|
toast("已移入回收站", "ok");
|
||||||
|
reload(postsPage);
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
comments.map((c) => {
|
||||||
|
const key = `c-${c.id}`;
|
||||||
|
const busy = actingKey === key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<Avatar name={c.user?.nickname || c.user?.username || "?"} src={c.user?.avatar || undefined} size={34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: c.deleted ? "var(--danger-soft)" : "var(--tint)",
|
||||||
|
color: c.deleted ? "var(--danger)" : "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel(c.status, c.deleted)}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11.5px]">{c.board?.name}</span>
|
||||||
|
<span className="meta text-[11.5px] tabular-nums">{formatRelative(c.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[13.5px] leading-relaxed" style={{ color: "var(--ink)" }}>
|
||||||
|
{excerpt(c.content)}
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12px] mt-0.5">
|
||||||
|
{c.user?.nickname} ·{" "}
|
||||||
|
{c.deleted ? (
|
||||||
|
c.post_title
|
||||||
|
) : (
|
||||||
|
<Link href={`/post/${c.post_id}`} className="hover:underline" style={{ color: "var(--accent)" }}>
|
||||||
|
{c.post_title || `帖子 #${c.post_id}`}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{c.deleted ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
|
||||||
|
恢复
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
askConfirm(
|
||||||
|
"彻底删除评论",
|
||||||
|
"该评论将永久删除且不可恢复。",
|
||||||
|
() =>
|
||||||
|
apiAdminPurgeComment(c.id).then(() => {
|
||||||
|
toast("评论已彻底删除", "ok");
|
||||||
|
reload(commentsPage);
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 清除
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
askConfirm(
|
||||||
|
"移入回收站",
|
||||||
|
"确定软删这条评论吗?可在「已删除」中恢复。",
|
||||||
|
() =>
|
||||||
|
apiAdminSoftDeleteComment(c.id).then(() => {
|
||||||
|
toast("已移入回收站", "ok");
|
||||||
|
reload(commentsPage);
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => {
|
||||||
|
const p = page - 1;
|
||||||
|
if (kind === "posts") void loadPosts(p, status, appliedQ);
|
||||||
|
else void loadComments(p, status, appliedQ);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一页
|
||||||
|
</button>
|
||||||
|
<span className="meta text-[12.5px] tabular-nums">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
onClick={() => {
|
||||||
|
const p = page + 1;
|
||||||
|
if (kind === "posts") void loadPosts(p, status, appliedQ);
|
||||||
|
else void loadComments(p, status, appliedQ);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下一页 <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirm}
|
||||||
|
title={confirm?.title || ""}
|
||||||
|
message={confirm?.message || ""}
|
||||||
|
danger={confirm?.danger}
|
||||||
|
busy={confirmBusy}
|
||||||
|
confirmLabel={confirm?.danger ? "确认删除" : "确定"}
|
||||||
|
onCancel={() => !confirmBusy && setConfirm(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!confirm) return;
|
||||||
|
setConfirmBusy(true);
|
||||||
|
confirm
|
||||||
|
.run()
|
||||||
|
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||||
|
.finally(() => {
|
||||||
|
setConfirmBusy(false);
|
||||||
|
setConfirm(null);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/app/admin/content/page.tsx
Normal file
56
frontend/app/admin/content/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import {
|
||||||
|
fetchAdminContentCounts,
|
||||||
|
fetchAdminContentPosts,
|
||||||
|
type AdminContentPost,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
import ContentAdmin from "./ContentAdmin";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "内容管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminContentPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !canModerateAny(me.user)) {
|
||||||
|
return <Forbidden text="你所在的角色没有内容管理权限,或尚未被授权任何板块。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialPosts: AdminContentPost[] = [];
|
||||||
|
let initialTotal = 0;
|
||||||
|
let initError = "";
|
||||||
|
const [postsSettled, counts] = await Promise.all([
|
||||||
|
fetchAdminContentPosts(1, "live", cookie || undefined)
|
||||||
|
.then((res) => ({ ok: true as const, res }))
|
||||||
|
.catch((e) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: e instanceof Error ? e.message : "获取内容列表失败",
|
||||||
|
})),
|
||||||
|
fetchAdminContentCounts(cookie || undefined),
|
||||||
|
]);
|
||||||
|
if (postsSettled.ok) {
|
||||||
|
initialPosts = postsSettled.res.posts;
|
||||||
|
initialTotal = postsSettled.res.total;
|
||||||
|
} else {
|
||||||
|
initError = postsSettled.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentAdmin
|
||||||
|
initialPosts={initialPosts}
|
||||||
|
initialPostsTotal={initialTotal}
|
||||||
|
initialCounts={counts}
|
||||||
|
initError={initError}
|
||||||
|
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||||
|
initialSettings={settings}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
apiAdminRejectPost,
|
apiAdminRejectPost,
|
||||||
apiAdminApproveComment,
|
apiAdminApproveComment,
|
||||||
apiAdminRejectComment,
|
apiAdminRejectComment,
|
||||||
apiUpdateSiteSettings,
|
|
||||||
type PendingCommentItem,
|
type PendingCommentItem,
|
||||||
type PendingCounts,
|
type PendingCounts,
|
||||||
type Post,
|
type Post,
|
||||||
@@ -48,15 +47,10 @@ export default function ModerationAdmin({
|
|||||||
initialPosts,
|
initialPosts,
|
||||||
initialPostsTotal,
|
initialPostsTotal,
|
||||||
initError,
|
initError,
|
||||||
canConfigureSettings,
|
|
||||||
initialTrustReviewedPublish,
|
|
||||||
}: {
|
}: {
|
||||||
initialPosts: Post[];
|
initialPosts: Post[];
|
||||||
initialPostsTotal: number;
|
initialPostsTotal: number;
|
||||||
initError: string;
|
initError: string;
|
||||||
/** 超管/站长可改站点免审策略(后端 PermSettings) */
|
|
||||||
canConfigureSettings: boolean;
|
|
||||||
initialTrustReviewedPublish: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("posts");
|
const [tab, setTab] = useState<Tab>("posts");
|
||||||
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
|
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
|
||||||
@@ -79,30 +73,12 @@ export default function ModerationAdmin({
|
|||||||
const [confirmKey, setConfirmKey] = useState<string | null>(null);
|
const [confirmKey, setConfirmKey] = useState<string | null>(null);
|
||||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [trustReviewedPublish, setTrustReviewedPublish] = useState(initialTrustReviewedPublish);
|
|
||||||
const [savingTrust, setSavingTrust] = useState(false);
|
|
||||||
|
|
||||||
const refreshCounts = useCallback(() => {
|
const refreshCounts = useCallback(() => {
|
||||||
apiAdminPendingCounts()
|
apiAdminPendingCounts()
|
||||||
.then(setCounts)
|
.then(setCounts)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTrustReviewedPublish = async () => {
|
|
||||||
if (!canConfigureSettings || savingTrust) return;
|
|
||||||
const next = !trustReviewedPublish;
|
|
||||||
setSavingTrust(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: next });
|
|
||||||
setTrustReviewedPublish(res.trust_reviewed_publish);
|
|
||||||
toast(res.trust_reviewed_publish ? "已开启:过审用户后续免审" : "已关闭:所有普通用户均需审核", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSavingTrust(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshCounts();
|
refreshCounts();
|
||||||
}, [refreshCounts]);
|
}, [refreshCounts]);
|
||||||
@@ -235,44 +211,10 @@ export default function ModerationAdmin({
|
|||||||
内容审核
|
内容审核
|
||||||
</h1>
|
</h1>
|
||||||
<p className="meta mt-1.5 text-[12.5px]">
|
<p className="meta mt-1.5 text-[12.5px]">
|
||||||
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容
|
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canConfigureSettings && (
|
|
||||||
<section
|
|
||||||
className="panel p-4 sm:p-5 mb-5 flex items-start justify-between gap-4 flex-wrap"
|
|
||||||
aria-label="审核策略"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
|
||||||
已过审用户后续免审
|
|
||||||
</h2>
|
|
||||||
<p className="meta mt-1 text-[12.5px] leading-relaxed">
|
|
||||||
开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={trustReviewedPublish}
|
|
||||||
disabled={savingTrust}
|
|
||||||
onClick={() => void toggleTrustReviewedPublish()}
|
|
||||||
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
|
||||||
style={{
|
|
||||||
background: trustReviewedPublish ? "var(--accent)" : "var(--line-2)",
|
|
||||||
}}
|
|
||||||
data-tip={trustReviewedPublish ? "点击关闭" : "点击开启"}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
|
||||||
style={{ transform: trustReviewedPublish ? "translateX(1.25rem)" : "translateX(0)" }}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">{trustReviewedPublish ? "已开启" : "已关闭"}</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 队列切换 */}
|
{/* 队列切换 */}
|
||||||
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
|
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
|
||||||
{(
|
{(
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
|
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
import { canModerateAny } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
import ModerationAdmin from "./ModerationAdmin";
|
import ModerationAdmin from "./ModerationAdmin";
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ export default async function ModerationPage() {
|
|||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
|
||||||
// 板块管理员及以上且(对板块管理员)拥有授权板块
|
|
||||||
if (!me.user || !canModerateAny(me.user)) {
|
if (!me.user || !canModerateAny(me.user)) {
|
||||||
return <Forbidden text="你所在的角色没有内容审核权限,或尚未被授权任何板块。" />;
|
return <Forbidden text="你所在的角色没有内容审核权限,或尚未被授权任何板块。" />;
|
||||||
}
|
}
|
||||||
@@ -33,16 +32,11 @@ export default async function ModerationPage() {
|
|||||||
initError = e instanceof Error ? e.message : "获取待审核内容失败";
|
initError = e instanceof Error ? e.message : "获取待审核内容失败";
|
||||||
}
|
}
|
||||||
|
|
||||||
const canConfigureSettings = isSuperOrOwner(me.user.role);
|
|
||||||
const settings = await getPublicSettingsCached();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModerationAdmin
|
<ModerationAdmin
|
||||||
initialPosts={initialPosts}
|
initialPosts={initialPosts}
|
||||||
initialPostsTotal={initialTotal}
|
initialPostsTotal={initialTotal}
|
||||||
initError={initError}
|
initError={initError}
|
||||||
canConfigureSettings={canConfigureSettings}
|
|
||||||
initialTrustReviewedPublish={settings.trust_reviewed_publish !== false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
frontend/app/admin/settings/SettingsAdmin.tsx
Normal file
179
frontend/app/admin/settings/SettingsAdmin.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onChange}
|
||||||
|
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
||||||
|
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
||||||
|
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="mb-5">
|
||||||
|
<h1
|
||||||
|
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<Globe size={18} />
|
||||||
|
</span>
|
||||||
|
站点设置
|
||||||
|
</h1>
|
||||||
|
<p className="meta mt-1.5 text-[12.5px]">
|
||||||
|
配置站点名称、简介与注册开关;主题色请到「外观」页调整
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="j13-module p-5 sm:p-6 mb-5">
|
||||||
|
<h2 className="text-[15px] font-semibold mb-1" style={{ color: "var(--ink)" }}>
|
||||||
|
基本信息
|
||||||
|
</h2>
|
||||||
|
<p className="meta text-[12.5px] mb-4">用于页眉品牌、页脚版权与 SEO 元信息</p>
|
||||||
|
|
||||||
|
<label className="field-label" htmlFor="site-name">
|
||||||
|
站点名称
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="site-name"
|
||||||
|
className="field mb-4"
|
||||||
|
maxLength={32}
|
||||||
|
value={siteName}
|
||||||
|
onChange={(e) => setSiteName(e.target.value)}
|
||||||
|
placeholder="姜十三论坛"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="field-label" htmlFor="site-desc">
|
||||||
|
站点简介
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="site-desc"
|
||||||
|
className="field min-h-[96px] mb-4"
|
||||||
|
maxLength={160}
|
||||||
|
value={siteDescription}
|
||||||
|
onChange={(e) => setSiteDescription(e.target.value)}
|
||||||
|
placeholder="一句话介绍社区定位…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
onClick={() => void saveIdentity()}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
保存基本信息
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="j13-module p-5 sm:p-6 mb-5 flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||||
|
<UserPlus size={15} /> 开放注册
|
||||||
|
</h2>
|
||||||
|
<p className="meta text-[12.5px] mt-1 leading-relaxed">
|
||||||
|
关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={allowRegister}
|
||||||
|
disabled={togglingReg}
|
||||||
|
onChange={() => void toggleRegister()}
|
||||||
|
label="开放注册"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/app/admin/settings/page.tsx
Normal file
21
frontend/app/admin/settings/page.tsx
Normal file
@@ -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 <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
|
||||||
|
}
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
return <SettingsAdmin initial={settings} />;
|
||||||
|
}
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
UserPlus,
|
Activity,
|
||||||
|
Hourglass,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -121,7 +123,15 @@ export default function UsersAdmin({
|
|||||||
}) {
|
}) {
|
||||||
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
|
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
|
||||||
const [summary, setSummary] = useState<AdminUserSummary>(
|
const [summary, setSummary] = useState<AdminUserSummary>(
|
||||||
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 [total, setTotal] = useState(initial?.total ?? 0);
|
||||||
const [page, setPage] = useState(initialQuery.page);
|
const [page, setPage] = useState(initialQuery.page);
|
||||||
@@ -468,37 +478,38 @@ export default function UsersAdmin({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 汇总 */}
|
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
<MiniStat
|
<MiniStat
|
||||||
label="注册用户"
|
label="当前在线"
|
||||||
value={String(summary.total)}
|
value={String(summary.online)}
|
||||||
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
|
sub="5 分钟内活跃"
|
||||||
icon={<UsersIcon size={16} />}
|
icon={<Activity size={16} />}
|
||||||
color="var(--accent)"
|
color="var(--ok)"
|
||||||
soft="var(--accent-soft)"
|
soft="var(--ok-soft)"
|
||||||
/>
|
/>
|
||||||
<MiniStat
|
<MiniStat
|
||||||
label="管理团队"
|
label="发帖冷静期中"
|
||||||
value={String(summary.admins)}
|
value={String(summary.in_cooldown)}
|
||||||
icon={<ShieldCheck size={16} />}
|
sub="普通用户"
|
||||||
|
icon={<Hourglass size={16} />}
|
||||||
color="var(--gold)"
|
color="var(--gold)"
|
||||||
soft="var(--gold-soft)"
|
soft="var(--gold-soft)"
|
||||||
/>
|
/>
|
||||||
<MiniStat
|
<MiniStat
|
||||||
label="已封禁"
|
label="近 7 日活跃"
|
||||||
value={String(summary.banned)}
|
value={String(summary.active_7d)}
|
||||||
icon={<Ban size={16} />}
|
icon={<Monitor size={16} />}
|
||||||
color="var(--danger)"
|
color="var(--accent)"
|
||||||
soft="var(--danger-soft)"
|
soft="var(--accent-soft)"
|
||||||
/>
|
/>
|
||||||
<MiniStat
|
<MiniStat
|
||||||
label="当前筛选"
|
label="24h 登录失败"
|
||||||
value={String(total)}
|
value={String(summary.failed_logins_24h)}
|
||||||
sub="人"
|
sub="次"
|
||||||
icon={<UserPlus size={16} />}
|
icon={<ShieldAlert size={16} />}
|
||||||
color="var(--ok)"
|
color="var(--danger)"
|
||||||
soft="var(--ok-soft)"
|
soft="var(--danger-soft)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,18 +37,23 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
const name = settings.site_name || "姜十三论坛";
|
||||||
|
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
||||||
|
return {
|
||||||
title: {
|
title: {
|
||||||
default: "姜十三论坛 · J13 FORUM",
|
default: `${name} · J13 FORUM`,
|
||||||
template: "%s · 姜十三论坛",
|
template: `%s · ${name}`,
|
||||||
},
|
},
|
||||||
description: "姜十三论坛 - 技术分享与讨论社区",
|
description: desc,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "姜十三论坛 · J13 FORUM",
|
title: `${name} · J13 FORUM`,
|
||||||
description: "姜十三论坛 - 技术分享与讨论社区",
|
description: desc,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -64,7 +69,8 @@ export default async function RootLayout({
|
|||||||
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
|
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
|
||||||
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
||||||
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
||||||
const { accent } = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
|
const accent = settings.accent;
|
||||||
const accentCSS = accentStyleCSS(accent || "");
|
const accentCSS = accentStyleCSS(accent || "");
|
||||||
|
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
@@ -101,6 +107,8 @@ export default async function RootLayout({
|
|||||||
initialUnread={unread}
|
initialUnread={unread}
|
||||||
initialChatUnread={chatUnread}
|
initialChatUnread={chatUnread}
|
||||||
initialTheme={theme}
|
initialTheme={theme}
|
||||||
|
siteName={settings.site_name}
|
||||||
|
allowRegister={settings.allow_register}
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
@@ -109,7 +117,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer siteName={settings.site_name} />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TipHost />
|
<TipHost />
|
||||||
<AccountGuard />
|
<AccountGuard />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MessagesSquare } from "lucide-react";
|
import { MessagesSquare } from "lucide-react";
|
||||||
import { apiLogin, apiMe } from "@/lib/api";
|
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
|
|
||||||
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
|
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
|
||||||
@@ -15,6 +15,28 @@ function safeRedirect(to: string | null): string {
|
|||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RegisterHint() {
|
||||||
|
const [allow, setAllow] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
apiGetSettings()
|
||||||
|
.then((s) => setAllow(s.allow_register))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
if (!allow) {
|
||||||
|
return (
|
||||||
|
<p className="meta text-center mt-5">当前站点暂不开放注册</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="meta text-center mt-5">
|
||||||
|
还没有账号?
|
||||||
|
<Link href="/register" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
|
||||||
|
立即注册
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -107,12 +129,7 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="meta text-center mt-5">
|
<RegisterHint />
|
||||||
还没有账号?
|
|
||||||
<Link href="/register" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
|
|
||||||
立即注册
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
import { apiRegister, apiMe } from "@/lib/api";
|
import { apiRegister, apiMe, apiGetSettings } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
@@ -14,6 +14,7 @@ export default function RegisterPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [registerClosed, setRegisterClosed] = useState(false);
|
||||||
|
|
||||||
// 已登录用户不必再注册,直接回首页
|
// 已登录用户不必再注册,直接回首页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,6 +26,14 @@ export default function RegisterPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGetSettings()
|
||||||
|
.then((s) => {
|
||||||
|
if (!s.allow_register) setRegisterClosed(true);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@@ -39,13 +48,31 @@ export default function RegisterPage() {
|
|||||||
// 极端情况:注册成功但自动签发失败,引导去登录
|
// 极端情况:注册成功但自动签发失败,引导去登录
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
} else {
|
} else {
|
||||||
setError(res.error || "注册失败");
|
const msg = res.error || "注册失败";
|
||||||
|
if (msg.includes("关闭注册")) setRegisterClosed(true);
|
||||||
|
setError(msg);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (registerClosed) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||||
|
<div className="panel p-8 text-center">
|
||||||
|
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||||
|
暂不开放注册
|
||||||
|
</h1>
|
||||||
|
<p className="meta mt-2 text-[13px]">站点当前已关闭新用户注册,已有账号可直接登录。</p>
|
||||||
|
<Link href="/login" className="btn btn-primary mt-6">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||||
<div className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
@@ -59,7 +86,7 @@ export default function RegisterPage() {
|
|||||||
创建账号
|
创建账号
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||||
加入姜十三,开始你的第一帖
|
加入社区,开始你的第一帖
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 year = new Date().getFullYear();
|
||||||
|
const brand = siteName.trim() || "姜十三社区";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="mt-14" style={{ borderTop: "1px solid var(--line)" }}>
|
<footer className="mt-14" style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
||||||
<span className="meta text-[12px] whitespace-nowrap">© {year} 姜十三社区</span>
|
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||||
<FooterTiming version={realVersion(pkg.version)} />
|
<FooterTiming version={realVersion(pkg.version)} />
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ export default function Header({
|
|||||||
initialUnread = 0,
|
initialUnread = 0,
|
||||||
initialChatUnread = 0,
|
initialChatUnread = 0,
|
||||||
initialTheme = "light",
|
initialTheme = "light",
|
||||||
|
siteName = "姜十三",
|
||||||
|
allowRegister = true,
|
||||||
}: {
|
}: {
|
||||||
initialUser: User | null;
|
initialUser: User | null;
|
||||||
initialUnread?: number;
|
initialUnread?: number;
|
||||||
initialChatUnread?: number;
|
initialChatUnread?: number;
|
||||||
initialTheme?: "light" | "dark";
|
initialTheme?: "light" | "dark";
|
||||||
|
siteName?: string;
|
||||||
|
allowRegister?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -226,7 +230,7 @@ export default function Header({
|
|||||||
</span>
|
</span>
|
||||||
<span className="leading-none">
|
<span className="leading-none">
|
||||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
||||||
姜十三
|
{siteName}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
|
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||||
J13 社区
|
J13 社区
|
||||||
@@ -332,9 +336,11 @@ export default function Header({
|
|||||||
<Link href="/login" className="btn btn-line btn-sm">
|
<Link href="/login" className="btn btn-line btn-sm">
|
||||||
<LogIn size={14} /> 登录
|
<LogIn size={14} /> 登录
|
||||||
</Link>
|
</Link>
|
||||||
|
{allowRegister && (
|
||||||
<Link href="/register" className="btn btn-primary btn-sm">
|
<Link href="/register" className="btn btn-primary btn-sm">
|
||||||
<UserPlus size={14} /> 注册
|
<UserPlus size={14} /> 注册
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -398,7 +404,7 @@ export default function Header({
|
|||||||
</MobileLink>
|
</MobileLink>
|
||||||
)}
|
)}
|
||||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||||
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ function serializeTags(tags: string[]): string {
|
|||||||
|
|
||||||
function mapPostError(msg: string): string {
|
function mapPostError(msg: string): string {
|
||||||
if (!msg) return "操作失败";
|
if (!msg) return "操作失败";
|
||||||
|
const cooldown = msg.match(/新用户注册\s*(\d+)\s*小时后才能发帖/);
|
||||||
|
if (cooldown) {
|
||||||
|
return `新用户注册满 ${cooldown[1]} 小时后才能发帖,请稍后再试`;
|
||||||
|
}
|
||||||
if (msg.includes("24 小时") || msg.includes("24小时") || msg.includes("冷静")) {
|
if (msg.includes("24 小时") || msg.includes("24小时") || msg.includes("冷静")) {
|
||||||
return "新用户注册满 24 小时后才能发帖,请稍后再试";
|
return "新用户尚在发帖冷静期内,请稍后再试";
|
||||||
}
|
}
|
||||||
if (msg.includes("积分")) return msg;
|
if (msg.includes("积分")) return msg;
|
||||||
if (msg.includes("rate") || msg.includes("频繁") || msg.includes("太快")) {
|
if (msg.includes("rate") || msg.includes("频繁") || msg.includes("太快")) {
|
||||||
|
|||||||
@@ -533,20 +533,46 @@ export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
|
|||||||
export interface PublicSettings {
|
export interface PublicSettings {
|
||||||
accent: string;
|
accent: string;
|
||||||
trust_reviewed_publish: boolean;
|
trust_reviewed_publish: boolean;
|
||||||
|
site_name: string;
|
||||||
|
site_description: string;
|
||||||
|
allow_register: boolean;
|
||||||
|
post_cooldown_hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||||
|
accent: "",
|
||||||
|
trust_reviewed_publish: true,
|
||||||
|
site_name: "姜十三论坛",
|
||||||
|
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
||||||
|
allow_register: true,
|
||||||
|
post_cooldown_hours: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
|
||||||
|
return {
|
||||||
|
accent: data?.accent ?? "",
|
||||||
|
trust_reviewed_publish: data?.trust_reviewed_publish !== false,
|
||||||
|
site_name: (data?.site_name || "").trim() || DEFAULT_PUBLIC_SETTINGS.site_name,
|
||||||
|
site_description:
|
||||||
|
data?.site_description !== undefined
|
||||||
|
? String(data.site_description).trim()
|
||||||
|
: DEFAULT_PUBLIC_SETTINGS.site_description,
|
||||||
|
allow_register: data?.allow_register !== false,
|
||||||
|
post_cooldown_hours:
|
||||||
|
typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0
|
||||||
|
? data.post_cooldown_hours
|
||||||
|
: DEFAULT_PUBLIC_SETTINGS.post_cooldown_hours,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
||||||
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
||||||
if (!res.ok) return { accent: "", trust_reviewed_publish: true };
|
if (!res.ok) return { ...DEFAULT_PUBLIC_SETTINGS };
|
||||||
const data = (await res.json()) as Partial<PublicSettings>;
|
return normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
|
||||||
return {
|
|
||||||
accent: data.accent ?? "",
|
|
||||||
trust_reviewed_publish: data.trust_reviewed_publish !== false,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return { accent: "", trust_reviewed_publish: true };
|
return { ...DEFAULT_PUBLIC_SETTINGS };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,10 +1208,13 @@ export interface LoginLogsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserSummary {
|
export interface AdminUserSummary {
|
||||||
|
online: number;
|
||||||
|
in_cooldown: number;
|
||||||
|
active_7d: number;
|
||||||
|
failed_logins_24h: number;
|
||||||
total: number;
|
total: number;
|
||||||
admins: number;
|
admins: number;
|
||||||
banned: number;
|
banned: number;
|
||||||
today_new: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUsersResponse {
|
export interface AdminUsersResponse {
|
||||||
@@ -1423,16 +1452,16 @@ export async function apiGetSettings(): Promise<PublicSettings> {
|
|||||||
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
|
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
|
||||||
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
||||||
if (!res.ok) throw new Error("获取站点设置失败");
|
if (!res.ok) throw new Error("获取站点设置失败");
|
||||||
const data = (await res.json()) as Partial<PublicSettings>;
|
return normalizePublicSettings(await res.json());
|
||||||
return {
|
|
||||||
accent: data.accent ?? "",
|
|
||||||
trust_reviewed_publish: data.trust_reviewed_publish !== false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateSiteSettingsBody = {
|
export type UpdateSiteSettingsBody = {
|
||||||
accent?: string;
|
accent?: string;
|
||||||
trust_reviewed_publish?: boolean;
|
trust_reviewed_publish?: boolean;
|
||||||
|
site_name?: string;
|
||||||
|
site_description?: string;
|
||||||
|
allow_register?: boolean;
|
||||||
|
post_cooldown_hours?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||||
@@ -1444,10 +1473,7 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "保存设置失败");
|
if (!res.ok) throw new Error(data.error || "保存设置失败");
|
||||||
return {
|
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||||
accent: data.accent ?? "",
|
|
||||||
trust_reviewed_publish: data.trust_reviewed_publish !== false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||||
@@ -1527,6 +1553,137 @@ export const apiAdminApproveComment = (id: number) =>
|
|||||||
export const apiAdminRejectComment = (id: number) =>
|
export const apiAdminRejectComment = (id: number) =>
|
||||||
moderationAction(`/api/admin/moderation/comments/${id}/reject`);
|
moderationAction(`/api/admin/moderation/comments/${id}/reject`);
|
||||||
|
|
||||||
|
// ===== 内容库(帖子 / 评论 + 回收站) =====
|
||||||
|
|
||||||
|
export type AdminContentStatus = "live" | "pending" | "rejected" | "published" | "deleted";
|
||||||
|
|
||||||
|
export interface AdminContentCounts {
|
||||||
|
posts_live: number;
|
||||||
|
posts_pending: number;
|
||||||
|
posts_rejected: number;
|
||||||
|
posts_deleted: number;
|
||||||
|
comments_live: number;
|
||||||
|
comments_pending: number;
|
||||||
|
comments_rejected: number;
|
||||||
|
comments_deleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminContentPost {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
deleted: boolean;
|
||||||
|
deleted_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
comment_count: number;
|
||||||
|
board_id: number;
|
||||||
|
board: Board;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminContentComment {
|
||||||
|
id: number;
|
||||||
|
post_id: number;
|
||||||
|
post_title: string;
|
||||||
|
board_id: number;
|
||||||
|
content: string;
|
||||||
|
status: string;
|
||||||
|
deleted: boolean;
|
||||||
|
deleted_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
board: Board;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminContentCounts(): Promise<AdminContentCounts> {
|
||||||
|
const res = await fetchWithRefresh("/api/admin/content/counts", { headers: clientHeaders() });
|
||||||
|
if (!res.ok) throw new Error("获取内容统计失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminContentPosts(
|
||||||
|
page = 1,
|
||||||
|
status: AdminContentStatus = "live",
|
||||||
|
q = ""
|
||||||
|
): Promise<{ posts: AdminContentPost[]; total: number; page: number }> {
|
||||||
|
const params = new URLSearchParams({ page: String(page), status });
|
||||||
|
if (q.trim()) params.set("q", q.trim());
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/content/posts?${params}`, {
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "获取帖子列表失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminContentComments(
|
||||||
|
page = 1,
|
||||||
|
status: AdminContentStatus = "live",
|
||||||
|
q = ""
|
||||||
|
): Promise<{ comments: AdminContentComment[]; total: number; page: number }> {
|
||||||
|
const params = new URLSearchParams({ page: String(page), status });
|
||||||
|
if (q.trim()) params.set("q", q.trim());
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/content/comments?${params}`, {
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "获取评论列表失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contentAction(url: string, method: string): Promise<void> {
|
||||||
|
const res = await fetchWithRefresh(url, { method, headers: clientHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "操作失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAdminSoftDeletePost = (id: number) =>
|
||||||
|
contentAction(`/api/admin/content/posts/${id}`, "DELETE");
|
||||||
|
export const apiAdminRestorePost = (id: number) =>
|
||||||
|
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
||||||
|
export const apiAdminPurgePost = (id: number) =>
|
||||||
|
contentAction(`/api/admin/content/posts/${id}/purge`, "DELETE");
|
||||||
|
export const apiAdminSoftDeleteComment = (id: number) =>
|
||||||
|
contentAction(`/api/admin/content/comments/${id}`, "DELETE");
|
||||||
|
export const apiAdminRestoreComment = (id: number) =>
|
||||||
|
contentAction(`/api/admin/content/comments/${id}/restore`, "PUT");
|
||||||
|
export const apiAdminPurgeComment = (id: number) =>
|
||||||
|
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE");
|
||||||
|
|
||||||
|
export async function fetchAdminContentCounts(
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<AdminContentCounts> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/content/counts`, ssrInit(cookieHeaders(cookieHeader)));
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
posts_live: 0,
|
||||||
|
posts_pending: 0,
|
||||||
|
posts_rejected: 0,
|
||||||
|
posts_deleted: 0,
|
||||||
|
comments_live: 0,
|
||||||
|
comments_pending: 0,
|
||||||
|
comments_rejected: 0,
|
||||||
|
comments_deleted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminContentPosts(
|
||||||
|
page = 1,
|
||||||
|
status: AdminContentStatus = "live",
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<{ posts: AdminContentPost[]; total: number; page: number }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/content/posts?page=${page}&status=${status}`,
|
||||||
|
ssrInit(cookieHeaders(cookieHeader))
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("获取帖子列表失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 群聊(二期) =====
|
// ===== 群聊(二期) =====
|
||||||
|
|
||||||
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)
|
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export interface RtPresenceData {
|
|||||||
export interface RtSettingsData {
|
export interface RtSettingsData {
|
||||||
accent: string;
|
accent: string;
|
||||||
trust_reviewed_publish?: boolean;
|
trust_reviewed_publish?: boolean;
|
||||||
|
site_name?: string;
|
||||||
|
site_description?: string;
|
||||||
|
allow_register?: boolean;
|
||||||
|
post_cooldown_hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||||
|
|||||||
Reference in New Issue
Block a user