fix: align business pages and detail drawers with prototype

This commit is contained in:
Yvan
2026-11-05 16:18:39 +08:00
parent 27cacffc7f
commit db11b6eb54
28 changed files with 603 additions and 87 deletions
+41 -4
View File
@@ -16,6 +16,7 @@ import (
var (
ErrChannelNotFound = errors.NotFound("SW_CHANNEL_NOT_FOUND", "渠道不存在")
ErrChannelScope = errors.Forbidden("SW_CHANNEL_SCOPE", "无权查看该渠道")
ErrChannelCreateScope = errors.Forbidden("SW_CHANNEL_CREATE_SCOPE", "仅三级商务可添加渠道")
ErrChannelInput = errors.BadRequest("SW_CHANNEL_INPUT", "渠道查询参数不正确")
ErrChannelAccount = errors.BadRequest("SW_CHANNEL_ACCOUNT", "渠道账号需为中文/英文/数字,长度 4-30 位")
ErrChannelPassword = errors.BadRequest("SW_CHANNEL_PASSWORD", "渠道密码长度需为 6-15 位")
@@ -35,6 +36,7 @@ type Channel struct {
ChannelKey
Account, Nickname, ParentName, RealName string
Phone string
BusinessPath []string
SettlementDays *int
Level, RegisterType, Status, LockStatus *int
ParentID, TopPromoteID, BusierID uint64
@@ -47,6 +49,9 @@ type Channel struct {
}
func (c *Channel) BusinessDisplayName() string {
if len(c.BusinessPath) > 0 {
return strings.Join(c.BusinessPath, "→")
}
account, user := strings.TrimSpace(c.BindingAccount), strings.TrimSpace(c.BindingUserName)
if account == "" {
return user
@@ -56,6 +61,26 @@ func (c *Channel) BusinessDisplayName() string {
}
return account + "(" + user + ")"
}
func (c *Channel) ParentDisplayName() string {
if c.Level != nil && *c.Level == 1 {
if len(c.BusinessPath) > 0 {
return c.BusinessPath[len(c.BusinessPath)-1]
}
return c.BusinessDisplayName()
}
return c.ParentName
}
func ChannelLockStatusLabel(status *int) string {
if status == nil || *status == 0 {
return "正常"
}
if *status == -2 {
return "解除合作"
}
return "锁定"
}
func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool {
if c.SourceDB != other.SourceDB {
return false
@@ -73,6 +98,7 @@ func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool {
}
type ChannelFilter struct {
IncludeSub, IDCardDedup bool
Effective *bool
Sources []string
BusierUserID *uint
@@ -179,6 +205,9 @@ func normalizeIDCard(raw string) string {
}
return value
}
// ChannelIdentity is shared by list metrics and storage-side metric ordering.
func ChannelIdentity(raw string) string { return normalizeIDCard(raw) }
func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, options ...ListOption) (*ChannelPage, error) {
scope, err := uc.scope(ctx)
if err != nil {
@@ -189,14 +218,15 @@ func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, option
return nil, err
}
}
page, err := uc.repo.List(ctx, scope, filter, options...)
if err != nil || len(page.Rows) == 0 {
return page, err
}
pref, err := uc.preferences.Current(ctx)
if err != nil {
return nil, err
}
filter.IncludeSub, filter.IDCardDedup = pref.IncludeSubPromote, pref.IDCardDedup
page, err := uc.repo.List(ctx, scope, filter, options...)
if err != nil || len(page.Rows) == 0 {
return page, err
}
statChannels := page.Rows
if pref.IncludeSubPromote {
statChannels, err = uc.repo.StatChannels(ctx, page.Rows)
@@ -290,6 +320,13 @@ func (uc *ChannelUsecase) Create(ctx context.Context, input *ChannelCreate) (*Ch
if input == nil {
return nil, ErrChannelInput
}
actor, accountOwner, err := uc.accounts.actor(ctx)
if err != nil {
return nil, err
}
if actor.AuthorityID != 888 && accountOwner.Level != 3 {
return nil, ErrChannelCreateScope
}
account := strings.TrimSpace(input.Account)
if !channelAccountPattern.MatchString(account) {
return nil, ErrChannelAccount
+9 -4
View File
@@ -110,7 +110,12 @@ func TestChannelLevelTwoScopeAndUnavailableMetrics(t *testing.T) {
func TestChannelCreateOwnsHashAndSettlementDefaults(t *testing.T) {
uc, r, _, writer, encoder := channelFixture()
input := &ChannelCreate{SourceDB: " LX ", Account: " 中文渠道 ", Password: " pass123 ", BusierID: 14, RegisterType: 1}
result, err := uc.Create(accountContext(8, 902), input)
for _, actor := range [][2]uint{{7, 901}, {8, 902}} {
if _, err := uc.Create(accountContext(actor[0], actor[1]), input); err != ErrChannelCreateScope {
t.Fatal("一级和二级商务不能添加渠道")
}
}
result, err := uc.Create(accountContext(9, 903), input)
if err != nil || result.ID != 123 || result.SourceDB != "lx" {
t.Fatalf("创建失败: %+v %v", result, err)
}
@@ -118,17 +123,17 @@ func TestChannelCreateOwnsHashAndSettlementDefaults(t *testing.T) {
t.Fatalf("创建参数或结算周期错误: %+v", writer.input)
}
input.BusierID = 999
if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelBinding {
if _, err := uc.Create(accountContext(9, 903), input); err != ErrChannelBinding {
t.Fatal("选择了范围外的绑定")
}
input.BusierID = 14
input.Account = "bad_underscore"
if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelAccount {
if _, err := uc.Create(accountContext(9, 903), input); err != ErrChannelAccount {
t.Fatal("渠道账号字符限制丢失")
}
input.Account = "valid123"
input.Password = "12345"
if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelPassword {
if _, err := uc.Create(accountContext(9, 903), input); err != ErrChannelPassword {
t.Fatal("密码长度未校验")
}
}
+32 -5
View File
@@ -172,7 +172,7 @@ func TestPerformanceRateSortKeepsUndefinedRatesLast(t *testing.T) {
}
}
func TestPerformanceMatrixTotalsFollowDisplayedMemberUnion(t *testing.T) {
func TestPerformanceMatrixTotalsKeepVisibleScopeWhenRowsAreFiltered(t *testing.T) {
_, uc, _, teams, _ := managementFixture()
teams.catalog.Groups[0].MemberIDs = []uint{8, 9, 10}
ctx := accountContext(7, 901)
@@ -181,17 +181,44 @@ func TestPerformanceMatrixTotalsFollowDisplayedMemberUnion(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(page.Rows) != 1 || page.MatrixTotal.Open != 1 || page.MatrixTotal.Effective != 1 || page.MatrixTotal.Pay != "9007199254740993.03" || page.Groups[0].UniqueTotal != page.MatrixTotal {
t.Fatalf("filtered member union/subtotal mismatch: %+v", page)
if len(page.Rows) != 1 || page.MatrixTotal != page.Total || page.MatrixTotal.Open != 2 || page.Groups[0].UniqueTotal.Open != 1 {
t.Fatalf("matrix total must retain the full visible scope, with a filtered group subtotal: %+v", page)
}
if page.Total.Open != 2 || page.Groups[0].Total.Open != 3 {
t.Fatal("scope cards and team contributions must retain their own scope")
}
f.Name, f.GroupID = "", func() *uint64 { id := uint64(999); return &id }()
page, err = uc.Query(ctx, f)
if err != nil || len(page.Rows) != 0 || page.MatrixTotal.Open != 0 || page.MatrixTotal.Pay != "0.00" || page.Total.Open != 2 {
t.Fatalf("empty matrix must have zero totals without changing scope cards: %+v %v", page, err)
if err != nil || len(page.Rows) != 0 || page.MatrixTotal != page.Total || page.Total.Open != 2 {
t.Fatalf("empty displayed rows must not erase visible-scope totals: %+v %v", page, err)
}
for period, total := range page.Trend {
if page.MatrixTrend[period] != total {
t.Fatalf("period total changed with filtering: %s", period)
}
}
}
func TestBusinessManagementIncludesCurrentGoalWithoutCurrentPerformance(t *testing.T) {
_, uc, _, _, _ := managementFixture()
amount := int64(2000000)
uc.goals.(*managementGoalFake).values = []*PerformanceGoal{{Kind: "person", TargetID: 9, Month: "2026-11", Amount: &amount}}
page, err := uc.Query(accountContext(7, 901), PerformanceFilter{ForManagement: true})
if err != nil {
t.Fatal(err)
}
if page.End != "2026-10-31" {
t.Fatal("business performance must still stop at the last completed month")
}
for _, row := range page.Rows {
if row.Member.ID == 9 {
if goal := row.Goals["2026-11"]; goal == nil || goal.Amount == nil || *goal.Amount != amount {
t.Fatal("the business list must include this month's configured goal")
}
return
}
}
t.Fatal("visible member was lost")
}
func TestPerformanceDetailSelectionPreservesContributionsAndScope(t *testing.T) {
+10 -11
View File
@@ -173,7 +173,6 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
return out, nil
}
all := newPerformanceBuckets(periods)
matrix := newPerformanceBuckets(periods)
needle := strings.ToLower(strings.TrimSpace(f.Name))
visible := func(m *BusinessMember) bool {
return (f.Level == nil || m.Level == *f.Level) && (needle == "" || strings.Contains(strings.ToLower(memberName(m)+" "+m.Username+" "+m.RealName), needle))
@@ -209,9 +208,8 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
}
}
type attribution struct {
members []uint
groups []uint64
inMatrix bool
members []uint
groups []uint64
}
owners := map[ChannelKey]attribution{}
forOwners := func(ownerIDs []uint) attribution {
@@ -227,7 +225,6 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
a := attribution{}
for id := range members {
a.members = append(a.members, id)
a.inMatrix = a.inMatrix || matrixVisible(byID[id])
}
for id := range groups {
a.groups = append(a.groups, id)
@@ -262,9 +259,6 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
return
}
all.add(key, metric, pay)
if a.inMatrix {
matrix.add(key, metric, pay)
}
for _, id := range a.members {
memberBuckets[id].add(key, metric, pay)
}
@@ -309,7 +303,12 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
}
add(a, periodKey(cash.Date), "pay", pay)
}
goals, err := uc.goals.List(ctx, begin.Format("2006-01"), end.Format("2006-01"))
goalEnd := end.Format("2006-01")
if f.ForManagement {
// The business list shows historical performance and this month's goal.
goalEnd = snapshotTime.Format("2006-01")
}
goals, err := uc.goals.List(ctx, begin.Format("2006-01"), goalEnd)
if err != nil {
return nil, err
}
@@ -362,9 +361,9 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
}
}
out.Total = all.total.metrics()
out.MatrixTotal = matrix.total.metrics()
out.MatrixTotal = out.Total
out.MatrixRate = performanceRate(out.MatrixTotal)
out.MatrixTrend = matrix.values()
out.MatrixTrend = all.values()
out.Prior = prior.metrics()
out.Rate = performanceRate(out.Total)
out.Trend = all.values()
+53
View File
@@ -0,0 +1,53 @@
package business
import (
"strconv"
"strings"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func businessDisplayPaths(db *gorm.DB, owners map[uint]string) (map[uint][]string, error) {
ids := map[uint]bool{}
paths := map[uint][]uint{}
for owner, path := range owners {
seen := map[uint]bool{}
for _, part := range strings.Split(path, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32)
if err == nil && id > 0 && uint(id) != owner && !seen[uint(id)] {
paths[owner] = append(paths[owner], uint(id))
ids[uint(id)], seen[uint(id)] = true, true
}
}
paths[owner] = append(paths[owner], owner)
ids[owner] = true
}
keys := make([]uint, 0, len(ids))
for id := range ids {
keys = append(keys, id)
}
names := map[uint]string{}
for start := 0; start < len(keys); start += 300 {
var rows []accountPO
if err := db.Select("id,username,nick_name").Where("id IN ? AND level>0", keys[start:min(start+300, len(keys))]).Find(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rows {
name := strings.TrimSpace(row.NickName)
if name == "" {
name = row.Username
}
names[row.ID] = name
}
}
result := map[uint][]string{}
for owner, ids := range paths {
for _, id := range ids {
if name := names[id]; name != "" {
result[owner] = append(result[owner], name)
}
}
}
return result, nil
}
+58 -5
View File
@@ -3,6 +3,7 @@ package business
import (
"context"
"errors"
"sort"
"strings"
"time"
@@ -140,11 +141,34 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
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.*,e.qualified_at").Order(order).Scan(&pos).Error; err != nil {
var pageQuery *gorm.DB
var metricKeys []business.ChannelKey
metricSort := filter.SortField == "playerCount" || filter.SortField == "totalPayAmount"
if metricSort {
metricKeys, err = channelMetricPage(db, q, filter, o)
if err != nil {
return nil, err
}
if len(metricKeys) == 0 {
return page, nil
}
pageQuery = joinEffectiveness(db.Table("tab_promote p").Where("(p.source_db,p.id) IN ?", channelPairs(metricKeys)))
} else {
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)).Order(order)
}
if err := pageQuery.Select("p.*,e.qualified_at").Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
if metricSort {
ranks := map[business.ChannelKey]int{}
for i, key := range metricKeys {
ranks[key] = i
}
sort.Slice(pos, func(i, j int) bool {
return ranks[business.ChannelKey{SourceDB: pos[i].SourceDB, ID: pos[i].ID}] < ranks[business.ChannelKey{SourceDB: pos[j].SourceDB, ID: pos[j].ID}]
})
}
if len(pos) == 0 {
return page, nil
}
@@ -157,10 +181,19 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
PlatformBusierID uint64
UserID uint
PlatformBusierAccount, UserName string
IDPath string
}
if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil {
if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil {
return nil, business.ErrStorage
}
ownerPaths := map[uint]string{}
for _, b := range bound {
ownerPaths[b.UserID] = b.IDPath
}
paths, err := businessDisplayPaths(db, ownerPaths)
if err != nil {
return nil, err
}
byKey := map[business.PlatformKey]int{}
for i, b := range bound {
key := business.PlatformKey{SourceDB: b.SourceDB, BusierID: b.PlatformBusierID}
@@ -175,6 +208,7 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
c.BusierUserID = &b.UserID
c.BindingAccount = b.PlatformBusierAccount
c.BindingUserName = b.UserName
c.BusinessPath = paths[b.UserID]
}
page.Rows = append(page.Rows, c)
}
@@ -205,7 +239,26 @@ func (r *channelRepo) Find(ctx context.Context, scope business.UserScope, key bu
return nil, business.ErrChannelScope
}
}
return toBizChannel(po), nil
c := toBizChannel(po)
var bindings []struct {
UserID uint
PlatformBusierAccount, UserName, IDPath string
}
if err := channelBindingQuery(db, scope, business.ChannelFilter{Sources: []string{key.SourceDB}}).
Where("b.platform_busier_id = ?", po.BusierID).Order("b.user_id").Limit(1).
Select("b.user_id,b.platform_busier_account,u.username AS user_name,u.id_path").Scan(&bindings).Error; err != nil {
return nil, business.ErrStorage
}
if len(bindings) > 0 {
b := bindings[0]
paths, err := businessDisplayPaths(db, map[uint]string{b.UserID: b.IDPath})
if err != nil {
return nil, err
}
c.BusierUserID, c.BindingAccount, c.BindingUserName = &b.UserID, b.PlatformBusierAccount, b.UserName
c.BusinessPath = paths[b.UserID]
}
return c, nil
}
func channelPairs(keys []business.ChannelKey) [][]any {
pairs := make([][]any, 0, len(keys))
+167
View File
@@ -0,0 +1,167 @@
package business
import (
"database/sql"
"math/big"
"sort"
"gorm.io/gorm"
"kra/internal/biz/business"
)
// Build the same per-channel membership as Channel.IncludesStat. UNION removes
// descendants reached through both parent_id and top_promote_id.
func channelMetricMembership(db, roots *gorm.DB, includeSub bool) *gorm.DB {
self := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,p.id AS leaf_id")
if !includeSub {
return db.Table("(?) cm", self)
}
direct := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id").
Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.parent_id=p.id").
Where("p.promote_level<3 AND (p.promote_level<>2 OR leaf.promote_level=3)")
top := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id").
Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.top_promote_id=p.id").
Where("p.promote_level<3 AND p.promote_level<>2")
return db.Table("(? UNION ? UNION ?) cm", self, direct, top)
}
// Only keys and scores for visible rows are retained. Identity rows are streamed
// one channel at a time, using the same normalization as the displayed count.
func channelMetricPage(db, roots *gorm.DB, f business.ChannelFilter, o business.ListOptions) ([]business.ChannelKey, error) {
var channels []channelPO
if err := roots.Session(&gorm.Session{}).Select("p.id,p.source_db,p.create_time").Scan(&channels).Error; err != nil {
return nil, business.ErrStorage
}
if len(channels) == 0 {
return nil, nil
}
table := "tab_user"
if f.SortField == "totalPayAmount" {
table = "tab_spend"
}
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrChannelUnavailable
}
scores := map[business.ChannelKey]*big.Rat{}
q := channelMetricMembership(db, roots, f.IncludeSub)
// MySQL 5.7 may choose the narrow pay_status index and then fetch millions
// of full records. Prefer the existing covering index for this full scan.
metricTable := table
index := "idx_sw_user_report_identity"
if table == "tab_spend" {
index = "idx_sw_spend_promote_summary"
}
if db.Dialector.Name() == "mysql" && db.Migrator().HasIndex(table, index) {
metricTable += " FORCE INDEX (" + index + ")"
}
leaves := db.Table(metricTable)
if len(f.Sources) > 0 {
leaves = leaves.Where("source_db IN ?", f.Sources)
}
if f.SortField == "totalPayAmount" {
leaves = leaves.Where("pay_status=1")
} else {
leaves = leaves.Where("puid=0 OR puid IS NULL")
}
if f.SortField == "playerCount" && f.IDCardDedup {
join := "STRAIGHT_JOIN"
if db.Dialector.Name() == "sqlite" {
join = "JOIN"
}
// Stream the covering user index once, then look up its channel roots.
// Grouping raw identity strings first creates a large disk temporary table
// and still cannot apply the domain's exact whitespace/X normalization.
leaves = leaves.Select("source_db,promote_id,idcard")
members := q.Select("cm.source_db,cm.root_id,cm.leaf_id")
rows, err := db.Table("(?) u", leaves).Joins(join+" (?) cm ON cm.source_db=u.source_db AND cm.leaf_id=u.promote_id", members).
Select("cm.source_db,cm.root_id,u.idcard").Order("cm.source_db,cm.root_id").Rows()
if err != nil {
return nil, business.ErrStorage
}
defer rows.Close()
var previous business.ChannelKey
identities := map[string]struct{}{}
flush := func() { scores[previous] = big.NewRat(int64(len(identities)), 1) }
for rows.Next() {
var key business.ChannelKey
var card sql.NullString
if err := rows.Scan(&key.SourceDB, &key.ID, &card); err != nil {
return nil, business.ErrStorage
}
if key != previous {
flush()
identities = map[string]struct{}{}
previous = key
}
if identity := business.ChannelIdentity(card.String); identity != "" {
identities[identity] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, business.ErrStorage
}
flush()
} else {
var values []struct {
SourceDB string
RootID uint64
Score string
}
if f.SortField == "totalPayAmount" {
leaves = leaves.Select("source_db,promote_id,COALESCE(SUM(pay_amount),0) AS score")
} else {
leaves = leaves.Select("source_db,promote_id,COUNT(*) AS score")
}
leaves = leaves.Group("source_db,promote_id")
q = q.Joins("JOIN (?) leaf ON leaf.source_db=cm.source_db AND leaf.promote_id=cm.leaf_id", leaves).
Select("cm.source_db,cm.root_id,SUM(leaf.score) AS score")
if err := q.Group("cm.source_db,cm.root_id").Scan(&values).Error; err != nil {
return nil, business.ErrStorage
}
for _, value := range values {
score, ok := new(big.Rat).SetString(value.Score)
if !ok {
return nil, business.ErrStorage
}
scores[business.ChannelKey{SourceDB: value.SourceDB, ID: value.RootID}] = score
}
}
zero := new(big.Rat)
score := func(c channelPO) *big.Rat {
if n := scores[business.ChannelKey{SourceDB: c.SourceDB, ID: c.ID}]; n != nil {
return n
}
return zero
}
sort.Slice(channels, func(i, j int) bool {
a, b := channels[i], channels[j]
if compared := score(a).Cmp(score(b)); compared != 0 {
return (compared < 0) == f.Ascending
}
if a.CreateTime == nil || b.CreateTime == nil {
if (a.CreateTime == nil) != (b.CreateTime == nil) {
return a.CreateTime != nil
}
} else if *a.CreateTime != *b.CreateTime {
return *a.CreateTime > *b.CreateTime
}
if a.ID != b.ID {
return a.ID > b.ID
}
return a.SourceDB < b.SourceDB
})
begin := min(max(0, o.Offset), len(channels))
end := len(channels)
if o.Limit > 0 && o.Limit < end-begin {
end = begin + o.Limit
}
keys := make([]business.ChannelKey, 0, end-begin)
for _, row := range channels[begin:end] {
keys = append(keys, business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID})
}
return keys, nil
}
@@ -0,0 +1,71 @@
package business
import (
"context"
"reflect"
"testing"
"kra/internal/biz/business"
)
func TestChannelMetricSortingUsesFullScopeAndPreferencesBeforePagination(t *testing.T) {
_, repo := prepareChannels(t)
scope := business.UserScope{SelfID: 9, OnlySelf: true}
for _, sample := range []struct {
field string
include, dedup bool
want []uint64
}{
{"totalPayAmount", true, false, []uint64{101, 100, 103, 102}},
{"totalPayAmount", false, true, []uint64{103, 102, 101, 100}},
{"playerCount", true, false, []uint64{101, 100, 103, 102}},
{"playerCount", true, true, []uint64{101, 103, 100, 102}},
{"playerCount", false, true, []uint64{103, 101, 100, 102}},
} {
t.Run(sample.field+"/"+map[bool]string{true: "sub", false: "self"}[sample.include]+"/"+map[bool]string{true: "dedup", false: "accounts"}[sample.dedup], func(t *testing.T) {
filter := business.ChannelFilter{SortField: sample.field, IncludeSub: sample.include, IDCardDedup: sample.dedup}
var got []uint64
for offset := range sample.want {
page, err := repo.List(context.Background(), scope, filter, business.ListOffset(offset), business.ListLimit(1))
if err != nil || page.Total != 4 || len(page.Rows) != 1 || page.StatusCounts.Normal != 4 {
t.Fatalf("unexpected page at %d: %+v %v", offset, page, err)
}
if page.Rows[0].SourceDB != "lx" {
t.Fatal("metric sorting leaked another business or platform")
}
got = append(got, page.Rows[0].ID)
}
if !reflect.DeepEqual(got, sample.want) {
t.Fatalf("want %v, got %v", sample.want, got)
}
filter.Ascending = true
filter.Keyword = "grandchild"
page, err := repo.List(context.Background(), scope, filter, business.ListLimit(1))
if err != nil || page.Total != 1 || len(page.Rows) != 1 || page.Rows[0].ID != 102 {
t.Fatalf("metric sorting lost the name filter: %+v %v", page, err)
}
})
}
}
func TestChannelMetricSortRetainsZeroRowsAndExactIdentityNormalization(t *testing.T) {
db, repo := prepareChannels(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','zero',1,0,0,14)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO tab_user VALUES('lx',1000,100,0,?),('lx',1001,100,0,?)", "\tabcX \r", "\t \r").Error; err != nil {
t.Fatal(err)
}
filter := business.ChannelFilter{SortField: "playerCount", IncludeSub: true, IDCardDedup: true, Ascending: true}
page, err := repo.List(context.Background(), business.UserScope{SelfID: 9, OnlySelf: true}, filter)
if err != nil {
t.Fatal(err)
}
var got []uint64
for _, row := range page.Rows {
got = append(got, row.ID)
}
if !reflect.DeepEqual(got, []uint64{200, 102, 103, 100, 101}) {
t.Fatalf("zero rows or identity normalization differ from displayed values: %v", got)
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ func TestChannelListMatchesBindingsAndLiteralBusinessKeyword(t *testing.T) {
t.Fatalf("指定商务应只取本人绑定: %+v %v", page, err)
}
for _, row := range page.Rows {
if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "lx-business(leaf)" {
if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "parent→branch→leaf" {
t.Fatalf("归属展示错误: %+v", row)
}
}
+2 -2
View File
@@ -370,7 +370,7 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
names := map[business.PlatformKey]bindName{}
for start := 0; start < len(pairs); start += 300 {
var rows []bindName
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,u.username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,COALESCE(NULLIF(u.nick_name,''),u.username) AS username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
if err := q.Scan(&rows).Error; err != nil {
return business.ErrStorage
}
@@ -399,7 +399,7 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
ancestors := map[uint]string{}
for start := 0; start < len(ids); start += 300 {
var rows []accountPO
if err := db.Select("id,username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
if err := db.Select("id,COALESCE(NULLIF(nick_name,''),username) AS username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
return business.ErrStorage
}
for _, row := range rows {
+2 -2
View File
@@ -45,7 +45,7 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto.
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 {
if filter.SortField != "" && filter.SortField != "createdAt" && filter.SortField != "playerCount" && filter.SortField != "totalPayAmount" || filter.SettlementDays != nil && *filter.SettlementDays < 1 {
return nil, business.ErrChannelInput
}
for _, pair := range []struct {
@@ -70,7 +70,7 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto.
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, LastLoginAt: c.LastLoginUnix, Phone: c.Phone, SettlementDays: c.SettlementDays})
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.ParentDisplayName(), 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
}
@@ -28,7 +28,11 @@ func (s *ChannelPortraitService) Portrait(ctx context.Context, req *dto.ChannelP
basic.ParentPromoteID = &parent.ID
basic.ParentPromoteName = &parent.Account
basic.ParentPromoteAccount = &parent.Account
} else if parent := c.ParentDisplayName(); parent != "" {
basic.ParentPromoteName = &parent
}
basic.Phone, basic.SettlementDays = c.Phone, c.SettlementDays
basic.LockStatus, basic.LockStatusLabel = c.LockStatus, business.ChannelLockStatusLabel(c.LockStatus)
out := &dto.ChannelPortraitResponse{Basic: basic, Period: p.Period, Available: p.Available, UnavailableReason: p.UnavailableReason, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, GameDistribution: []*dto.ChannelGameResponse{}, PayTiers: []*dto.ChannelPayTier{}}
if m := p.Metrics; m != nil {
out.Metrics = &dto.ChannelPortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), Active: portraitMetricResponse(m.Active)}
@@ -0,0 +1,34 @@
package business
import (
"context"
"testing"
"kra/internal/biz/business"
"kra/internal/service/dto"
)
type channelPortraitTransportFake struct{ result *business.ChannelPortrait }
func (f channelPortraitTransportFake) Portrait(context.Context, business.ChannelKey, string) (*business.ChannelPortrait, error) {
return f.result, nil
}
func TestChannelPortraitKeepsContactAndCooperationStatusSeparateFromAudit(t *testing.T) {
level, auditStatus, days := 1, 1, 7
for _, sample := range []struct {
status int
label string
}{{0, "正常"}, {-1, "锁定"}, {-2, "解除合作"}} {
channel := &business.Channel{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 100}, Account: "渠道账号", Level: &level, Status: &auditStatus, LockStatus: &sample.status, Phone: "13812345678", SettlementDays: &days, BusinessPath: []string{"一级商务", "所属商务"}}
service := &ChannelPortraitService{uc: channelPortraitTransportFake{&business.ChannelPortrait{Basic: &business.ChannelPortraitBasic{Channel: channel}}}}
result, err := service.Portrait(context.Background(), &dto.ChannelPortraitQuery{SourceDB: "lx", PromoteID: 100})
if err != nil {
t.Fatal(err)
}
b := result.Basic
if b.LockStatusLabel != sample.label || b.StatusLabel != "正常" || b.Phone != channel.Phone || b.SettlementDays == nil || *b.SettlementDays != 7 || b.BusierDisplayName != "一级商务→所属商务" || b.ParentPromoteName == nil || *b.ParentPromoteName != "所属商务" {
t.Fatalf("portrait metadata or status mismatch: %+v", b)
}
}
}
@@ -62,7 +62,7 @@ func (s *BusinessPerformanceService) Export(ctx context.Context, req *dto.Perfor
}
}
}
appendRow("筛选结果去重合计", "上下级不重复计算", "", "", data.MatrixTotal, data.MatrixRate, data.MatrixTrend)
appendRow("可见范围合计", "不随人员筛选变化,上下级不重复计算", "", "", data.MatrixTotal, data.MatrixRate, data.MatrixTrend)
return ledgerWorkbook("商务绩效统计", headers, rows)
}
+4
View File
@@ -12,6 +12,10 @@ type ChannelPortraitBasic struct {
Account string `json:"account"`
Nickname string `json:"nickname"`
RealName string `json:"realName"`
Phone string `json:"phone"`
SettlementDays *int `json:"settlementDays"`
LockStatus *int `json:"lockStatus"`
LockStatusLabel string `json:"lockStatusLabel"`
PromoteLevel *int `json:"promoteLevel"`
PromoteLevelLabel string `json:"promoteLevelLabel"`
Status *int `json:"status"`
+1 -1
View File
@@ -1,6 +1,6 @@
import service from '@/utils/businessRequest'
export const listChannels = params => service({ url: '/sw/promote/list', method: 'get', params })
export const listChannels = (params, options = {}) => service({ ...options, url: '/sw/promote/list', method: 'get', params })
export const channelBindOptions = (sourceDb, options = {}) => service({ ...options, url: '/sw/promote/bindOptions', method: 'get', params: { sourceDb } })
export const createChannel = data => service({ url: '/sw/promote', method: 'post', data })
export const channelGames = (params, options = {}) => service({ ...options, url: '/sw/promote/game-distribution', method: 'get', params })
+13 -8
View File
@@ -11,10 +11,10 @@
<div class="kra-table-box" v-loading="loading">
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
<el-tabs v-model="tab"><el-tab-pane label="商务绩效" name="business" /><el-tab-pane label="趋势图" name="trend" /><el-tab-pane label="小组绩效" name="groups" /></el-tabs>
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">每行包含本人及全部下级,组小计和合计按渠道去重;按住表格可拖动查看。</el-text></div>
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">点数字查看该商务对应周期的渠道明细</el-text></div>
<performance-scroll v-if="tab === 'business' && data" vertical>
<table class="matrix" :style="{ width: `${businessWidth}px` }"><colgroup><col v-for="col in businessColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col v-for="metric in metrics" :key="metric.key" style="width:110px" /></template></colgroup>
<thead><tr><th v-for="col in businessColumns" :key="col.key" rowspan="2" class="sticky" :style="{ left: `${col.left}px` }" @click="col.sort && sortBy(col.key)"><el-tooltip :content="col.tip || col.label"><span>{{ col.label }}<span v-if="col.sort" class="sort-icon">{{ query.sort === col.key ? query.order === 'asc' ? '▲' : '▼' : '◇' }}</span></span></el-tooltip></th><th v-for="period in descendingPeriods" :key="period.key" colspan="3">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th v-for="metric in metrics" :key="metric.key" :class="metric.key">{{ metric.label }}</th></template></tr></thead>
<thead><tr><th v-for="col in businessColumns" :key="col.key" rowspan="2" class="sticky" :style="{ left: `${col.left}px` }" @click="col.sort && sortBy(col.key)"><el-tooltip :content="col.tip || col.label"><span>{{ col.label }}<span v-if="col.sort" class="sort-icon" :class="query.sort === col.key ? query.order : ''"><i class="sort-up" /><i class="sort-down" /></span></span></el-tooltip></th><th v-for="period in descendingPeriods" :key="period.key" colspan="3">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th v-for="metric in metrics" :key="metric.key" :class="metric.key">{{ metric.label }}</th></template></tr></thead>
<tbody><tr v-for="row in matrixRows" :key="row.key" :class="{ subtotal: row.subtotal, departed: row.member?.employment === 'departed' }">
<td v-if="row.span" :rowspan="row.span" class="sticky group-cell" style="left:0"><strong>{{ row.groupName || '—' }}</strong><div v-if="row.groupName" class="muted">{{ row.memberCount }} 人</div></td>
<td class="sticky" :style="{ left: `${businessColumns[1].left}px` }" :title="row.member ? `含 ${row.descendantCount} 个下级商务` : '成员及其下级的渠道并集去重'"><strong>{{ row.name }}</strong><div v-if="row.member" class="muted">{{ businessLevels[row.member.level] }}</div></td>
@@ -23,7 +23,7 @@
<td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ rateText(effectiveRate(row.total)) }}</td>
<template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key" :class="metric.key"><button :disabled="!Number(row.periods[period.key]?.[metric.key])" @click="drill(row, period, metric.key)">{{ displayMetric(metric.key, row.periods[period.key]?.[metric.key] ?? 0) }}</button></td></template>
</tr><tr v-if="!matrixRows.length"><td :colspan="8 + descendingPeriods.length * 3">暂无符合条件的商务</td></tr></tbody>
<tfoot><tr><td colspan="4" class="sticky" style="left:0">筛选结果去重合计</td><td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :style="{ left: `${businessColumns[index + 4].left}px` }">{{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }}</td><td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }}</td><template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key">{{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }}</td></template></tr></tfoot>
<tfoot><tr><td colspan="4" class="sticky" style="left:0" title="可见范围内全部渠道去重合计,仅随统计区间变化,不随商务名称、层级或小组筛选变化。">可见范围合计</td><td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :style="{ left: `${businessColumns[index + 4].left}px` }">{{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }}</td><td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }}</td><template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key">{{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }}</td></template></tr></tfoot>
</table>
</performance-scroll>
<template v-if="tab === 'groups' && data">
@@ -44,7 +44,7 @@
</template>
<div v-if="tab === 'trend' && data" class="trend-layout"><performance-trend :periods="data?.periods || []" :trend="data?.trend || {}" /><div><el-text type="info">共 {{ descendingPeriods.length }} {{ data?.granularity === 'day' ? '天,表内可滚动' : '个月' }}</el-text><el-table scrollbar-always-on :data="trendRows" height="421" class="mt-2"><el-table-column prop="label" :label="data?.granularity === 'day' ? '日期' : '月份'" width="90" /><el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="100"><template #default="{ row }">{{ displayMetric(metric.key, row.values[metric.key]) }}</template></el-table-column><el-table-column label="有效率" width="95"><template #default="{ row }">{{ rateText(effectiveRate(row.values)) }}</template></el-table-column><el-table-column v-for="metric in metrics" :key="`${metric.key}-change`" :label="`${metric.label}环比`" min-width="115"><template #default="{ row }">{{ row.changes[metric.key] }}</template></el-table-column></el-table></div></div>
</div>
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :title="detailTitle" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
</div>
</template>
<script setup>
@@ -73,15 +73,15 @@ const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125
const businessWidth = computed(() => businessColumns.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 330)
const groupColumns = computed(() => withOffsets([{ key: 'name', label: '小组 / 成员', width: 220 }, { key: 'count', label: '人数 / 在职', width: 105 }, ...(data.value?.granularity === 'day' ? [{ key: 'target', label: '目标', width: 160 }] : []), { key: 'actual', label: '达成', width: 165 }, { key: 'rate', label: '完成率', width: 145 }]))
const groupWidth = computed(() => groupColumns.value.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 325)
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, groupName: group.group.name, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
const toggleGroup = id => { const next = new Set(expanded.value); if (next.has(id)) next.delete(id); else next.add(id); expanded.value = next }
const teamRows = computed(() => (data.value?.groups || []).flatMap(group => { const row = { key: `group-${group.group.id}`, id: group.group.id, kind: 'team', name: group.group.name, total: group.total, periods: group.periods, goals: group.goals, memberCount: group.group.memberIds.length, overlapping: group.group.overlapping }; return [row, ...(expanded.value.has(group.group.id) ? group.members.map(m => ({ ...m, key: `group-${group.group.id}-person-${m.member.userId}`, id: m.member.userId, kind: 'person', name: displayMember(m.member) })) : [])] }))
const periodTarget = (row, period, metric) => { const value = row.goals?.[period.key.slice(0,7)]?.[metric.goal]; if (value == null) return null; if (data.value?.granularity !== 'day') return value; const [y, m] = period.key.split('-').map(Number); return value / new Date(y, m, 0).getDate() }
const periodRate = (row, period) => completionRate(row.periods[period.key]?.pay, periodTarget(row, period, metrics[2]))
const groupRate = (row, metric) => { let actual = 0, target = 0; const months = new Set(); for (const p of data.value?.periods || []) { const month = p.key.slice(0,7), value = row.goals?.[month]?.[metric.goal]; if (value == null) continue; actual += Number(row.periods[p.key]?.[metric.key] || 0); if (!months.has(month)) { target += value; months.add(month) } } return completionRate(actual, target) }
const trendRows = computed(() => (data.value?.periods || []).map((p, i, arr) => { const values = data.value.trend[p.key], previous = i ? data.value.trend[arr[i - 1].key] : data.value.prior; return { ...p, values, changes: Object.fromEntries(metrics.map(m => [m.key, previous && Number(previous[m.key]) ? `${((Number(values[m.key]) - Number(previous[m.key])) / Number(previous[m.key]) * 100).toFixed(1)}%` : '—'])) } }).reverse())
const detailOpen = ref(false), detailQuery = ref(null), detailTitle = ref('')
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
const detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailContext.value = { name: row.name, groupName: row.groupName || row.member?.groupName || (row.kind === 'group' || row.kind === 'team' ? row.name : ''), periodKey: period?.key, descendantCount: row.descendantCount, metrics: { ...(period ? row.periods[period.key] : row.total) }, settings: data.value.settings }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
const download = async () => { if (exporting.value || !data.value || !loadedParams.value || loading.value) return; exporting.value = true; try { const result = await exportPerformance({ ...loadedParams.value, groupId: tab.value === 'business' ? loadedParams.value.groupId : undefined, view: tab.value }); const blob = result instanceof Blob ? result : result.data; if (!(blob instanceof Blob) || blob.type.includes('json')) return; const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = '商务绩效统计.xlsx'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } finally { exporting.value = false } }
onMounted(load)
</script>
@@ -103,7 +103,12 @@ onMounted(load)
.matrix button:not(:disabled):hover { text-decoration: underline; }
.open { color: #4f8cff; }.effective { color: #22b98a; }.pay { color: #b98b1f; }
.muted, .metric-line small { color: var(--el-text-color-secondary); font-size: 11px; }
.sort-icon { margin-left: 5px; color: var(--el-color-primary); }
.sort-icon { display: inline-flex; flex-direction: column; gap: 2px; margin-left: 6px; vertical-align: middle; }
.sort-icon i { width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; }
.sort-up { border-bottom: 5px solid var(--el-text-color-placeholder); }
.sort-down { border-top: 5px solid var(--el-text-color-placeholder); }
.sort-icon.asc .sort-up { border-bottom-color: var(--el-color-primary); }
.sort-icon.desc .sort-down { border-top-color: var(--el-color-primary); }
.subtotal td, tfoot td { font-weight: 600; background: var(--el-fill-color-light) !important; }
.subtotal td { border-top: 1px dashed var(--el-border-color); }
.departed { color: var(--el-text-color-placeholder); }
+29 -10
View File
@@ -1,6 +1,5 @@
<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>
@@ -19,6 +18,13 @@
</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>
<div class="channel-status-summary" role="status">
<div v-for="card in statusCards" :key="card.label" class="channel-status-item">
<el-tooltip content="统计当前筛选结果的全部渠道,正常、锁定、解除合作三类互斥,合计等于渠道数量。"><span>{{ card.label }}</span></el-tooltip>
<strong :class="card.type">{{ card.value == null ? '—' : channelCount(card.value) }}</strong>
</div>
<span class="channel-status-hint">按当前搜索结果统计</span>
</div>
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入。" />
<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">
@@ -33,8 +39,8 @@
<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 prop="playerCount" label="玩家数量" min-width="110" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #default="{ row }"><metric-value :value="row.playerCount" /></template></el-table-column>
<el-table-column prop="totalPayAmount" label="累计充值" min-width="150" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #default="{ row }"><metric-value kind="money" :value="row.totalPayAmount" /></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>
@@ -51,7 +57,8 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, onBeforeUnmount, onDeactivated, onActivated, reactive, ref } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
@@ -68,14 +75,14 @@ import ChannelProfileDrawer from './components/ChannelProfileDrawer.vue'
defineOptions({ name: 'SwChannelList' })
const user = useUserStore()
const buttons = useBtnAuth()
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || !!buttons['sw:promote:add'])
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3 && !!buttons['sw:promote:add'])
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: '', 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 statusCards = computed(() => [{ label: '渠道数量', type: 'total', value: statusCounts.value ? total.value : null }, { label: '正常渠道', type: 'normal', value: statusCounts.value?.normal }, { label: '锁定渠道', type: 'locked', value: statusCounts.value?.locked }, { label: '解除合作', type: 'terminated', value: statusCounts.value?.terminated }])
const rows = ref([])
const total = ref(0)
const available = ref(true)
@@ -87,12 +94,15 @@ const profileOpen = ref(false)
const profileChannel = ref(null)
const openProfile = row => { profileChannel.value = row; profileOpen.value = true }
let requestVersion = 0
const requests = createLatestRequest()
let interrupted = false
const load = async () => {
const version = ++requestVersion
const request = requests.start()
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(), phone: query.phone.trim(), createdBegin: createdDates.value?.[0] || '', createdEnd: createdDates.value?.[1] || '', loginBegin: loginDates.value?.[0] || '', loginEnd: loginDates.value?.[1] || '' })
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] || '' }, { signal: request.signal })
if (version !== requestVersion) return
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 || '渠道查询失败' }
@@ -102,10 +112,19 @@ const sortChanged = ({ prop, order }) => { query.sortField = order ? prop : '';
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const openGames = row => { selected.value = row; gamesOpen.value = true }
onMounted(load)
const cancel = () => { ++requestVersion; requests.cancel(); loading.value = false }
onBeforeUnmount(cancel)
onDeactivated(() => { interrupted = loading.value; cancel() })
onActivated(() => { if (interrupted) { interrupted = false; 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 { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 28px; padding: 12px 16px; margin-bottom: 14px; border-radius: 6px; background: var(--el-fill-color-light); }
.channel-status-item { display: inline-flex; align-items: baseline; gap: 8px; }
.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)); } }
.channel-status-summary strong { font-size: 20px; font-variant-numeric: tabular-nums; }
.channel-status-summary .total { color: var(--el-color-primary); }
.channel-status-summary .normal { color: var(--el-color-success); }
.channel-status-summary .locked { color: var(--el-color-warning); }
.channel-status-summary .terminated { color: var(--el-color-danger); }
.channel-status-hint { margin-left: auto; }
</style>
+4 -4
View File
@@ -14,7 +14,7 @@
<el-alert v-if="error || optionError" class="mb-4" type="error" :closable="false" :title="error || optionError" />
<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}:${row.gameId}:${row.serverNum}`" show-summary :summary-method="summaries" :default-sort="{ prop: 'totalPay', order: 'descending' }" @sort-change="sortChanged">
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.gameId}:${row.serverNum}`" 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="serverNum" label="区服编号" min-width="110" show-overflow-tooltip />
<el-table-column prop="serverName" label="区服名称" min-width="130" show-overflow-tooltip />
@@ -43,7 +43,7 @@ const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const dates = ref(normalizeReportDates())
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), serverOptions = ref([])
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
const query = reactive({ pageNum: 1, pageSize: 10, serverNum: '', sortField: 'totalPay', sortOrder: 'desc' })
const query = reactive({ pageNum: 1, pageSize: 10, serverNum: '', sortField: '', sortOrder: 'desc' })
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref(''), optionError = ref('')
const gameLoading = ref(false), serverLoading = ref(false)
let version = 0, gameVersion = 0, serverVersion = 0
@@ -80,8 +80,8 @@ const search = () => {
else if (!games.value.length) ElMessage.warning('请选择游戏')
return load()
}
const sortChanged = event => { query.pageNum = 1; Object.assign(query, remoteSort(event, 'totalPay')); return load() }
const currentSort = computed(() => sortDescription(query, { ...Object.fromEntries(columns.map(c => [c.key, c.label])), openTime: '开服日期' }))
const sortChanged = event => { query.pageNum = 1; Object.assign(query, remoteSort(event, '')); return load() }
const currentSort = computed(() => query.sortField ? sortDescription(query, { ...Object.fromEntries(columns.map(c => [c.key, c.label])), openTime: '开服日期' }) : '排序:默认顺序')
const loadGames = async () => {
const current = ++gameVersion; gameLoading.value = true; optionError.value = ''
try {
@@ -2,7 +2,7 @@
<div class="sw-page">
<div v-if="!userId" class="kra-search-box">
<el-form :inline="true" :model="query" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="query.sourceDb" multiple clearable placeholder="全部平台" style="width: 220px"><el-option v-for="(label, value) in platforms" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="所属平台"><el-select v-model="query.sourceDb" multiple clearable placeholder="全部平台" style="width: 220px" @change="search"><el-option v-for="(label, value) in platforms" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="SDK商务账号"><el-input v-model="query.platformBusierAccount" clearable /></el-form-item>
<el-form-item label="审核状态"><el-select v-model="query.status" clearable style="width: 130px"><el-option v-for="(label, value) in statuses" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
@@ -16,7 +16,7 @@
<el-table-column prop="platformBusierAccount" label="SDK商务账号" class-name="sw-col-identity" min-width="140" />
<el-table-column v-if="!userId" prop="bindUserName" label="绑定商务" min-width="140" />
<el-table-column v-if="!userId" label="旗下渠道数" min-width="110"><template #default="{ row }"><span v-if="row.promoteCount === null" class="text-gray-400">未接入</span><el-button v-else type="primary" link aria-label="查看旗下渠道" :disabled="row.status !== '0'" @click="showChannels(row)">{{ row.promoteCount }}</el-button></template></el-table-column>
<el-table-column label="审核状态" width="110"><template #default="{ row }"><el-tag size="small" :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
<el-table-column label="审核状态" width="110"><template #default="{ row }"><el-button link aria-label="查看审核详情" @click="showReject(row)"><el-tag size="small" :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></el-button></template></el-table-column>
<el-table-column prop="createTime" label="绑定时间" class-name="sw-col-meta" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
<el-table-column v-if="!userId" label="操作" width="145" fixed="right">
<template #default="{ row }">
@@ -1,6 +1,6 @@
<template>
<div class="sw-page">
<el-tabs v-model="tab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane label="小组列表" name="groups" /><el-tab-pane label="绑定列表" name="bindings" /></el-tabs>
<el-tabs v-model="tab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane label="小组列表" name="groups" /><el-tab-pane v-if="canSeeBindings" label="绑定列表" name="bindings" /></el-tabs>
<business-bindings v-if="tab === 'bindings'" @changed="refresh" />
<template v-else>
<div class="kra-search-box">
@@ -36,7 +36,7 @@
<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 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-column label="操作" width="265" 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="hasGoal(row.currentGoal)" :plain="!hasGoal(row.currentGoal)" :type="hasGoal(row.currentGoal) ? 'warning' : 'primary'" @click="openGoal('person', row.userId, `${displayMember(row)}(${businessLevels[row.level]})`)">{{ goalSummary(row.currentGoal) }}</el-button></template></el-table-column>
</el-table>
<el-table scrollbar-always-on v-else v-loading="loading" :data="groupRows" row-key="id" @sort-change="sortGroups">
<el-table-column prop="name" label="小组" min-width="145" sortable="custom"><template #default="{ row }">{{ row.name }} <el-tag v-if="row.mine" size="small">本组</el-tag></template></el-table-column>
@@ -52,7 +52,7 @@
</template>
<team-settings-dialog v-model="teamSettings" :catalog="catalog" @saved="teamsSaved" />
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="loadMetrics" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :title="detailTitle" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
<el-drawer class="sw-dialog" v-model="channelsOpen" append-to-body title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="selectedID" /></el-drawer>
<el-drawer v-model="bindingsOpen" append-to-body title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="selectedID" /></el-drawer>
<business-profile-drawer v-model="portraitOpen" :user-id="selectedID" :name="selectedName" />
@@ -72,6 +72,7 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
@@ -79,7 +80,7 @@ import { createBusiness } from '@/api/business'
import { getTeams, getManagement, updateMember } from '@/api/performance'
import LevelTag from './LevelTag.vue'
import MetricValue from './MetricValue.vue'
import { businessLevels, employmentLabels, employmentTypes, displayMember } from '../performance-display'
import { businessLevels, employmentLabels, employmentTypes, displayMember, currentMonth, hasGoal, goalSummary } from '../performance-display'
import TeamSettingsDialog from './TeamSettings.vue'
import GoalDrawer from './GoalDrawer.vue'
import PerformanceDetailDrawer from './PerformanceDetailDrawer.vue'
@@ -88,13 +89,19 @@ import BusinessChannels from './BusinessChannels.vue'
import BusinessProfileDrawer from './BusinessProfileDrawer.vue'
const user = useUserStore(), buttons = useBtnAuth()
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) < 3 && !!buttons['sw:business:add'])
const tab = ref('accounts'), catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
const canSeeBindings = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3)
const route = useRoute(), router = useRouter()
const tab = computed({
get: () => { const value = { '#bind': 'bindings', '#grp': 'groups' }[route.hash] || (['accounts', 'groups', 'bindings'].includes(route.query.tab) ? route.query.tab : 'accounts'); return value === 'bindings' && !canSeeBindings.value ? 'accounts' : value },
set: value => router.replace({ query: { ...route.query, tab: value }, hash: '' })
})
const catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
const filters = reactive({ name: '', account: '', level: '', group: '', employment: '' }), applied = ref({ ...filters }), groupFilters = reactive({ keyword: '', relation: '', employment: '' })
const page = ref(1), size = ref(10), groupOrder = ref(null), groupSort = ref({})
const metricsByID = computed(() => new Map((metrics.value?.rows || []).map(row => [row.member.userId, row])))
const filteredMembers = computed(() => {
const f = applied.value, order = new Map(catalog.value.groups.map((g, i) => [g.id, i]))
return catalog.value.members.map(member => ({ ...member, metrics: metricsByID.value.get(member.userId)?.total, rate: metricsByID.value.get(member.userId)?.rate })).filter(m => (!f.name || `${displayMember(m)} ${m.realName}`.includes(f.name)) && (!f.account || m.userName.toLowerCase().includes(f.account.toLowerCase())) && (!f.level || m.level === f.level) && (!f.group || String(m.groupId) === f.group) && (!f.employment || m.employment === f.employment)).sort((a, b) => {
return catalog.value.members.map(member => ({ ...member, metrics: metricsByID.value.get(member.userId)?.total, rate: metricsByID.value.get(member.userId)?.rate, currentGoal: metricsByID.value.get(member.userId)?.goals?.[currentMonth()] })).filter(m => (!f.name || `${displayMember(m)} ${m.realName}`.includes(f.name)) && (!f.account || m.userName.toLowerCase().includes(f.account.toLowerCase())) && (!f.level || m.level === f.level) && (!f.group || String(m.groupId) === f.group) && (!f.employment || m.employment === f.employment)).sort((a, b) => {
if (!!a.groupId !== !!b.groupId) return a.groupId ? -1 : 1
if (groupOrder.value) { const c = a.groupName.localeCompare(b.groupName, 'zh-CN'); if (c) return groupOrder.value === 'ascending' ? c : -c }
return (order.get(a.groupId) ?? Infinity) - (order.get(b.groupId) ?? Infinity) || 0
@@ -116,10 +123,10 @@ const reset = () => { Object.assign(filters, { name: '', account: '', level: '',
const sortMembers = ({ order }) => { groupOrder.value = order; page.value = 1 }
const sortGroups = value => { groupSort.value = value }
const viewMembers = group => { reset(); filters.group = String(group.id); search(); tab.value = 'accounts' }
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailTitle = ref('')
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
const teamsSaved = value => { catalog.value = value; if (filters.group && filters.group !== '0' && !value.groups.some(g => String(g.id) === filters.group)) { filters.group = ''; search() } loadMetrics() }
const openGoal = (kind, id, name) => { goalTarget.value = { kind, id, name }; goalOpen.value = true }
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailContext.value = { name: displayMember(row), groupName: row.groupName, descendantCount: metricsByID.value.get(row.userId)?.descendantCount, metrics: { ...row.metrics }, settings: metrics.value.settings }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
const channelsOpen = ref(false), bindingsOpen = ref(false), portraitOpen = ref(false), selectedID = ref(0), selectedName = ref('')
const showChannels = row => { selectedID.value = row.userId; channelsOpen.value = true }
const showBindings = row => { selectedID.value = row.userId; bindingsOpen.value = true }
@@ -5,13 +5,15 @@
<template v-if="profile">
<h3 class="profile-heading">基本信息</h3>
<dl class="profile-info">
<div><dt>渠道名称</dt><dd>{{ basic.nickname || '—' }}</dd></div>
<div><dt>渠道名称</dt><dd>{{ basic.nickname || basic.account || '—' }}</dd></div>
<div><dt>渠道ID</dt><dd>{{ basic.promoteId ?? '—' }}</dd></div>
<div><dt>归属商务</dt><dd>{{ basic.busierDisplayName || '—' }}</dd></div>
<div><dt>渠道类型</dt><dd>{{ basic.registerTypeLabel || '—' }}</dd></div>
<div><dt>渠道层级</dt><dd>{{ basic.promoteLevelLabel || '—' }}</dd></div>
<div><dt>上级渠道</dt><dd>{{ basic.parentPromoteName || '—' }}</dd></div>
<div><dt>状态</dt><dd><el-tag :type="basic.statusLabel === '正常' ? 'success' : 'info'" effect="plain">{{ basic.statusLabel || '—' }}</el-tag></dd></div>
<div><dt>状态</dt><dd><el-tag :type="{ 正常: 'success', 锁定: 'warning', 解除合作: 'danger' }[basic.lockStatusLabel] || 'info'" effect="plain">{{ basic.lockStatusLabel || basic.statusLabel || '—' }}</el-tag></dd></div>
<div><dt>手机号</dt><dd>{{ basic.phone || '—' }}</dd></div>
<div><dt>结算周期</dt><dd>{{ basic.settlementDays == null ? '—' : `${basic.settlementDays} 天` }}</dd></div>
<div><dt>推广游戏</dt><dd><el-button v-if="basic.gameCount != null" type="primary" link @click="gamesOpen = true">{{ channelCount(basic.gameCount) }} 款</el-button><span v-else>未接入</span></dd></div>
<div><dt>注册时间</dt><dd>{{ basic.createTime || '—' }}</dd></div>
<div><dt>最后登录时间</dt><dd>{{ basic.lastLoginTime || '—' }}</dd></div>
+1 -1
View File
@@ -18,7 +18,7 @@
</template>
<el-table scrollbar-always-on v-else :data="history" size="small">
<el-table-column prop="month" label="月份" width="85" />
<el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="120"><template #default="{ row }"><span v-if="row.goal?.[metric.goal] != null">{{ displayTarget(metric.goal, row.goal[metric.goal]) }} / {{ row.available === false ? '未接入' : displayMetric(metric.key, row.actual[metric.key]) }}</span><span v-else>—</span></template></el-table-column>
<el-table-column v-for="metric in metrics" :key="metric.key" :label="`${metric.label}(目标 / 实际)`" min-width="150"><template #default="{ row }">{{ displayTarget(metric.goal, row.goal?.[metric.goal]) }} / {{ row.available === false ? '未接入' : displayMetric(metric.key, row.actual[metric.key]) }}</template></el-table-column>
<el-table-column label="达成" width="95"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status === 'missing' ? `差 ${row.missing} 项` : statuses[row.status] }}</el-tag></template></el-table-column>
<el-table-column width="60"><template #default="{ row }"><el-button v-if="row.canEdit" link type="primary" @click="editMonth(row.month)">编辑</el-button></template></el-table-column>
</el-table>
@@ -1,17 +1,26 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body :title="title" size="min(1100px,96vw)" destroy-on-close>
<el-text type="info">{{ query?.begin }} 至 {{ query?.end }} · {{ query?.kind === 'team' ? '按成员贡献统计,同一渠道可计入多位成员' : '所选成员及全部下级商务,按渠道去重' }}</el-text>
<el-descriptions class="detail-summary" :column="2" border>
<el-descriptions-item label="统计范围">{{ context?.name || '所选商务' }}<div class="detail-scope">{{ scopeText }}</div></el-descriptions-item>
<el-descriptions-item :label="periodLabel">{{ context?.periodKey || `${query?.begin || '—'} 至 ${query?.end || '—'}` }}</el-descriptions-item>
<el-descriptions-item label="所属小组">{{ context?.groupName || '—' }}</el-descriptions-item>
<el-descriptions-item label="开端数">{{ displayMetric('open', context?.metrics?.open) }}</el-descriptions-item>
<el-descriptions-item label="有效端数 / 有效率">{{ displayMetric('effective', context?.metrics?.effective) }} / {{ rateText(effectiveRate(context?.metrics)) }}</el-descriptions-item>
<el-descriptions-item label="区间充值流水">{{ displayMetric('pay', context?.metrics?.pay) }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mt-3" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" class="mt-3" :row-key="row => `${row.sourceDb}:${row.id}:${row.contributorId || 0}`">
<el-table-column prop="sourceDbLabel" label="所属平台" width="100" />
<el-table-column v-if="query?.kind === 'team'" prop="contributorName" label="计入成员" min-width="140" />
<el-table-column prop="account" label="渠道" min-width="150" />
<el-table-column prop="account" label="渠道名称" min-width="150"><template #default="{ row }">{{ row.name || row.account || '—' }}</template></el-table-column>
<el-table-column prop="ownerName" label="归属商务" min-width="140" />
<el-table-column label="开端日期" width="125"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
<el-table-column label="判定窗口流水" min-width="145"><template #default="{ row }">{{ displayMetric('pay', row.windowPay) }}</template></el-table-column>
<el-table-column :label="`${windowDays} 天流水`" min-width="145"><template #header><el-tooltip :content="`渠道注册后 ${windowDays} 天判定窗口内的累计充值,与本次所选统计区间的流水不同。`"><span>{{ windowDays }} 天流水</span></el-tooltip></template><template #default="{ row }">{{ displayMetric('pay', row.windowPay) }}</template></el-table-column>
<el-table-column label="区间充值流水" min-width="145"><template #default="{ row }">{{ displayMetric('pay', row.periodPay) }}</template></el-table-column>
<el-table-column label="有效端" width="90"><template #default="{ row }"><el-tag :type="row.qualifiedAt ? 'success' : 'info'">{{ row.qualifiedAt ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="达标日期" width="125"><template #default="{ row }">{{ dateText(row.qualifiedAt) }}</template></el-table-column>
<el-table-column label="达标日期" width="125"><template #default="{ row }">{{ row.qualifiedAt ? dateText(row.qualifiedAt) : '未达标' }}</template></el-table-column>
</el-table>
<el-text class="detail-hint" type="info">{{ detailHint }}</el-text>
<el-pagination v-model:current-page="page" v-model:page-size="size" class="mt-4" :total="total" :page-sizes="[20,50,100]" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" />
</el-drawer>
</template>
@@ -19,15 +28,23 @@
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { getPerformanceDetails } from '@/api/performance'
import { dateText, displayMetric } from '../performance-display'
const props = defineProps({ modelValue: Boolean, query: Object, title: { type: String, default: '渠道明细' } }), emit = defineEmits(['update:modelValue'])
import { dateText, displayMetric, effectiveRate, rateText } from '../performance-display'
const props = defineProps({ modelValue: Boolean, query: Object, context: Object, title: { type: String, default: '渠道明细' } }), emit = defineEmits(['update:modelValue'])
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const rows = ref([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), error = ref('')
const requests = createLatestRequest()
const load = async () => { if (!visible.value || !props.query) return; const request = requests.start(); loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }, { signal: request.signal }); if (!request.isCurrent()) return; if (res.code !== 0) { error.value = res.msg || '明细读取失败'; rows.value = []; total.value = 0; return } rows.value = res.data.rows; total.value = res.data.total } catch { if (request.isCurrent()) error.value = '明细读取失败,请重试' } finally { if (request.isCurrent()) loading.value = false } }
const windowDays = computed(() => props.context?.settings?.days ?? 30)
const periodLabel = computed(() => props.context?.periodKey?.length === 10 ? '统计日期' : props.context?.periodKey?.length === 7 ? '统计月份' : '统计区间')
const scopeText = computed(() => props.query?.kind === 'team' ? '按成员贡献统计,同一渠道可计入多位成员' : props.context?.descendantCount != null ? `本人及全部下级(${props.context.descendantCount + 1} 个商务),按渠道去重` : '所选成员及全部下级商务,按渠道去重')
const detailHint = computed(() => ({ open: '只列所选区间内新开端的渠道;跨月达标不计入本区间有效端数。', effective: '只列所选区间内达成有效端的渠道,开端日期可能在之前的月份。', pay: '只列所选区间内产生充值流水的渠道;区间流水与开端后的判定窗口流水分别统计。', all: '展示所选区间内有开端、达标或充值记录的渠道。' }[props.query?.metric] || ''))
const load = async () => { if (!visible.value || !props.query) return; const request = requests.start(); loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }, { signal: request.signal }); if (!request.isCurrent()) return; if (res.code !== 0) { error.value = res.msg || '明细读取失败'; rows.value = []; total.value = 0; return } rows.value = res.data.rows; total.value = res.data.total } catch { if (request.isCurrent()) { error.value = '明细读取失败,请重试'; rows.value = []; total.value = 0 } } finally { if (request.isCurrent()) loading.value = false } }
const search = () => { page.value = 1; return load() }
const cancel = () => { requests.cancel(); loading.value = false }
watch(() => [props.modelValue, props.query], ([open]) => { if (open) { rows.value = []; search() } else cancel() }, { flush: 'sync' })
watch(() => [props.modelValue, props.query], ([open]) => { if (open) { rows.value = []; total.value = 0; search() } else cancel() }, { flush: 'sync' })
onBeforeUnmount(cancel)
onDeactivated(() => { visible.value = false; cancel() })
</script>
<style scoped>
.detail-scope { margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary); }
.detail-hint { display: block; margin-top: 12px; line-height: 1.7; }
</style>
@@ -2,12 +2,12 @@
<div ref="host" class="performance-trend">
<div class="legend"><span v-for="line in lines" :key="line.key"><i :style="{ background: line.color }" />{{ line.label }}{{ line.key === 'pay' ? '(右轴,万元)' : '' }}</span></div>
<svg :viewBox="`0 0 ${width} 396`" role="img" aria-label="开端、有效端和充值流水趋势图" tabindex="0" @mousemove="hover" @mouseleave="active = null" @focus="active = 0" @blur="active = null" @keydown.left.prevent="move(-1)" @keydown.right.prevent="move(1)">
<g v-for="tick in [0,1,2,3,4]" :key="tick"><line x1="52" :x2="width - 60" :y1="350 - tick * 78" :y2="350 - tick * 78" class="grid" /><text x="45" :y="354 - tick * 78" text-anchor="end">{{ Math.round(maxCount * tick / 4) }}</text><text :x="width - 52" :y="354 - tick * 78" style="fill:#d49b20">{{ ((minPay + paySpan * tick / 4) / 10000).toFixed(2) }}</text></g>
<g v-for="tick in [0,1,2,3,4]" :key="tick"><line x1="52" :x2="width - 60" :y1="350 - tick * 78" :y2="350 - tick * 78" class="grid" /><text x="45" :y="354 - tick * 78" text-anchor="end">{{ Math.round(maxCount * tick / 4) }}</text><text :x="width - 52" :y="354 - tick * 78" style="fill:#d49b20">{{ (minPay + paySpan * tick / 4) / 10000 }}</text></g>
<polyline v-for="line in lines" :key="line.key" :points="points(line.key)" fill="none" :style="{ stroke: line.color }" stroke-width="2.5" stroke-linejoin="round" />
<text v-for="i in labelIndexes" :key="i" :x="x(i)" y="383" text-anchor="middle">{{ periods[i]?.label }}</text>
<line v-if="selected" :x1="x(active)" :x2="x(active)" y1="28" y2="350" class="cursor" />
</svg>
<div v-if="selected" class="trend-tooltip" :style="{ left: `${Math.max(8, Math.min(width - 210, x(active)))}px` }"><strong>{{ selected.key }}</strong><div v-for="line in lines" :key="line.key" :style="{ color: line.color }">{{ line.label }}:{{ displayMetric(line.key, trend[selected.key]?.[line.key] || 0) }}</div></div>
<div v-if="selected" class="trend-tooltip" :style="{ left: `${Math.max(8, Math.min(width - 210, x(active)))}px` }"><strong>{{ selected.key }}</strong><div v-for="line in lines" :key="line.key" :style="{ color: line.color }">{{ line.label }}:{{ line.key === 'pay' ? `¥${(Number(trend[selected.key]?.pay || 0) / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 6 })}万` : displayMetric(line.key, trend[selected.key]?.[line.key] || 0) }}</div></div>
</div>
</template>
<script setup>
@@ -18,8 +18,8 @@ const host = ref(), width = ref(600), active = ref(null)
const lines = [{ key: 'open', label: '开端', color: '#4f8cff' }, { key: 'effective', label: '有效端', color: '#22b98a' }, { key: 'pay', label: '充值流水', color: '#d49b20' }]
const maxCount = computed(() => Math.ceil(Math.max(4, ...Object.values(props.trend).flatMap(v => [v.open, v.effective])) / 4) * 4)
const maxPay = computed(() => Math.max(1, ...Object.values(props.trend).map(v => Number(v.pay))))
const minPay = computed(() => Math.min(0, ...Object.values(props.trend).map(v => Number(v.pay))))
const paySpan = computed(() => Math.max(1, maxPay.value - minPay.value))
const minPay = computed(() => Math.floor(Math.min(0, ...Object.values(props.trend).map(v => Number(v.pay))) / 10000) * 10000)
const paySpan = computed(() => Math.max(40000, Math.ceil((maxPay.value - minPay.value) / 40000) * 40000))
const x = index => 52 + index * (width.value - 112) / Math.max(1, props.periods.length - 1)
const points = key => props.periods.map((p, i) => { const value = Number(props.trend[p.key]?.[key] || 0); return `${x(i)},${350 - (key === 'pay' ? (value - minPay.value) / paySpan.value : value / maxCount.value) * 312}` }).join(' ')
const labelIndexes = computed(() => [...new Set([0,1,2,3,4].map(i => Math.round(i * Math.max(0, props.periods.length - 1) / 4)))])
@@ -19,7 +19,7 @@
<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><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 scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" 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="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 />
@@ -55,7 +55,7 @@ const startShortcuts = shortcuts.map(item => ({ text: item.text, value: () => it
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: isChannel.value ? '' : 'totalPay', sortOrder: 'desc' })
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: '', 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
@@ -97,7 +97,7 @@ const download = async () => {
} 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, isChannel.value ? '' : 'totalPay')); return search() }
const sortChanged = event => { Object.assign(query, remoteSort(event, '')); 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 () => {
+5
View File
@@ -19,4 +19,9 @@ export const dateText = value => value == null ? '—' : new Date(Number(value)
export const currentMonth = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` }
export const monthOffset = (month, offset) => { const [y, m] = month.split('-').map(Number), d = new Date(y, m - 1 + offset, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` }
export const hasGoal = goal => goal && [goal.open, goal.effective, goal.amount].some(v => v != null)
export const goalSummary = goal => {
if (!hasGoal(goal)) return '设目标'
const amount = goal.amount == null ? '—' : goal.amount >= 10000 ? `${(goal.amount / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}万` : String(goal.amount)
return `目标 ${goal.open ?? '—'}/${goal.effective ?? '—'}/${amount}`
}
export const effectiveRate = metrics => metrics?.open ? Number(metrics.effective) / Number(metrics.open) * 100 : null
+8 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { goalAchieved, completionRate, hasGoal, monthOffset, personalGoalRows } from './performance-display.js'
import { goalAchieved, completionRate, hasGoal, monthOffset, personalGoalRows, goalSummary } from './performance-display.js'
test('goal attainment compares exact cents even when rounded percentages look complete', () => {
assert.equal(goalAchieved('9007199254740990.99', 9007199254740991, 'pay'), false)
@@ -24,3 +24,10 @@ test('empty and zero targets stay distinct and year boundaries retain the month'
assert.equal(monthOffset('2026-01', -1), '2025-12')
assert.equal(monthOffset('2026-12', 1), '2027-01')
})
test('goal button summarizes configured targets and keeps unset and zero distinct', () => {
assert.equal(goalSummary(null), '设目标')
assert.equal(goalSummary({ open: null, effective: null, amount: null }), '设目标')
assert.equal(goalSummary({ open: 30, effective: 12, amount: 2000000 }), '目标 30/12/200万')
assert.equal(goalSummary({ open: 0, effective: null, amount: 0 }), '目标 0/—/0')
})