Merge pull request 'feat(be+admin): 后台看板扩展 + 社区内容安全审核' (#4) from feat/dev into main
deploy / deploy (push) Successful in 2m5s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-30 11:10:16 +00:00
25 changed files with 1453 additions and 156 deletions
+5
View File
@@ -43,6 +43,11 @@ AUTH_DEV_LOGIN=false
# ── 微信小程序 ── # ── 微信小程序 ──
WECHAT_APP_ID=wx开头的AppID WECHAT_APP_ID=wx开头的AppID
WECHAT_APP_SECRET=小程序密钥 WECHAT_APP_SECRET=小程序密钥
# 内容安全图片异步检测的结果回调校验 Token。
# 要和「小程序后台 → 开发管理 → 消息推送」里填的 Token 完全一致(英文数字,3-32 位)。
# 消息推送 URL 填 https://pet.sundynix.cn/api/wx/sec-callback,加密方式选「明文模式」。
# 没配消息推送前可留空(社区内容会走后台手动审核)。
WECHAT_MSG_TOKEN=petsSecCheck2026
# ── 管理后台(/admin) ── # ── 管理后台(/admin) ──
ADMIN_USERNAME=sundynix ADMIN_USERNAME=sundynix
+3
View File
@@ -45,6 +45,9 @@ services:
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?} PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?} PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
# 内容安全图片异步检测的结果回调校验 Token(消息推送服务器配置里的 Token)。
# 可选:留空则回调不校验签名;配了才和微信后台的 Token 对上
PETS_WECHAT_MSG_TOKEN: ${WECHAT_MSG_TOKEN:-}
# 生产必须 false,否则任何人都能用昵称直接登录 # 生产必须 false,否则任何人都能用昵称直接登录
PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false} PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-BH08rQ_o.js"></script> <script type="module" crossorigin src="/admin/assets/index-f13soFkd.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4
View File
@@ -76,6 +76,9 @@ type JWTConfig struct {
type WeChatConfig struct { type WeChatConfig struct {
AppID string `mapstructure:"app_id"` AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"` AppSecret string `mapstructure:"app_secret"`
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
MsgToken string `mapstructure:"msg_token"`
} }
type AuthConfig struct { type AuthConfig struct {
@@ -115,6 +118,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("wechat.app_id", "") v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "") v.SetDefault("wechat.app_secret", "")
v.SetDefault("wechat.msg_token", "") // 用 PETS_WECHAT_MSG_TOKEN 覆盖
v.SetDefault("auth.dev_login", false) v.SetDefault("auth.dev_login", false)
+28 -1
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware" "github.com/sundynix/pets-be/internal/middleware"
@@ -109,6 +111,17 @@ func (h *Handler) AdminStats(c *gin.Context) {
response.OK(c, stats) response.OK(c, stats)
} }
// AdminAnalytics GET /api/admin/analytics?days=30 看板的活跃/新增/留存
func (h *Handler) AdminAnalytics(c *gin.Context) {
days, _ := strconv.Atoi(c.Query("days"))
data, err := h.svc.AdminAnalytics(days)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type adminListReq struct { type adminListReq struct {
response.PageQuery response.PageQuery
Keyword string `form:"keyword"` Keyword string `form:"keyword"`
@@ -196,7 +209,7 @@ func (h *Handler) AdminListComments(c *gin.Context) {
var req adminListReq var req adminListReq
_ = c.ShouldBindQuery(&req) _ = c.ShouldBindQuery(&req)
req.Normalize() req.Normalize()
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit()) comments, total, err := h.svc.ListCommentsAdmin(req.Status, req.Offset(), req.Limit())
if err != nil { if err != nil {
response.FailErr(c, err) response.FailErr(c, err)
return return
@@ -204,6 +217,20 @@ func (h *Handler) AdminListComments(c *gin.Context) {
response.OK(c, response.NewPage(comments, total, req.PageQuery)) response.OK(c, response.NewPage(comments, total, req.PageQuery))
} }
// AdminSetCommentStatus PUT /api/admin/comments/:id/status
func (h *Handler) AdminSetCommentStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetCommentStatus(idParam(c, "id"), req.Status); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminDeleteComment DELETE /api/admin/comments/:id // AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) { func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil { if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
+76
View File
@@ -0,0 +1,76 @@
package handler
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
)
// secCallbackMsg 兼容消息推送的两种数据格式:JSON 和 XML(字段名一致,标签两套都标)
type secCallbackMsg struct {
Event string `json:"Event" xml:"Event"`
MsgType string `json:"MsgType" xml:"MsgType"`
TraceID string `json:"trace_id" xml:"trace_id"`
Result struct {
Suggest string `json:"suggest" xml:"suggest"`
} `json:"result" xml:"result"`
}
// 图片异步安全检测的结果回调(微信「消息推送」服务器配置指向这里)。
// GET 校验服务器:把 echostr 原样返回
// POST 接收 wxa_media_check 结果:用 trace_id 找回帖子/评论并放出或打回
//
// 明文模式(消息加解密方式选「明文」)下 body 就是 JSON,直接解析;
// 若配了加密模式还需要 AES 解密,这里暂不处理(上线时消息推送选明文即可)。
// verifyWxSignature 校验 signature = sha1(sort(token, timestamp, nonce))
func (h *Handler) verifyWxSignature(c *gin.Context) bool {
token := h.cfg.WeChat.MsgToken
if token == "" {
return true // 没配 token(本地调试):不校验
}
sig := c.Query("signature")
parts := []string{token, c.Query("timestamp"), c.Query("nonce")}
sort.Strings(parts)
sum := sha1.Sum([]byte(strings.Join(parts, "")))
return hex.EncodeToString(sum[:]) == sig
}
// WxSecCallback GET+POST /api/wx/sec-callback
func (h *Handler) WxSecCallback(c *gin.Context) {
if !h.verifyWxSignature(c) {
c.String(http.StatusForbidden, "invalid signature")
return
}
if c.Request.Method == http.MethodGet {
c.String(http.StatusOK, c.Query("echostr")) // 服务器配置校验
return
}
raw, _ := io.ReadAll(c.Request.Body)
// 数据格式 JSON / XML 都兼容:以 '<' 开头当 XML,否则当 JSON
var msg secCallbackMsg
var err error
if trimmed := strings.TrimSpace(string(raw)); strings.HasPrefix(trimmed, "<") {
err = xml.Unmarshal(raw, &msg)
} else {
err = json.Unmarshal(raw, &msg)
}
if err != nil {
log.Printf("[warn] sec-callback 解析失败: %v, body=%s", err, string(raw))
c.String(http.StatusOK, "success") // 回 success 让微信别重推
return
}
if msg.Event == "wxa_media_check" && msg.TraceID != "" {
h.svc.ResolveMediaCheck(msg.TraceID, msg.Result.Suggest)
}
c.String(http.StatusOK, "success")
}
+1
View File
@@ -52,5 +52,6 @@ func AllModels() []any {
&RefreshToken{}, &RefreshToken{},
&AIQuotaConfig{}, &AIQuotaConfig{},
&AIUsage{}, &AIUsage{},
&MediaCheck{},
} }
} }
+17
View File
@@ -0,0 +1,17 @@
package model
// 图片异步安全检测的一次提交。mediaCheckAsync 返回 trace_id
// 结果稍后由微信 push 回调,用 trace_id 找回它属于哪条帖子/评论。
const (
MediaPending = "pending"
MediaPass = "pass"
MediaRisky = "risky"
)
type MediaCheck struct {
Base
TraceID string `gorm:"size:64;index" json:"trace_id"`
TargetType string `gorm:"size:16;index" json:"target_type"` // post | comment
TargetID string `gorm:"size:24;index" json:"target_id"`
Status string `gorm:"size:16" json:"status"` // pending | pass | risky
}
+3 -1
View File
@@ -4,7 +4,9 @@ import "gorm.io/datatypes"
// 帖子状态 // 帖子状态
const ( const (
PostPublished = "published" PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
PostPublished = "published" // 已过审,正常展示
PostRejected = "rejected" // 内容安全判定风险,打回
PostHidden = "hidden" PostHidden = "hidden"
PostDeleted = "deleted" PostDeleted = "deleted"
) )
+4
View File
@@ -20,6 +20,8 @@ func New(h *handler.Handler, jm *appjwt.Manager, mode string) *gin.Engine {
r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) }) r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
// 根路径跳转后台 // 根路径跳转后台
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") }) r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
// 微信图片异步安全检测的结果回调(消息推送),公开、自带签名校验
r.Any("/api/wx/sec-callback", h.WxSecCallback)
api := r.Group("/api") api := r.Group("/api")
registerAuth(api, h) registerAuth(api, h)
@@ -141,6 +143,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.GET("/me", h.AdminMe) g.GET("/me", h.AdminMe)
g.GET("/stats", h.AdminStats) g.GET("/stats", h.AdminStats)
g.GET("/analytics", h.AdminAnalytics)
g.GET("/users", h.AdminListUsers) g.GET("/users", h.AdminListUsers)
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled) g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
@@ -151,6 +154,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.PUT("/posts/:id/status", h.AdminSetPostStatus) g.PUT("/posts/:id/status", h.AdminSetPostStatus)
g.GET("/comments", h.AdminListComments) g.GET("/comments", h.AdminListComments)
g.PUT("/comments/:id/status", h.AdminSetCommentStatus)
g.DELETE("/comments/:id", h.AdminDeleteComment) g.DELETE("/comments/:id", h.AdminDeleteComment)
g.GET("/articles", h.AdminListArticles) g.GET("/articles", h.AdminListArticles)
+31 -4
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model" "github.com/sundynix/pets-be/internal/model"
) )
@@ -133,6 +134,8 @@ func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post
q.Count(&total) q.Count(&total)
var posts []model.Post var posts []model.Post
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
// 真实帖子的图存的是 file id,要解析成 URL 后台才看得到图
s.attachPostImages(posts)
return posts, total, err return posts, total, err
} }
@@ -148,15 +151,39 @@ func (s *Service) SetPostStatus(id string, status string) error {
return nil return nil
} }
// ListCommentsAdmin 评论分页 // ListCommentsAdmin 评论分页,status 非空则按状态过滤(待审核用 pending)
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) { func (s *Service) ListCommentsAdmin(status string, offset, limit int) ([]model.Comment, int64, error) {
q := s.db.Model(&model.Comment{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64 var total int64
s.db.Model(&model.Comment{}).Count(&total) q.Count(&total)
var comments []model.Comment var comments []model.Comment
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
return comments, total, err return comments, total, err
} }
// SetCommentStatus 审核评论。从待审核过审时补上楼主的评论数
func (s *Service) SetCommentStatus(id, status string) error {
var c model.Comment
if err := s.db.First(&c, "id = ?", id).Error; err != nil {
return ErrNotFound
}
if c.Status == status {
return nil
}
if err := s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", status).Error; err != nil {
return err
}
// pending → published:这条评论此前没计数,过审后补 1
if status == model.PostPublished && c.Status == model.PostPending {
s.db.Model(&model.Post{}).Where("id = ?", c.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
return nil
}
// DeleteCommentAdmin 删除评论 // DeleteCommentAdmin 删除评论
func (s *Service) DeleteCommentAdmin(id string) error { func (s *Service) DeleteCommentAdmin(id string) error {
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
+199
View File
@@ -0,0 +1,199 @@
package service
import (
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 后台看板的分析数据。真实用户口径(排除 bot),活跃口径=当天产生过
// 健康记录/发帖/评论中的任意一种。数据量在预生产规模下很小,直接拉进内存算,
// 省掉一堆按天分组的 SQL。
// DayPoint 折线/柱状的一个点。Date 视粒度是 MM-DD 或 YYYY-MM
type DayPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
// RetPoint 留存曲线一个点:注册后第 Day 天仍活跃的比例
type RetPoint struct {
Day int `json:"day"` // 注册后天数
Rate float64 `json:"rate"` // 0~1
Base int `json:"base"` // 该口径下够“年龄”的用户数(分母)
}
type Analytics struct {
Summary struct {
TotalUsers int `json:"total_users"` // 真实用户总数
NewToday int `json:"new_today"`
NewMonth int `json:"new_month"`
DAU int `json:"dau"` // 今日活跃
WAU int `json:"wau"` // 近 7 日活跃
MAU int `json:"mau"` // 近 30 日活跃
TotalPets int `json:"total_pets"`
TotalPosts int `json:"total_posts"`
TotalRecords int `json:"total_records"`
} `json:"summary"`
NewTrend []DayPoint `json:"new_trend"` // 每日新增,近 days 天
ActiveTrend []DayPoint `json:"active_trend"` // 每日活跃(DAU),近 days 天
MauTrend []DayPoint `json:"mau_trend"` // 月活,近 6 个月
Retention []RetPoint `json:"retention"` // 留存曲线
}
// dayKey 把时间压成 yyyymmdd 整数,单调,便于比较和当 map key
func dayKey(t time.Time) int {
return t.Year()*10000 + int(t.Month())*100 + t.Day()
}
// AdminAnalytics days=趋势天数(默认 30,封顶 180)
func (s *Service) AdminAnalytics(days int) (*Analytics, error) {
if days <= 0 || days > 180 {
days = 30
}
loc := time.Local
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
out := &Analytics{}
// —— 真实用户(排除 bot)——
type uRow struct {
ID string
CreatedAt time.Time
}
var users []uRow
s.db.Model(&model.User{}).
Where("is_bot IS NULL OR is_bot = 0").
Select("id, created_at").Scan(&users)
signup := make(map[string]int, len(users)) // userID -> 注册日 dayKey
signupTime := make(map[string]time.Time, len(users))
ids := make([]string, 0, len(users))
todayKey := dayKey(today)
monthStart := dayKey(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc))
for _, u := range users {
t := u.CreatedAt.In(loc)
signup[u.ID] = dayKey(t)
signupTime[u.ID] = t
ids = append(ids, u.ID)
k := dayKey(t)
if k == todayKey {
out.Summary.NewToday++
}
if k >= monthStart {
out.Summary.NewMonth++
}
}
out.Summary.TotalUsers = len(users)
// —— 活跃事件:近 180 天,真实用户的 记录/帖子/评论 ——
windowStart := today.AddDate(0, 0, -180)
type ev struct {
UserID string
CreatedAt time.Time
}
// userID -> 活跃日集合;以及每日活跃用户集合(供 DAU/MAU 用)
userActive := make(map[string]map[int]bool)
dayUsers := make(map[int]map[string]bool)
mark := func(table string) {
if len(ids) == 0 {
return
}
var rows []ev
s.db.Table(table).Select("user_id, created_at").
Where("created_at >= ?", windowStart).
Where("user_id IN ?", ids).Scan(&rows)
for _, r := range rows {
k := dayKey(r.CreatedAt.In(loc))
if userActive[r.UserID] == nil {
userActive[r.UserID] = map[int]bool{}
}
userActive[r.UserID][k] = true
if dayUsers[k] == nil {
dayUsers[k] = map[string]bool{}
}
dayUsers[k][r.UserID] = true
}
}
mark("sundynix_health_records")
mark("sundynix_posts")
mark("sundynix_comments")
// distinctInRange 统计 [fromDay, toDay] 内的去重活跃用户
distinctInRange := func(from, to time.Time) int {
seen := map[string]bool{}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
for u := range dayUsers[dayKey(d)] {
seen[u] = true
}
}
return len(seen)
}
out.Summary.DAU = len(dayUsers[todayKey])
out.Summary.WAU = distinctInRange(today.AddDate(0, 0, -6), today)
out.Summary.MAU = distinctInRange(today.AddDate(0, 0, -29), today)
// —— 趋势:新增 + DAU,近 days 天 ——
for i := days - 1; i >= 0; i-- {
d := today.AddDate(0, 0, -i)
k := dayKey(d)
label := d.Format("01-02")
newN := 0
for _, sk := range signup {
if sk == k {
newN++
}
}
out.NewTrend = append(out.NewTrend, DayPoint{Date: label, Count: newN})
out.ActiveTrend = append(out.ActiveTrend, DayPoint{Date: label, Count: len(dayUsers[k])})
}
// —— 月活:近 6 个月 ——
for i := 5; i >= 0; i-- {
mStart := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0)
mEnd := mStart.AddDate(0, 1, -1)
out.MauTrend = append(out.MauTrend, DayPoint{
Date: mStart.Format("2006-01"),
Count: distinctInRange(mStart, mEnd),
})
}
// —— 留存曲线:注册后第 K 天仍活跃(滚动口径:K 天当天或之后还有活跃)——
// 分母只算“年龄”够 K 天、且在观测窗内的用户
offsets := []int{1, 3, 7, 14, 30}
minSignup := dayKey(windowStart)
for _, K := range offsets {
base, retained := 0, 0
for id, sk := range signup {
if sk < minSignup {
continue // 太老,活跃窗看不全
}
plusK := dayKey(signupTime[id].AddDate(0, 0, K))
if plusK > todayKey {
continue // 还没到第 K 天,不够“年龄”
}
base++
for ak := range userActive[id] {
if ak >= plusK {
retained++
break
}
}
}
rate := 0.0
if base > 0 {
rate = float64(retained) / float64(base)
}
out.Retention = append(out.Retention, RetPoint{Day: K, Rate: rate, Base: base})
}
var pets, posts, records int64
s.db.Model(&model.Pet{}).Count(&pets)
s.db.Model(&model.Post{}).Count(&posts)
s.db.Model(&model.HealthRecord{}).Count(&records)
out.Summary.TotalPets = int(pets)
out.Summary.TotalPosts = int(posts)
out.Summary.TotalRecords = int(records)
return out, nil
}
+46 -4
View File
@@ -125,19 +125,50 @@ func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
} }
} }
} }
// 取作者 openid(内容安全接口 v2 要求带上),并把图片 file id 解析成可访问 URL
var user model.User
s.db.Select("id, open_id").First(&user, userID)
imgURLs := s.resolveImageURLs(in.ImageFileIDs)
// 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调
status, _ := s.moderateInitial(in.Content, user.OpenID, 3, len(imgURLs) > 0)
p := model.Post{ p := model.Post{
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji, UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images, Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
ImageFileIDs: in.ImageFileIDs, Status: model.PostPublished, ImageFileIDs: in.ImageFileIDs, Status: status,
} }
if err := s.db.Create(&p).Error; err != nil { if err := s.db.Create(&p).Error; err != nil {
return nil, err return nil, err
} }
// 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回
if status == model.PostPending && len(imgURLs) > 0 {
s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3)
}
one := []model.Post{p} one := []model.Post{p}
s.attachPostImages(one) s.attachPostImages(one)
return &one[0], nil return &one[0], nil
} }
// resolveImageURLs 把 file id 数组(JSON)解析成可访问 URL 列表
func (s *Service) resolveImageURLs(fileIDs datatypes.JSON) []string {
if len(fileIDs) == 0 {
return nil
}
var ids []string
if json.Unmarshal(fileIDs, &ids) != nil || len(ids) == 0 {
return nil
}
m := s.fileURLs(ids)
urls := make([]string, 0, len(ids))
for _, id := range ids {
if u := m[id]; u != "" {
urls = append(urls, u)
}
}
return urls
}
// LikePost 点赞(幂等:已赞则不重复计数) // LikePost 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID string) (int, error) { func (s *Service) LikePost(userID, postID string) (int, error) {
if _, err := s.GetPost(postID); err != nil { if _, err := s.GetPost(postID); err != nil {
@@ -275,9 +306,12 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
} }
} }
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
comment := model.Comment{ comment := model.Comment{
PostID: postID, UserID: userID, AuthorName: name, Content: content, PostID: postID, UserID: userID, AuthorName: name, Content: content,
Status: "published", ParentID: parentID, ReplyToName: replyTo, Status: status, ParentID: parentID, ReplyToName: replyTo,
} }
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空 // 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
if b, err := json.Marshal(images); err == nil && len(images) > 0 { if b, err := json.Marshal(images); err == nil && len(images) > 0 {
@@ -287,11 +321,19 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
if err := tx.Create(&comment).Error; err != nil { if err := tx.Create(&comment).Error; err != nil {
return err return err
} }
return tx.Model(&model.Post{}).Where("id = ?", postID). // 待审核/打回的评论先不计入楼主的评论数,过审后再补
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error if status == model.PostPublished {
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
}
return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
// 文本已过、有图 → 异步查图
if status == model.PostPending && len(images) > 0 {
s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2)
}
return &comment, nil return &comment, nil
} }
+171
View File
@@ -0,0 +1,171 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 微信官方 UGC 内容安全:
// - msg_sec_check 文本,同步返回 pass/review/risky
// - media_check_async 图片,异步:先提交拿 trace_id,结果由微信 push 回调
//
// 口径:社区所有内容默认不自动放出,只有文本检测明确 pass 且没有图片时才自动过审;
// 有图片走异步检测,结果回来再决定;检测不了(如小程序未发布、无 openid)一律转人工。
// MsgSecCheck 文本安全检测(v2)。scene: 1资料 2评论 3论坛 4日志。
// 返回 suggest: pass / review / risky
func (s *Service) MsgSecCheck(content, openid string, scene int) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "pass", nil // 没文本不用查
}
token, err := s.wechatAccessToken()
if err != nil {
return "", err
}
payload, _ := json.Marshal(map[string]any{
"content": content,
"version": 2,
"scene": scene,
"openid": openid,
})
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Result struct {
Suggest string `json:"suggest"`
Label int `json:"label"`
} `json:"result"`
}
if err := postJSON("https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+token, payload, &out); err != nil {
return "", err
}
if out.ErrCode != 0 {
return "", fmt.Errorf("msg_sec_check(%d): %s", out.ErrCode, out.ErrMsg)
}
if out.Result.Suggest == "" {
return "pass", nil
}
return out.Result.Suggest, nil
}
// MediaCheckAsync 提交一张图片做异步检测,返回 trace_id。mediaType 2=图片
func (s *Service) MediaCheckAsync(mediaURL, openid string, scene int) (string, error) {
token, err := s.wechatAccessToken()
if err != nil {
return "", err
}
payload, _ := json.Marshal(map[string]any{
"media_url": mediaURL,
"media_type": 2,
"version": 2,
"scene": scene,
"openid": openid,
})
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
TraceID string `json:"trace_id"`
}
if err := postJSON("https://api.weixin.qq.com/wxa/media_check_async?access_token="+token, payload, &out); err != nil {
return "", err
}
if out.ErrCode != 0 {
return "", fmt.Errorf("media_check_async(%d): %s", out.ErrCode, out.ErrMsg)
}
return out.TraceID, nil
}
// moderateInitial 发布前的初判:跑文本检测,决定初始状态和是否要提交图片异步检测。
// 检测不了一律转 pending 人工审核——保证「必须通过后才自动放出」。
func (s *Service) moderateInitial(content, openid string, scene int, hasImages bool) (status string, submitImages bool) {
suggest, err := s.MsgSecCheck(content, openid, scene)
if err != nil {
return model.PostPending, false // 验证不了 → 人工
}
switch suggest {
case "risky":
return model.PostRejected, false
case "review":
return model.PostPending, false
}
// 文本 pass
if hasImages {
return model.PostPending, true // 图片还要异步查,先挂 pending
}
return model.PostPublished, true // 纯文本且 pass:自动过审(submitImages 无意义)
}
// submitImageChecks 逐张提交图片检测,落 trace 记录,供回调找回目标
func (s *Service) submitImageChecks(targetType, targetID, openid string, urls []string, scene int) {
for _, u := range urls {
if u == "" {
continue
}
trace, err := s.MediaCheckAsync(u, openid, scene)
if err != nil || trace == "" {
continue // 提交失败的图不阻塞,靠人工兜底
}
s.db.Create(&model.MediaCheck{
TraceID: trace, TargetType: targetType, TargetID: targetID, Status: model.MediaPending,
})
}
}
// ResolveMediaCheck 处理一条异步回调结果:更新 trace 记录,并据此决定目标状态。
// 任一张图 risky → 打回;全部 pass 且目标仍 pending → 放出。
func (s *Service) ResolveMediaCheck(traceID, suggest string) {
var mc model.MediaCheck
if err := s.db.Where("trace_id = ?", traceID).First(&mc).Error; err != nil {
return // 不认识的 trace,忽略
}
st := model.MediaPass
if suggest == "risky" || suggest == "review" {
st = model.MediaRisky
}
s.db.Model(&model.MediaCheck{}).Where("id = ?", mc.ID).Update("status", st)
if st == model.MediaRisky {
s.setTargetStatus(mc.TargetType, mc.TargetID, model.PostRejected, model.PostPending)
return
}
// 这张 pass 了——看这条目标是否还有没回来的图
var pending int64
s.db.Model(&model.MediaCheck{}).
Where("target_type = ? AND target_id = ? AND status = ?", mc.TargetType, mc.TargetID, model.MediaPending).
Count(&pending)
if pending == 0 {
s.setTargetStatus(mc.TargetType, mc.TargetID, model.PostPublished, model.PostPending)
}
}
// setTargetStatus 把帖子/评论改成 newStatus,仅当它当前还是 onlyIf(避免覆盖人工已处理的)
func (s *Service) setTargetStatus(targetType, targetID, newStatus, onlyIf string) {
switch targetType {
case "post":
s.db.Model(&model.Post{}).Where("id = ? AND status = ?", targetID, onlyIf).Update("status", newStatus)
case "comment":
s.db.Model(&model.Comment{}).Where("id = ? AND status = ?", targetID, onlyIf).Update("status", newStatus)
}
}
// postJSON 一个小的 JSON POST 帮手
func postJSON(url string, body []byte, out any) error {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(out)
}
+389
View File
@@ -18,6 +18,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
@@ -844,6 +845,32 @@
} }
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -1108,6 +1135,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -1119,6 +1158,69 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.2", "version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
@@ -1149,6 +1251,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
@@ -1526,6 +1634,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1543,6 +1772,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1648,6 +1883,17 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1658,6 +1904,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1926,6 +2178,25 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/immer": {
"version": "11.1.15",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2742,6 +3013,36 @@
"react": "^19.2.7" "react": "^19.2.7"
} }
}, },
"node_modules/react-is": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -2885,6 +3186,57 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3105,6 +3457,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3243,6 +3601,15 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3250,6 +3617,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.3", "version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
@@ -12,6 +12,7 @@ const badgeVariants = cva(
destructive: 'border-transparent bg-destructive/15 text-destructive', destructive: 'border-transparent bg-destructive/15 text-destructive',
outline: 'text-foreground', outline: 'text-foreground',
success: 'border-transparent bg-emerald-100 text-emerald-700', success: 'border-transparent bg-emerald-100 text-emerald-700',
warning: 'border-transparent bg-amber-100 text-amber-700',
muted: 'border-transparent bg-muted text-muted-foreground', muted: 'border-transparent bg-muted text-muted-foreground',
}, },
}, },
+2
View File
@@ -101,6 +101,7 @@ export const api = {
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }), logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
me: () => http.get<any, any>('/admin/me'), me: () => http.get<any, any>('/admin/me'),
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'), stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
analytics: (days = 30) => http.get<any, any>('/admin/analytics', { params: { days } }),
users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }), users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }),
setUserDisabled: (id: number, disabled: boolean) => setUserDisabled: (id: number, disabled: boolean) =>
@@ -112,6 +113,7 @@ export const api = {
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }), setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }), comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }),
setCommentStatus: (id: number, status: string) => http.put(`/admin/comments/${id}/status`, { status }),
deleteComment: (id: number) => http.delete(`/admin/comments/${id}`), deleteComment: (id: number) => http.delete(`/admin/comments/${id}`),
articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }), articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }),
+88 -21
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged' import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -6,9 +7,31 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager' import { Pager } from '@/components/Pager'
export default function Comments() { const statusMap: Record<string, { label: string; variant: any }> = {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments) pending: { label: '待审核', variant: 'warning' },
published: { label: '正常', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
deleted: { label: '已删除', variant: 'destructive' },
}
const FILTERS = [
{ label: '待审核', status: 'pending' },
{ label: '正常', status: 'published' },
{ label: '全部', status: '' },
]
function isUrl(s: string) {
return typeof s === 'string' && s.indexOf('http') === 0
}
export default function Comments() {
const [status, setStatus] = useState('pending')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments, { status })
async function setStat(id: number, s: string) {
await api.setCommentStatus(id, s)
reload()
}
async function del(id: number) { async function del(id: number) {
await api.deleteComment(id) await api.deleteComment(id)
reload() reload()
@@ -16,39 +39,83 @@ export default function Comments() {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-6"></h1> <div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{FILTERS.map((f) => (
<button
key={f.status}
onClick={() => { setStatus(f.status); setPage(1) }}
className={`rounded-md px-3 py-1 text-sm transition ${
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.list?.map((c) => ( {data?.list?.map((c) => {
<TableRow key={c.id}> const imgs: string[] = Array.isArray(c.images) ? c.images.filter(isUrl) : []
<TableCell>{c.id}</TableCell> return (
<TableCell>{c.post_id}</TableCell> <TableRow key={c.id}>
<TableCell className="whitespace-nowrap">{c.author_name}</TableCell> <TableCell className="whitespace-nowrap align-top">{c.author_name}</TableCell>
<TableCell className="max-w-sm truncate">{c.content}</TableCell> <TableCell className="max-w-xs align-top">
<TableCell> <div className="line-clamp-3 whitespace-pre-wrap text-sm">{c.content || '—'}</div>
{c.status === 'deleted' ? <Badge variant="destructive"></Badge> : <Badge variant="success"></Badge>} </TableCell>
</TableCell> <TableCell className="align-top">
<TableCell className="text-right"> {imgs.length ? (
{c.status !== 'deleted' && ( <div className="flex flex-wrap gap-1" style={{ maxWidth: 180 }}>
<Button variant="destructive" size="sm" onClick={() => del(c.id)}> {imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
</Button> <img src={u} className="h-14 w-14 rounded-md border object-cover" loading="lazy" />
)} </a>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="align-top text-xs text-muted-foreground">{c.post_id}</TableCell>
<TableCell className="align-top">
<Badge variant={statusMap[c.status]?.variant}>{statusMap[c.status]?.label ?? c.status}</Badge>
</TableCell>
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
{c.status !== 'published' && c.status !== 'deleted' && (
<Button variant="default" size="sm" onClick={() => setStat(c.id, 'published')}>
</Button>
)}
{c.status !== 'deleted' && (
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
</Button>
)}
</TableCell>
</TableRow>
)
})}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
</TableCell> </TableCell>
</TableRow> </TableRow>
))} )}
</TableBody> </TableBody>
</Table> </Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} /> <Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
+167 -19
View File
@@ -1,39 +1,187 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Users, PawPrint, MessageSquare, Activity } from 'lucide-react' import { Users, PawPrint, MessageSquare, Activity, TrendingUp, CalendarDays, UserPlus, Repeat } from 'lucide-react'
import {
ResponsiveContainer, AreaChart, Area, BarChart, Bar, LineChart, Line,
XAxis, YAxis, CartesianGrid, Tooltip, Cell,
} from 'recharts'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
type Stats = { users: number; pets: number; posts: number; records: number } const ORANGE = '#F5A93E'
const GREEN = '#3FB984'
const BLUE = '#5B9BD5'
type Pt = { date: string; count: number }
type Ret = { day: number; rate: number; base: number }
type Analytics = {
summary: {
total_users: number; new_today: number; new_month: number
dau: number; wau: number; mau: number
total_pets: number; total_posts: number; total_records: number
}
new_trend: Pt[]; active_trend: Pt[]; mau_trend: Pt[]; retention: Ret[]
}
const RANGES = [
{ label: '近 7 天', days: 7 },
{ label: '近 30 天', days: 30 },
{ label: '近 90 天', days: 90 },
]
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null) const [days, setDays] = useState(30)
useEffect(() => { const [a, setA] = useState<Analytics | null>(null)
api.stats().then(setStats).catch(() => {}) const [loading, setLoading] = useState(true)
}, [])
const cards = [ useEffect(() => {
{ label: '用户', value: stats?.users, icon: Users }, setLoading(true)
{ label: '宠物', value: stats?.pets, icon: PawPrint }, api.analytics(days).then((d) => { setA(d); setLoading(false) }).catch(() => setLoading(false))
{ label: '帖子', value: stats?.posts, icon: MessageSquare }, }, [days])
{ label: '健康记录', value: stats?.records, icon: Activity },
const s = a?.summary
const kpis = [
{ label: '真实用户', value: s?.total_users, icon: Users, tint: ORANGE },
{ label: '日活 DAU', value: s?.dau, icon: Activity, tint: GREEN },
{ label: '周活 WAU', value: s?.wau, icon: CalendarDays, tint: BLUE },
{ label: '月活 MAU', value: s?.mau, icon: TrendingUp, tint: ORANGE },
{ label: '今日新增', value: s?.new_today, icon: UserPlus, tint: GREEN },
{ label: '本月新增', value: s?.new_month, icon: UserPlus, tint: BLUE },
] ]
const mini = [
{ label: '宠物', value: s?.total_pets, icon: PawPrint },
{ label: '帖子', value: s?.total_posts, icon: MessageSquare },
{ label: '健康记录', value: s?.total_records, icon: Activity },
]
const retData = (a?.retention || []).map((r) => ({ ...r, pct: Math.round(r.rate * 1000) / 10 }))
return ( return (
<div> <div className="space-y-6">
<h1 className="text-2xl font-bold mb-6"></h1> <div className="flex items-center justify-between">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <h1 className="text-2xl font-bold"></h1>
{cards.map((c) => ( <div className="flex gap-1 rounded-lg border bg-card p-1">
{RANGES.map((r) => (
<button
key={r.days}
onClick={() => setDays(r.days)}
className={`rounded-md px-3 py-1 text-sm transition ${
days === r.days ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* KPI 卡 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
{kpis.map((c) => (
<Card key={c.label}> <Card key={c.label}>
<CardHeader className="flex-row items-center justify-between pb-2"> <CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-sm text-muted-foreground">{c.label}</CardTitle> <CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4 text-primary" /> <c.icon className="h-4 w-4" style={{ color: c.tint }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold">{c.value ?? '—'}</div> <div className="text-2xl font-bold">{c.value ?? '—'}</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
{/* 图表区 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="新增趋势" hint={`每日新增真实用户 · ${days}`}>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={a?.new_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<defs>
<linearGradient id="gNew" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={ORANGE} stopOpacity={0.35} />
<stop offset="100%" stopColor={ORANGE} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Area type="monotone" dataKey="count" name="新增" stroke={ORANGE} strokeWidth={2} fill="url(#gNew)" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="活跃趋势" hint={`每日活跃用户 DAU · ${days}`}>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={a?.active_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Line type="monotone" dataKey="count" name="活跃" stroke={GREEN} strokeWidth={2.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="月活 MAU" hint="近 6 个月去重活跃用户">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={a?.mau_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Bar dataKey="count" name="月活" fill={BLUE} radius={[6, 6, 0, 0]} maxBarSize={44} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="留存曲线" hint="注册后第 N 天仍活跃的比例(滚动口径)">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={retData} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="day" tick={{ fontSize: 11 }} tickFormatter={(d) => `D${d}`} />
<YAxis tick={{ fontSize: 11 }} width={40} unit="%" domain={[0, 100]} />
<Tooltip
contentStyle={tipProps.contentStyle}
formatter={(v: any, _n: any, p: any) => [`${v}%(分母 ${p.payload.base}`, '留存率']}
labelFormatter={(d) => `注册后第 ${d}`}
/>
<Line type="monotone" dataKey="pct" name="留存率" stroke={ORANGE} strokeWidth={2.5}>
{retData.map((_, i) => <Cell key={i} />)}
</Line>
</LineChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* 底部小计数 */}
<div className="grid grid-cols-3 gap-4">
{mini.map((c) => (
<Card key={c.label}>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent><div className="text-2xl font-bold">{c.value ?? '—'}</div></CardContent>
</Card>
))}
</div>
{loading && <div className="text-center text-sm text-muted-foreground"></div>}
</div> </div>
) )
} }
const tipProps = {
contentStyle: { borderRadius: 10, border: '1px solid #eee', fontSize: 12 },
cursor: { fill: 'rgba(245,169,62,.08)' },
}
function ChartCard({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
+97 -40
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged' import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -7,12 +8,25 @@ import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager' import { Pager } from '@/components/Pager'
const statusMap: Record<string, { label: string; variant: any }> = { const statusMap: Record<string, { label: string; variant: any }> = {
pending: { label: '待审核', variant: 'warning' },
published: { label: '已发布', variant: 'success' }, published: { label: '已发布', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
hidden: { label: '已隐藏', variant: 'muted' }, hidden: { label: '已隐藏', variant: 'muted' },
deleted: { label: '已删除', variant: 'destructive' }, deleted: { label: '已删除', variant: 'destructive' },
} }
// 今天的只看时分,往前的看月日——审核时关心的是「多久以前发的」 const FILTERS = [
{ label: '待审核', status: 'pending' },
{ label: '已发布', status: 'published' },
{ label: '已打回', status: 'rejected' },
{ label: '全部', status: '' },
]
// 后台展示的图片可能是真实 URL,也可能是机器人帖的 emoji 占位——只渲染 URL
function isUrl(s: string) {
return typeof s === 'string' && s.indexOf('http') === 0
}
function fmtTime(iso?: string) { function fmtTime(iso?: string) {
if (!iso) return '—' if (!iso) return '—'
const d = new Date(iso) const d = new Date(iso)
@@ -21,72 +35,115 @@ function fmtTime(iso?: string) {
const hm = `${p(d.getHours())}:${p(d.getMinutes())}` const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
const now = new Date() const now = new Date()
if (d.toDateString() === now.toDateString()) return `今天 ${hm}` if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
const y = new Date(now.getTime() - 86400000)
if (d.toDateString() === y.toDateString()) return `昨天 ${hm}`
const sameYear = d.getFullYear() === now.getFullYear() const sameYear = d.getFullYear() === now.getFullYear()
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}` return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
} }
export default function Posts() { export default function Posts() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts) const [status, setStatus] = useState('pending')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts, { status })
async function setStatus(id: number, status: string) { async function setPostStatus(id: number, s: string) {
await api.setPostStatus(id, status) await api.setPostStatus(id, s)
reload() reload()
} }
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-6"></h1> <div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{FILTERS.map((f) => (
<button
key={f.status}
onClick={() => { setStatus(f.status); setPage(1) }}
className={`rounded-md px-3 py-1 text-sm transition ${
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead> <TableHead>/</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.list?.map((p) => ( {data?.list?.map((p) => {
<TableRow key={p.id}> const imgs: string[] = Array.isArray(p.images) ? p.images.filter(isUrl) : []
<TableCell>{p.id}</TableCell> return (
<TableCell className="whitespace-nowrap"> <TableRow key={p.id}>
<span className="mr-1">{p.author_emoji}</span> <TableCell className="whitespace-nowrap align-top">
{p.author_name} <span className="mr-1">{p.author_emoji}</span>
</TableCell> {p.author_name}
<TableCell className="max-w-sm truncate">{p.content}</TableCell> {p.is_ai && <Badge variant="muted" className="ml-1">AI</Badge>}
<TableCell className="whitespace-nowrap"> </TableCell>
{p.like_count} / {p.comment_count} <TableCell className="max-w-xs align-top">
</TableCell> <div className="line-clamp-3 whitespace-pre-wrap text-sm">{p.content || '—'}</div>
<TableCell className="whitespace-nowrap text-muted-foreground"> </TableCell>
{fmtTime(p.created_at)} <TableCell className="align-top">
</TableCell> {imgs.length ? (
<TableCell> <div className="flex flex-wrap gap-1" style={{ maxWidth: 220 }}>
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge> {imgs.map((u, i) => (
</TableCell> <a key={i} href={u} target="_blank" rel="noreferrer">
<TableCell className="text-right space-x-2 whitespace-nowrap"> <img
{p.status !== 'published' && ( src={u}
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'published')}> className="h-16 w-16 rounded-md object-cover border"
loading="lazy"
/>
</a>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap align-top">
{p.like_count} / {p.comment_count}
</TableCell>
<TableCell className="whitespace-nowrap align-top text-muted-foreground">
{fmtTime(p.created_at)}
</TableCell>
<TableCell className="align-top">
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
</TableCell>
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
{p.status !== 'published' && (
<Button variant="default" size="sm" onClick={() => setPostStatus(p.id, 'published')}>
</Button>
)}
{(p.status === 'pending' || p.status === 'published') && (
<Button variant="outline" size="sm" onClick={() => setPostStatus(p.id, 'rejected')}>
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => setPostStatus(p.id, 'deleted')}>
</Button> </Button>
)} </TableCell>
{p.status === 'published' && ( </TableRow>
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'hidden')}> )
})}
</Button> {!data?.list?.length && (
)} <TableRow>
<Button variant="destructive" size="sm" onClick={() => setStatus(p.id, 'deleted')}> <TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} )}
</TableBody> </TableBody>
</Table> </Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} /> <Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />