diff --git a/cmd/analytics_http_test.go b/cmd/analytics_http_test.go index 013489a..9a3d52f 100644 --- a/cmd/analytics_http_test.go +++ b/cmd/analytics_http_test.go @@ -36,6 +36,7 @@ func TestChannelAndGameAnalyticsHTTP(t *testing.T) { "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'SDK账号','0'),(99,'hw',20,'其他账号','0')", "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,register_type INTEGER)", "INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14,0),(101,'lx','child-channel',2,100,14,0),(200,'lx','guild-channel',1,0,14,1),(100,'hw','other-platform',1,0,20,0)", + "ALTER TABLE tab_promote ADD COLUMN nickname TEXT", "CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)", "INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1),('lx',200,2,1)", "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)", diff --git a/internal/biz/business/analytics.go b/internal/biz/business/analytics.go index a3ff5d8..50d70cb 100644 --- a/internal/biz/business/analytics.go +++ b/internal/biz/business/analytics.go @@ -254,6 +254,7 @@ func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascen } allowed := map[string]bool{"newRegisterCount": true, "activeCount": true, "payUserCount": true, "newPayUserCount": true, "totalPay": true, "payRate": true} if kind == GameAnalytics { + allowed["gameId"] = true allowed["arpu"] = true allowed["arppu"] = true } else { @@ -277,7 +278,7 @@ func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascen n = big.NewRat(row.Counts.Pay, 1) case "newPayUserCount": n = big.NewRat(row.Counts.NewPay, 1) - case "promoteId": + case "promoteId", "gameId": n = big.NewRat(row.ID, 1) case "promoteType": value := int64(0) diff --git a/internal/biz/business/analytics_test.go b/internal/biz/business/analytics_test.go index c27411d..d4f0df6 100644 --- a/internal/biz/business/analytics_test.go +++ b/internal/biz/business/analytics_test.go @@ -6,6 +6,16 @@ import ( "time" ) +func TestGameIDSortDoesNotFallBackToRechargeAmount(t *testing.T) { + rows := []*AnalyticsRow{{AnalyticsKey: AnalyticsKey{ID: 20, SourceDB: "lx"}, Pay: "1.00"}, {AnalyticsKey: AnalyticsKey{ID: 3, SourceDB: "lx"}, Pay: "900.00"}} + if err := sortAnalytics(rows, GameAnalytics, "gameId", true); err != nil || rows[0].ID != 3 { + t.Fatal("game ID ascending sort failed") + } + if err := sortAnalytics(rows, GameAnalytics, "gameId", false); err != nil || rows[0].ID != 20 { + t.Fatal("game ID descending sort fell back to recharge amount") + } +} + type analyticsRepoFake struct { data *AnalyticsData scope AnalyticsScope diff --git a/internal/biz/business/ledger.go b/internal/biz/business/ledger.go index 55a307b..a1c3a21 100644 --- a/internal/biz/business/ledger.go +++ b/internal/biz/business/ledger.go @@ -142,6 +142,8 @@ func registrationSort(field string) string { } func spendSort(field string) string { switch field { + case "id": + return "id" case "payAmount", "pay_amount", "amount": return "payAmount" } diff --git a/internal/data/business/analytics.go b/internal/data/business/analytics.go index fa108fb..070c521 100644 --- a/internal/data/business/analytics.go +++ b/internal/data/business/analytics.go @@ -3,6 +3,7 @@ package business import ( "context" "sort" + "strings" "time" "golang.org/x/sync/errgroup" @@ -25,7 +26,8 @@ func analyticsPromotes(db *gorm.DB, scope business.AnalyticsScope, f business.An if f.PromoteName == "" { return q } - hits := analyticsBasePromotes(db, scope, f, "hit").Select("1").Where("hit.account LIKE ?", "%"+f.PromoteName+"%").Where("hit.source_db=" + alias + ".source_db") + keyword := strings.ToLower(strings.TrimSpace(f.PromoteName)) + hits := analyticsBasePromotes(db, scope, f, "hit").Select("1").Where("INSTR(LOWER(hit.account),?)>0 OR INSTR(LOWER(hit.nickname),?)>0", keyword, keyword).Where("hit.source_db=" + alias + ".source_db") if scope.IncludeSub { root := "CASE WHEN hit.promote_level=1 THEN hit.id WHEN hit.top_promote_id>0 THEN hit.top_promote_id ELSE hit.id END" hits = hits.Where("(" + alias + ".id=(" + root + ") OR " + alias + ".top_promote_id=(" + root + "))") diff --git a/internal/data/business/analytics_test.go b/internal/data/business/analytics_test.go index 42e010c..d75c265 100644 --- a/internal/data/business/analytics_test.go +++ b/internal/data/business/analytics_test.go @@ -102,6 +102,24 @@ func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) { t.Fatal("不能用当前游戏表名替代报表快照过滤") } } + +func TestAnalyticsChannelSearchMatchesNicknameAndKeepsScope(t *testing.T) { + db, repo, scope, filter := prepareAnalyticsData(t) + if err := db.Exec("UPDATE tab_promote SET nickname='同名推广' WHERE id=100").Error; err != nil { + t.Fatal(err) + } + scope.Viewer = business.UserScope{SelfID: 9, OnlySelf: true} + filter.PromoteName = "推广" + data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || len(data.Rows) != 1 || data.Rows[0].SourceDB != "lx" || data.Rows[0].ID != 100 { + t.Fatalf("nickname search must remain scoped: %+v %v", data, err) + } + filter.PromoteName = "%" + data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || len(data.Rows) != 0 { + t.Fatal("wildcard keyword must not broaden the query") + } +} func TestAnalyticsMissingScopeAndTablesDoNotBroaden(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) scope.IncludeSub = false diff --git a/internal/data/business/channel.go b/internal/data/business/channel.go index c0cb032..b7f7f07 100644 --- a/internal/data/business/channel.go +++ b/internal/data/business/channel.go @@ -86,7 +86,8 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter q = q.Where("p.source_db IN ?", filter.Sources) } if filter.Keyword != "" { - q = q.Where("p.account LIKE ?", "%"+filter.Keyword+"%") + 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) } for _, field := range []struct { column string diff --git a/internal/data/business/channel_test.go b/internal/data/business/channel_test.go index 3b541d1..0e9e118 100644 --- a/internal/data/business/channel_test.go +++ b/internal/data/business/channel_test.go @@ -43,6 +43,23 @@ func prepareChannels(t *testing.T) (*gorm.DB, business.ChannelRepo) { func channelTestScope() business.UserScope { return business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"} } + +func TestChannelKeywordMatchesNameAndContactWithinScope(t *testing.T) { + db, repo := prepareChannels(t) + if err := db.Exec("UPDATE tab_promote SET nickname='同名公会', real_name='负责人甲' WHERE id=100").Error; err != nil { + t.Fatal(err) + } + for _, keyword := range []string{"公会", "负责人甲"} { + page, err := repo.List(context.Background(), business.UserScope{SelfID: 9, OnlySelf: true}, business.ChannelFilter{Keyword: keyword}) + if err != nil || page.Total != 1 || len(page.Rows) != 1 || page.Rows[0].SourceDB != "lx" { + t.Fatalf("keyword must not escape the business scope: %q %+v %v", keyword, page, err) + } + } + page, err := repo.List(context.Background(), channelTestScope(), business.ChannelFilter{Keyword: "%"}) + if err != nil || page.Total != 0 { + t.Fatal("keyword wildcards must be treated literally") + } +} func TestChannelListMatchesBindingsAndLiteralBusinessKeyword(t *testing.T) { db, repo := prepareChannels(t) ctx := context.Background() diff --git a/internal/data/business/ledger.go b/internal/data/business/ledger.go index 05331c4..4889c41 100644 --- a/internal/data/business/ledger.go +++ b/internal/data/business/ledger.go @@ -287,6 +287,8 @@ func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, fil order := "r.pay_time" if filter.SortField == "payAmount" { order = "r.pay_amount" + } else if filter.SortField == "id" { + order = "r.id" } if filter.Ascending { order += " ASC" diff --git a/internal/data/business/ledger_test.go b/internal/data/business/ledger_test.go index 84056d2..c9315c7 100644 --- a/internal/data/business/ledger_test.go +++ b/internal/data/business/ledger_test.go @@ -147,3 +147,24 @@ func TestLedgerChannelNicknameAndLiteralFuzzyFilter(t *testing.T) { t.Fatal("模糊搜索不得扩大商务范围") } } + +func TestSpendIDSortIsGlobalAndStableAcrossPages(t *testing.T) { + db, repo, scope, filter := prepareLedgerData(t) + if err := db.Exec("UPDATE tab_spend SET pay_time=1999 WHERE id=901").Error; err != nil { + t.Fatal(err) + } + filter.SortField, filter.Ascending = "id", true + first, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 2}) + if err != nil { + t.Fatal(err) + } + second, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Offset: 2, Limit: 2}) + if err != nil || len(first.Rows) != 2 || len(second.Rows) != 2 || first.Rows[1].ID >= second.Rows[0].ID { + t.Fatal("ascending sort must be applied before pagination") + } + filter.Ascending = false + last, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 2}) + if err != nil || last.Rows[0].ID != 909 || last.Rows[1].ID != 904 { + t.Fatalf("ID ordering must not fall back to timestamps: %+v %v", last, err) + } +} diff --git a/web/src/main.js b/web/src/main.js index 710de89..960ae05 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -7,6 +7,7 @@ import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' // 主题扩展样式(菜单风格/卡片盒子模式/圆角),置于 EP 基础样式之后以便覆盖 import '@/style/theme.scss' +import '@/style/business.scss' // 引入kra前端初始化相关内容 import './core/kra' // 引入封装的router diff --git a/web/src/style/business.scss b/web/src/style/business.scss new file mode 100644 index 0000000..7624f85 --- /dev/null +++ b/web/src/style/business.scss @@ -0,0 +1,47 @@ +// Business views share a compact table rhythm and semantic emphasis. +// Scope these rules so framework administration tables retain their own layout. +.sw-page, .sw-dialog { + .el-table { + --el-table-header-bg-color: var(--el-fill-color-light); + --el-table-row-hover-bg-color: var(--el-color-primary-light-9); + --el-table-border-color: var(--el-border-color-lighter); + font-size: 13px; + + th.el-table__cell { background: var(--el-table-header-bg-color); padding: 9px 0; } + th.el-table__cell .cell { line-height: 24px; font-weight: 600; color: var(--el-text-color-regular); } + td.el-table__cell { padding: 10px 0; } + td.el-table__cell .cell { line-height: 24px; } + .el-button.is-link, .el-tag { white-space: nowrap; } + .el-button.is-link { font-weight: 500; } + .el-tag { font-size: 12px; } + .el-scrollbar__bar.is-horizontal { height: 9px; opacity: 1; } + .el-scrollbar__thumb { background: var(--el-color-primary-light-3); } + .el-table__footer-wrapper td { background: var(--el-fill-color-light); font-weight: 600; } + .sw-col-meta .cell { color: var(--el-text-color-secondary); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } + .sw-col-identity .cell { font-weight: 500; } + .sw-col-number .cell { font-variant-numeric: tabular-nums; white-space: nowrap; } + } + + .kra-search-box { + .el-form-item { margin-right: 20px; margin-bottom: 14px; } + .el-form-item__label { font-size: 13px; color: var(--el-text-color-secondary); } + } + + .kra-pagination .el-pagination { margin-top: 18px; } + .sw-table-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } + .sw-sort-label { color: var(--el-text-color-secondary); font-size: 12px; } + .sw-table-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 12px 14px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-light); color: var(--el-text-color-secondary); font-size: 13px; } + .sw-table-summary strong { color: var(--el-color-primary); font-variant-numeric: tabular-nums; font-size: 16px; } + .sw-empty-value { color: var(--el-text-color-placeholder); } +} + +@media (max-width: 640px) { + .sw-page .kra-search-box { + .el-form-item { display: flex; flex-direction: column; width: 100%; margin-right: 0; } + .el-form-item__label { justify-content: flex-start; height: auto; line-height: 22px; padding-bottom: 4px; } + .el-form-item__content { width: 100%; min-width: 0; } + .el-form-item__content > .el-input, + .el-form-item__content > .el-select, + .el-form-item__content > .el-date-editor { width: 100% !important; } + } +} diff --git a/web/src/view/dashboard/components/BusinessDashboard.vue b/web/src/view/dashboard/components/BusinessDashboard.vue index 5bfa04d..5707922 100644 --- a/web/src/view/dashboard/components/BusinessDashboard.vue +++ b/web/src/view/dashboard/components/BusinessDashboard.vue @@ -1,5 +1,5 @@