feat(invitation): 新增返利统计汇总与时间筛选
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) {
|
||||
@@ -74,6 +76,244 @@ func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedRebateSummaryFixture 构造覆盖订单/提号、买家/卖家、三个周期阶段与跨月时间的流水。
|
||||
func seedRebateSummaryFixture(t *testing.T, db *gorm.DB) []model.User {
|
||||
t.Helper()
|
||||
|
||||
users := []model.User{
|
||||
{Phone: "13800001001", Nickname: "邀请人A", Status: "active", RealnameStatus: "verified"},
|
||||
{Phone: "13800001002", Nickname: "邀请人B", Status: "active", RealnameStatus: "verified"},
|
||||
{Phone: "13900001001", Nickname: "被邀请人C", Status: "active", RealnameStatus: "verified"},
|
||||
{Phone: "13900001002", Nickname: "被邀请人D", Status: "active", RealnameStatus: "verified"},
|
||||
}
|
||||
for i := range users {
|
||||
if err := db.Create(&users[i]).Error; err != nil {
|
||||
t.Fatalf("创建测试用户失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ledgers := []model.InvitationRebateLedger{
|
||||
{
|
||||
OrderID: 3001,
|
||||
OrderNo: "ORDER-2026-01-10-A",
|
||||
Source: "rental_order",
|
||||
InviterID: users[0].ID,
|
||||
InviteeID: users[2].ID,
|
||||
TradeRole: "buyer",
|
||||
RateApplied: 0.6,
|
||||
CycleStage: CycleFirstMonth,
|
||||
RebateAmountCent: 100,
|
||||
Status: "settled",
|
||||
SettledAt: time.Date(2026, 1, 10, 10, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
OrderID: 3002,
|
||||
OrderNo: "ORDER-2026-01-10-B",
|
||||
Source: "rental_order",
|
||||
InviterID: users[0].ID,
|
||||
InviteeID: users[3].ID,
|
||||
TradeRole: "seller",
|
||||
RateApplied: 0.3,
|
||||
CycleStage: CycleSecondMonth,
|
||||
RebateAmountCent: 200,
|
||||
Status: "settled",
|
||||
SettledAt: time.Date(2026, 1, 10, 15, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
OrderID: 3003,
|
||||
OrderNo: "PICKUP-2026-02-05",
|
||||
Source: "pickup",
|
||||
InviterID: users[1].ID,
|
||||
InviteeID: users[2].ID,
|
||||
TradeRole: "seller",
|
||||
RateApplied: 0.15,
|
||||
CycleStage: CycleSubsequent,
|
||||
RebateAmountCent: 300,
|
||||
Status: "settled",
|
||||
SettledAt: time.Date(2026, 2, 5, 9, 30, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
OrderID: 3004,
|
||||
OrderNo: "ORDER-2026-03-01",
|
||||
Source: "rental_order",
|
||||
InviterID: users[0].ID,
|
||||
InviteeID: users[3].ID,
|
||||
TradeRole: "buyer",
|
||||
RateApplied: 0.6,
|
||||
CycleStage: CycleFirstMonth,
|
||||
RebateAmountCent: 400,
|
||||
Status: "settled",
|
||||
SettledAt: time.Date(2026, 3, 1, 18, 0, 0, 0, time.Local),
|
||||
},
|
||||
}
|
||||
for i := range ledgers {
|
||||
if err := db.Create(&ledgers[i]).Error; err != nil {
|
||||
t.Fatalf("创建返利流水失败: %v", err)
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func TestListAdminRebateLedgersFiltersSourceAndDateRange(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
seedRebateSummaryFixture(t, db)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query AdminRebateLedgerQuery
|
||||
want int64
|
||||
}{
|
||||
{name: "来源筛选取号", query: AdminRebateLedgerQuery{Source: "pickup"}, want: 1},
|
||||
{name: "来源筛选订单", query: AdminRebateLedgerQuery{Source: "rental_order"}, want: 3},
|
||||
{name: "交易端筛选买家", query: AdminRebateLedgerQuery{TradeRole: "buyer"}, want: 2},
|
||||
{
|
||||
name: "时间范围筛选单月",
|
||||
query: AdminRebateLedgerQuery{StartDate: "2026-01-01", EndDate: "2026-01-31"},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "时间范围筛选起始日",
|
||||
query: AdminRebateLedgerQuery{StartDate: "2026-02-01"},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "时间范围筛选结束日",
|
||||
query: AdminRebateLedgerQuery{EndDate: "2026-01-31"},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "来源与时间范围组合",
|
||||
query: AdminRebateLedgerQuery{
|
||||
Source: "rental_order",
|
||||
StartDate: "2026-02-01",
|
||||
EndDate: "2026-02-28",
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.query.Page = 1
|
||||
tc.query.PageSize = 20
|
||||
_, total, err := repo.ListAdminRebateLedgers(ctx, tc.query)
|
||||
if err != nil {
|
||||
t.Fatalf("查询返利流水失败: %v", err)
|
||||
}
|
||||
if total != tc.want {
|
||||
t.Fatalf("查询结果数量 = %d, want %d", total, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRebateSummaryAggregatesAndBreakdowns(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
users := seedRebateSummaryFixture(t, db)
|
||||
|
||||
summary, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{})
|
||||
if err != nil {
|
||||
t.Fatalf("查询返利统计失败: %v", err)
|
||||
}
|
||||
|
||||
if summary.TotalRebateCent != 1000 {
|
||||
t.Fatalf("返利总额 = %d, want 1000", summary.TotalRebateCent)
|
||||
}
|
||||
if summary.LedgerCount != 4 {
|
||||
t.Fatalf("流水笔数 = %d, want 4", summary.LedgerCount)
|
||||
}
|
||||
if summary.InviterCount != 2 {
|
||||
t.Fatalf("邀请人数 = %d, want 2", summary.InviterCount)
|
||||
}
|
||||
if summary.InviteeCount != 2 {
|
||||
t.Fatalf("被邀请人数 = %d, want 2", summary.InviteeCount)
|
||||
}
|
||||
if summary.AvgRebateCent != 250 {
|
||||
t.Fatalf("平均返利 = %d, want 250", summary.AvgRebateCent)
|
||||
}
|
||||
if summary.OrderRebateCent != 700 || summary.OrderCount != 3 {
|
||||
t.Fatalf("订单返利 = %d/%d 笔, want 700/3", summary.OrderRebateCent, summary.OrderCount)
|
||||
}
|
||||
if summary.PickupRebateCent != 300 || summary.PickupCount != 1 {
|
||||
t.Fatalf("提号返利 = %d/%d 笔, want 300/1", summary.PickupRebateCent, summary.PickupCount)
|
||||
}
|
||||
if summary.BuyerRebateCent != 500 || summary.BuyerCount != 2 {
|
||||
t.Fatalf("买家端返利 = %d/%d 笔, want 500/2", summary.BuyerRebateCent, summary.BuyerCount)
|
||||
}
|
||||
if summary.SellerRebateCent != 500 || summary.SellerCount != 2 {
|
||||
t.Fatalf("卖家端返利 = %d/%d 笔, want 500/2", summary.SellerRebateCent, summary.SellerCount)
|
||||
}
|
||||
if summary.FirstMonthCent != 500 || summary.SecondMonthCent != 200 || summary.SubsequentCent != 300 {
|
||||
t.Fatalf(
|
||||
"周期返利 = %d/%d/%d, want 500/200/300",
|
||||
summary.FirstMonthCent,
|
||||
summary.SecondMonthCent,
|
||||
summary.SubsequentCent,
|
||||
)
|
||||
}
|
||||
|
||||
if len(summary.Daily) != 3 {
|
||||
t.Fatalf("每日统计行数 = %d, want 3", len(summary.Daily))
|
||||
}
|
||||
daily := make(map[string]AdminRebateDailyStat, len(summary.Daily))
|
||||
for _, row := range summary.Daily {
|
||||
daily[row.Date] = row
|
||||
}
|
||||
january, ok := daily["2026-01-10"]
|
||||
if !ok {
|
||||
t.Fatalf("缺少 2026-01-10 的每日统计: %+v", summary.Daily)
|
||||
}
|
||||
if january.LedgerCount != 2 || january.RebateAmountCent != 300 ||
|
||||
january.OrderAmountCent != 300 || january.PickupAmountCent != 0 {
|
||||
t.Fatalf("2026-01-10 统计错误: %+v", january)
|
||||
}
|
||||
if got := daily["2026-02-05"]; got.PickupAmountCent != 300 || got.OrderAmountCent != 0 {
|
||||
t.Fatalf("2026-02-05 统计错误: %+v", got)
|
||||
}
|
||||
if got := daily["2026-03-01"]; got.RebateAmountCent != 400 {
|
||||
t.Fatalf("2026-03-01 统计错误: %+v", got)
|
||||
}
|
||||
|
||||
if len(summary.TopInviters) != 2 {
|
||||
t.Fatalf("Top 邀请人数 = %d, want 2", len(summary.TopInviters))
|
||||
}
|
||||
top := summary.TopInviters[0]
|
||||
if top.InviterID != users[0].ID || top.RebateAmountCent != 700 || top.LedgerCount != 3 {
|
||||
t.Fatalf("Top 邀请人统计错误: %+v", top)
|
||||
}
|
||||
if top.InviterPhone != users[0].Phone || top.InviterNickname != users[0].Nickname {
|
||||
t.Fatalf("Top 邀请人信息错误: %+v", top)
|
||||
}
|
||||
|
||||
filtered, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{
|
||||
StartDate: "2026-01-01",
|
||||
EndDate: "2026-01-31",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("按时间查询返利统计失败: %v", err)
|
||||
}
|
||||
if filtered.TotalRebateCent != 300 || filtered.LedgerCount != 2 || len(filtered.Daily) != 1 {
|
||||
t.Fatalf(
|
||||
"时间筛选统计错误: total=%d count=%d daily=%d",
|
||||
filtered.TotalRebateCent,
|
||||
filtered.LedgerCount,
|
||||
len(filtered.Daily),
|
||||
)
|
||||
}
|
||||
|
||||
pickupOnly, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{Source: "pickup"})
|
||||
if err != nil {
|
||||
t.Fatalf("按来源查询返利统计失败: %v", err)
|
||||
}
|
||||
if pickupOnly.TotalRebateCent != 300 || pickupOnly.LedgerCount != 1 ||
|
||||
pickupOnly.OrderCount != 0 || pickupOnly.PickupCount != 1 {
|
||||
t.Fatalf("来源筛选统计错误: %+v", pickupOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAdminInvitationsIncludesInviterAggregateStats(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
@@ -98,8 +98,49 @@ type AdminRebateLedgerQuery struct {
|
||||
InviterPhone string `form:"inviter_phone"`
|
||||
InviteePhone string `form:"invitee_phone"`
|
||||
TradeRole string `form:"trade_role"`
|
||||
Source string `form:"source"`
|
||||
StartDate string `form:"start_date"`
|
||||
EndDate string `form:"end_date"`
|
||||
DistributionSiteID *uint64 `form:"distribution_site_id"`
|
||||
// InviterID / InviteeID 供用户管理「明细」按用户精确筛选。
|
||||
InviterID uint64 `form:"inviter_id"`
|
||||
InviteeID uint64 `form:"invitee_id"`
|
||||
}
|
||||
|
||||
// AdminRebateSummaryDTO 返利统计页汇总数据,所有金额单位为分。
|
||||
type AdminRebateSummaryDTO struct {
|
||||
TotalRebateCent int64 `json:"total_rebate_cent"`
|
||||
LedgerCount int64 `json:"ledger_count"`
|
||||
InviterCount int64 `json:"inviter_count"`
|
||||
InviteeCount int64 `json:"invitee_count"`
|
||||
AvgRebateCent int64 `json:"avg_rebate_cent"`
|
||||
OrderRebateCent int64 `json:"order_rebate_cent"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PickupRebateCent int64 `json:"pickup_rebate_cent"`
|
||||
PickupCount int64 `json:"pickup_count"`
|
||||
BuyerRebateCent int64 `json:"buyer_rebate_cent"`
|
||||
BuyerCount int64 `json:"buyer_count"`
|
||||
SellerRebateCent int64 `json:"seller_rebate_cent"`
|
||||
SellerCount int64 `json:"seller_count"`
|
||||
FirstMonthCent int64 `json:"first_month_cent"`
|
||||
SecondMonthCent int64 `json:"second_month_cent"`
|
||||
SubsequentCent int64 `json:"subsequent_cent"`
|
||||
Daily []AdminRebateDailyStat `json:"daily"`
|
||||
TopInviters []AdminRebateInviterStat `json:"top_inviters"`
|
||||
}
|
||||
|
||||
type AdminRebateDailyStat struct {
|
||||
Date string `json:"date"`
|
||||
LedgerCount int64 `json:"ledger_count"`
|
||||
RebateAmountCent int64 `json:"rebate_amount_cent"`
|
||||
OrderAmountCent int64 `json:"order_amount_cent"`
|
||||
PickupAmountCent int64 `json:"pickup_amount_cent"`
|
||||
}
|
||||
|
||||
type AdminRebateInviterStat struct {
|
||||
InviterID uint64 `json:"inviter_id"`
|
||||
InviterPhone string `json:"inviter_phone"`
|
||||
InviterNickname string `json:"inviter_nickname"`
|
||||
LedgerCount int64 `json:"ledger_count"`
|
||||
RebateAmountCent int64 `json:"rebate_amount_cent"`
|
||||
}
|
||||
|
||||
@@ -170,6 +170,21 @@ func (h *Handler) AdminRebates(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AdminRebateSummary 管理员查看返利统计汇总
|
||||
func (h *Handler) AdminRebateSummary(c *gin.Context) {
|
||||
var query AdminRebateLedgerQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "查询参数不正确")
|
||||
return
|
||||
}
|
||||
summary, err := h.service.AdminRebateSummary(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
writeInvitationError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, summary)
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
|
||||
@@ -998,17 +998,8 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// ListAdminRebateLedgers 管理后台查询返利流水
|
||||
func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, ErrDependencyUnavailable
|
||||
}
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize < 1 || query.PageSize > 100 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
// adminRebateLedgerBase 构造后台返利流水/统计数据共用的筛选范围。
|
||||
func (r *Repository) adminRebateLedgerBase(ctx context.Context, query AdminRebateLedgerQuery) *gorm.DB {
|
||||
db := r.db.WithContext(ctx).Table("invitation_rebate_ledgers irl").
|
||||
Joins("LEFT JOIN users inviter ON inviter.id = irl.inviter_id").
|
||||
Joins("LEFT JOIN users invitee ON invitee.id = irl.invitee_id")
|
||||
@@ -1025,6 +1016,15 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
if t := strings.TrimSpace(query.TradeRole); t != "" {
|
||||
db = db.Where("irl.trade_role = ?", t)
|
||||
}
|
||||
if s := strings.TrimSpace(query.Source); s != "" {
|
||||
db = db.Where("irl.source = ?", s)
|
||||
}
|
||||
if start, ok := parseAdminRebateDate(query.StartDate); ok {
|
||||
db = db.Where("irl.settled_at >= ?", start)
|
||||
}
|
||||
if end, ok := parseAdminRebateDate(query.EndDate); ok {
|
||||
db = db.Where("irl.settled_at < ?", end.AddDate(0, 0, 1))
|
||||
}
|
||||
if query.DistributionSiteID != nil {
|
||||
db = db.Where("irl.distribution_site_id = ?", *query.DistributionSiteID)
|
||||
}
|
||||
@@ -1034,6 +1034,34 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
if query.InviteeID > 0 {
|
||||
db = db.Where("irl.invitee_id = ?", query.InviteeID)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func parseAdminRebateDate(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339} {
|
||||
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ListAdminRebateLedgers 管理后台查询返利流水
|
||||
func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, ErrDependencyUnavailable
|
||||
}
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize < 1 || query.PageSize > 100 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
db := r.adminRebateLedgerBase(ctx, query)
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
@@ -1095,6 +1123,141 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// AdminRebateSummary 管理后台返利统计分析,沿用流水页的筛选条件。
|
||||
func (r *Repository) AdminRebateSummary(ctx context.Context, query AdminRebateLedgerQuery) (*AdminRebateSummaryDTO, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
type aggregateRow struct {
|
||||
TotalRebateCent int64 `gorm:"column:total_rebate_cent"`
|
||||
LedgerCount int64 `gorm:"column:ledger_count"`
|
||||
InviterCount int64 `gorm:"column:inviter_count"`
|
||||
InviteeCount int64 `gorm:"column:invitee_count"`
|
||||
OrderRebateCent int64 `gorm:"column:order_rebate_cent"`
|
||||
OrderCount int64 `gorm:"column:order_count"`
|
||||
PickupRebateCent int64 `gorm:"column:pickup_rebate_cent"`
|
||||
PickupCount int64 `gorm:"column:pickup_count"`
|
||||
BuyerRebateCent int64 `gorm:"column:buyer_rebate_cent"`
|
||||
BuyerCount int64 `gorm:"column:buyer_count"`
|
||||
SellerRebateCent int64 `gorm:"column:seller_rebate_cent"`
|
||||
SellerCount int64 `gorm:"column:seller_count"`
|
||||
FirstMonthCent int64 `gorm:"column:first_month_cent"`
|
||||
SecondMonthCent int64 `gorm:"column:second_month_cent"`
|
||||
SubsequentCent int64 `gorm:"column:subsequent_cent"`
|
||||
}
|
||||
var aggregate aggregateRow
|
||||
err := r.adminRebateLedgerBase(ctx, query).Select(`
|
||||
COALESCE(SUM(irl.rebate_amount_cent), 0) AS total_rebate_cent,
|
||||
COUNT(*) AS ledger_count,
|
||||
COUNT(DISTINCT irl.inviter_id) AS inviter_count,
|
||||
COUNT(DISTINCT irl.invitee_id) AS invitee_count,
|
||||
COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS order_rebate_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN 1 ELSE 0 END), 0) AS order_count,
|
||||
COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS pickup_rebate_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN 1 ELSE 0 END), 0) AS pickup_count,
|
||||
COALESCE(SUM(CASE WHEN irl.trade_role = 'buyer' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS buyer_rebate_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.trade_role = 'buyer' THEN 1 ELSE 0 END), 0) AS buyer_count,
|
||||
COALESCE(SUM(CASE WHEN irl.trade_role = 'seller' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS seller_rebate_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.trade_role = 'seller' THEN 1 ELSE 0 END), 0) AS seller_count,
|
||||
COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS first_month_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS second_month_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS subsequent_cent
|
||||
`, CycleFirstMonth, CycleSecondMonth, CycleSubsequent).Scan(&aggregate).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &AdminRebateSummaryDTO{
|
||||
TotalRebateCent: aggregate.TotalRebateCent,
|
||||
LedgerCount: aggregate.LedgerCount,
|
||||
InviterCount: aggregate.InviterCount,
|
||||
InviteeCount: aggregate.InviteeCount,
|
||||
OrderRebateCent: aggregate.OrderRebateCent,
|
||||
OrderCount: aggregate.OrderCount,
|
||||
PickupRebateCent: aggregate.PickupRebateCent,
|
||||
PickupCount: aggregate.PickupCount,
|
||||
BuyerRebateCent: aggregate.BuyerRebateCent,
|
||||
BuyerCount: aggregate.BuyerCount,
|
||||
SellerRebateCent: aggregate.SellerRebateCent,
|
||||
SellerCount: aggregate.SellerCount,
|
||||
FirstMonthCent: aggregate.FirstMonthCent,
|
||||
SecondMonthCent: aggregate.SecondMonthCent,
|
||||
SubsequentCent: aggregate.SubsequentCent,
|
||||
Daily: make([]AdminRebateDailyStat, 0),
|
||||
TopInviters: make([]AdminRebateInviterStat, 0),
|
||||
}
|
||||
if result.LedgerCount > 0 {
|
||||
result.AvgRebateCent = result.TotalRebateCent / result.LedgerCount
|
||||
}
|
||||
|
||||
type dailyRow struct {
|
||||
Date string `gorm:"column:date"`
|
||||
LedgerCount int64 `gorm:"column:ledger_count"`
|
||||
RebateAmountCent int64 `gorm:"column:rebate_amount_cent"`
|
||||
OrderAmountCent int64 `gorm:"column:order_amount_cent"`
|
||||
PickupAmountCent int64 `gorm:"column:pickup_amount_cent"`
|
||||
}
|
||||
var daily []dailyRow
|
||||
err = r.adminRebateLedgerBase(ctx, query).
|
||||
Select(`
|
||||
DATE_FORMAT(irl.settled_at, '%Y-%m-%d') AS date,
|
||||
COUNT(*) AS ledger_count,
|
||||
COALESCE(SUM(irl.rebate_amount_cent), 0) AS rebate_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS order_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS pickup_amount_cent
|
||||
`).
|
||||
Group("DATE_FORMAT(irl.settled_at, '%Y-%m-%d')").
|
||||
Order("DATE_FORMAT(irl.settled_at, '%Y-%m-%d') DESC").
|
||||
Scan(&daily).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range daily {
|
||||
result.Daily = append(result.Daily, AdminRebateDailyStat{
|
||||
Date: row.Date,
|
||||
LedgerCount: row.LedgerCount,
|
||||
RebateAmountCent: row.RebateAmountCent,
|
||||
OrderAmountCent: row.OrderAmountCent,
|
||||
PickupAmountCent: row.PickupAmountCent,
|
||||
})
|
||||
}
|
||||
|
||||
type topInviterRow struct {
|
||||
InviterID uint64 `gorm:"column:inviter_id"`
|
||||
InviterPhone string `gorm:"column:inviter_phone"`
|
||||
InviterNickname string `gorm:"column:inviter_nickname"`
|
||||
LedgerCount int64 `gorm:"column:ledger_count"`
|
||||
RebateAmountCent int64 `gorm:"column:rebate_amount_cent"`
|
||||
}
|
||||
var top []topInviterRow
|
||||
err = r.adminRebateLedgerBase(ctx, query).
|
||||
Select(`
|
||||
irl.inviter_id,
|
||||
COALESCE(inviter.phone, '') AS inviter_phone,
|
||||
COALESCE(inviter.nickname, '') AS inviter_nickname,
|
||||
COUNT(*) AS ledger_count,
|
||||
COALESCE(SUM(irl.rebate_amount_cent), 0) AS rebate_amount_cent
|
||||
`).
|
||||
Group("irl.inviter_id, inviter.phone, inviter.nickname").
|
||||
Order("rebate_amount_cent DESC").
|
||||
Limit(10).
|
||||
Scan(&top).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range top {
|
||||
result.TopInviters = append(result.TopInviters, AdminRebateInviterStat{
|
||||
InviterID: row.InviterID,
|
||||
InviterPhone: row.InviterPhone,
|
||||
InviterNickname: row.InviterNickname,
|
||||
LedgerCount: row.LedgerCount,
|
||||
RebateAmountCent: row.RebateAmountCent,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProcessOrderInvitationRebateTx 便捷事务入口,供订单结账完成终态时调用
|
||||
func ProcessOrderInvitationRebateTx(tx *gorm.DB, orderID uint64) error {
|
||||
return ProcessOrderInvitationRebateWithOptionsTx(tx, orderID, OrderRebateOptions{})
|
||||
|
||||
@@ -107,3 +107,10 @@ func (s *Service) ListAdminRebateLedgers(ctx context.Context, query AdminRebateL
|
||||
}
|
||||
return s.repo.ListAdminRebateLedgers(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) AdminRebateSummary(ctx context.Context, query AdminRebateLedgerQuery) (*AdminRebateSummaryDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.AdminRebateSummary(ctx, query)
|
||||
}
|
||||
|
||||
@@ -912,6 +912,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/invitations/configs", requirePerm("distribution:site_view"), invitationHandler.AdminGetConfig)
|
||||
adminRoutes.PUT("/invitations/configs", requirePerm("distribution:site_manage"), invitationHandler.AdminUpdateConfig)
|
||||
adminRoutes.GET("/invitations", requirePerm("distribution:site_view"), invitationHandler.AdminInvitations)
|
||||
adminRoutes.GET("/invitations/rebates/summary", requirePerm("distribution:settlement_view"), invitationHandler.AdminRebateSummary)
|
||||
adminRoutes.GET("/invitations/rebates", requirePerm("distribution:settlement_view"), invitationHandler.AdminRebates)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ export interface AdminInvitationQuery {
|
||||
invitee_phone?: string
|
||||
order_no?: string
|
||||
trade_role?: string
|
||||
source?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
distribution_site_id?: number
|
||||
inviter_id?: number
|
||||
invitee_id?: number
|
||||
@@ -63,6 +66,43 @@ export interface AdminInvitationQuery {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminRebateDailyStat {
|
||||
date: string
|
||||
ledger_count: number
|
||||
rebate_amount_cent: number
|
||||
order_amount_cent: number
|
||||
pickup_amount_cent: number
|
||||
}
|
||||
|
||||
export interface AdminRebateInviterStat {
|
||||
inviter_id: number
|
||||
inviter_phone: string
|
||||
inviter_nickname: string
|
||||
ledger_count: number
|
||||
rebate_amount_cent: number
|
||||
}
|
||||
|
||||
export interface AdminRebateSummary {
|
||||
total_rebate_cent: number
|
||||
ledger_count: number
|
||||
inviter_count: number
|
||||
invitee_count: number
|
||||
avg_rebate_cent: number
|
||||
order_rebate_cent: number
|
||||
order_count: number
|
||||
pickup_rebate_cent: number
|
||||
pickup_count: number
|
||||
buyer_rebate_cent: number
|
||||
buyer_count: number
|
||||
seller_rebate_cent: number
|
||||
seller_count: number
|
||||
first_month_cent: number
|
||||
second_month_cent: number
|
||||
subsequent_cent: number
|
||||
daily: AdminRebateDailyStat[]
|
||||
top_inviters: AdminRebateInviterStat[]
|
||||
}
|
||||
|
||||
export async function fetchAdminInvitations(query: AdminInvitationQuery = {}) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminInvitationRow>>>(
|
||||
'/admin/invitations',
|
||||
@@ -86,3 +126,31 @@ export async function fetchAdminRebates(query: AdminInvitationQuery = {}) {
|
||||
total: Number(result?.total ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminRebateSummary(query: AdminInvitationQuery = {}) {
|
||||
const { data } = await apiClient.get<ApiResponse<AdminRebateSummary>>(
|
||||
'/admin/invitations/rebates/summary',
|
||||
{ params: query }
|
||||
)
|
||||
const result = data.data
|
||||
return {
|
||||
total_rebate_cent: Number(result?.total_rebate_cent ?? 0),
|
||||
ledger_count: Number(result?.ledger_count ?? 0),
|
||||
inviter_count: Number(result?.inviter_count ?? 0),
|
||||
invitee_count: Number(result?.invitee_count ?? 0),
|
||||
avg_rebate_cent: Number(result?.avg_rebate_cent ?? 0),
|
||||
order_rebate_cent: Number(result?.order_rebate_cent ?? 0),
|
||||
order_count: Number(result?.order_count ?? 0),
|
||||
pickup_rebate_cent: Number(result?.pickup_rebate_cent ?? 0),
|
||||
pickup_count: Number(result?.pickup_count ?? 0),
|
||||
buyer_rebate_cent: Number(result?.buyer_rebate_cent ?? 0),
|
||||
buyer_count: Number(result?.buyer_count ?? 0),
|
||||
seller_rebate_cent: Number(result?.seller_rebate_cent ?? 0),
|
||||
seller_count: Number(result?.seller_count ?? 0),
|
||||
first_month_cent: Number(result?.first_month_cent ?? 0),
|
||||
second_month_cent: Number(result?.second_month_cent ?? 0),
|
||||
subsequent_cent: Number(result?.subsequent_cent ?? 0),
|
||||
daily: Array.isArray(result?.daily) ? result.daily : [],
|
||||
top_inviters: Array.isArray(result?.top_inviters) ? result.top_inviters : [],
|
||||
} satisfies AdminRebateSummary
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Setting, User, Coin, Refresh, Search, Check } from '@element-plus/icons-vue'
|
||||
import { Setting, User, Coin, Refresh, Search, Check, DataAnalysis } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchAdminRebateConfig,
|
||||
updateAdminRebateConfig,
|
||||
type InvitationRebateConfig,
|
||||
} from '@/features/invitation/api/invitation'
|
||||
import {
|
||||
fetchAdminRebateSummary,
|
||||
type AdminRebateSummary,
|
||||
} from '@/features/admin/api/adminInvitations'
|
||||
import {
|
||||
fetchDistributionSites,
|
||||
type DistributionSite,
|
||||
@@ -15,7 +19,7 @@ import { apiClient } from '@/shared/api/client'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const activeTab = ref<'config' | 'invitations' | 'rebates'>('config')
|
||||
const activeTab = ref<'config' | 'invitations' | 'rebates' | 'summary'>('config')
|
||||
|
||||
// 站点列表
|
||||
const sites = ref<DistributionSite[]>([])
|
||||
@@ -55,6 +59,16 @@ const rebateQuery = reactive({
|
||||
const rebates = ref<any[]>([])
|
||||
const rebatesTotal = ref(0)
|
||||
|
||||
// 返利统计
|
||||
const loadingSummary = ref(false)
|
||||
const summaryQuery = reactive({
|
||||
dateRange: null as string[] | null,
|
||||
source: '',
|
||||
trade_role: '',
|
||||
distribution_site_id: undefined as number | undefined,
|
||||
})
|
||||
const summary = ref<AdminRebateSummary | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
// 三个页签的数据一起预加载,避免邀请关系/返利流水标签在首次进入时显示 0。
|
||||
void Promise.all([loadSites(), loadConfig(), loadInvitations(), loadRebates()])
|
||||
@@ -160,12 +174,48 @@ async function loadRebates() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tab: 'config' | 'invitations' | 'rebates') {
|
||||
async function loadSummary() {
|
||||
loadingSummary.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
if (summaryQuery.dateRange && summaryQuery.dateRange.length === 2) {
|
||||
params.start_date = summaryQuery.dateRange[0]
|
||||
params.end_date = summaryQuery.dateRange[1]
|
||||
}
|
||||
if (summaryQuery.source) params.source = summaryQuery.source
|
||||
if (summaryQuery.trade_role) params.trade_role = summaryQuery.trade_role
|
||||
if (typeof summaryQuery.distribution_site_id === 'number') {
|
||||
params.distribution_site_id = summaryQuery.distribution_site_id
|
||||
}
|
||||
summary.value = await fetchAdminRebateSummary(params)
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取返利统计失败'))
|
||||
} finally {
|
||||
loadingSummary.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSummary() {
|
||||
summaryQuery.dateRange = null
|
||||
summaryQuery.source = ''
|
||||
summaryQuery.trade_role = ''
|
||||
summaryQuery.distribution_site_id = undefined
|
||||
void loadSummary()
|
||||
}
|
||||
|
||||
function percentOf(part: number, total: number) {
|
||||
if (!total) return '0%'
|
||||
return `${Math.round((part / total) * 1000) / 10}%`
|
||||
}
|
||||
|
||||
function handleTabChange(tab: 'config' | 'invitations' | 'rebates' | 'summary') {
|
||||
activeTab.value = tab
|
||||
if (tab === 'invitations') {
|
||||
void loadInvitations()
|
||||
} else if (tab === 'rebates') {
|
||||
void loadRebates()
|
||||
} else if (tab === 'summary') {
|
||||
void loadSummary()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +273,13 @@ function cycleLabel(cycle: string) {
|
||||
>
|
||||
<el-icon><Coin /></el-icon> 返利结算流水 ({{ rebatesTotal }})
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'summary' }"
|
||||
@click="handleTabChange('summary')"
|
||||
>
|
||||
<el-icon><DataAnalysis /></el-icon> 返利统计
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 配置面板 -->
|
||||
@@ -492,6 +549,209 @@ function cycleLabel(cycle: string) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返利统计面板 -->
|
||||
<div v-if="activeTab === 'summary'" class="panel-card">
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="summaryQuery.dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
unlink-panels
|
||||
style="width: 260px"
|
||||
/>
|
||||
<el-select v-model="summaryQuery.source" placeholder="来源" clearable style="width: 120px">
|
||||
<el-option label="订单" value="rental_order" />
|
||||
<el-option label="提号" value="pickup" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="summaryQuery.trade_role"
|
||||
placeholder="交易端"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="买家端" value="buyer" />
|
||||
<el-option label="卖家端" value="seller" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-if="sites.length"
|
||||
v-model="summaryQuery.distribution_site_id"
|
||||
placeholder="全部分站"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option v-for="s in sites" :key="s.id" :value="s.id" :label="s.site_name" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="loadSummary">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetSummary">重置</el-button>
|
||||
</div>
|
||||
<div class="table-hint">
|
||||
统计口径与「返利结算流水」一致,按入账时间筛选,金额按实际发放返利汇总。
|
||||
</div>
|
||||
|
||||
<div v-loading="loadingSummary" class="summary-body">
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card stat-card-primary">
|
||||
<div class="stat-label">返利总额</div>
|
||||
<div class="stat-value">¥{{ formatCent(summary?.total_rebate_cent ?? 0) }}</div>
|
||||
<div class="stat-sub">共 {{ summary?.ledger_count ?? 0 }} 笔结算流水</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">流水笔数</div>
|
||||
<div class="stat-value">{{ summary?.ledger_count ?? 0 }}</div>
|
||||
<div class="stat-sub">含订单与提号返利</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">邀请人数</div>
|
||||
<div class="stat-value">{{ summary?.inviter_count ?? 0 }}</div>
|
||||
<div class="stat-sub">产生过返利的邀请人</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">被邀请人数</div>
|
||||
<div class="stat-value">{{ summary?.invitee_count ?? 0 }}</div>
|
||||
<div class="stat-sub">产生过返利的被邀请人</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">平均单笔返利</div>
|
||||
<div class="stat-value">¥{{ formatCent(summary?.avg_rebate_cent ?? 0) }}</div>
|
||||
<div class="stat-sub">返利总额 ÷ 流水笔数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="breakdown-grid">
|
||||
<div class="breakdown-card">
|
||||
<div class="breakdown-title">按来源拆分</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">订单返利</span>
|
||||
<span class="breakdown-num">
|
||||
¥{{ formatCent(summary?.order_rebate_cent ?? 0) }} ·
|
||||
{{ summary?.order_count ?? 0 }} 笔
|
||||
</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.order_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">提号返利</span>
|
||||
<span class="breakdown-num">
|
||||
¥{{ formatCent(summary?.pickup_rebate_cent ?? 0) }} ·
|
||||
{{ summary?.pickup_count ?? 0 }} 笔
|
||||
</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.pickup_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="breakdown-card">
|
||||
<div class="breakdown-title">按交易端拆分</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">买家端</span>
|
||||
<span class="breakdown-num">
|
||||
¥{{ formatCent(summary?.buyer_rebate_cent ?? 0) }} ·
|
||||
{{ summary?.buyer_count ?? 0 }} 笔
|
||||
</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.buyer_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">卖家端</span>
|
||||
<span class="breakdown-num">
|
||||
¥{{ formatCent(summary?.seller_rebate_cent ?? 0) }} ·
|
||||
{{ summary?.seller_count ?? 0 }} 笔
|
||||
</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.seller_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="breakdown-card">
|
||||
<div class="breakdown-title">按周期阶段拆分</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">首月高额</span>
|
||||
<span class="breakdown-num">¥{{ formatCent(summary?.first_month_cent ?? 0) }}</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.first_month_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">次月奖励</span>
|
||||
<span class="breakdown-num">¥{{ formatCent(summary?.second_month_cent ?? 0) }}</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.second_month_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-label">长期分成</span>
|
||||
<span class="breakdown-num">¥{{ formatCent(summary?.subsequent_cent ?? 0) }}</span>
|
||||
<span class="breakdown-percent">
|
||||
{{ percentOf(summary?.subsequent_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-section">
|
||||
<div class="summary-section-title">每日返利趋势</div>
|
||||
<el-table
|
||||
:data="summary?.daily ?? []"
|
||||
border
|
||||
size="small"
|
||||
max-height="340"
|
||||
empty-text="当前筛选条件下暂无返利数据"
|
||||
>
|
||||
<el-table-column prop="date" label="日期" width="130" />
|
||||
<el-table-column prop="ledger_count" label="流水笔数" width="110" align="right" />
|
||||
<el-table-column label="返利合计" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong style="color: #dc2626"
|
||||
>+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单返利" min-width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatCent(row.order_amount_cent || 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提号返利" min-width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatCent(row.pickup_amount_cent || 0) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="summary-section">
|
||||
<div class="summary-section-title">返利 Top 10 邀请人</div>
|
||||
<el-table
|
||||
:data="summary?.top_inviters ?? []"
|
||||
border
|
||||
size="small"
|
||||
empty-text="当前筛选条件下暂无邀请人数据"
|
||||
>
|
||||
<el-table-column label="排名" width="70" align="center">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="邀请人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.inviter_nickname || '-' }}</div>
|
||||
<code style="font-size: 12px; color: #64748b">{{ row.inviter_phone || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ledger_count" label="返利笔数" width="110" align="right" />
|
||||
<el-table-column label="返利金额" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong style="color: #dc2626"
|
||||
>+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -616,6 +876,110 @@ function cycleLabel(cycle: string) {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.stat-card-primary {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-card-primary .stat-value {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.breakdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.breakdown-card {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.breakdown-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
width: 70px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.breakdown-num {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.breakdown-percent {
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.summary-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pagination-box {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user