feat(be+admin): 后台看板扩展 + 社区内容安全审核

看板(Recharts):
- 概览页加 DAU/WAU/MAU、今日/本月新增 KPI,新增趋势/活跃趋势/月活/留存曲线
- GET /admin/analytics 内存计算,活跃口径=当天有记录/发帖/评论,排除 bot

内容安全(微信官方 UGC):
- 文本 msg_sec_check 同步判、图片 media_check_async 异步查
- 帖子/评论加 pending/rejected 审核态,feed 只放 published
- 图片结果回调 /api/wx/sec-callback,JSON/XML + 明文模式,签名校验
- 检测不了/未发布时一律转待审核,走后台手动审核
- 后台帖子/评论审核页:修好看不到图(补 attachPostImages)、
  加状态筛选 + 通过/打回;新增评论审核状态接口

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-30 19:01:48 +08:00
parent c48e241fe1
commit 67b97e4b38
23 changed files with 1444 additions and 156 deletions
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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-BH08rQ_o.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
<script type="module" crossorigin src="/admin/assets/index-f13soFkd.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
</head>
<body>
<div id="root"></div>
+3
View File
@@ -76,6 +76,9 @@ type JWTConfig struct {
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
MsgToken string `mapstructure:"msg_token"`
}
type AuthConfig struct {
+28 -1
View File
@@ -1,6 +1,8 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
@@ -109,6 +111,17 @@ func (h *Handler) AdminStats(c *gin.Context) {
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 {
response.PageQuery
Keyword string `form:"keyword"`
@@ -196,7 +209,7 @@ func (h *Handler) AdminListComments(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
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 {
response.FailErr(c, err)
return
@@ -204,6 +217,20 @@ func (h *Handler) AdminListComments(c *gin.Context) {
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
func (h *Handler) AdminDeleteComment(c *gin.Context) {
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{},
&AIQuotaConfig{},
&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 (
PostPublished = "published"
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
PostPublished = "published" // 已过审,正常展示
PostRejected = "rejected" // 内容安全判定风险,打回
PostHidden = "hidden"
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("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
// 微信图片异步安全检测的结果回调(消息推送),公开、自带签名校验
r.Any("/api/wx/sec-callback", h.WxSecCallback)
api := r.Group("/api")
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("/stats", h.AdminStats)
g.GET("/analytics", h.AdminAnalytics)
g.GET("/users", h.AdminListUsers)
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.GET("/comments", h.AdminListComments)
g.PUT("/comments/:id/status", h.AdminSetCommentStatus)
g.DELETE("/comments/:id", h.AdminDeleteComment)
g.GET("/articles", h.AdminListArticles)
+31 -4
View File
@@ -2,6 +2,7 @@ package service
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"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)
var posts []model.Post
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
// 真实帖子的图存的是 file id,要解析成 URL 后台才看得到图
s.attachPostImages(posts)
return posts, total, err
}
@@ -148,15 +151,39 @@ func (s *Service) SetPostStatus(id string, status string) error {
return nil
}
// ListCommentsAdmin 评论分页
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
// ListCommentsAdmin 评论分页,status 非空则按状态过滤(待审核用 pending)
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
s.db.Model(&model.Comment{}).Count(&total)
q.Count(&total)
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
}
// 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 删除评论
func (s *Service) DeleteCommentAdmin(id string) 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{
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
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 {
return nil, err
}
// 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回
if status == model.PostPending && len(imgURLs) > 0 {
s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3)
}
one := []model.Post{p}
s.attachPostImages(one)
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 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID string) (int, error) {
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{
PostID: postID, UserID: userID, AuthorName: name, Content: content,
Status: "published", ParentID: parentID, ReplyToName: replyTo,
Status: status, ParentID: parentID, ReplyToName: replyTo,
}
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
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 {
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 {
return nil, err
}
// 文本已过、有图 → 异步查图
if status == model.PostPending && len(images) > 0 {
s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2)
}
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-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
"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": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -1108,6 +1135,18 @@
"dev": true,
"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": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -1119,6 +1158,69 @@
"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": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
@@ -1149,6 +1251,12 @@
"@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": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
@@ -1526,6 +1634,127 @@
"devOptional": true,
"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": {
"version": "4.4.3",
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1648,6 +1883,17 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1658,6 +1904,12 @@
"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": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1926,6 +2178,25 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2742,6 +3013,36 @@
"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": {
"version": "2.7.2",
"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"
}
},
"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": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3105,6 +3457,12 @@
"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": {
"version": "0.2.17",
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3250,6 +3617,28 @@
"dev": true,
"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": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
@@ -12,6 +12,7 @@ const badgeVariants = cva(
destructive: 'border-transparent bg-destructive/15 text-destructive',
outline: 'text-foreground',
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',
},
},
+2
View File
@@ -101,6 +101,7 @@ export const api = {
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
me: () => http.get<any, any>('/admin/me'),
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 }),
setUserDisabled: (id: number, disabled: boolean) =>
@@ -112,6 +113,7 @@ export const api = {
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
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}`),
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 { usePaged } from '@/lib/usePaged'
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 { Pager } from '@/components/Pager'
export default function Comments() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments)
const statusMap: Record<string, { label: string; variant: any }> = {
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) {
await api.deleteComment(id)
reload()
@@ -16,39 +39,83 @@ export default function Comments() {
return (
<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>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.id}</TableCell>
<TableCell>{c.post_id}</TableCell>
<TableCell className="whitespace-nowrap">{c.author_name}</TableCell>
<TableCell className="max-w-sm truncate">{c.content}</TableCell>
<TableCell>
{c.status === 'deleted' ? <Badge variant="destructive"></Badge> : <Badge variant="success"></Badge>}
</TableCell>
<TableCell className="text-right">
{c.status !== 'deleted' && (
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
</Button>
)}
{data?.list?.map((c) => {
const imgs: string[] = Array.isArray(c.images) ? c.images.filter(isUrl) : []
return (
<TableRow key={c.id}>
<TableCell className="whitespace-nowrap align-top">{c.author_name}</TableCell>
<TableCell className="max-w-xs align-top">
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{c.content || '—'}</div>
</TableCell>
<TableCell className="align-top">
{imgs.length ? (
<div className="flex flex-wrap gap-1" style={{ maxWidth: 180 }}>
{imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
<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>
</TableRow>
))}
)}
</TableBody>
</Table>
<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 { 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 { 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() {
const [stats, setStats] = useState<Stats | null>(null)
useEffect(() => {
api.stats().then(setStats).catch(() => {})
}, [])
const [days, setDays] = useState(30)
const [a, setA] = useState<Analytics | null>(null)
const [loading, setLoading] = useState(true)
const cards = [
{ label: '用户', value: stats?.users, icon: Users },
{ label: '宠物', value: stats?.pets, icon: PawPrint },
{ label: '帖子', value: stats?.posts, icon: MessageSquare },
{ label: '健康记录', value: stats?.records, icon: Activity },
useEffect(() => {
setLoading(true)
api.analytics(days).then((d) => { setA(d); setLoading(false) }).catch(() => setLoading(false))
}, [days])
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 (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((c) => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<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}>
<CardHeader className="flex-row items-center justify-between pb-2">
<CardTitle className="text-sm text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4 text-primary" />
<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" style={{ color: c.tint }} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{c.value ?? '—'}</div>
<div className="text-2xl font-bold">{c.value ?? '—'}</div>
</CardContent>
</Card>
))}
</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>
)
}
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 { usePaged } from '@/lib/usePaged'
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'
const statusMap: Record<string, { label: string; variant: any }> = {
pending: { label: '待审核', variant: 'warning' },
published: { label: '已发布', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
hidden: { label: '已隐藏', variant: 'muted' },
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) {
if (!iso) return '—'
const d = new Date(iso)
@@ -21,72 +35,115 @@ function fmtTime(iso?: string) {
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
const now = new Date()
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()
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
}
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) {
await api.setPostStatus(id, status)
async function setPostStatus(id: number, s: string) {
await api.setPostStatus(id, s)
reload()
}
return (
<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>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.id}</TableCell>
<TableCell className="whitespace-nowrap">
<span className="mr-1">{p.author_emoji}</span>
{p.author_name}
</TableCell>
<TableCell className="max-w-sm truncate">{p.content}</TableCell>
<TableCell className="whitespace-nowrap">
{p.like_count} / {p.comment_count}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{fmtTime(p.created_at)}
</TableCell>
<TableCell>
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
</TableCell>
<TableCell className="text-right space-x-2 whitespace-nowrap">
{p.status !== 'published' && (
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'published')}>
{data?.list?.map((p) => {
const imgs: string[] = Array.isArray(p.images) ? p.images.filter(isUrl) : []
return (
<TableRow key={p.id}>
<TableCell className="whitespace-nowrap align-top">
<span className="mr-1">{p.author_emoji}</span>
{p.author_name}
{p.is_ai && <Badge variant="muted" className="ml-1">AI</Badge>}
</TableCell>
<TableCell className="max-w-xs align-top">
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{p.content || '—'}</div>
</TableCell>
<TableCell className="align-top">
{imgs.length ? (
<div className="flex flex-wrap gap-1" style={{ maxWidth: 220 }}>
{imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
<img
src={u}
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>
)}
{p.status === 'published' && (
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'hidden')}>
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => setStatus(p.id, 'deleted')}>
</Button>
</TableCell>
</TableRow>
)
})}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />