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:
yml2213
2026-10-10 14:58:33 +08:00
parent 6d34f86765
commit 4ea08e355b
17 changed files with 956 additions and 60 deletions
+1
View File
@@ -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"`
+6 -4
View File
@@ -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)
+41 -25
View File
@@ -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
+4 -4
View File
@@ -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)
}
+1
View File
@@ -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;
+4
View File
@@ -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>
+52
View File
@@ -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
+57 -27
View File
@@ -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>
+418
View File
@@ -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