feat(distribution): 分销站点增加用户数量统计与用户信息展示模块
- 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属 - 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签 - 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示 - 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片 - 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查
This commit is contained in:
@@ -20,6 +20,7 @@ type User struct {
|
|||||||
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
||||||
// 注册归因:注册时由前端携带的推广短码。
|
// 注册归因:注册时由前端携带的推广短码。
|
||||||
RegisterTrackCode string `gorm:"size:64;index:idx_users_register_track_code" json:"register_track_code,omitempty"`
|
RegisterTrackCode string `gorm:"size:64;index:idx_users_register_track_code" json:"register_track_code,omitempty"`
|
||||||
|
DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_users_distribution_site_id" json:"distribution_site_id"` // 0=主站, >0=对应分销站点
|
||||||
InviteCode *string `gorm:"size:32;uniqueIndex:uk_users_invite_code" json:"invite_code,omitempty"`
|
InviteCode *string `gorm:"size:32;uniqueIndex:uk_users_invite_code" json:"invite_code,omitempty"`
|
||||||
InvitedByID uint64 `gorm:"not null;default:0;index:idx_users_invited_by_id" json:"invited_by_id,omitempty"`
|
InvitedByID uint64 `gorm:"not null;default:0;index:idx_users_invited_by_id" json:"invited_by_id,omitempty"`
|
||||||
InviteVerifiedAt *time.Time `json:"invite_verified_at,omitempty"`
|
InviteVerifiedAt *time.Time `json:"invite_verified_at,omitempty"`
|
||||||
|
|||||||
@@ -148,13 +148,14 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
response.BadRequest(c, "手机号和验证码不能为空")
|
response.BadRequest(c, "手机号和验证码不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode))
|
siteCode := sitecode.FromRequest(c)
|
||||||
|
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode), siteCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
||||||
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
|
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
|
||||||
}
|
}
|
||||||
setAccessCookie(c, result.Tokens.AccessToken)
|
setAccessCookie(c, result.Tokens.AccessToken)
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
@@ -234,13 +235,14 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
|
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode))
|
siteCode := sitecode.FromRequest(c)
|
||||||
|
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode), siteCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
||||||
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
|
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
|
||||||
}
|
}
|
||||||
setAccessCookie(c, result.Tokens.AccessToken)
|
setAccessCookie(c, result.Tokens.AccessToken)
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
|
|||||||
@@ -69,21 +69,35 @@ func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname
|
|||||||
return r.FindByID(ctx, id)
|
return r.FindByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode ...string) (*model.User, error) {
|
func (r *UserRepository) resolveSiteID(ctx context.Context, siteCode string) uint64 {
|
||||||
|
siteCode = strings.TrimSpace(siteCode)
|
||||||
|
if siteCode == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var ds model.DistributionSite
|
||||||
|
if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).Select("id").First(&ds).Error; err == nil {
|
||||||
|
return ds.ID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode string, siteCode ...string) (*model.User, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var code string
|
code := sanitizeTrackCode(trackCode)
|
||||||
if len(trackCode) > 0 {
|
var siteID uint64
|
||||||
code = sanitizeTrackCode(trackCode[0])
|
if len(siteCode) > 0 {
|
||||||
|
siteID = r.resolveSiteID(ctx, siteCode[0])
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
Phone: phone,
|
Phone: phone,
|
||||||
Nickname: "用户" + phone[len(phone)-4:],
|
Nickname: "用户" + phone[len(phone)-4:],
|
||||||
RealnameStatus: "unverified",
|
RealnameStatus: "unverified",
|
||||||
RiskStatus: "normal",
|
RiskStatus: "normal",
|
||||||
CreditScore: 100,
|
CreditScore: 100,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
RegisterTrackCode: code,
|
RegisterTrackCode: code,
|
||||||
LastLoginAt: &now,
|
DistributionSiteID: siteID,
|
||||||
|
LastLoginAt: &now,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
err := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
@@ -122,22 +136,24 @@ func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error
|
|||||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode ...string) (*model.User, error) {
|
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode string, siteCode ...string) (*model.User, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var code string
|
code := sanitizeTrackCode(trackCode)
|
||||||
if len(trackCode) > 0 {
|
var siteID uint64
|
||||||
code = sanitizeTrackCode(trackCode[0])
|
if len(siteCode) > 0 {
|
||||||
|
siteID = r.resolveSiteID(ctx, siteCode[0])
|
||||||
}
|
}
|
||||||
user := model.User{
|
user := model.User{
|
||||||
Phone: phone,
|
Phone: phone,
|
||||||
PasswordHash: hash,
|
PasswordHash: hash,
|
||||||
Nickname: "用户" + phone[len(phone)-4:],
|
Nickname: "用户" + phone[len(phone)-4:],
|
||||||
RealnameStatus: "unverified",
|
RealnameStatus: "unverified",
|
||||||
RiskStatus: "normal",
|
RiskStatus: "normal",
|
||||||
CreditScore: 100,
|
CreditScore: 100,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
RegisterTrackCode: code,
|
RegisterTrackCode: code,
|
||||||
LastLoginAt: &now,
|
DistributionSiteID: siteID,
|
||||||
|
LastLoginAt: &now,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Create(&user).Error
|
err := r.db.WithContext(ctx).Create(&user).Error
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode ...string) (LoginResult, error) {
|
func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode string, siteCode ...string) (LoginResult, error) {
|
||||||
if s.redis == nil || s.users == nil {
|
if s.redis == nil || s.users == nil {
|
||||||
return LoginResult{}, ErrDependencyUnavailable
|
return LoginResult{}, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, t
|
|||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode...)
|
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode, siteCode...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -385,7 +385,7 @@ func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode ...string) (LoginResult, error) {
|
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode string, siteCode ...string) (LoginResult, error) {
|
||||||
if s.redis == nil || s.users == nil {
|
if s.redis == nil || s.users == nil {
|
||||||
return LoginResult{}, ErrDependencyUnavailable
|
return LoginResult{}, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
@@ -418,7 +418,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
|
|||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode...)
|
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode, siteCode...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
|
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
|
||||||
return LoginResult{}, ErrUserAlreadyExists
|
return LoginResult{}, ErrUserAlreadyExists
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type SiteDTO struct {
|
|||||||
AdminUsername string `json:"admin_username,omitempty"`
|
AdminUsername string `json:"admin_username,omitempty"`
|
||||||
AdminNickname string `json:"admin_nickname,omitempty"`
|
AdminNickname string `json:"admin_nickname,omitempty"`
|
||||||
MemberType string `json:"member_type,omitempty"`
|
MemberType string `json:"member_type,omitempty"`
|
||||||
|
UserCount int64 `json:"user_count"`
|
||||||
|
OrderUserCount int64 `json:"order_user_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -575,6 +577,9 @@ type DistributionAnalyticsDTO struct {
|
|||||||
TodayProfitCent int64 `json:"today_profit_cent"`
|
TodayProfitCent int64 `json:"today_profit_cent"`
|
||||||
MonthProfitCent int64 `json:"month_profit_cent"`
|
MonthProfitCent int64 `json:"month_profit_cent"`
|
||||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||||
|
TodayUserCount int64 `json:"today_user_count"`
|
||||||
|
TotalUserCount int64 `json:"total_user_count"`
|
||||||
|
TotalOrderUserCount int64 `json:"total_order_user_count"`
|
||||||
TrendDays []DistributionTrendPoint `json:"trend_days"`
|
TrendDays []DistributionTrendPoint `json:"trend_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,3 +623,36 @@ type TestSiteSMSConfigRequest struct {
|
|||||||
SignName *string `json:"sign_name"`
|
SignName *string `json:"sign_name"`
|
||||||
LoginTemplateCode *string `json:"login_template_code"`
|
LoginTemplateCode *string `json:"login_template_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分销站点客户/用户信息 DTO
|
||||||
|
type DistributionUserDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
RealnameStatus string `json:"realname_status"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Source string `json:"source"` // register=本站注册, order=本站下单, both=注册并下单
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalSpentCent int64 `json:"total_spent_cent"`
|
||||||
|
LastOrderAt *time.Time `json:"last_order_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DistributionUserPageDTO struct {
|
||||||
|
Items []DistributionUserDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
TotalUsers int64 `json:"total_users"`
|
||||||
|
OrderUsers int64 `json:"order_users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListDistributionUsersQuery struct {
|
||||||
|
SiteID uint64 `form:"site_id"`
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
Source string `form:"source"` // all / register / order
|
||||||
|
Page int `form:"page,default=1"`
|
||||||
|
PageSize int `form:"page_size,default=20"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -986,6 +986,26 @@ func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
|
|||||||
response.OK(c, analytics)
|
response.OK(c, analytics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListDistributionUsers(c *gin.Context) {
|
||||||
|
siteID, err := h.resolveSiteID(c)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := currentAdminID(c)
|
||||||
|
var query ListDistributionUsersQuery
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
response.BadRequest(c, "查询参数格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.service.ListDistributionUsers(c.Request.Context(), adminID, siteID, query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func isHTTPSRequest(c *gin.Context) bool {
|
func isHTTPSRequest(c *gin.Context) bool {
|
||||||
if c.Request.TLS != nil {
|
if c.Request.TLS != nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -314,6 +314,34 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countRow struct {
|
||||||
|
SiteID uint64 `gorm:"column:distribution_site_id"`
|
||||||
|
Cnt int64 `gorm:"column:cnt"`
|
||||||
|
}
|
||||||
|
var userCounts []countRow
|
||||||
|
_ = r.db.WithContext(ctx).Table("users").
|
||||||
|
Select("distribution_site_id, COUNT(*) AS cnt").
|
||||||
|
Where("distribution_site_id IN ?", siteIDs).
|
||||||
|
Group("distribution_site_id").Scan(&userCounts).Error
|
||||||
|
userCountMap := make(map[uint64]int64, len(userCounts))
|
||||||
|
for _, c := range userCounts {
|
||||||
|
userCountMap[c.SiteID] = c.Cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderCountRow struct {
|
||||||
|
SiteID uint64 `gorm:"column:distribution_site_id"`
|
||||||
|
Cnt int64 `gorm:"column:cnt"`
|
||||||
|
}
|
||||||
|
var orderUserCounts []orderCountRow
|
||||||
|
_ = r.db.WithContext(ctx).Table("rental_orders").
|
||||||
|
Select("distribution_site_id, COUNT(DISTINCT renter_id) AS cnt").
|
||||||
|
Where("distribution_site_id IN ? AND status NOT IN ('cancelled', 'closed')", siteIDs).
|
||||||
|
Group("distribution_site_id").Scan(&orderUserCounts).Error
|
||||||
|
orderCountMap := make(map[uint64]int64, len(orderUserCounts))
|
||||||
|
for _, c := range orderUserCounts {
|
||||||
|
orderCountMap[c.SiteID] = c.Cnt
|
||||||
|
}
|
||||||
|
|
||||||
for i := range items {
|
for i := range items {
|
||||||
if m, ok := memberMap[items[i].ID]; ok {
|
if m, ok := memberMap[items[i].ID]; ok {
|
||||||
items[i].AdminUserID = &m.AdminUserID
|
items[i].AdminUserID = &m.AdminUserID
|
||||||
@@ -321,6 +349,8 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
|||||||
items[i].AdminNickname = m.Nickname
|
items[i].AdminNickname = m.Nickname
|
||||||
items[i].MemberType = m.MemberType
|
items[i].MemberType = m.MemberType
|
||||||
}
|
}
|
||||||
|
items[i].UserCount = userCountMap[items[i].ID]
|
||||||
|
items[i].OrderUserCount = orderCountMap[items[i].ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
@@ -3344,6 +3374,19 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
|||||||
Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, monthStr+"-01 00:00:00").
|
Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, monthStr+"-01 00:00:00").
|
||||||
Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&monthProfit).Error
|
Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&monthProfit).Error
|
||||||
|
|
||||||
|
var todayUserCount int64
|
||||||
|
_ = r.db.WithContext(ctx).Model(&model.User{}).
|
||||||
|
Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayUserCount).Error
|
||||||
|
|
||||||
|
var totalUserCount int64
|
||||||
|
_ = r.db.WithContext(ctx).Model(&model.User{}).
|
||||||
|
Where("distribution_site_id = ?", siteID).Count(&totalUserCount).Error
|
||||||
|
|
||||||
|
var totalOrderUserCount int64
|
||||||
|
_ = r.db.WithContext(ctx).Table("rental_orders").
|
||||||
|
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
|
||||||
|
Distinct("renter_id").Count(&totalOrderUserCount).Error
|
||||||
|
|
||||||
var wallet model.WalletAccount
|
var wallet model.WalletAccount
|
||||||
_ = r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&wallet).Error
|
_ = r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&wallet).Error
|
||||||
|
|
||||||
@@ -3422,6 +3465,9 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
|||||||
TodayProfitCent: todayProfit,
|
TodayProfitCent: todayProfit,
|
||||||
MonthProfitCent: monthProfit,
|
MonthProfitCent: monthProfit,
|
||||||
AvailableBalanceCent: wallet.AvailableBalanceCent,
|
AvailableBalanceCent: wallet.AvailableBalanceCent,
|
||||||
|
TodayUserCount: todayUserCount,
|
||||||
|
TotalUserCount: totalUserCount,
|
||||||
|
TotalOrderUserCount: totalOrderUserCount,
|
||||||
TrendDays: trendDays,
|
TrendDays: trendDays,
|
||||||
}
|
}
|
||||||
if r.redis != nil {
|
if r.redis != nil {
|
||||||
@@ -3431,3 +3477,117 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
|||||||
}
|
}
|
||||||
return dto, nil
|
return dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListDistributionUsers(ctx context.Context, adminID, siteID uint64, query ListDistributionUsersQuery) (*DistributionUserPageDTO, error) {
|
||||||
|
if siteID == 0 {
|
||||||
|
return nil, ErrSiteNotFound
|
||||||
|
}
|
||||||
|
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
page := query.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := query.PageSize
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// 统计全站总注册人数与成交客户数
|
||||||
|
var totalUsersCount int64
|
||||||
|
var orderUsersCount int64
|
||||||
|
|
||||||
|
_ = r.db.WithContext(ctx).Model(&model.User{}).
|
||||||
|
Where("distribution_site_id = ?", siteID).Count(&totalUsersCount).Error
|
||||||
|
|
||||||
|
_ = r.db.WithContext(ctx).Table("rental_orders").
|
||||||
|
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
|
||||||
|
Distinct("renter_id").Count(&orderUsersCount).Error
|
||||||
|
|
||||||
|
orderSubquery := r.db.Table("rental_orders").
|
||||||
|
Select("renter_id, COUNT(*) AS ord_count, COALESCE(SUM(rent_amount_cent), 0) AS ord_spent, MAX(created_at) AS last_ord_at").
|
||||||
|
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
|
||||||
|
Group("renter_id")
|
||||||
|
|
||||||
|
baseQuery := r.db.WithContext(ctx).Table("users AS u").
|
||||||
|
Joins("LEFT JOIN (?) AS ord ON ord.renter_id = u.id", orderSubquery)
|
||||||
|
|
||||||
|
switch strings.TrimSpace(query.Source) {
|
||||||
|
case "register":
|
||||||
|
baseQuery = baseQuery.Where("u.distribution_site_id = ?", siteID)
|
||||||
|
case "order":
|
||||||
|
baseQuery = baseQuery.Where("ord.renter_id IS NOT NULL")
|
||||||
|
default:
|
||||||
|
baseQuery = baseQuery.Where("u.distribution_site_id = ? OR ord.renter_id IS NOT NULL", siteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
||||||
|
baseQuery = baseQuery.Where("u.phone LIKE ? OR u.nickname LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFiltered int64
|
||||||
|
if err := baseQuery.Count(&totalFiltered).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRow struct {
|
||||||
|
ID uint64 `gorm:"column:id"`
|
||||||
|
Phone string `gorm:"column:phone"`
|
||||||
|
Nickname string `gorm:"column:nickname"`
|
||||||
|
AvatarURL string `gorm:"column:avatar_url"`
|
||||||
|
RealnameStatus string `gorm:"column:realname_status"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
LastLoginAt *time.Time `gorm:"column:last_login_at"`
|
||||||
|
OrderCount int64 `gorm:"column:ord_count"`
|
||||||
|
TotalSpentCent int64 `gorm:"column:ord_spent"`
|
||||||
|
LastOrderAt *time.Time `gorm:"column:last_ord_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []userRow
|
||||||
|
if err := baseQuery.
|
||||||
|
Select("u.id, u.phone, u.nickname, u.avatar_url, u.realname_status, u.status, u.distribution_site_id, u.created_at, u.last_login_at, COALESCE(ord.ord_count, 0) AS ord_count, COALESCE(ord.ord_spent, 0) AS ord_spent, ord.last_ord_at").
|
||||||
|
Order("COALESCE(ord.last_ord_at, u.created_at) DESC").
|
||||||
|
Offset(offset).Limit(pageSize).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]DistributionUserDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
source := "register"
|
||||||
|
if row.DistributionSiteID == siteID && row.OrderCount > 0 {
|
||||||
|
source = "both"
|
||||||
|
} else if row.DistributionSiteID != siteID && row.OrderCount > 0 {
|
||||||
|
source = "order"
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, DistributionUserDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
Phone: row.Phone,
|
||||||
|
Nickname: row.Nickname,
|
||||||
|
AvatarURL: row.AvatarURL,
|
||||||
|
RealnameStatus: row.RealnameStatus,
|
||||||
|
Status: row.Status,
|
||||||
|
Source: source,
|
||||||
|
OrderCount: row.OrderCount,
|
||||||
|
TotalSpentCent: row.TotalSpentCent,
|
||||||
|
LastOrderAt: row.LastOrderAt,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
LastLoginAt: row.LastLoginAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DistributionUserPageDTO{
|
||||||
|
Items: items,
|
||||||
|
Total: totalFiltered,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
TotalUsers: totalUsersCount,
|
||||||
|
OrderUsers: orderUsersCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1518,3 +1518,125 @@ func TestBatchOperationsAndCachingOptimizations(t *testing.T) {
|
|||||||
t.Fatalf("paged keyword mismatch: total=%d, items=%+v", pagedResult.Total, pagedResult.Items)
|
t.Fatalf("paged keyword mismatch: total=%d, items=%+v", pagedResult.Total, pagedResult.Items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListDistributionUsersAndAnalytics(t *testing.T) {
|
||||||
|
db := distributionTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
|
||||||
|
site := model.DistributionSite{SiteCode: "user_test_site", SiteName: "用户测试站", Domain: "users.example.com", Status: "active"}
|
||||||
|
if err := db.Create(&site).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherSite := model.DistributionSite{SiteCode: "other_site", SiteName: "其他站", Domain: "other.example.com", Status: "active"}
|
||||||
|
if err := db.Create(&otherSite).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// 用户 1: 本站注册,未下单
|
||||||
|
u1 := model.User{Phone: "13800000001", Nickname: "用户一", DistributionSiteID: site.ID, Status: "active", CreatedAt: now}
|
||||||
|
// 用户 2: 本站注册,且在本站下过单
|
||||||
|
u2 := model.User{Phone: "13800000002", Nickname: "用户二", DistributionSiteID: site.ID, Status: "active", CreatedAt: now}
|
||||||
|
// 用户 3: 在主站注册 (DistributionSiteID=0),但在本站下过单
|
||||||
|
u3 := model.User{Phone: "13800000003", Nickname: "用户三", DistributionSiteID: 0, Status: "active", CreatedAt: now}
|
||||||
|
// 用户 4: 在其他站注册 (DistributionSiteID=otherSite.ID),未在本站下过单
|
||||||
|
u4 := model.User{Phone: "13800000004", Nickname: "用户四", DistributionSiteID: otherSite.ID, Status: "active", CreatedAt: now}
|
||||||
|
|
||||||
|
if err := db.Create(&u1).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&u2).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&u3).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&u4).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单 1: 用户 2 在本站下单
|
||||||
|
o1 := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-U2", ListingID: 101, AccountID: 201, OwnerID: 99, RenterID: u2.ID,
|
||||||
|
DistributionSiteID: &site.ID, RentAmountCent: 5000, Status: "completed", CreatedAt: now,
|
||||||
|
}
|
||||||
|
// 订单 2: 用户 3 在本站下单
|
||||||
|
o2 := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-U3", ListingID: 102, AccountID: 202, OwnerID: 99, RenterID: u3.ID,
|
||||||
|
DistributionSiteID: &site.ID, RentAmountCent: 3000, Status: "completed", CreatedAt: now,
|
||||||
|
}
|
||||||
|
// 订单 3: 用户 4 在其他站下单
|
||||||
|
o3 := model.RentalOrder{
|
||||||
|
OrderNo: "ORD-U4", ListingID: 103, AccountID: 203, OwnerID: 99, RenterID: u4.ID,
|
||||||
|
DistributionSiteID: &otherSite.ID, RentAmountCent: 8000, Status: "completed", CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&o1).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&o2).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&o3).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 查询全部用户 (应该包含 u1, u2, u3;不包含 u4)
|
||||||
|
resAll, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Page: 1, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDistributionUsers all failed: %v", err)
|
||||||
|
}
|
||||||
|
if resAll.Total != 3 {
|
||||||
|
t.Fatalf("expected 3 total users, got %d", resAll.Total)
|
||||||
|
}
|
||||||
|
if resAll.TotalUsers != 2 { // u1, u2 本站注册
|
||||||
|
t.Fatalf("expected 2 total registered users, got %d", resAll.TotalUsers)
|
||||||
|
}
|
||||||
|
if resAll.OrderUsers != 2 { // u2, u3 本站下单
|
||||||
|
t.Fatalf("expected 2 order users, got %d", resAll.OrderUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证手机号完整输出
|
||||||
|
for _, it := range resAll.Items {
|
||||||
|
if len(it.Phone) != 11 {
|
||||||
|
t.Fatalf("expected full 11-digit phone, got %s", it.Phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 筛选 source=register
|
||||||
|
resReg, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Source: "register", Page: 1, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDistributionUsers register failed: %v", err)
|
||||||
|
}
|
||||||
|
if resReg.Total != 2 {
|
||||||
|
t.Fatalf("expected 2 register users, got %d", resReg.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 筛选 source=order
|
||||||
|
resOrd, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Source: "order", Page: 1, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDistributionUsers order failed: %v", err)
|
||||||
|
}
|
||||||
|
if resOrd.Total != 2 {
|
||||||
|
t.Fatalf("expected 2 order users, got %d", resOrd.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 关键字搜索手机号
|
||||||
|
resKw, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Keyword: "13800000001", Page: 1, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDistributionUsers kw failed: %v", err)
|
||||||
|
}
|
||||||
|
if resKw.Total != 1 || resKw.Items[0].ID != u1.ID {
|
||||||
|
t.Fatalf("keyword search failed: %+v", resKw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 验证大盘用户统计
|
||||||
|
analytics, err := repo.GetDashboardAnalytics(t.Context(), 0, site.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDashboardAnalytics failed: %v", err)
|
||||||
|
}
|
||||||
|
if analytics.TodayUserCount != 2 || analytics.TotalUserCount != 2 || analytics.TotalOrderUserCount != 2 {
|
||||||
|
t.Fatalf("analytics user count mismatch: today=%d, total=%d, order=%d",
|
||||||
|
analytics.TodayUserCount, analytics.TotalUserCount, analytics.TotalOrderUserCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -481,3 +481,10 @@ func (s *Service) TestSiteSMSConfig(ctx context.Context, adminID, siteID uint64,
|
|||||||
|
|
||||||
return s.smsResolver.TestSendSMS(ctx, siteID, testCfg, phone)
|
return s.smsResolver.TestSendSMS(ctx, siteID, testCfg, phone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListDistributionUsers(ctx context.Context, adminID, siteID uint64, query ListDistributionUsersQuery) (*DistributionUserPageDTO, error) {
|
||||||
|
if err := s.available(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repo.ListDistributionUsers(ctx, adminID, siteID, query)
|
||||||
|
}
|
||||||
|
|||||||
@@ -735,6 +735,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/distribution/sites/:id/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteSiteQrCodeHandler)
|
adminRoutes.POST("/distribution/sites/:id/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteSiteQrCodeHandler)
|
||||||
adminRoutes.PATCH("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.UpdateSiteQrCodeHandler)
|
adminRoutes.PATCH("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.UpdateSiteQrCodeHandler)
|
||||||
adminRoutes.DELETE("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.DeleteSiteQrCodeHandler)
|
adminRoutes.DELETE("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.DeleteSiteQrCodeHandler)
|
||||||
|
adminRoutes.GET("/distribution/users", requirePerm("distribution:site_view"), distributionHandler.ListDistributionUsers)
|
||||||
adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings)
|
adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings)
|
||||||
adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus)
|
adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus)
|
||||||
adminRoutes.GET("/distribution/announcements", requirePerm("distribution:announcement_view"), distributionHandler.ListSiteAnnouncements)
|
adminRoutes.GET("/distribution/announcements", requirePerm("distribution:announcement_view"), distributionHandler.ListSiteAnnouncements)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '归属分销站点ID (0=主站)' AFTER register_track_code,
|
||||||
|
ADD KEY idx_users_distribution_site_id (distribution_site_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP KEY idx_users_distribution_site_id,
|
||||||
|
DROP COLUMN distribution_site_id;
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
WalletOutlined,
|
WalletOutlined,
|
||||||
GiftOutlined,
|
GiftOutlined,
|
||||||
|
TeamOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { getMe, logout, type CurrentMember, getStoredToken } from './api'
|
import { getMe, logout, type CurrentMember, getStoredToken } from './api'
|
||||||
import { MemberContext } from './MemberContext'
|
import { MemberContext } from './MemberContext'
|
||||||
@@ -31,6 +32,7 @@ import Wallet from './pages/Wallet'
|
|||||||
import Announcements from './pages/Announcements'
|
import Announcements from './pages/Announcements'
|
||||||
import SiteSettings from './pages/SiteSettings'
|
import SiteSettings from './pages/SiteSettings'
|
||||||
import Invitations from './pages/Invitations'
|
import Invitations from './pages/Invitations'
|
||||||
|
import Users from './pages/Users'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Storefront from './pages/Storefront'
|
import Storefront from './pages/Storefront'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
@@ -44,6 +46,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
|
|||||||
...(member.is_superadmin
|
...(member.is_superadmin
|
||||||
? [{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销站点管理</Link> }]
|
? [{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销站点管理</Link> }]
|
||||||
: []),
|
: []),
|
||||||
|
{ key: '/users', icon: <TeamOutlined />, label: <Link to="/users">客户与用户</Link> },
|
||||||
{ key: '/orders', icon: <OrderedListOutlined />, label: <Link to="/orders">全部分销订单</Link> },
|
{ key: '/orders', icon: <OrderedListOutlined />, label: <Link to="/orders">全部分销订单</Link> },
|
||||||
{ key: '/sold', icon: <FileSearchOutlined />, label: <Link to="/sold">售出账号订单</Link> },
|
{ key: '/sold', icon: <FileSearchOutlined />, label: <Link to="/sold">售出账号订单</Link> },
|
||||||
{ key: '/receive', icon: <AccountBookOutlined />, label: <Link to="/receive">分销收号管理</Link> },
|
{ key: '/receive', icon: <AccountBookOutlined />, label: <Link to="/receive">分销收号管理</Link> },
|
||||||
@@ -169,6 +172,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
|
|||||||
<Route path="/sold" element={<Orders kind="sold" />} />
|
<Route path="/sold" element={<Orders kind="sold" />} />
|
||||||
<Route path="/receive" element={<Orders kind="receive" />} />
|
<Route path="/receive" element={<Orders kind="receive" />} />
|
||||||
<Route path="/orders" element={<Orders kind="all" />} />
|
<Route path="/orders" element={<Orders kind="all" />} />
|
||||||
|
<Route path="/users" element={<Users />} />
|
||||||
<Route path="/invitations" element={<Invitations />} />
|
<Route path="/invitations" element={<Invitations />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export interface Site {
|
|||||||
admin_username?: string
|
admin_username?: string
|
||||||
admin_nickname?: string
|
admin_nickname?: string
|
||||||
member_type?: string
|
member_type?: string
|
||||||
|
user_count?: number
|
||||||
|
order_user_count?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +822,9 @@ export interface DistributionAnalytics {
|
|||||||
today_profit_cent: number
|
today_profit_cent: number
|
||||||
month_profit_cent: number
|
month_profit_cent: number
|
||||||
available_balance_cent: number
|
available_balance_cent: number
|
||||||
|
today_user_count?: number
|
||||||
|
total_user_count?: number
|
||||||
|
total_order_user_count?: number
|
||||||
trend_days: {
|
trend_days: {
|
||||||
date: string
|
date: string
|
||||||
received_count: number
|
received_count: number
|
||||||
@@ -834,6 +839,53 @@ export async function fetchDashboardAnalytics(siteID: number): Promise<Distribut
|
|||||||
return extract(resp)
|
return extract(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DistributionUser {
|
||||||
|
id: number
|
||||||
|
phone: string
|
||||||
|
nickname: string
|
||||||
|
avatar_url: string
|
||||||
|
realname_status: string
|
||||||
|
status: string
|
||||||
|
source: 'register' | 'order' | 'both'
|
||||||
|
order_count: number
|
||||||
|
total_spent_cent: number
|
||||||
|
last_order_at?: string
|
||||||
|
created_at: string
|
||||||
|
last_login_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionUserPage {
|
||||||
|
items: DistributionUser[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total_users: number
|
||||||
|
order_users: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDistributionUsersParams {
|
||||||
|
siteID?: number
|
||||||
|
keyword?: string
|
||||||
|
source?: 'all' | 'register' | 'order'
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDistributionUsers(
|
||||||
|
params: ListDistributionUsersParams = {}
|
||||||
|
): Promise<DistributionUserPage> {
|
||||||
|
const resp = await api.get<ApiResponse<DistributionUserPage>>('/admin/distribution/users', {
|
||||||
|
params: {
|
||||||
|
site_id: params.siteID,
|
||||||
|
keyword: params.keyword,
|
||||||
|
source: params.source,
|
||||||
|
page: params.page,
|
||||||
|
page_size: params.pageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return extract(resp)
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteSMSConfig {
|
export interface SiteSMSConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
provider: string
|
provider: string
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
SkinOutlined,
|
SkinOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
ShoppingOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -197,34 +199,35 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* 核心业务指标看板 */}
|
{/* 核心业务指标看板 */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} sm={12} md={8} lg={4}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
<Card bordered={false} style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }} loading={loading}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="今日收号 / 累计收号"
|
title="今日注册 / 累计用户"
|
||||||
value={analytics?.today_received_count ?? 0}
|
value={analytics?.today_user_count ?? 0}
|
||||||
suffix={`/ ${analytics?.total_received_count ?? 0} 个`}
|
suffix={`/ ${analytics?.total_user_count ?? 0} 人`}
|
||||||
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
prefix={<TeamOutlined style={{ color: '#52c41a' }} />}
|
||||||
/>
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<Link to="/receive" style={{ fontSize: 12 }}>查看收号资产 <ArrowRightOutlined /></Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} md={8} lg={4}>
|
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
|
||||||
<Statistic
|
|
||||||
title="在售可租货源"
|
|
||||||
value={analytics?.active_listing_count ?? 0}
|
|
||||||
prefix={<AppstoreOutlined style={{ color: '#52c41a' }} />}
|
|
||||||
suffix="个"
|
|
||||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Link to="/accounts" style={{ fontSize: 12 }}>管理商品货架 <ArrowRightOutlined /></Link>
|
<Link to="/users" style={{ fontSize: 12 }}>查看客户列表 <ArrowRightOutlined /></Link>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8} lg={4}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card bordered={false} style={{ background: '#fff7e6', border: '1px solid #ffd591' }} loading={loading}>
|
||||||
|
<Statistic
|
||||||
|
title="本站成交客户"
|
||||||
|
value={analytics?.total_order_user_count ?? 0}
|
||||||
|
suffix="人"
|
||||||
|
prefix={<ShoppingOutlined style={{ color: '#fa8c16' }} />}
|
||||||
|
valueStyle={{ color: '#fa8c16', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Link to="/users" style={{ fontSize: 12 }}>成交客户分析 <ArrowRightOutlined /></Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="今日订单量"
|
title="今日订单量"
|
||||||
@@ -238,7 +241,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8} lg={4}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="今日成交 GMV"
|
title="今日成交 GMV"
|
||||||
@@ -251,7 +254,34 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8} lg={4}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
||||||
|
<Statistic
|
||||||
|
title="今日收号 / 累计收号"
|
||||||
|
value={analytics?.today_received_count ?? 0}
|
||||||
|
suffix={`/ ${analytics?.total_received_count ?? 0} 个`}
|
||||||
|
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Link to="/receive" style={{ fontSize: 12 }}>查看收号资产 <ArrowRightOutlined /></Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
||||||
|
<Statistic
|
||||||
|
title="在售可租货源"
|
||||||
|
value={analytics?.active_listing_count ?? 0}
|
||||||
|
prefix={<AppstoreOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
suffix="个"
|
||||||
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Link to="/accounts" style={{ fontSize: 12 }}>管理商品货架 <ArrowRightOutlined /></Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
<Card bordered={false} style={{ background: '#fafafa' }} loading={loading}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="本月预估分润收益"
|
title="本月预估分润收益"
|
||||||
@@ -265,14 +295,14 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8} lg={4}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card bordered={false} style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }} loading={loading}>
|
<Card bordered={false} style={{ background: '#e6f4ff', border: '1px solid #91caff' }} loading={loading}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="钱包可提现余额"
|
title="钱包可提现余额"
|
||||||
value={((analytics?.available_balance_cent ?? 0) / 100).toFixed(2)}
|
value={((analytics?.available_balance_cent ?? 0) / 100).toFixed(2)}
|
||||||
prefix={<WalletOutlined style={{ color: '#52c41a' }} />}
|
prefix={<WalletOutlined style={{ color: '#1677ff' }} />}
|
||||||
suffix="元"
|
suffix="元"
|
||||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Link to="/wallet" style={{ fontSize: 12 }}>立即提现 <ArrowRightOutlined /></Link>
|
<Link to="/wallet" style={{ fontSize: 12 }}>立即提现 <ArrowRightOutlined /></Link>
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Button,
|
||||||
|
Avatar,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
message,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
ShoppingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
listDistributionUsers,
|
||||||
|
listSites,
|
||||||
|
formatMoney,
|
||||||
|
type DistributionUser,
|
||||||
|
type Site,
|
||||||
|
} from '../api'
|
||||||
|
import { useMember } from '../MemberContext'
|
||||||
|
|
||||||
|
function formatDateTime(str?: string): string {
|
||||||
|
if (!str) return '-'
|
||||||
|
try {
|
||||||
|
const d = new Date(str)
|
||||||
|
if (isNaN(d.getTime())) return str
|
||||||
|
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`)
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
} catch {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const member = useMember()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
|
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
|
||||||
|
const [users, setUsers] = useState<DistributionUser[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [totalUsers, setTotalUsers] = useState(0)
|
||||||
|
const [orderUsers, setOrderUsers] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [source, setSource] = useState<'all' | 'register' | 'order'>('all')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
|
// 复制状态记录
|
||||||
|
const [copiedPhone, setCopiedPhone] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard?.writeText(text)
|
||||||
|
setCopiedPhone(text)
|
||||||
|
message.success(`已复制手机号:${text}`)
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedPhone(null)
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (curSiteID?: number, curPage = page, curSize = pageSize) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await listDistributionUsers({
|
||||||
|
siteID: curSiteID,
|
||||||
|
keyword: keyword.trim() || undefined,
|
||||||
|
source,
|
||||||
|
page: curPage,
|
||||||
|
pageSize: curSize,
|
||||||
|
})
|
||||||
|
setUsers(res.items || [])
|
||||||
|
setTotal(res.total || 0)
|
||||||
|
setTotalUsers(res.total_users || 0)
|
||||||
|
setOrderUsers(res.order_users || 0)
|
||||||
|
} catch {
|
||||||
|
message.error('加载用户列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[keyword, source, page, pageSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function init() {
|
||||||
|
if (member?.is_superadmin) {
|
||||||
|
try {
|
||||||
|
const rows = await listSites()
|
||||||
|
setSites(rows)
|
||||||
|
if (!siteID && rows.length > 0) {
|
||||||
|
setSiteID(rows[0].id)
|
||||||
|
void loadData(rows[0].id, 1, pageSize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadData(siteID, 1, pageSize)
|
||||||
|
}
|
||||||
|
void init()
|
||||||
|
}, [member]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1)
|
||||||
|
void loadData(siteID, 1, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setKeyword('')
|
||||||
|
setSource('all')
|
||||||
|
setPage(1)
|
||||||
|
setTimeout(() => {
|
||||||
|
void loadData(siteID, 1, pageSize)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '用户 ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 90,
|
||||||
|
render: (id: number) => (
|
||||||
|
<Typography.Text code style={{ fontSize: 12 }}>
|
||||||
|
#{id}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户手机号',
|
||||||
|
dataIndex: 'phone',
|
||||||
|
key: 'phone',
|
||||||
|
width: 170,
|
||||||
|
render: (phone: string) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Typography.Text strong style={{ fontFamily: 'monospace', fontSize: 13, color: '#1677ff' }}>
|
||||||
|
{phone}
|
||||||
|
</Typography.Text>
|
||||||
|
<Tooltip title={copiedPhone === phone ? '已复制' : '点击复制完整手机号'}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={copiedPhone === phone ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
|
||||||
|
onClick={() => copyToClipboard(phone)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '昵称 / 头像',
|
||||||
|
key: 'user_info',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, row: DistributionUser) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Avatar
|
||||||
|
size="small"
|
||||||
|
src={row.avatar_url || undefined}
|
||||||
|
icon={!row.avatar_url ? <UserOutlined /> : undefined}
|
||||||
|
style={{ backgroundColor: '#1677ff' }}
|
||||||
|
/>
|
||||||
|
<Typography.Text style={{ fontSize: 13 }}>{row.nickname || '-'}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '归属来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
width: 130,
|
||||||
|
render: (src: string) => {
|
||||||
|
if (src === 'both') {
|
||||||
|
return <Tag color="blue">注册且下单</Tag>
|
||||||
|
}
|
||||||
|
if (src === 'order') {
|
||||||
|
return <Tag color="orange">本站下单客户</Tag>
|
||||||
|
}
|
||||||
|
return <Tag color="green">本站注册用户</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本站下单数',
|
||||||
|
dataIndex: 'order_count',
|
||||||
|
key: 'order_count',
|
||||||
|
width: 120,
|
||||||
|
render: (count: number, row: DistributionUser) => {
|
||||||
|
if (count > 0) {
|
||||||
|
return (
|
||||||
|
<Tooltip title="点击查看该用户在当前站点的全部订单">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 0, fontWeight: 'bold' }}
|
||||||
|
onClick={() => navigate(`/orders?q=${encodeURIComponent(row.phone)}`)}
|
||||||
|
>
|
||||||
|
{count} 单 ↗
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Typography.Text type="secondary">0 单</Typography.Text>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本站累计消费',
|
||||||
|
dataIndex: 'total_spent_cent',
|
||||||
|
key: 'total_spent_cent',
|
||||||
|
width: 130,
|
||||||
|
render: (cent: number) => {
|
||||||
|
if (cent > 0) {
|
||||||
|
return (
|
||||||
|
<Typography.Text strong style={{ color: '#fa8c16' }}>
|
||||||
|
{formatMoney(cent)}
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Typography.Text type="secondary">¥0.00</Typography.Text>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实名状态',
|
||||||
|
dataIndex: 'realname_status',
|
||||||
|
key: 'realname_status',
|
||||||
|
width: 110,
|
||||||
|
render: (st: string) => {
|
||||||
|
if (st === 'verified') {
|
||||||
|
return (
|
||||||
|
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||||
|
已实名
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tag icon={<ClockCircleOutlined />} color="default">
|
||||||
|
未实名
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '注册时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (t: string) => (
|
||||||
|
<Typography.Text style={{ fontSize: 12, color: '#666' }}>{formatDateTime(t)}</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后登录',
|
||||||
|
dataIndex: 'last_login_at',
|
||||||
|
key: 'last_login_at',
|
||||||
|
width: 170,
|
||||||
|
render: (t?: string) => (
|
||||||
|
<Typography.Text style={{ fontSize: 12, color: '#888' }}>{formatDateTime(t)}</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedSiteName =
|
||||||
|
member?.site_name || sites.find((s) => s.id === siteID)?.site_name || '当前分销商'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size="large" className="page" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||||
|
客户与用户管理
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
当前站点:{selectedSiteName} · 查看在本分销站注册的专属用户以及在本站产生交易的客户
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
{member?.is_superadmin && sites.length > 1 && (
|
||||||
|
<Select
|
||||||
|
placeholder="切换分销网站"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
value={siteID}
|
||||||
|
options={sites.map((site) => ({
|
||||||
|
label: `${site.site_name} (${site.site_code})`,
|
||||||
|
value: site.id,
|
||||||
|
}))}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSiteID(val)
|
||||||
|
setPage(1)
|
||||||
|
void loadData(val, 1, pageSize)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadData(siteID, page, pageSize)}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 顶部统计卡片 */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card bordered={false} style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Statistic
|
||||||
|
title="本站注册用户"
|
||||||
|
value={totalUsers}
|
||||||
|
prefix={<TeamOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
suffix="人"
|
||||||
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: '#52c41a' }}>
|
||||||
|
通过本分销站点/专属域名注册绑定的私域用户
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card bordered={false} style={{ background: '#fff7e6', border: '1px solid #ffd591' }}>
|
||||||
|
<Statistic
|
||||||
|
title="本站成交客户"
|
||||||
|
value={orderUsers}
|
||||||
|
prefix={<ShoppingOutlined style={{ color: '#fa8c16' }} />}
|
||||||
|
suffix="人"
|
||||||
|
valueStyle={{ color: '#fa8c16', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: '#fa8c16' }}>
|
||||||
|
在本分销站点完成过账号租借交易的有效客户
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card bordered={false} style={{ background: '#e6f4ff', border: '1px solid #91caff' }}>
|
||||||
|
<Statistic
|
||||||
|
title="关联用户总规模"
|
||||||
|
value={total}
|
||||||
|
prefix={<UserOutlined style={{ color: '#1677ff' }} />}
|
||||||
|
suffix="人"
|
||||||
|
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: '#1677ff' }}>
|
||||||
|
本站注册或产生过消费行为的全部用户总计
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 筛选与检索栏 */}
|
||||||
|
<Card bordered={false}>
|
||||||
|
<Space wrap size="middle" style={{ marginBottom: 16 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户手机号 / 昵称"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={source}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSource(val)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
options={[
|
||||||
|
{ label: '全部来源客户', value: 'all' },
|
||||||
|
{ label: '仅本站注册用户', value: 'register' },
|
||||||
|
{ label: '仅本站下单客户', value: 'order' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 个用户`,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p)
|
||||||
|
setPageSize(ps)
|
||||||
|
void loadData(siteID, p, ps)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export interface DistributionSite {
|
|||||||
admin_username?: string
|
admin_username?: string
|
||||||
admin_nickname?: string
|
admin_nickname?: string
|
||||||
member_type?: string
|
member_type?: string
|
||||||
|
user_count?: number
|
||||||
|
order_user_count?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -701,6 +701,18 @@ onMounted(() => {
|
|||||||
<span v-else class="text-muted">未关联</span>
|
<span v-else class="text-muted">未关联</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="用户体量" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="user-stats-cell">
|
||||||
|
<div>
|
||||||
|
<strong style="color: #67c23a">{{ row.user_count || 0 }}</strong> 注册
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size: 12px">
|
||||||
|
{{ row.order_user_count || 0 }} 成交客户
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="运行状态" prop="status" width="110">
|
<el-table-column label="运行状态" prop="status" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
|
|||||||
Reference in New Issue
Block a user