Merge pull request 'feat(be+admin): 后台看板扩展 + 社区内容安全审核' (#4) from feat/dev into main
deploy / deploy (push) Successful in 2m5s
deploy / deploy (push) Successful in 2m5s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -43,6 +43,11 @@ AUTH_DEV_LOGIN=false
|
|||||||
# ── 微信小程序 ──
|
# ── 微信小程序 ──
|
||||||
WECHAT_APP_ID=wx开头的AppID
|
WECHAT_APP_ID=wx开头的AppID
|
||||||
WECHAT_APP_SECRET=小程序密钥
|
WECHAT_APP_SECRET=小程序密钥
|
||||||
|
# 内容安全图片异步检测的结果回调校验 Token。
|
||||||
|
# 要和「小程序后台 → 开发管理 → 消息推送」里填的 Token 完全一致(英文数字,3-32 位)。
|
||||||
|
# 消息推送 URL 填 https://pet.sundynix.cn/api/wx/sec-callback,加密方式选「明文模式」。
|
||||||
|
# 没配消息推送前可留空(社区内容会走后台手动审核)。
|
||||||
|
WECHAT_MSG_TOKEN=petsSecCheck2026
|
||||||
|
|
||||||
# ── 管理后台(/admin) ──
|
# ── 管理后台(/admin) ──
|
||||||
ADMIN_USERNAME=sundynix
|
ADMIN_USERNAME=sundynix
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ services:
|
|||||||
|
|
||||||
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
||||||
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
||||||
|
# 内容安全图片异步检测的结果回调校验 Token(消息推送服务器配置里的 Token)。
|
||||||
|
# 可选:留空则回调不校验签名;配了才和微信后台的 Token 对上
|
||||||
|
PETS_WECHAT_MSG_TOKEN: ${WECHAT_MSG_TOKEN:-}
|
||||||
# 生产必须 false,否则任何人都能用昵称直接登录
|
# 生产必须 false,否则任何人都能用昵称直接登录
|
||||||
PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false}
|
PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin</title>
|
<title>admin</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-BH08rQ_o.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-f13soFkd.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ type JWTConfig struct {
|
|||||||
type WeChatConfig struct {
|
type WeChatConfig struct {
|
||||||
AppID string `mapstructure:"app_id"`
|
AppID string `mapstructure:"app_id"`
|
||||||
AppSecret string `mapstructure:"app_secret"`
|
AppSecret string `mapstructure:"app_secret"`
|
||||||
|
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
|
||||||
|
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
|
||||||
|
MsgToken string `mapstructure:"msg_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@@ -115,6 +118,7 @@ func setDefaults(v *viper.Viper) {
|
|||||||
|
|
||||||
v.SetDefault("wechat.app_id", "")
|
v.SetDefault("wechat.app_id", "")
|
||||||
v.SetDefault("wechat.app_secret", "")
|
v.SetDefault("wechat.app_secret", "")
|
||||||
|
v.SetDefault("wechat.msg_token", "") // 用 PETS_WECHAT_MSG_TOKEN 覆盖
|
||||||
|
|
||||||
v.SetDefault("auth.dev_login", false)
|
v.SetDefault("auth.dev_login", false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
"github.com/sundynix/pets-be/internal/middleware"
|
||||||
@@ -109,6 +111,17 @@ func (h *Handler) AdminStats(c *gin.Context) {
|
|||||||
response.OK(c, stats)
|
response.OK(c, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminAnalytics GET /api/admin/analytics?days=30 看板的活跃/新增/留存
|
||||||
|
func (h *Handler) AdminAnalytics(c *gin.Context) {
|
||||||
|
days, _ := strconv.Atoi(c.Query("days"))
|
||||||
|
data, err := h.svc.AdminAnalytics(days)
|
||||||
|
if err != nil {
|
||||||
|
response.FailErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
type adminListReq struct {
|
type adminListReq struct {
|
||||||
response.PageQuery
|
response.PageQuery
|
||||||
Keyword string `form:"keyword"`
|
Keyword string `form:"keyword"`
|
||||||
@@ -196,7 +209,7 @@ func (h *Handler) AdminListComments(c *gin.Context) {
|
|||||||
var req adminListReq
|
var req adminListReq
|
||||||
_ = c.ShouldBindQuery(&req)
|
_ = c.ShouldBindQuery(&req)
|
||||||
req.Normalize()
|
req.Normalize()
|
||||||
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit())
|
comments, total, err := h.svc.ListCommentsAdmin(req.Status, req.Offset(), req.Limit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.FailErr(c, err)
|
response.FailErr(c, err)
|
||||||
return
|
return
|
||||||
@@ -204,6 +217,20 @@ func (h *Handler) AdminListComments(c *gin.Context) {
|
|||||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminSetCommentStatus PUT /api/admin/comments/:id/status
|
||||||
|
func (h *Handler) AdminSetCommentStatus(c *gin.Context) {
|
||||||
|
var req postStatusReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.FailParams(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.SetCommentStatus(idParam(c, "id"), req.Status); err != nil {
|
||||||
|
response.FailErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// AdminDeleteComment DELETE /api/admin/comments/:id
|
// AdminDeleteComment DELETE /api/admin/comments/:id
|
||||||
func (h *Handler) AdminDeleteComment(c *gin.Context) {
|
func (h *Handler) AdminDeleteComment(c *gin.Context) {
|
||||||
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
|
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
|
||||||
|
|||||||
@@ -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{},
|
&RefreshToken{},
|
||||||
&AIQuotaConfig{},
|
&AIQuotaConfig{},
|
||||||
&AIUsage{},
|
&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 (
|
const (
|
||||||
PostPublished = "published"
|
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
|
||||||
|
PostPublished = "published" // 已过审,正常展示
|
||||||
|
PostRejected = "rejected" // 内容安全判定风险,打回
|
||||||
PostHidden = "hidden"
|
PostHidden = "hidden"
|
||||||
PostDeleted = "deleted"
|
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("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
|
||||||
// 根路径跳转后台
|
// 根路径跳转后台
|
||||||
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
|
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
|
||||||
|
// 微信图片异步安全检测的结果回调(消息推送),公开、自带签名校验
|
||||||
|
r.Any("/api/wx/sec-callback", h.WxSecCallback)
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
registerAuth(api, h)
|
registerAuth(api, h)
|
||||||
@@ -141,6 +143,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
|
|||||||
|
|
||||||
g.GET("/me", h.AdminMe)
|
g.GET("/me", h.AdminMe)
|
||||||
g.GET("/stats", h.AdminStats)
|
g.GET("/stats", h.AdminStats)
|
||||||
|
g.GET("/analytics", h.AdminAnalytics)
|
||||||
|
|
||||||
g.GET("/users", h.AdminListUsers)
|
g.GET("/users", h.AdminListUsers)
|
||||||
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
|
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
|
||||||
@@ -151,6 +154,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
|
|||||||
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
|
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
|
||||||
|
|
||||||
g.GET("/comments", h.AdminListComments)
|
g.GET("/comments", h.AdminListComments)
|
||||||
|
g.PUT("/comments/:id/status", h.AdminSetCommentStatus)
|
||||||
g.DELETE("/comments/:id", h.AdminDeleteComment)
|
g.DELETE("/comments/:id", h.AdminDeleteComment)
|
||||||
|
|
||||||
g.GET("/articles", h.AdminListArticles)
|
g.GET("/articles", h.AdminListArticles)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
)
|
)
|
||||||
@@ -133,6 +134,8 @@ func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post
|
|||||||
q.Count(&total)
|
q.Count(&total)
|
||||||
var posts []model.Post
|
var posts []model.Post
|
||||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
|
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
|
||||||
|
// 真实帖子的图存的是 file id,要解析成 URL 后台才看得到图
|
||||||
|
s.attachPostImages(posts)
|
||||||
return posts, total, err
|
return posts, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +151,39 @@ func (s *Service) SetPostStatus(id string, status string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCommentsAdmin 评论分页
|
// ListCommentsAdmin 评论分页,status 非空则按状态过滤(待审核用 pending)
|
||||||
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
|
func (s *Service) ListCommentsAdmin(status string, offset, limit int) ([]model.Comment, int64, error) {
|
||||||
|
q := s.db.Model(&model.Comment{})
|
||||||
|
if status != "" {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
var total int64
|
var total int64
|
||||||
s.db.Model(&model.Comment{}).Count(&total)
|
q.Count(&total)
|
||||||
var comments []model.Comment
|
var comments []model.Comment
|
||||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
|
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
|
||||||
return comments, total, err
|
return comments, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCommentStatus 审核评论。从待审核过审时补上楼主的评论数
|
||||||
|
func (s *Service) SetCommentStatus(id, status string) error {
|
||||||
|
var c model.Comment
|
||||||
|
if err := s.db.First(&c, "id = ?", id).Error; err != nil {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if c.Status == status {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", status).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// pending → published:这条评论此前没计数,过审后补 1
|
||||||
|
if status == model.PostPublished && c.Status == model.PostPending {
|
||||||
|
s.db.Model(&model.Post{}).Where("id = ?", c.PostID).
|
||||||
|
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCommentAdmin 删除评论
|
// DeleteCommentAdmin 删除评论
|
||||||
func (s *Service) DeleteCommentAdmin(id string) error {
|
func (s *Service) DeleteCommentAdmin(id string) error {
|
||||||
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
|
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
|
||||||
|
|||||||
@@ -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{
|
p := model.Post{
|
||||||
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
|
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
|
||||||
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
|
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
|
||||||
ImageFileIDs: in.ImageFileIDs, Status: model.PostPublished,
|
ImageFileIDs: in.ImageFileIDs, Status: status,
|
||||||
}
|
}
|
||||||
if err := s.db.Create(&p).Error; err != nil {
|
if err := s.db.Create(&p).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回
|
||||||
|
if status == model.PostPending && len(imgURLs) > 0 {
|
||||||
|
s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3)
|
||||||
|
}
|
||||||
one := []model.Post{p}
|
one := []model.Post{p}
|
||||||
s.attachPostImages(one)
|
s.attachPostImages(one)
|
||||||
return &one[0], nil
|
return &one[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveImageURLs 把 file id 数组(JSON)解析成可访问 URL 列表
|
||||||
|
func (s *Service) resolveImageURLs(fileIDs datatypes.JSON) []string {
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
if json.Unmarshal(fileIDs, &ids) != nil || len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := s.fileURLs(ids)
|
||||||
|
urls := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if u := m[id]; u != "" {
|
||||||
|
urls = append(urls, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
// LikePost 点赞(幂等:已赞则不重复计数)
|
// LikePost 点赞(幂等:已赞则不重复计数)
|
||||||
func (s *Service) LikePost(userID, postID string) (int, error) {
|
func (s *Service) LikePost(userID, postID string) (int, error) {
|
||||||
if _, err := s.GetPost(postID); err != nil {
|
if _, err := s.GetPost(postID); err != nil {
|
||||||
@@ -275,9 +306,12 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
|
||||||
|
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
|
||||||
|
|
||||||
comment := model.Comment{
|
comment := model.Comment{
|
||||||
PostID: postID, UserID: userID, AuthorName: name, Content: content,
|
PostID: postID, UserID: userID, AuthorName: name, Content: content,
|
||||||
Status: "published", ParentID: parentID, ReplyToName: replyTo,
|
Status: status, ParentID: parentID, ReplyToName: replyTo,
|
||||||
}
|
}
|
||||||
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
|
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
|
||||||
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
|
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
|
||||||
@@ -287,11 +321,19 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
|
|||||||
if err := tx.Create(&comment).Error; err != nil {
|
if err := tx.Create(&comment).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
// 待审核/打回的评论先不计入楼主的评论数,过审后再补
|
||||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
|
if status == model.PostPublished {
|
||||||
|
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 文本已过、有图 → 异步查图
|
||||||
|
if status == model.PostPending && len(images) > 0 {
|
||||||
|
s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2)
|
||||||
|
}
|
||||||
return &comment, nil
|
return &comment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -844,6 +845,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -1108,6 +1135,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
@@ -1119,6 +1158,69 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "24.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||||
@@ -1149,6 +1251,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
||||||
@@ -1526,6 +1634,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1543,6 +1772,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1648,6 +1883,17 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.50.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
|
||||||
|
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1658,6 +1904,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -1926,6 +2178,25 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
|
||||||
|
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -2742,6 +3013,36 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||||
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -2885,6 +3186,57 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -3105,6 +3457,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -3243,6 +3601,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -3250,6 +3617,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const badgeVariants = cva(
|
|||||||
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
||||||
outline: 'text-foreground',
|
outline: 'text-foreground',
|
||||||
success: 'border-transparent bg-emerald-100 text-emerald-700',
|
success: 'border-transparent bg-emerald-100 text-emerald-700',
|
||||||
|
warning: 'border-transparent bg-amber-100 text-amber-700',
|
||||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const api = {
|
|||||||
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
|
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
|
||||||
me: () => http.get<any, any>('/admin/me'),
|
me: () => http.get<any, any>('/admin/me'),
|
||||||
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
|
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
|
||||||
|
analytics: (days = 30) => http.get<any, any>('/admin/analytics', { params: { days } }),
|
||||||
|
|
||||||
users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }),
|
users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }),
|
||||||
setUserDisabled: (id: number, disabled: boolean) =>
|
setUserDisabled: (id: number, disabled: boolean) =>
|
||||||
@@ -112,6 +113,7 @@ export const api = {
|
|||||||
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
|
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
|
||||||
|
|
||||||
comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }),
|
comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }),
|
||||||
|
setCommentStatus: (id: number, status: string) => http.put(`/admin/comments/${id}/status`, { status }),
|
||||||
deleteComment: (id: number) => http.delete(`/admin/comments/${id}`),
|
deleteComment: (id: number) => http.delete(`/admin/comments/${id}`),
|
||||||
|
|
||||||
articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }),
|
articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { usePaged } from '@/lib/usePaged'
|
import { usePaged } from '@/lib/usePaged'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
@@ -6,9 +7,31 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Pager } from '@/components/Pager'
|
import { Pager } from '@/components/Pager'
|
||||||
|
|
||||||
export default function Comments() {
|
const statusMap: Record<string, { label: string; variant: any }> = {
|
||||||
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments)
|
pending: { label: '待审核', variant: 'warning' },
|
||||||
|
published: { label: '正常', variant: 'success' },
|
||||||
|
rejected: { label: '已打回', variant: 'destructive' },
|
||||||
|
deleted: { label: '已删除', variant: 'destructive' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ label: '待审核', status: 'pending' },
|
||||||
|
{ label: '正常', status: 'published' },
|
||||||
|
{ label: '全部', status: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isUrl(s: string) {
|
||||||
|
return typeof s === 'string' && s.indexOf('http') === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Comments() {
|
||||||
|
const [status, setStatus] = useState('pending')
|
||||||
|
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments, { status })
|
||||||
|
|
||||||
|
async function setStat(id: number, s: string) {
|
||||||
|
await api.setCommentStatus(id, s)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
async function del(id: number) {
|
async function del(id: number) {
|
||||||
await api.deleteComment(id)
|
await api.deleteComment(id)
|
||||||
reload()
|
reload()
|
||||||
@@ -16,39 +39,83 @@ export default function Comments() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">评论管理</h1>
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">评论审核</h1>
|
||||||
|
<div className="flex gap-1 rounded-lg border bg-card p-1">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.status}
|
||||||
|
onClick={() => { setStatus(f.status); setPage(1) }}
|
||||||
|
className={`rounded-md px-3 py-1 text-sm transition ${
|
||||||
|
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
|
||||||
<TableHead>帖子ID</TableHead>
|
|
||||||
<TableHead>作者</TableHead>
|
<TableHead>作者</TableHead>
|
||||||
<TableHead>内容</TableHead>
|
<TableHead>内容</TableHead>
|
||||||
|
<TableHead>配图</TableHead>
|
||||||
|
<TableHead>帖子</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.list?.map((c) => (
|
{data?.list?.map((c) => {
|
||||||
<TableRow key={c.id}>
|
const imgs: string[] = Array.isArray(c.images) ? c.images.filter(isUrl) : []
|
||||||
<TableCell>{c.id}</TableCell>
|
return (
|
||||||
<TableCell>{c.post_id}</TableCell>
|
<TableRow key={c.id}>
|
||||||
<TableCell className="whitespace-nowrap">{c.author_name}</TableCell>
|
<TableCell className="whitespace-nowrap align-top">{c.author_name}</TableCell>
|
||||||
<TableCell className="max-w-sm truncate">{c.content}</TableCell>
|
<TableCell className="max-w-xs align-top">
|
||||||
<TableCell>
|
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{c.content || '—'}</div>
|
||||||
{c.status === 'deleted' ? <Badge variant="destructive">已删除</Badge> : <Badge variant="success">正常</Badge>}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="align-top">
|
||||||
<TableCell className="text-right">
|
{imgs.length ? (
|
||||||
{c.status !== 'deleted' && (
|
<div className="flex flex-wrap gap-1" style={{ maxWidth: 180 }}>
|
||||||
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
|
{imgs.map((u, i) => (
|
||||||
删除
|
<a key={i} href={u} target="_blank" rel="noreferrer">
|
||||||
</Button>
|
<img src={u} className="h-14 w-14 rounded-md border object-cover" loading="lazy" />
|
||||||
)}
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">无</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top text-xs text-muted-foreground">{c.post_id}</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Badge variant={statusMap[c.status]?.variant}>{statusMap[c.status]?.label ?? c.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
|
||||||
|
{c.status !== 'published' && c.status !== 'deleted' && (
|
||||||
|
<Button variant="default" size="sm" onClick={() => setStat(c.id, 'published')}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{c.status !== 'deleted' && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{!data?.list?.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||||
|
这个状态下暂无评论
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
|
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
|
||||||
|
|||||||
@@ -1,39 +1,187 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Users, PawPrint, MessageSquare, Activity } from 'lucide-react'
|
import { Users, PawPrint, MessageSquare, Activity, TrendingUp, CalendarDays, UserPlus, Repeat } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, AreaChart, Area, BarChart, Bar, LineChart, Line,
|
||||||
|
XAxis, YAxis, CartesianGrid, Tooltip, Cell,
|
||||||
|
} from 'recharts'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
type Stats = { users: number; pets: number; posts: number; records: number }
|
const ORANGE = '#F5A93E'
|
||||||
|
const GREEN = '#3FB984'
|
||||||
|
const BLUE = '#5B9BD5'
|
||||||
|
|
||||||
|
type Pt = { date: string; count: number }
|
||||||
|
type Ret = { day: number; rate: number; base: number }
|
||||||
|
type Analytics = {
|
||||||
|
summary: {
|
||||||
|
total_users: number; new_today: number; new_month: number
|
||||||
|
dau: number; wau: number; mau: number
|
||||||
|
total_pets: number; total_posts: number; total_records: number
|
||||||
|
}
|
||||||
|
new_trend: Pt[]; active_trend: Pt[]; mau_trend: Pt[]; retention: Ret[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ label: '近 7 天', days: 7 },
|
||||||
|
{ label: '近 30 天', days: 30 },
|
||||||
|
{ label: '近 90 天', days: 90 },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [days, setDays] = useState(30)
|
||||||
useEffect(() => {
|
const [a, setA] = useState<Analytics | null>(null)
|
||||||
api.stats().then(setStats).catch(() => {})
|
const [loading, setLoading] = useState(true)
|
||||||
}, [])
|
|
||||||
|
|
||||||
const cards = [
|
useEffect(() => {
|
||||||
{ label: '用户', value: stats?.users, icon: Users },
|
setLoading(true)
|
||||||
{ label: '宠物', value: stats?.pets, icon: PawPrint },
|
api.analytics(days).then((d) => { setA(d); setLoading(false) }).catch(() => setLoading(false))
|
||||||
{ label: '帖子', value: stats?.posts, icon: MessageSquare },
|
}, [days])
|
||||||
{ label: '健康记录', value: stats?.records, icon: Activity },
|
|
||||||
|
const s = a?.summary
|
||||||
|
const kpis = [
|
||||||
|
{ label: '真实用户', value: s?.total_users, icon: Users, tint: ORANGE },
|
||||||
|
{ label: '日活 DAU', value: s?.dau, icon: Activity, tint: GREEN },
|
||||||
|
{ label: '周活 WAU', value: s?.wau, icon: CalendarDays, tint: BLUE },
|
||||||
|
{ label: '月活 MAU', value: s?.mau, icon: TrendingUp, tint: ORANGE },
|
||||||
|
{ label: '今日新增', value: s?.new_today, icon: UserPlus, tint: GREEN },
|
||||||
|
{ label: '本月新增', value: s?.new_month, icon: UserPlus, tint: BLUE },
|
||||||
]
|
]
|
||||||
|
const mini = [
|
||||||
|
{ label: '宠物', value: s?.total_pets, icon: PawPrint },
|
||||||
|
{ label: '帖子', value: s?.total_posts, icon: MessageSquare },
|
||||||
|
{ label: '健康记录', value: s?.total_records, icon: Activity },
|
||||||
|
]
|
||||||
|
const retData = (a?.retention || []).map((r) => ({ ...r, pct: Math.round(r.rate * 1000) / 10 }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold mb-6">概览</h1>
|
<div className="flex items-center justify-between">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<h1 className="text-2xl font-bold">概览</h1>
|
||||||
{cards.map((c) => (
|
<div className="flex gap-1 rounded-lg border bg-card p-1">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.days}
|
||||||
|
onClick={() => setDays(r.days)}
|
||||||
|
className={`rounded-md px-3 py-1 text-sm transition ${
|
||||||
|
days === r.days ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 卡 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
|
||||||
|
{kpis.map((c) => (
|
||||||
<Card key={c.label}>
|
<Card key={c.label}>
|
||||||
<CardHeader className="flex-row items-center justify-between pb-2">
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
|
||||||
<CardTitle className="text-sm text-muted-foreground">{c.label}</CardTitle>
|
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
|
||||||
<c.icon className="h-4 w-4 text-primary" />
|
<c.icon className="h-4 w-4" style={{ color: c.tint }} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold">{c.value ?? '—'}</div>
|
<div className="text-2xl font-bold">{c.value ?? '—'}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 图表区 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<ChartCard title="新增趋势" hint={`每日新增真实用户 · ${days} 天`}>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={a?.new_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gNew" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={ORANGE} stopOpacity={0.35} />
|
||||||
|
<stop offset="100%" stopColor={ORANGE} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
|
||||||
|
<Tooltip {...tipProps} />
|
||||||
|
<Area type="monotone" dataKey="count" name="新增" stroke={ORANGE} strokeWidth={2} fill="url(#gNew)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="活跃趋势" hint={`每日活跃用户 DAU · ${days} 天`}>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={a?.active_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
|
||||||
|
<Tooltip {...tipProps} />
|
||||||
|
<Line type="monotone" dataKey="count" name="活跃" stroke={GREEN} strokeWidth={2.5} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="月活 MAU" hint="近 6 个月去重活跃用户">
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={a?.mau_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
|
||||||
|
<Tooltip {...tipProps} />
|
||||||
|
<Bar dataKey="count" name="月活" fill={BLUE} radius={[6, 6, 0, 0]} maxBarSize={44} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="留存曲线" hint="注册后第 N 天仍活跃的比例(滚动口径)">
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={retData} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
|
||||||
|
<XAxis dataKey="day" tick={{ fontSize: 11 }} tickFormatter={(d) => `D${d}`} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} width={40} unit="%" domain={[0, 100]} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tipProps.contentStyle}
|
||||||
|
formatter={(v: any, _n: any, p: any) => [`${v}%(分母 ${p.payload.base})`, '留存率']}
|
||||||
|
labelFormatter={(d) => `注册后第 ${d} 天`}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="pct" name="留存率" stroke={ORANGE} strokeWidth={2.5}>
|
||||||
|
{retData.map((_, i) => <Cell key={i} />)}
|
||||||
|
</Line>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部小计数 */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{mini.map((c) => (
|
||||||
|
<Card key={c.label}>
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
|
||||||
|
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
|
||||||
|
<c.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent><div className="text-2xl font-bold">{c.value ?? '—'}</div></CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="text-center text-sm text-muted-foreground">加载中…</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tipProps = {
|
||||||
|
contentStyle: { borderRadius: 10, border: '1px solid #eee', fontSize: 12 },
|
||||||
|
cursor: { fill: 'rgba(245,169,62,.08)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCard({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { usePaged } from '@/lib/usePaged'
|
import { usePaged } from '@/lib/usePaged'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
@@ -7,12 +8,25 @@ import { Card, CardContent } from '@/components/ui/card'
|
|||||||
import { Pager } from '@/components/Pager'
|
import { Pager } from '@/components/Pager'
|
||||||
|
|
||||||
const statusMap: Record<string, { label: string; variant: any }> = {
|
const statusMap: Record<string, { label: string; variant: any }> = {
|
||||||
|
pending: { label: '待审核', variant: 'warning' },
|
||||||
published: { label: '已发布', variant: 'success' },
|
published: { label: '已发布', variant: 'success' },
|
||||||
|
rejected: { label: '已打回', variant: 'destructive' },
|
||||||
hidden: { label: '已隐藏', variant: 'muted' },
|
hidden: { label: '已隐藏', variant: 'muted' },
|
||||||
deleted: { label: '已删除', variant: 'destructive' },
|
deleted: { label: '已删除', variant: 'destructive' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// 今天的只看时分,往前的看月日——审核时关心的是「多久以前发的」
|
const FILTERS = [
|
||||||
|
{ label: '待审核', status: 'pending' },
|
||||||
|
{ label: '已发布', status: 'published' },
|
||||||
|
{ label: '已打回', status: 'rejected' },
|
||||||
|
{ label: '全部', status: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 后台展示的图片可能是真实 URL,也可能是机器人帖的 emoji 占位——只渲染 URL
|
||||||
|
function isUrl(s: string) {
|
||||||
|
return typeof s === 'string' && s.indexOf('http') === 0
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(iso?: string) {
|
function fmtTime(iso?: string) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
@@ -21,72 +35,115 @@ function fmtTime(iso?: string) {
|
|||||||
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
|
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
|
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
|
||||||
const y = new Date(now.getTime() - 86400000)
|
|
||||||
if (d.toDateString() === y.toDateString()) return `昨天 ${hm}`
|
|
||||||
const sameYear = d.getFullYear() === now.getFullYear()
|
const sameYear = d.getFullYear() === now.getFullYear()
|
||||||
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
|
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Posts() {
|
export default function Posts() {
|
||||||
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts)
|
const [status, setStatus] = useState('pending')
|
||||||
|
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts, { status })
|
||||||
|
|
||||||
async function setStatus(id: number, status: string) {
|
async function setPostStatus(id: number, s: string) {
|
||||||
await api.setPostStatus(id, status)
|
await api.setPostStatus(id, s)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">帖子审核</h1>
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">帖子审核</h1>
|
||||||
|
<div className="flex gap-1 rounded-lg border bg-card p-1">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.status}
|
||||||
|
onClick={() => { setStatus(f.status); setPage(1) }}
|
||||||
|
className={`rounded-md px-3 py-1 text-sm transition ${
|
||||||
|
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
|
||||||
<TableHead>作者</TableHead>
|
<TableHead>作者</TableHead>
|
||||||
<TableHead>内容</TableHead>
|
<TableHead>内容</TableHead>
|
||||||
|
<TableHead>配图</TableHead>
|
||||||
<TableHead>赞/评</TableHead>
|
<TableHead>赞/评</TableHead>
|
||||||
<TableHead>发布时间</TableHead>
|
<TableHead>时间</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.list?.map((p) => (
|
{data?.list?.map((p) => {
|
||||||
<TableRow key={p.id}>
|
const imgs: string[] = Array.isArray(p.images) ? p.images.filter(isUrl) : []
|
||||||
<TableCell>{p.id}</TableCell>
|
return (
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableRow key={p.id}>
|
||||||
<span className="mr-1">{p.author_emoji}</span>
|
<TableCell className="whitespace-nowrap align-top">
|
||||||
{p.author_name}
|
<span className="mr-1">{p.author_emoji}</span>
|
||||||
</TableCell>
|
{p.author_name}
|
||||||
<TableCell className="max-w-sm truncate">{p.content}</TableCell>
|
{p.is_ai && <Badge variant="muted" className="ml-1">AI</Badge>}
|
||||||
<TableCell className="whitespace-nowrap">
|
</TableCell>
|
||||||
{p.like_count} / {p.comment_count}
|
<TableCell className="max-w-xs align-top">
|
||||||
</TableCell>
|
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{p.content || '—'}</div>
|
||||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
</TableCell>
|
||||||
{fmtTime(p.created_at)}
|
<TableCell className="align-top">
|
||||||
</TableCell>
|
{imgs.length ? (
|
||||||
<TableCell>
|
<div className="flex flex-wrap gap-1" style={{ maxWidth: 220 }}>
|
||||||
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
|
{imgs.map((u, i) => (
|
||||||
</TableCell>
|
<a key={i} href={u} target="_blank" rel="noreferrer">
|
||||||
<TableCell className="text-right space-x-2 whitespace-nowrap">
|
<img
|
||||||
{p.status !== 'published' && (
|
src={u}
|
||||||
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'published')}>
|
className="h-16 w-16 rounded-md object-cover border"
|
||||||
恢复
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">无</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap align-top">
|
||||||
|
{p.like_count} / {p.comment_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap align-top text-muted-foreground">
|
||||||
|
{fmtTime(p.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
|
||||||
|
{p.status !== 'published' && (
|
||||||
|
<Button variant="default" size="sm" onClick={() => setPostStatus(p.id, 'published')}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(p.status === 'pending' || p.status === 'published') && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPostStatus(p.id, 'rejected')}>
|
||||||
|
打回
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => setPostStatus(p.id, 'deleted')}>
|
||||||
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</TableCell>
|
||||||
{p.status === 'published' && (
|
</TableRow>
|
||||||
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'hidden')}>
|
)
|
||||||
隐藏
|
})}
|
||||||
</Button>
|
{!data?.list?.length && (
|
||||||
)}
|
<TableRow>
|
||||||
<Button variant="destructive" size="sm" onClick={() => setStatus(p.id, 'deleted')}>
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||||
删除
|
这个状态下暂无帖子
|
||||||
</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
|
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
|
||||||
|
|||||||
Reference in New Issue
Block a user