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:
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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -52,5 +52,6 @@ func AllModels() []any {
|
||||
&RefreshToken{},
|
||||
&AIQuotaConfig{},
|
||||
&AIUsage{},
|
||||
&MediaCheck{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import "gorm.io/datatypes"
|
||||
|
||||
// 帖子状态
|
||||
const (
|
||||
PostPublished = "published"
|
||||
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
|
||||
PostPublished = "published" // 已过审,正常展示
|
||||
PostRejected = "rejected" // 内容安全判定风险,打回
|
||||
PostHidden = "hidden"
|
||||
PostDeleted = "deleted"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Generated
+389
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user