fix: 补齐渠道全量合计筛选导出及有效端目标遗漏
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gorm.io/gorm"
|
||||
businessbiz "kra/internal/biz/business"
|
||||
"kra/internal/biz/system"
|
||||
@@ -120,6 +123,38 @@ func TestChannelAndGameAnalyticsHTTP(t *testing.T) {
|
||||
if first["busierName"] != "SDK账号" || first["newRegisterCount"] != float64(1) {
|
||||
t.Fatal("渠道树去重或商务展示未保留")
|
||||
}
|
||||
t.Run("export includes every authorized row and all totals", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", server.URL+"/sw/channelData/list?export=true&sourceDb=lx,hw&beginTime=2026-11-04&endTime=2026-11-04&pageSize=1&pageNum=99", nil)
|
||||
req.Header.Set("X-Test-User", "7")
|
||||
res, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Header.Get("success") != "true" {
|
||||
t.Fatalf("export failed: %s", body)
|
||||
}
|
||||
book, err := excelize.OpenReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer book.Close()
|
||||
rows, err := book.GetRows("渠道数据统计")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 4 || rows[1][0] != "龙翔" || rows[2][0] != "龙翔" || rows[3][0] != "全部" || rows[3][1] != "合计(有效端 0)" || rows[3][9] != "66.67%" {
|
||||
t.Fatalf("导出必须包含可见全量及完整合计,不受分页影响: %+v", rows)
|
||||
}
|
||||
amount, _ := book.GetCellValue("渠道数据统计", "G4", excelize.Options{RawCellValue: true})
|
||||
if amount != "60.00" {
|
||||
t.Fatalf("export amount=%s", amount)
|
||||
}
|
||||
})
|
||||
data = call(1, "channelData", "sourceDb=lx,hw", 0)
|
||||
if data["total"] != float64(5) || data["totalRegisterCount"] != float64(3) || data["totalPayAmount"] != "112.00" || data["avgPayRate"] != "75.00" {
|
||||
t.Fatalf("超管应全局去重并包含官方/未知渠道: %+v", data)
|
||||
|
||||
@@ -30,6 +30,7 @@ type AnalyticsFilter struct {
|
||||
Sources, GameNames []string
|
||||
PromoteName string
|
||||
PromoteType *int
|
||||
HasRegister, HasPay *bool
|
||||
StartDate, EndDate *time.Time
|
||||
Begin, End, SortField string
|
||||
Ascending bool
|
||||
@@ -113,6 +114,12 @@ func analyticsValues(row *AnalyticsRow) error {
|
||||
return nil
|
||||
}
|
||||
func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int) (*AnalyticsPage, error) {
|
||||
return uc.list(ctx, kind, filter, page, size, false)
|
||||
}
|
||||
func (uc *AnalyticsUsecase) ListAll(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter) (*AnalyticsPage, error) {
|
||||
return uc.list(ctx, kind, filter, 1, 0, true)
|
||||
}
|
||||
func (uc *AnalyticsUsecase) list(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int, all bool) (*AnalyticsPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -130,16 +137,23 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
filter.PromoteName = ""
|
||||
filter.PromoteType = nil
|
||||
}
|
||||
if filter.StartDate == nil || filter.EndDate == nil {
|
||||
if filter.EndDate == nil || kind == GameAnalytics && filter.StartDate == nil {
|
||||
now := uc.now().In(portraitLocation)
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
filter.StartDate = &day
|
||||
filter.EndDate = &day
|
||||
if kind == GameAnalytics && filter.StartDate == nil {
|
||||
filter.StartDate = &day
|
||||
}
|
||||
if filter.EndDate == nil {
|
||||
filter.EndDate = &day
|
||||
}
|
||||
}
|
||||
if filter.EndDate.Before(*filter.StartDate) {
|
||||
if filter.StartDate != nil && filter.EndDate.Before(*filter.StartDate) {
|
||||
return nil, ErrAnalyticsDateOrder
|
||||
}
|
||||
filter.Begin = filter.StartDate.Format("2006-01-02")
|
||||
filter.Begin = ""
|
||||
if filter.StartDate != nil {
|
||||
filter.Begin = filter.StartDate.Format("2006-01-02")
|
||||
}
|
||||
filter.End = filter.EndDate.Format("2006-01-02")
|
||||
out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true}
|
||||
scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}
|
||||
@@ -221,6 +235,18 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
return nil, err
|
||||
}
|
||||
out.Total = int64(len(data.Rows))
|
||||
if all {
|
||||
if out.Total > 10000 {
|
||||
return nil, ErrLedgerExportLimit
|
||||
}
|
||||
out.Rows = data.Rows
|
||||
if !namesBeforeSort {
|
||||
if err := fillNames(out.Rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -243,7 +269,7 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
return out, nil
|
||||
}
|
||||
func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error {
|
||||
if kind == GameAnalytics && field == "" {
|
||||
if field == "" {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].SourceDB != rows[j].SourceDB {
|
||||
return rows[i].SourceDB < rows[j].SourceDB
|
||||
|
||||
@@ -81,6 +81,30 @@ func TestChannelAnalyticsSummaryDependsOnViewer(t *testing.T) {
|
||||
t.Fatal("超管应使用筛选范围内全局去重汇总")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAnalyticsAllPagesSummaryAndOpenDateRange(t *testing.T) {
|
||||
uc, repo := analyticsFixture()
|
||||
repo.data.EffectiveCount = 3
|
||||
for _, pageNum := range []int{1, 2, 8} {
|
||||
page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{}, pageNum, 1)
|
||||
if err != nil || page.Total != 2 || page.Summary.Counts.Register != 5 || page.Summary.Counts.Active != 10 || page.Summary.Counts.Pay != 3 || page.Summary.Counts.NewPay != 2 || page.Summary.Pay != "50.00" || page.Summary.PayRate != "30.00" || page.EffectiveCount != 3 {
|
||||
t.Fatalf("翻页不能改变完整合计: page=%d %+v %v", pageNum, page, err)
|
||||
}
|
||||
if page.Begin != "" || page.End != "2026-11-04" || repo.filter.StartDate != nil {
|
||||
t.Fatalf("渠道统计缺省应从最早数据统计至今天: %+v", repo.filter)
|
||||
}
|
||||
}
|
||||
for _, sample := range []struct {
|
||||
field string
|
||||
asc bool
|
||||
source string
|
||||
}{{"totalPay", true, "lx"}, {"totalPay", false, "hw"}, {"", false, "hw"}} {
|
||||
page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{SortField: sample.field, Ascending: sample.asc}, 1, 1)
|
||||
if err != nil || page.Rows[0].SourceDB != sample.source {
|
||||
t.Fatalf("取消排序应恢复稳定原始顺序: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAnalyticsSortAndMissingData(t *testing.T) {
|
||||
uc, r := analyticsFixture()
|
||||
r.data.Rows[0].Pay = "9007199254740993.00"
|
||||
|
||||
@@ -34,6 +34,8 @@ type ChannelKey struct {
|
||||
type Channel struct {
|
||||
ChannelKey
|
||||
Account, Nickname, ParentName, RealName string
|
||||
Phone string
|
||||
SettlementDays *int
|
||||
Level, RegisterType, Status, LockStatus *int
|
||||
ParentID, TopPromoteID, BusierID uint64
|
||||
BusierUserID *uint
|
||||
@@ -75,12 +77,21 @@ type ChannelFilter struct {
|
||||
Sources []string
|
||||
BusierUserID *uint
|
||||
BusierAccount, Keyword string
|
||||
Phone, SortField string
|
||||
SettlementDays *int
|
||||
CreatedBegin, CreatedEnd *time.Time
|
||||
LoginBegin, LoginEnd *time.Time
|
||||
Ascending bool
|
||||
Level, RegisterType, Status, LockStatus *int
|
||||
}
|
||||
type ChannelStatusCounts struct {
|
||||
Normal, Locked, Terminated int64
|
||||
}
|
||||
type ChannelPage struct {
|
||||
Rows []*Channel
|
||||
Total int64
|
||||
Available bool
|
||||
Rows []*Channel
|
||||
Total int64
|
||||
Available bool
|
||||
StatusCounts *ChannelStatusCounts
|
||||
}
|
||||
type ChannelPlayer struct {
|
||||
ChannelKey
|
||||
|
||||
@@ -2,6 +2,7 @@ package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -59,7 +60,13 @@ func prepareAnalytics(db *gorm.DB, scope business.AnalyticsScope, f business.Ana
|
||||
return len(rows) == 0, true, nil
|
||||
}
|
||||
func analyticsQuery(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int) *gorm.DB {
|
||||
q := db.Table(table+" r").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode = ? AND r.game_id > 0", f.Begin, f.End, mode)
|
||||
q := db.Table(table+" r").Where("r.dedup_mode = ? AND r.game_id > 0", mode)
|
||||
if f.Begin != "" {
|
||||
q = q.Where("r.stat_date >= ?", f.Begin)
|
||||
}
|
||||
if f.End != "" {
|
||||
q = q.Where("r.stat_date <= ?", f.End)
|
||||
}
|
||||
if len(f.Sources) > 0 {
|
||||
q = q.Where("r.source_db IN ?", f.Sources)
|
||||
}
|
||||
@@ -165,6 +172,7 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
var global []portraitCountPO
|
||||
globalPay := "0"
|
||||
withGlobal := kind == business.ChannelAnalytics && scope.Viewer.All
|
||||
metricFiltered := kind == business.ChannelAnalytics && (f.HasRegister != nil || f.HasPay != nil)
|
||||
group, queryContext := errgroup.WithContext(ctx)
|
||||
// 每个请求最多两个并行查询,复用同一存储客户端;结果读取完后再组装 DO。
|
||||
group.SetLimit(2)
|
||||
@@ -183,7 +191,7 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if withGlobal {
|
||||
if withGlobal && !metricFiltered {
|
||||
group.Go(func() error {
|
||||
q := analyticsQuery(queryDB, scope, f, "tab_sw_report_day_key", mode)
|
||||
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
|
||||
@@ -208,6 +216,23 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
||||
}
|
||||
}
|
||||
// Keep visible channels with no events in the selected period so that
|
||||
// "no registration / no recharge" can find genuinely inactive channels.
|
||||
if kind == business.ChannelAnalytics && len(f.GameNames) == 0 {
|
||||
root := "p.id"
|
||||
if scope.IncludeSub {
|
||||
root = "CASE WHEN p.promote_level=1 THEN p.id WHEN p.top_promote_id>0 THEN p.top_promote_id ELSE p.id END"
|
||||
}
|
||||
var channels []analyticsAggPO
|
||||
if err := analyticsPromotes(db, scope, f, "p").Select("DISTINCT p.source_db," + root + " AS entity_id").Scan(&channels).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range channels {
|
||||
if po.ID != nil {
|
||||
rowFor(po)
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == business.ChannelAnalytics && !scope.Viewer.All {
|
||||
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
|
||||
return nil, err
|
||||
@@ -218,6 +243,33 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if metricFiltered {
|
||||
for key, row := range rowMap {
|
||||
pay, ok := new(big.Rat).SetString(row.Pay)
|
||||
if !ok {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if f.HasRegister != nil && (row.Counts.Register > 0) != *f.HasRegister || f.HasPay != nil && (pay.Sign() > 0) != *f.HasPay {
|
||||
delete(rowMap, key)
|
||||
}
|
||||
}
|
||||
// Recompute the administrator's distinct-player totals over matched
|
||||
// channel trees, before pagination. Summing per-row ratios is incorrect.
|
||||
if withGlobal && len(rowMap) > 0 {
|
||||
keys := make([]business.AnalyticsKey, 0, len(rowMap))
|
||||
for key := range rowMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
q, id := analyticsRoot(db, kind, scope, f, "tab_sw_report_day_key", mode, false)
|
||||
if err := q.Where("(r.source_db,"+id+") IN ?", analyticsPairs(keys)).Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
q, id = analyticsRoot(db, kind, scope, f, "tab_sw_report_day", 0, true)
|
||||
if err := q.Where("(r.source_db,"+id+") IN ?", analyticsPairs(keys)).Select("COALESCE(SUM(r.total_pay),0)").Scan(&globalPay).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
}
|
||||
}
|
||||
if withGlobal {
|
||||
totals := toPortraitCounts(global)
|
||||
result.GlobalCounts, result.GlobalPay = &totals, globalPay
|
||||
@@ -227,15 +279,26 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
}
|
||||
if kind == business.ChannelAnalytics {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
begin, _ := time.ParseInLocation("2006-01-02", f.Begin, location)
|
||||
end, _ := time.ParseInLocation("2006-01-02", f.End, location)
|
||||
root := "p.id"
|
||||
if scope.IncludeSub {
|
||||
root = "CASE WHEN p.promote_level=1 THEN p.id WHEN p.top_promote_id>0 THEN p.top_promote_id ELSE p.id END"
|
||||
}
|
||||
keys := []business.AnalyticsKey{}
|
||||
for key := range rowMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
for start := 0; start < len(keys); start += 300 {
|
||||
var count int64
|
||||
if err := db.Table("sys_business_channel_effectiveness e").Joins("JOIN sys_business_effectiveness_state s ON s.id=1 AND s.rule_key=e.rule_key").Where("(e.source_db,e.promote_id) IN ? AND e.qualified_at>=? AND e.qualified_at<?", analyticsPairs(keys[start:min(start+300, len(keys))]), begin.Unix(), end.AddDate(0, 0, 1).Unix()).Count(&count).Error; err != nil {
|
||||
q := analyticsPromotes(db, scope, f, "p").Joins("JOIN sys_business_channel_effectiveness e ON e.source_db=p.source_db AND e.promote_id=p.id").Joins("JOIN sys_business_effectiveness_state s ON s.id=1 AND s.rule_key=e.rule_key").Where("(p.source_db,"+root+") IN ? AND e.qualified_at IS NOT NULL", analyticsPairs(keys[start:min(start+300, len(keys))]))
|
||||
if f.Begin != "" {
|
||||
begin, _ := time.ParseInLocation("2006-01-02", f.Begin, location)
|
||||
q = q.Where("e.qualified_at >= ?", begin.Unix())
|
||||
}
|
||||
if f.End != "" {
|
||||
end, _ := time.ParseInLocation("2006-01-02", f.End, location)
|
||||
q = q.Where("e.qualified_at < ?", end.AddDate(0, 0, 1).Unix())
|
||||
}
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
result.EffectiveCount += count
|
||||
|
||||
@@ -3,6 +3,7 @@ package business
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
@@ -59,6 +60,69 @@ func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) {
|
||||
t.Fatalf("超管全局去重或官方/历史渠道遗漏: %+v %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsFiltersIncludeIdleChannelsAndRecalculateTotals(t *testing.T) {
|
||||
db, repo, scope, filter := prepareAnalyticsData(t)
|
||||
if err := db.Exec("INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(200,'lx','idle',1,0,0,14)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yes, no := true, false
|
||||
filter.HasRegister, filter.HasPay = &no, &no
|
||||
for _, viewer := range []business.UserScope{scope.Viewer, {All: true}} {
|
||||
scope.Viewer = viewer
|
||||
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, row := range data.Rows {
|
||||
if row.Counts.Register != 0 || portraitTestMoney(row.Pay) != "0.00" {
|
||||
t.Fatalf("筛选后的每行必须同时满足两个条件: %+v", row)
|
||||
}
|
||||
found = found || row.SourceDB == "lx" && row.ID == 200
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("整个期间没有事件的可见渠道不能遗漏")
|
||||
}
|
||||
if viewer.All && (data.GlobalCounts.Register != 0 || portraitTestMoney(data.GlobalPay) != "0.00") {
|
||||
t.Fatalf("管理员合计未跟随过滤: %+v", data)
|
||||
}
|
||||
}
|
||||
filter.HasRegister, filter.HasPay = &yes, &yes
|
||||
filter.Sources, filter.PromoteName = []string{"lx"}, "root-channel"
|
||||
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 1 || portraitTestMoney(data.GlobalPay) != "60.60" || data.GlobalCounts.Register != data.Rows[0].Counts.Register {
|
||||
t.Fatalf("平台、名称及指标筛选须共同约束列表与全量合计: %+v %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsEffectiveCountIncludesQualifiedDescendants(t *testing.T) {
|
||||
db, repo, scope, filter := prepareAnalyticsData(t)
|
||||
qualified := time.Date(2026, 11, 2, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).Unix()
|
||||
if err := db.Exec("UPDATE sys_business_effectiveness_state SET rule_key='audit' WHERE id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []business.ChannelKey{{SourceDB: "lx", ID: 100}, {SourceDB: "lx", ID: 101}, {SourceDB: "yq", ID: 100}} {
|
||||
if err := db.Create(&effectivenessPO{RuleKey: "audit", SourceDB: key.SourceDB, PromoteID: key.ID, QualifiedAt: &qualified, WindowPay: "3000"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
filter.PromoteName = "root-channel"
|
||||
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 1 || data.EffectiveCount != 2 {
|
||||
t.Fatalf("合并渠道树展示仍须计入下级达标,且不能混入权限外同号渠道: %+v %v", data, err)
|
||||
}
|
||||
scope.IncludeSub = false
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || data.EffectiveCount != 1 {
|
||||
t.Fatalf("关闭包含下级后只统计自身: %+v %v", data, err)
|
||||
}
|
||||
filter.Begin = "2026-11-03"
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || data.EffectiveCount != 0 {
|
||||
t.Fatalf("有效端必须按达标时间归属统计区间: %+v %v", data, err)
|
||||
}
|
||||
}
|
||||
func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) {
|
||||
db, repo, scope, filter := prepareAnalyticsData(t)
|
||||
filter.PromoteName = "child"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
@@ -18,11 +19,13 @@ type channelPO struct {
|
||||
ParentID, TopPromoteID, BusierID uint64
|
||||
CreateTime *int64
|
||||
LastLoginTime *int64 `gorm:"column:last_login_time"`
|
||||
MobilePhone string
|
||||
SettlementDayPeriod *int
|
||||
}
|
||||
|
||||
func (channelPO) TableName() string { return "tab_promote" }
|
||||
func toBizChannel(p channelPO) *business.Channel {
|
||||
return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime, QualifiedAt: p.QualifiedAt}
|
||||
return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime, QualifiedAt: p.QualifiedAt, Phone: p.MobilePhone, SettlementDays: p.SettlementDayPeriod}
|
||||
}
|
||||
|
||||
type channelRepo struct{ data Provider }
|
||||
@@ -58,7 +61,7 @@ func (r *channelRepo) Bindings(ctx context.Context, scope business.UserScope, so
|
||||
}
|
||||
func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter business.ChannelFilter, options ...business.ListOption) (*business.ChannelPage, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
page := &business.ChannelPage{Rows: []*business.Channel{}, Available: true}
|
||||
page := &business.ChannelPage{Rows: []*business.Channel{}, Available: true, StatusCounts: &business.ChannelStatusCounts{}}
|
||||
bindings := channelBindingQuery(db, scope, filter)
|
||||
constrain := !scope.All || filter.BusierUserID != nil || filter.BusierAccount != ""
|
||||
if constrain {
|
||||
@@ -89,6 +92,26 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
|
||||
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
|
||||
q = q.Where("INSTR(LOWER(p.account),?)>0 OR INSTR(LOWER(p.nickname),?)>0 OR INSTR(LOWER(p.real_name),?)>0", keyword, keyword, keyword)
|
||||
}
|
||||
if filter.Phone != "" {
|
||||
q = q.Where("INSTR(p.mobile_phone,?) > 0", filter.Phone)
|
||||
}
|
||||
if filter.SettlementDays != nil {
|
||||
q = q.Where("p.settlement_day_period = ?", *filter.SettlementDays)
|
||||
}
|
||||
for _, period := range []struct {
|
||||
column string
|
||||
begin, end *time.Time
|
||||
}{
|
||||
{"p.create_time", filter.CreatedBegin, filter.CreatedEnd},
|
||||
{"p.last_login_time", filter.LoginBegin, filter.LoginEnd},
|
||||
} {
|
||||
if period.begin != nil {
|
||||
q = q.Where(period.column+" >= ?", period.begin.Unix())
|
||||
}
|
||||
if period.end != nil {
|
||||
q = q.Where(period.column+" < ?", period.end.AddDate(0, 0, 1).Unix())
|
||||
}
|
||||
}
|
||||
for _, field := range []struct {
|
||||
column string
|
||||
value *int
|
||||
@@ -105,14 +128,21 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
|
||||
q = q.Where("e.qualified_at IS NULL")
|
||||
}
|
||||
}
|
||||
if err := q.Count(&page.Total).Error; err != nil {
|
||||
var counts struct{ Total, Normal, Locked, Terminated int64 }
|
||||
if err := q.Session(&gorm.Session{}).Select("COUNT(*) AS total,COALESCE(SUM(CASE WHEN COALESCE(p.lock_status,0)=0 THEN 1 ELSE 0 END),0) AS normal,COALESCE(SUM(CASE WHEN COALESCE(p.lock_status,0) NOT IN (0,-2) THEN 1 ELSE 0 END),0) AS locked,COALESCE(SUM(CASE WHEN p.lock_status=-2 THEN 1 ELSE 0 END),0) AS terminated").Scan(&counts).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
page.Total = counts.Total
|
||||
page.StatusCounts = &business.ChannelStatusCounts{Normal: counts.Normal, Locked: counts.Locked, Terminated: counts.Terminated}
|
||||
var pos []channelPO
|
||||
o := business.NewListOptions(options...)
|
||||
keysQuery := q.Select("p.id,p.source_db").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit)
|
||||
order := "p.create_time DESC,p.id DESC,p.source_db ASC"
|
||||
if filter.SortField == "createdAt" && filter.Ascending {
|
||||
order = "p.create_time ASC,p.id ASC,p.source_db ASC"
|
||||
}
|
||||
keysQuery := q.Select("p.id,p.source_db").Order(order).Offset(o.Offset).Limit(o.Limit)
|
||||
pageQuery := joinEffectiveness(db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery))
|
||||
if err := pageQuery.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id,p.create_time,e.qualified_at").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Scan(&pos).Error; err != nil {
|
||||
if err := pageQuery.Select("p.*,e.qualified_at").Order(order).Scan(&pos).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(pos) == 0 {
|
||||
|
||||
@@ -3,7 +3,9 @@ package business
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
@@ -40,6 +42,42 @@ func prepareChannels(t *testing.T) (*gorm.DB, business.ChannelRepo) {
|
||||
return db, NewChannelRepo(testData{db})
|
||||
}
|
||||
|
||||
func TestChannelMetadataFiltersSortingAndFullStatusCounts(t *testing.T) {
|
||||
db, repo := prepareChannels(t)
|
||||
day := time.Date(2026, 11, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
for _, sql := range []string{"ALTER TABLE tab_promote ADD COLUMN mobile_phone TEXT", "ALTER TABLE tab_promote ADD COLUMN settlement_day_period INTEGER", "ALTER TABLE tab_promote ADD COLUMN last_login_time INTEGER"} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i, status := range []int{0, -1, -2} {
|
||||
if err := db.Exec("UPDATE tab_promote SET create_time=?,last_login_time=?,mobile_phone=?,settlement_day_period=7,lock_status=? WHERE source_db='lx' AND id=?", day.Unix()+int64(i+1)*100, day.Unix()+1000, "1381234001"+strconv.Itoa(i), status, 100+i).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope := business.UserScope{SelfID: 9, OnlySelf: true}
|
||||
seven := 7
|
||||
filter := business.ChannelFilter{Phone: "4001", SettlementDays: &seven, CreatedBegin: &day, CreatedEnd: &day, LoginBegin: &day, LoginEnd: &day, SortField: "createdAt", Ascending: true}
|
||||
page, err := repo.List(context.Background(), scope, filter, business.ListLimit(1))
|
||||
if err != nil || page.Total != 3 || len(page.Rows) != 1 || page.Rows[0].ID != 100 || page.Rows[0].Phone != "13812340010" || page.Rows[0].SettlementDays == nil || *page.Rows[0].SettlementDays != 7 || page.Rows[0].LastLoginUnix == nil {
|
||||
t.Fatalf("筛选、排序或元数据遗漏: %+v %v", page, err)
|
||||
}
|
||||
if page.StatusCounts.Normal != 1 || page.StatusCounts.Locked != 1 || page.StatusCounts.Terminated != 1 {
|
||||
t.Fatalf("状态汇总必须覆盖全量三条而非当前页: %+v", page.StatusCounts)
|
||||
}
|
||||
filter.Ascending = false
|
||||
page, err = repo.List(context.Background(), scope, filter, business.ListLimit(1))
|
||||
if err != nil || page.Rows[0].ID != 102 {
|
||||
t.Fatalf("降序必须作用于所有页: %+v %v", page, err)
|
||||
}
|
||||
locked := -1
|
||||
filter.LockStatus = &locked
|
||||
page, err = repo.List(context.Background(), scope, filter)
|
||||
if err != nil || page.Total != 1 || page.StatusCounts.Normal != 0 || page.StatusCounts.Locked != 1 || page.StatusCounts.Terminated != 0 {
|
||||
t.Fatalf("筛选变化应同步刷新汇总: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func channelTestScope() business.UserScope {
|
||||
return business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,17 @@ func (h *Analytics) Channels(c *gin.Context) {
|
||||
Fail(c, "统计查询参数不正确")
|
||||
return
|
||||
}
|
||||
if req.Export {
|
||||
data, err := h.service.Export(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=channel-statistics.xlsx")
|
||||
c.Header("success", "true")
|
||||
c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
return
|
||||
}
|
||||
data, err := h.service.Channels(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
type AnalyticsUsecase interface {
|
||||
List(context.Context, business.AnalyticsKind, business.AnalyticsFilter, int, int) (*business.AnalyticsPage, error)
|
||||
ListAll(context.Context, business.AnalyticsKind, business.AnalyticsFilter) (*business.AnalyticsPage, error)
|
||||
}
|
||||
type AnalyticsService struct{ uc AnalyticsUsecase }
|
||||
|
||||
@@ -24,22 +25,21 @@ func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error)
|
||||
if err != nil {
|
||||
return business.AnalyticsFilter{}, err
|
||||
}
|
||||
f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc")}
|
||||
f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, HasRegister: req.HasRegister, HasPay: req.HasPay, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc")}
|
||||
begin, end := strings.TrimSpace(req.BeginTime), strings.TrimSpace(req.EndTime)
|
||||
if (begin == "") != (end == "") {
|
||||
return f, business.ErrAnalyticsDate
|
||||
}
|
||||
if begin != "" && end != "" {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
location := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
if begin != "" {
|
||||
b, err := time.ParseInLocation("2006-01-02", begin, location)
|
||||
if err != nil {
|
||||
return f, business.ErrAnalyticsDate
|
||||
}
|
||||
f.StartDate = &b
|
||||
}
|
||||
if end != "" {
|
||||
e, err := time.ParseInLocation("2006-01-02", end, location)
|
||||
if err != nil || e.Before(b) {
|
||||
if err != nil || f.StartDate != nil && e.Before(*f.StartDate) {
|
||||
return f, business.ErrAnalyticsDate
|
||||
}
|
||||
f.StartDate = &b
|
||||
f.EndDate = &e
|
||||
}
|
||||
return f, nil
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
func (s *AnalyticsService) Export(ctx context.Context, req *dto.AnalyticsQuery) ([]byte, error) {
|
||||
f, err := analyticsRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.uc.ListAll(ctx, business.ChannelAnalytics, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !page.Available {
|
||||
return nil, business.ErrChannelReportsUnavailable
|
||||
}
|
||||
headers := []string{"所属平台", "渠道名称", "归属商务", "渠道类型", "新增注册", "活跃玩家", "充值额", "付费玩家", "新增付费", "付费率"}
|
||||
rows := make([][]ledgerCell, 0, len(page.Rows)+1)
|
||||
appendRow := func(row *business.AnalyticsRow, summary bool) {
|
||||
platform, name, owner, kind := business.PlatformName(row.SourceDB), row.Name, row.BusinessName(), "—"
|
||||
if row.PromoteType != nil {
|
||||
if *row.PromoteType == 1 {
|
||||
kind = "公会"
|
||||
} else if *row.PromoteType == 0 {
|
||||
kind = "个人"
|
||||
}
|
||||
}
|
||||
if summary {
|
||||
platform, name, owner, kind = "全部", "合计(有效端 "+strconv.FormatInt(page.EffectiveCount, 10)+")", "—", "—"
|
||||
}
|
||||
rows = append(rows, []ledgerCell{textCell(platform), textCell(name), textCell(owner), textCell(kind), numericCell(strconv.FormatInt(row.Counts.Register, 10)), numericCell(strconv.FormatInt(row.Counts.Active, 10)), numericCell(row.Pay), numericCell(strconv.FormatInt(row.Counts.Pay, 10)), numericCell(strconv.FormatInt(row.Counts.NewPay, 10)), textCell(row.PayRate + "%")})
|
||||
}
|
||||
for _, row := range page.Rows {
|
||||
appendRow(row, false)
|
||||
}
|
||||
if page.Summary != nil {
|
||||
appendRow(page.Summary, true)
|
||||
}
|
||||
return ledgerWorkbook("渠道数据统计", headers, rows)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"kra/internal/biz/business"
|
||||
@@ -42,13 +43,34 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto.
|
||||
return nil, business.ErrChannelInput
|
||||
}
|
||||
filter := business.ChannelFilter{Sources: sources, BusierUserID: req.BusierUserID, BusierAccount: strings.TrimSpace(req.BusierAccount), Keyword: strings.TrimSpace(req.Keyword), Level: req.Level, RegisterType: req.RegisterType, Status: req.Status, LockStatus: req.LockStatus, Effective: req.Effective}
|
||||
filter.Phone, filter.SettlementDays = strings.TrimSpace(req.Phone), req.SettlementDays
|
||||
filter.SortField, filter.Ascending = req.SortField, req.SortOrder == "asc"
|
||||
if filter.SortField != "" && filter.SortField != "createdAt" || filter.SettlementDays != nil && *filter.SettlementDays < 1 {
|
||||
return nil, business.ErrChannelInput
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
begin, end string
|
||||
outBegin, outEnd **time.Time
|
||||
}{
|
||||
{req.CreatedBegin, req.CreatedEnd, &filter.CreatedBegin, &filter.CreatedEnd},
|
||||
{req.LoginBegin, req.LoginEnd, &filter.LoginBegin, &filter.LoginEnd},
|
||||
} {
|
||||
parsed, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: pair.begin, EndTime: pair.end})
|
||||
if err != nil {
|
||||
return nil, business.ErrChannelInput
|
||||
}
|
||||
*pair.outBegin, *pair.outEnd = parsed.StartDate, parsed.EndDate
|
||||
}
|
||||
result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &dto.ChannelPage{Rows: []*dto.ChannelResponse{}, Total: result.Total, Available: result.Available}
|
||||
if result.StatusCounts != nil {
|
||||
out.StatusCounts = &dto.ChannelStatusCounts{Normal: result.StatusCounts.Normal, Locked: result.StatusCounts.Locked, Terminated: result.StatusCounts.Terminated}
|
||||
}
|
||||
for _, c := range result.Rows {
|
||||
out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount, Effective: c.QualifiedAt != nil, QualifiedAt: c.QualifiedAt, CreatedAt: c.CreatedUnix})
|
||||
out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount, Effective: c.QualifiedAt != nil, QualifiedAt: c.QualifiedAt, CreatedAt: c.CreatedUnix, LastLoginAt: c.LastLoginUnix, Phone: c.Phone, SettlementDays: c.SettlementDays})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestRejectIncompleteAndReversedDateRanges(t *testing.T) {
|
||||
for _, dates := range [][2]string{{"2026-11-01", ""}, {"", "2026-11-01"}, {"2026-11-03", "2026-11-01"}, {"bad", "2026-11-01"}} {
|
||||
if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err == nil {
|
||||
if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); dates[0] != "" && dates[1] != "" && err == nil {
|
||||
t.Errorf("analytics accepted %v", dates)
|
||||
}
|
||||
for _, spend := range []bool{false, true} {
|
||||
@@ -31,3 +31,18 @@ func TestRejectIncompleteAndReversedDateRanges(t *testing.T) {
|
||||
t.Fatal("account query silently ignored incomplete date range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsOpenEndedDatesAndMetricFilters(t *testing.T) {
|
||||
no, yes := false, true
|
||||
for _, dates := range [][2]string{{"", ""}, {"2026-11-01", ""}, {"", "2026-11-03"}} {
|
||||
filter, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1], HasRegister: &no, HasPay: &yes})
|
||||
if err != nil || (filter.StartDate == nil) != (dates[0] == "") || (filter.EndDate == nil) != (dates[1] == "") || *filter.HasRegister || !*filter.HasPay {
|
||||
t.Fatalf("独立起止日期及是否有数据筛选必须原样传递: %+v %v", filter, err)
|
||||
}
|
||||
}
|
||||
for _, dates := range [][2]string{{"bad", ""}, {"", "bad"}, {"2026-11-03", "2026-11-01"}} {
|
||||
if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err == nil {
|
||||
t.Fatalf("invalid dates accepted: %v", dates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package dto
|
||||
|
||||
type AnalyticsQuery struct {
|
||||
Export bool `form:"export"`
|
||||
Page int `form:"pageNum"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Sources []string `form:"sourceDb"`
|
||||
GameNames []string `form:"gameName"`
|
||||
PromoteType *int `form:"promoteType"`
|
||||
HasRegister *bool `form:"hasRegister"`
|
||||
HasPay *bool `form:"hasPay"`
|
||||
PromoteName string `form:"promoteName"`
|
||||
BeginTime string `form:"beginTime"`
|
||||
EndTime string `form:"endTime"`
|
||||
|
||||
@@ -3,22 +3,33 @@ package dto
|
||||
import "encoding/json"
|
||||
|
||||
type ChannelQuery struct {
|
||||
Effective *bool `form:"effective"`
|
||||
Page int `form:"pageNum"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Sources []string `form:"sourceDb"`
|
||||
BusierUserID *uint `form:"busierUserId"`
|
||||
BusierAccount string `form:"busierAccount"`
|
||||
Keyword string `form:"keyword"`
|
||||
Level *int `form:"promoteLevel"`
|
||||
RegisterType *int `form:"registerType"`
|
||||
Status *int `form:"status"`
|
||||
LockStatus *int `form:"lockStatus"`
|
||||
Effective *bool `form:"effective"`
|
||||
Page int `form:"pageNum"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Sources []string `form:"sourceDb"`
|
||||
BusierUserID *uint `form:"busierUserId"`
|
||||
BusierAccount string `form:"busierAccount"`
|
||||
Keyword string `form:"keyword"`
|
||||
Level *int `form:"promoteLevel"`
|
||||
RegisterType *int `form:"registerType"`
|
||||
Status *int `form:"status"`
|
||||
LockStatus *int `form:"lockStatus"`
|
||||
Phone string `form:"phone"`
|
||||
SettlementDays *int `form:"settlementDays"`
|
||||
CreatedBegin string `form:"createdBegin"`
|
||||
CreatedEnd string `form:"createdEnd"`
|
||||
LoginBegin string `form:"loginBegin"`
|
||||
LoginEnd string `form:"loginEnd"`
|
||||
SortField string `form:"sortField"`
|
||||
SortOrder string `form:"sortOrder"`
|
||||
}
|
||||
type ChannelResponse struct {
|
||||
Effective bool `json:"effective"`
|
||||
QualifiedAt *int64 `json:"qualifiedAt"`
|
||||
CreatedAt *int64 `json:"createdAt"`
|
||||
LastLoginAt *int64 `json:"lastLoginAt"`
|
||||
Phone string `json:"phone"`
|
||||
SettlementDays *int `json:"settlementDays"`
|
||||
ID uint64 `json:"id"`
|
||||
SourceDB string `json:"sourceDb"`
|
||||
PlatformName string `json:"platformName"`
|
||||
@@ -39,9 +50,15 @@ type ChannelResponse struct {
|
||||
TotalPayAmount *string `json:"totalPayAmount"`
|
||||
}
|
||||
type ChannelPage struct {
|
||||
Rows []*ChannelResponse `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
Available bool `json:"available"`
|
||||
Rows []*ChannelResponse `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
Available bool `json:"available"`
|
||||
StatusCounts *ChannelStatusCounts `json:"statusCounts"`
|
||||
}
|
||||
type ChannelStatusCounts struct {
|
||||
Normal int64 `json:"normal"`
|
||||
Locked int64 `json:"locked"`
|
||||
Terminated int64 `json:"terminated"`
|
||||
}
|
||||
type ChannelBindOption struct {
|
||||
SourceDB string `json:"sourceDb"`
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import service from '@/utils/request'
|
||||
export const listAnalytics = (kind, params) => service({ url: `/sw/${kind === 'channel' ? 'channelData' : 'gameData'}/list`, method: 'get', params })
|
||||
export const exportChannelAnalytics = params => service({ url: '/sw/channelData/list', method: 'get', params: { ...params, export: true }, responseType: 'blob' })
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
<template>
|
||||
<div class="sw-page">
|
||||
<div class="channel-status-summary"><el-card v-for="card in statusCards" :key="card.label" shadow="never"><el-tooltip content="统计当前筛选结果的全部渠道,正常、锁定、解除合作三类互斥,合计等于渠道数量。"><span>{{ card.label }}</span></el-tooltip><strong>{{ card.value == null ? '—' : channelCount(card.value) }}</strong></el-card></div>
|
||||
<div class="kra-search-box">
|
||||
<el-form :model="query" :inline="true" @submit.prevent="search">
|
||||
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="商务账号"><el-input v-model="query.busierAccount" clearable placeholder="新商务 / SDK 商务账号" /></el-form-item>
|
||||
<el-form-item label="渠道名称"><el-input v-model="query.keyword" clearable placeholder="渠道名称 / 账号 / 负责人" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="query.phone" clearable placeholder="支持部分号码" /></el-form-item>
|
||||
<el-form-item label="结算周期"><el-select v-model="query.settlementDays" clearable placeholder="全部周期" style="width:130px"><el-option label="1 天(日结)" :value="1" /><el-option label="7 天(周结)" :value="7" /></el-select></el-form-item>
|
||||
<el-form-item label="渠道层级"><el-select v-model="query.promoteLevel" clearable placeholder="全部层级" style="width: 140px"><el-option v-for="(label, value) in levels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item label="渠道类型"><el-select v-model="query.registerType" clearable placeholder="全部类型" style="width: 140px"><el-option label="个人" :value="0" /><el-option label="公会" :value="1" /></el-select></el-form-item>
|
||||
<el-form-item label="锁定状态"><el-select v-model="query.lockStatus" clearable placeholder="全部状态" style="width: 140px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.lockStatus" clearable placeholder="全部状态" style="width: 140px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item label="有效端"><el-select v-model="query.effective" clearable placeholder="全部" style="width:120px"><el-option label="是" :value="true" /><el-option label="否" :value="false" /></el-select></el-form-item>
|
||||
<el-form-item label="注册时间"><el-date-picker v-model="createdDates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="注册开始时间" end-placeholder="注册结束时间" style="width:290px" /></el-form-item>
|
||||
<el-form-item label="登录时间"><el-date-picker v-model="loginDates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="登录开始时间" end-placeholder="登录结束时间" style="width:290px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<div class="kra-btn-list"><el-button v-if="canAdd" type="primary" icon="plus" @click="adding = true">添加渠道</el-button></div>
|
||||
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入。" />
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`">
|
||||
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" @sort-change="sortChanged">
|
||||
<el-table-column prop="platformName" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.platformName" /></template></el-table-column>
|
||||
<el-table-column prop="id" label="ID" min-width="90" class-name="sw-col-meta" />
|
||||
<el-table-column prop="account" label="渠道名称" class-name="sw-col-identity" min-width="150" show-overflow-tooltip />
|
||||
@@ -23,14 +29,17 @@
|
||||
<el-table-column prop="busierDisplayName" label="归属商务" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="上级渠道" min-width="130" show-overflow-tooltip><template #default="{ row }">{{ row.parentName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="负责人" min-width="100"><template #default="{ row }">{{ row.realName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" class-name="sw-col-meta"><template #default="{ row }">{{ row.phone || '—' }}</template></el-table-column>
|
||||
<el-table-column label="结算周期" width="110"><template #default="{ row }">{{ row.settlementDays == null ? '—' : `${row.settlementDays} 天` }}</template></el-table-column>
|
||||
<el-table-column label="渠道类型" min-width="100"><template #default="{ row }"><el-tag v-if="row.registerType === 0 || row.registerType === 1" size="small" :type="row.registerType === 1 ? 'warning' : 'info'" effect="plain">{{ row.registerType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
|
||||
<el-table-column label="推广游戏" min-width="115"><template #default="{ row }"><el-button v-if="row.gameCount != null" link type="primary" @click="openGames(row)">{{ channelCount(row.gameCount) }} 款</el-button><span v-else>未接入</span></template></el-table-column>
|
||||
<el-table-column label="玩家数量" min-width="110" align="right" class-name="sw-col-number"><template #default="{ row }"><metric-value :value="row.playerCount" /></template></el-table-column>
|
||||
<el-table-column label="累计充值" min-width="150" align="right" class-name="sw-col-number"><template #default="{ row }"><metric-value kind="money" :value="row.totalPayAmount" /></template></el-table-column>
|
||||
<el-table-column label="注册时间" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="createdAt" label="注册时间" min-width="150" sortable="custom" :sort-orders="['descending','ascending',null]" class-name="sw-col-meta"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="最后登录时间" min-width="150" class-name="sw-col-meta"><template #default="{ row }">{{ row.lastLoginAt ? dateText(row.lastLoginAt) : '—' }}</template></el-table-column>
|
||||
<el-table-column label="有效端" width="100"><template #default="{ row }"><el-tag size="small" :type="row.effective ? 'success' : 'info'">{{ row.effective ? '是' : '否' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="达标时间" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ dateText(row.qualifiedAt) }}</template></el-table-column>
|
||||
<el-table-column label="锁定状态" min-width="110"><template #default="{ row }"><el-tag size="small" :type="{ 0: 'success', '-1': 'danger', '-2': 'info' }[row.lockStatus] || 'info'" effect="plain">{{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag size="small" :type="{ 0: 'success', '-1': 'danger', '-2': 'info' }[row.lockStatus] || 'info'" effect="plain">{{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openProfile(row)">画像</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
@@ -64,7 +73,9 @@ const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const levels = { 1: '一级渠道', 2: '二级渠道', 3: '三级渠道' }
|
||||
const statuses = { 0: '正常', '-1': '已锁定', '-2': '解除合作' }
|
||||
const sources = ref(Object.keys(platforms))
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', keyword: '', promoteLevel: undefined, registerType: undefined, lockStatus: undefined, effective: undefined })
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', keyword: '', phone: '', settlementDays: undefined, promoteLevel: undefined, registerType: undefined, lockStatus: undefined, effective: undefined, sortField: '', sortOrder: '' })
|
||||
const createdDates = ref([]), loginDates = ref([]), statusCounts = ref(null), error = ref('')
|
||||
const statusCards = computed(() => [{ label: '渠道数量', value: statusCounts.value ? total.value : null }, { label: '正常渠道', value: statusCounts.value?.normal }, { label: '锁定渠道', value: statusCounts.value?.locked }, { label: '解除合作', value: statusCounts.value?.terminated }])
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const available = ref(true)
|
||||
@@ -78,16 +89,23 @@ const openProfile = row => { profileChannel.value = row; profileOpen.value = tru
|
||||
let requestVersion = 0
|
||||
const load = async () => {
|
||||
const version = ++requestVersion
|
||||
if (!sources.value.length) { rows.value = []; total.value = 0; available.value = true; loading.value = false; return }
|
||||
loading.value = true
|
||||
if (!sources.value.length) { rows.value = []; total.value = 0; statusCounts.value = null; error.value = ''; available.value = true; loading.value = false; return }
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res = await listChannels({ ...query, sourceDb: sources.value.join(','), busierAccount: query.busierAccount.trim(), keyword: query.keyword.trim() })
|
||||
const res = await listChannels({ ...query, sourceDb: sources.value.join(','), busierAccount: query.busierAccount.trim(), keyword: query.keyword.trim(), phone: query.phone.trim(), createdBegin: createdDates.value?.[0] || '', createdEnd: createdDates.value?.[1] || '', loginBegin: loginDates.value?.[0] || '', loginEnd: loginDates.value?.[1] || '' })
|
||||
if (version !== requestVersion) return
|
||||
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available !== false }
|
||||
else { rows.value = []; total.value = 0 }
|
||||
} catch { if (version === requestVersion) { rows.value = []; total.value = 0 } } finally { if (version === requestVersion) loading.value = false }
|
||||
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; statusCounts.value = res.data.available === false ? null : res.data.statusCounts; available.value = res.data.available !== false }
|
||||
else { rows.value = []; total.value = 0; statusCounts.value = null; error.value = res.msg || '渠道查询失败' }
|
||||
} catch { if (version === requestVersion) { rows.value = []; total.value = 0; statusCounts.value = null; error.value = '渠道查询失败,请重试' } } finally { if (version === requestVersion) loading.value = false }
|
||||
}
|
||||
const sortChanged = ({ prop, order }) => { query.sortField = order ? prop : ''; query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return search() }
|
||||
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
|
||||
const openGames = row => { selected.value = row; gamesOpen.value = true }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<style scoped>
|
||||
.channel-status-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }
|
||||
.channel-status-summary span { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.channel-status-summary strong { display: block; font-size: 26px; margin-top: 10px; }
|
||||
@media(max-width:600px) { .channel-status-summary { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<el-alert v-if="error || metricError" :title="error || metricError" type="error" :closable="false" class="mb-3" />
|
||||
<el-table scrollbar-always-on v-if="tab === 'accounts'" v-loading="loading" :data="pagedMembers" row-key="userId" @sort-change="sortMembers">
|
||||
<el-table-column prop="userId" label="ID" width="80" class-name="sw-col-meta" />
|
||||
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="userName" label="商务账号" class-name="sw-col-identity" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="商务等级" width="105"><template #default="{ row }"><level-tag :level="row.level" /></template></el-table-column>
|
||||
<el-table-column prop="parentUserName" label="上级商务" min-width="120" show-overflow-tooltip />
|
||||
@@ -34,7 +35,6 @@
|
||||
<el-table-column v-for="metric in ['open', 'effective']" :key="metric" :label="metric === 'open' ? '开端数' : '有效端数'" width="105"><template #header><el-tooltip :content="periodTip"><span>{{ metric === 'open' ? '开端数' : '有效端数' }}</span></el-tooltip></template><template #default="{ row }"><el-button v-if="row.metrics?.[metric]" link :class="metric" @click="details(row, metric)">{{ row.metrics[metric] }}</el-button><span v-else :class="metric">{{ metricLoading ? '…' : row.metrics ? 0 : '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="有效率" width="90"><template #default="{ row }"><span :class="row.rate != null && Number(row.rate) < 50 ? 'low-rate' : 'effective'">{{ row.rate == null ? '—' : `${row.rate}%` }}</span></template></el-table-column>
|
||||
<el-table-column prop="hireDate" label="入职日期" width="115" class-name="sw-col-meta"><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="在职状态" width="100"><template #default="{ row }"><el-tag size="small" :type="employmentTypes[row.employment]">{{ employmentLabels[row.employment] }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="195" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="row.canEdit" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.canSetGoal" link type="primary" @click="openGoal('person', row.userId, displayMember(row))">设目标</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
@@ -126,7 +126,7 @@ const showBindings = row => { selectedID.value = row.userId; bindingsOpen.value
|
||||
const showPortrait = row => { selectedID.value = row.userId; selectedName.value = displayMember(row); portraitOpen.value = true }
|
||||
const memberDrawer = ref(false), editing = ref(false), memberForm = ref(), saving = ref(false), form = ref({})
|
||||
const rules = computed(() => ({ userName: [{ required: true, message: '请输入商务账号' }], realName: [{ required: true, message: '请输入负责人姓名' }], hireDate: [{ required: true, message: '请选择入职日期' }], phoneNumber: [{ pattern: /^[0-9 -]*$/, message: '联系电话只允许数字、空格和短横线', trigger: 'blur' }], password: [{ validator: (_r, value, cb) => cb((editing.value && !value || value?.length >= 5 && value.length <= 50) ? undefined : new Error('密码为5至50个字符')) }] }))
|
||||
const openCreate = () => { editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: '', password: '' }; memberDrawer.value = true }
|
||||
const openCreate = () => { const today = new Date(); editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`, password: '' }; memberDrawer.value = true }
|
||||
const openEdit = row => { editing.value = true; form.value = { ...row, password: '' }; memberDrawer.value = true }
|
||||
const saveMember = async () => { if (saving.value || !await memberForm.value.validate().catch(() => false)) return; saving.value = true; try { const data = editing.value ? { userId: form.value.userId, realName: form.value.realName, phoneNumber: form.value.phoneNumber, hireDate: form.value.hireDate, employment: form.value.employment, password: form.value.password } : { ...form.value, nickName: form.value.realName, status: '0' }; const res = await (editing.value ? updateMember(data) : createBusiness(data)); if (res.code === 0) { ElMessage.success('保存成功'); memberDrawer.value = false; await refresh() } } finally { saving.value = false } }
|
||||
onMounted(refresh)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<el-alert v-if="error" :title="error" type="warning" :closable="false" />
|
||||
<div v-for="row in rows" :key="row.key" class="goal-row">
|
||||
<span>{{ row.label }}</span><strong>{{ displayMetric(row.key, row.actual) }} / {{ displayTarget(row.goal, row.target) }}</strong>
|
||||
<div class="goal-progress"><span><i :class="{ achieved: row.achieved }" :style="{ width: `${progressWidth(row.rate)}%` }" /></span><small>{{ rateText(row.rate) }}</small></div>
|
||||
<div class="goal-progress"><span><i :class="{ achieved: row.achieved }" :style="{ width: `${row.target === 0 && row.achieved ? 100 : progressWidth(row.rate)}%` }" /></span><small>{{ rateText(row.rate) }}</small></div>
|
||||
<el-tag :type="row.achieved ? 'success' : 'primary'">{{ row.achieved ? '已达标' : '未达标' }}</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -12,9 +12,9 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getMyGoal } from '@/api/performance'
|
||||
import { performanceMetrics, displayMetric, displayTarget, completionRate, progressWidth, rateText, goalAchieved } from '../performance-display'
|
||||
import { displayMetric, displayTarget, progressWidth, rateText, personalGoalRows } from '../performance-display'
|
||||
const data = ref(null), error = ref(''), loading = ref(false)
|
||||
const rows = computed(() => performanceMetrics.filter(metric => Number(data.value?.goal?.[metric.goal]) > 0).map(metric => { const target = data.value.goal[metric.goal], actual = data.value.actual[metric.key]; return { ...metric, target, actual, rate: completionRate(actual, target), achieved: goalAchieved(actual, target, metric.key) } }))
|
||||
const rows = computed(() => personalGoalRows(data.value))
|
||||
onMounted(async () => { loading.value = true; try { const res = await getMyGoal(); if (res.code === 0) data.value = res.data; else error.value = res.msg || '本月目标读取失败' } catch { error.value = '本月目标读取失败,请刷新重试' } finally { loading.value = false } })
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div class="sw-page">
|
||||
<div v-if="isChannel" v-loading="loading" class="report-cards">
|
||||
<el-card v-for="card in cards" :key="card.label" shadow="never"><div class="report-card-label">{{ card.label }}</div><strong>{{ card.value }}</strong></el-card>
|
||||
</div>
|
||||
<div class="kra-search-box">
|
||||
<el-form :model="query" :inline="true" @submit.prevent="search">
|
||||
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px" @change="sourceChanged"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
@@ -12,7 +9,10 @@
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isChannel" label="渠道类型"><el-select v-model="query.promoteType" clearable placeholder="全部渠道类型" style="width: 150px"><el-option label="公会" :value="1" /><el-option label="个人" :value="0" /></el-select></el-form-item>
|
||||
<el-form-item v-if="isChannel" label="渠道名称"><el-input v-model="query.promoteName" clearable placeholder="渠道名称或账号" /></el-form-item>
|
||||
<el-form-item label="统计时间"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
|
||||
<el-form-item v-if="isChannel" label="是否有注册"><el-select v-model="query.hasRegister" clearable placeholder="全部" style="width:130px" @change="search"><el-option label="有注册" :value="true" /><el-option label="无注册" :value="false" /></el-select></el-form-item>
|
||||
<el-form-item v-if="isChannel" label="是否有充值"><el-select v-model="query.hasPay" clearable placeholder="全部" style="width:130px" @change="search"><el-option label="有充值" :value="true" /><el-option label="无充值" :value="false" /></el-select></el-form-item>
|
||||
<el-form-item v-if="isChannel" label="统计时间"><div class="report-date-range"><el-date-picker v-model="dates[0]" type="date" value-format="YYYY-MM-DD" placeholder="统计开始时间" :shortcuts="startShortcuts" clearable /><span>至</span><el-date-picker v-model="dates[1]" type="date" value-format="YYYY-MM-DD" placeholder="统计结束时间" :shortcuts="endShortcuts" clearable /></div></el-form-item>
|
||||
<el-form-item v-else label="统计时间"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">{{ isChannel ? '搜索' : '查询' }}</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -20,30 +20,31 @@
|
||||
<div v-if="!isChannel" class="report-heading"><strong>核心指标</strong><el-text type="info">统计周期:{{ period }}</el-text></div>
|
||||
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
|
||||
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="统计所需业务汇总尚未接入。" />
|
||||
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span></div>
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="{ prop: 'totalPay', order: 'descending' }" :show-summary="!isChannel" :summary-method="summaries" @sort-change="sortChanged">
|
||||
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span><el-button v-if="isChannel" :loading="exporting" :disabled="loading || !loadedQuery || !available" @click="download">导出</el-button></div>
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="isChannel ? undefined : { prop: 'totalPay', order: 'descending' }" show-summary :summary-method="summaries" @sort-change="sortChanged">
|
||||
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
|
||||
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="170" show-overflow-tooltip class-name="sw-col-identity" />
|
||||
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="190" show-overflow-tooltip class-name="sw-col-identity"><template #default="{ row }"><el-button v-if="isChannel && row.promoteId > 0" link type="primary" @click="openChannel(row)">{{ row.promoteName }}</el-button><span v-else>{{ isChannel ? row.promoteName : row.gameName }}</span></template></el-table-column>
|
||||
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column v-if="isChannel" label="渠道类型" min-width="100"><template #default="{ row }"><el-tag v-if="row.promoteType === 0 || row.promoteType === 1" size="small" :type="row.promoteType === 1 ? 'warning' : 'info'" effect="plain">{{ row.promoteType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
|
||||
<el-table-column v-else prop="gameId" label="游戏ID" min-width="100" sortable="custom" class-name="sw-col-meta" />
|
||||
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom" align="right" class-name="sw-col-number"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }"><metric-value :kind="column.type" :value="row[column.key]" /></template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="isChannel" class="sw-table-summary">合计:有效端 <el-text type="success">{{ result?.effectiveCount == null ? '未接入' : result.effectiveCount }}</el-text> 个</p>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<channel-profile-drawer v-if="isChannel" v-model="profileOpen" :channel="profileChannel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listAnalytics } from '@/api/analytics'
|
||||
import { listAnalytics, exportChannelAnalytics } from '@/api/analytics'
|
||||
import { listLedgerGames } from '@/api/ledger'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
import { channelCount } from '../channel-display'
|
||||
import { createGameOptionsLoader } from '../game-options-loader'
|
||||
import PlatformTag from './PlatformTag.vue'
|
||||
import MetricValue from './MetricValue.vue'
|
||||
import ChannelProfileDrawer from './ChannelProfileDrawer.vue'
|
||||
import { metricTips, remoteSort, sortDescription } from '../table-display'
|
||||
import { analysisGameSelection } from '../analysis-game-selection'
|
||||
const fetchGameOptions = createGameOptionsLoader(listLedgerGames)
|
||||
@@ -52,39 +53,59 @@ const isChannel = computed(() => props.kind === 'channel')
|
||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const day = () => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()) }
|
||||
const ymd = value => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
|
||||
const end = day(), begin = day(); if (isChannel.value) begin.setDate(begin.getDate() - 29)
|
||||
const dates = ref([ymd(begin), ymd(end)])
|
||||
const dates = ref(isChannel.value ? ['', ''] : [ymd(day()), ymd(day())])
|
||||
const shortcuts = [
|
||||
{ text: '今日', value: () => [day(), day()] }, { text: '昨日', value: () => { const d = day(); d.setDate(d.getDate() - 1); return [d, new Date(d)] } },
|
||||
{ text: '本周', value: () => { const e = day(), b = day(); b.setDate(b.getDate() - (b.getDay() || 7) + 1); return [b, e] } },
|
||||
{ text: '本月', value: () => { const e = day(); return [new Date(e.getFullYear(), e.getMonth(), 1), e] } }
|
||||
]
|
||||
const startShortcuts = shortcuts.map(item => ({ text: item.text, value: () => item.value()[0] }))
|
||||
const endShortcuts = shortcuts.map(item => ({ text: item.text, value: () => item.value()[1] }))
|
||||
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), gameLoading = ref(false)
|
||||
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: 'totalPay', sortOrder: 'desc' })
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', hasRegister: undefined, hasPay: undefined, sortField: isChannel.value ? '' : 'totalPay', sortOrder: 'desc' })
|
||||
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref('')
|
||||
const loadedQuery = ref(null), exporting = ref(false)
|
||||
let version = 0, gameVersion = 0
|
||||
const period = computed(() => result.value?.beginTime && result.value?.endTime ? `${result.value.beginTime} 至 ${result.value.endTime}` : dates.value.join(' 至 '))
|
||||
const fields = { newRegisterCount: { key: 'newRegisterCount', label: '新增注册', type: 'count' }, activeCount: { key: 'activeCount', label: '活跃玩家', type: 'count' }, payUserCount: { key: 'payUserCount', label: '付费玩家', type: 'count' }, newPayUserCount: { key: 'newPayUserCount', label: '新增付费', type: 'count' }, totalPay: { key: 'totalPay', label: '充值金额', type: 'money', width: 145 }, payRate: { key: 'payRate', label: '付费率', type: 'percent' }, arpu: { key: 'arpu', label: 'ARPU', type: 'money' }, arppu: { key: 'arppu', label: 'ARPPU', type: 'money' } }
|
||||
const columns = computed(() => (isChannel.value ? ['newRegisterCount', 'activeCount', 'totalPay', 'payUserCount', 'newPayUserCount', 'payRate'] : ['newRegisterCount', 'activeCount', 'payUserCount', 'totalPay', 'payRate', 'arpu', 'arppu', 'newPayUserCount']).map(key => fields[key]))
|
||||
const format = (type, value) => value == null ? '未接入' : type === 'money' ? channelMoney(value) : type === 'percent' ? `${value}%` : channelCount(value)
|
||||
const cards = computed(() => [{ label: '渠道总数', key: 'channelCount', type: 'count' }, { label: '渠道总注册玩家', key: 'totalRegisterCount', type: 'count' }, { label: '渠道总充值', key: 'totalPayAmount', type: 'money' }, { label: '平均付费率', key: 'avgPayRate', type: 'percent' }].map(card => ({ label: card.label, value: error.value ? '未获取' : format(card.type, result.value?.[card.key]) })))
|
||||
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => { if (index === 0) return '全部游戏'; if (index === 1) return ''; const field = fields[column.property]; return field ? format(field.type, summary.value?.[column.property]) : '' })
|
||||
const clear = () => { rows.value = []; total.value = 0; summary.value = null; result.value = null }
|
||||
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => {
|
||||
if (index === 0) return isChannel.value ? '全部' : '全部游戏'
|
||||
if (index === 1) return isChannel.value ? h('span', { class: 'report-summary-title', title: '合计覆盖当前筛选结果的全部页;有效端按所选区间内达标的渠道数统计。' }, [h('strong', '合计'), h('span', '有效端 '), h('strong', { class: 'report-effective-count' }, result.value?.effectiveCount == null ? '—' : channelCount(result.value.effectiveCount))]) : ''
|
||||
const field = fields[column.property]
|
||||
return field ? summary.value ? h(MetricValue, { kind: field.type, value: summary.value[column.property] }) : '—' : isChannel.value ? '—' : ''
|
||||
})
|
||||
const profileOpen = ref(false), profileChannel = ref(null)
|
||||
const openChannel = row => { profileChannel.value = { id: row.promoteId, sourceDb: row.sourceDb, account: row.promoteName }; profileOpen.value = true }
|
||||
const clear = () => { rows.value = []; total.value = 0; summary.value = null; result.value = null; loadedQuery.value = null }
|
||||
const load = async () => {
|
||||
if (dates.value[0] && dates.value[1] && dates.value[0] > dates.value[1]) { ElMessage.warning('统计开始时间不能晚于结束时间'); return }
|
||||
const current = ++version
|
||||
if (!sources.value.length) { clear(); loading.value = false; available.value = true; return }
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res = await listAnalytics(props.kind, { ...query, promoteName: query.promoteName.trim(), sourceDb: sources.value.join(','), gameName: games.value.join(','), beginTime: dates.value[0], endTime: dates.value[1] })
|
||||
const requested = { ...query, promoteName: query.promoteName.trim(), sourceDb: sources.value.join(','), gameName: games.value.join(','), beginTime: dates.value[0] || '', endTime: dates.value[1] || '' }
|
||||
const res = await listAnalytics(props.kind, requested)
|
||||
if (current !== version) return
|
||||
if (res.code === 0) { result.value = res.data; rows.value = res.data.rows; total.value = res.data.total; summary.value = res.data.summary; available.value = res.data.available !== false }
|
||||
if (res.code === 0) { result.value = res.data; rows.value = res.data.rows; total.value = res.data.total; summary.value = res.data.summary; available.value = res.data.available !== false; loadedQuery.value = requested }
|
||||
else { clear(); error.value = res.msg || '统计查询失败' }
|
||||
} catch { if (current === version) { clear(); error.value = '统计查询失败,请重试' } } finally { if (current === version) loading.value = false }
|
||||
}
|
||||
const download = async () => {
|
||||
if (exporting.value || loading.value || !loadedQuery.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const response = await exportChannelAnalytics(loadedQuery.value), blob = response instanceof Blob ? response : response.data
|
||||
if (!(blob instanceof Blob)) return
|
||||
if (blob.type.includes('json')) { const data = JSON.parse(await blob.text()); ElMessage.error(data.msg || '导出失败'); return }
|
||||
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||
link.href = url; link.download = '渠道数据统计.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch { ElMessage.error('导出失败,请重试') } finally { exporting.value = false }
|
||||
}
|
||||
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
|
||||
const sortChanged = event => { Object.assign(query, remoteSort(event, 'totalPay')); return search() }
|
||||
const currentSort = computed(() => sortDescription(query, { ...Object.fromEntries(Object.values(fields).map(f => [f.key, f.label])), gameId: '游戏ID' }))
|
||||
const sortChanged = event => { Object.assign(query, remoteSort(event, isChannel.value ? '' : 'totalPay')); return search() }
|
||||
const currentSort = computed(() => query.sortField ? sortDescription(query, { ...Object.fromEntries(Object.values(fields).map(f => [f.key, f.label])), gameId: '游戏ID' }) : '排序:默认顺序')
|
||||
const gameChanged = () => { analysisGameSelection.value = selectedGame.value; return search() }
|
||||
const sourceChanged = async () => {
|
||||
++version; ++gameVersion; games.value = []; gameOptions.value = []; gameLoading.value = false; loading.value = false; error.value = ''; clear()
|
||||
@@ -107,9 +128,9 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
|
||||
.report-card-label { color: var(--el-text-color-secondary); font-size: 13px; margin-bottom: 12px; }
|
||||
.report-cards strong { font-size: 25px; overflow-wrap: anywhere; }
|
||||
.report-date-range { display: flex; align-items: center; gap: 8px; }
|
||||
.report-date-range :deep(.el-date-editor) { width: 165px; }
|
||||
:deep(.report-summary-title) { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
:deep(.report-effective-count) { color: var(--el-color-success); }
|
||||
.report-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
@media (max-width: 800px) { .report-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,11 @@ export const displayMember = member => member?.nickName || member?.userName || '
|
||||
export const displayMetric = (key, value) => value == null ? '—' : key === 'pay' || key === 'amount' ? channelMoney(value) : channelCount(value)
|
||||
export const displayTarget = (key, value) => value == null ? '—' : key === 'amount' ? channelMoney(value) : Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
export const completionRate = (actual, target) => target == null || Number(target) <= 0 ? null : Number(actual || 0) / Number(target) * 100
|
||||
export const goalAchieved = (actual, target, key) => target != null && Number(target) > 0 && (key === 'pay' ? compareAmounts(actual, String(target)) >= 0 : Number(actual) >= target)
|
||||
export const goalAchieved = (actual, target, key) => actual != null && target != null && Number(target) >= 0 && (key === 'pay' ? compareAmounts(actual, String(target)) >= 0 : Number(actual) >= target)
|
||||
export const personalGoalRows = data => performanceMetrics.filter(metric => data?.goal?.[metric.goal] != null).map(metric => {
|
||||
const target = data.goal[metric.goal], actual = data.actual?.[metric.key]
|
||||
return { ...metric, target, actual, rate: completionRate(actual, target), achieved: goalAchieved(actual, target, metric.key) }
|
||||
})
|
||||
export const rateText = rate => rate == null ? '—' : `${rate.toFixed(1)}%`
|
||||
export const progressWidth = rate => Math.max(0, Math.min(100, rate || 0))
|
||||
export const dateText = value => value == null ? '—' : new Date(Number(value) * 1000).toLocaleDateString('zh-CN').replaceAll('/', '-')
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { goalAchieved, completionRate, hasGoal, monthOffset } from './performance-display.js'
|
||||
import { goalAchieved, completionRate, hasGoal, monthOffset, personalGoalRows } from './performance-display.js'
|
||||
|
||||
test('goal attainment compares exact cents even when rounded percentages look complete', () => {
|
||||
assert.equal(goalAchieved('9007199254740990.99', 9007199254740991, 'pay'), false)
|
||||
assert.equal(goalAchieved('9007199254740991.00', 9007199254740991, 'pay'), true)
|
||||
assert.equal(goalAchieved('100.00', 0, 'pay'), false)
|
||||
assert.equal(goalAchieved('100.00', 0, 'pay'), true)
|
||||
assert.equal(goalAchieved(10, 10, 'open'), true)
|
||||
assert.equal(completionRate(12, 10), 120)
|
||||
})
|
||||
|
||||
test('zero targets remain visible and attainable while blank targets hide their rows', () => {
|
||||
const rows = personalGoalRows({ goal: { open: 0, effective: null, amount: 0 }, actual: { open: 0, effective: 3, pay: '0.00' } })
|
||||
assert.deepEqual(rows.map(row => row.key), ['open', 'pay'])
|
||||
assert.ok(rows.every(row => row.achieved && row.rate === null))
|
||||
assert.deepEqual(personalGoalRows({ goal: { open: null, effective: null, amount: null } }), [])
|
||||
assert.equal(goalAchieved(null, 0, 'open'), false)
|
||||
})
|
||||
|
||||
test('empty and zero targets stay distinct and year boundaries retain the month', () => {
|
||||
assert.equal(hasGoal({ open: null, effective: null, amount: null }), false)
|
||||
assert.equal(hasGoal({ open: 0, effective: null, amount: null }), true)
|
||||
|
||||
Reference in New Issue
Block a user