From 04cb4388692414f14a0e684648b32edd6e7b2be5 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:29:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=A8=A1=E5=9E=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E7=BB=9F=E4=B8=80=E8=B4=A2=E5=8A=A1=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=B9=B6=E6=94=AF=E6=8C=81=E5=B7=B2=E6=9C=89=E5=88=86?= =?UTF-8?q?=E7=BB=84=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/admin.go | 19 +- backend/internal/app/analytics.go | 101 +++--- backend/internal/app/analytics_finance.go | 90 +++++ .../internal/app/analytics_finance_test.go | 215 ++++++++++++ docs/content/docs/backend/http-api.mdx | 8 + docs/content/docs/progress/pending-test.mdx | 14 + .../admin/components/analytics-finance.ts | 22 ++ .../admin/components/analytics-panel.tsx | 328 ++---------------- .../admin/components/channel-model-editor.tsx | 14 +- web/src/services/api/auth.ts | 19 +- web/src/styles/admin-ui.css | 57 ++- web/test/admin-ui-regressions.test.ts | 29 +- web/test/analytics-finance.test.ts | 33 ++ 13 files changed, 522 insertions(+), 427 deletions(-) create mode 100644 backend/internal/app/analytics_finance.go create mode 100644 backend/internal/app/analytics_finance_test.go create mode 100644 web/src/pages/admin/components/analytics-finance.ts create mode 100644 web/test/analytics-finance.test.ts diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index ea1cc5cd4..88e37b5e2 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -75,10 +75,11 @@ type AdminUserReference struct { } type AdminChannelReference struct { - ID string `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - Models []string `json:"models"` + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Models []string `json:"models"` + ModelDisplayNames []string `json:"modelDisplayNames"` } type AdminReferenceData struct { @@ -194,15 +195,19 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName}) } for _, channel := range channels { - items, itemErr := s.repo.ChannelModels(channel.ID, false) + items, itemErr := s.repo.ChannelModels(channel.ID, true) if itemErr != nil { return nil, itemErr } models := make([]string, 0, len(items)) + displayNames := make([]string, 0, len(items)) for _, item := range items { - models = append(models, item.ModelKey) + if item.Enabled { + models = append(models, item.ModelKey) + } + displayNames = append(displayNames, firstNonEmpty(strings.TrimSpace(item.DisplayName), item.ModelKey)) } - result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models)}) + result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)}) } return result, nil } diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index 48a5d4957..2f1150780 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -38,18 +38,16 @@ type AnalyticsOverview struct { } type AnalyticsKPI struct { - ActiveUsers int `json:"activeUsers"` - DAU int `json:"dau"` - WAU int `json:"wau"` - MAU int `json:"mau"` - GenerationTasks int `json:"generationTasks"` - UpstreamRequests int `json:"upstreamRequests"` - SuccessRate float64 `json:"successRate"` - P95DurationMs int64 `json:"p95DurationMs"` - CurrentQueuedTasks int64 `json:"currentQueuedTasks"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency"` + ActiveUsers int `json:"activeUsers"` + DAU int `json:"dau"` + WAU int `json:"wau"` + MAU int `json:"mau"` + GenerationTasks int `json:"generationTasks"` + UpstreamRequests int `json:"upstreamRequests"` + SuccessRate float64 `json:"successRate"` + P95DurationMs int64 `json:"p95DurationMs"` + CurrentQueuedTasks int64 `json:"currentQueuedTasks"` + Finance AnalyticsFinance `json:"finance"` } type AnalyticsTrendPoint struct { @@ -61,24 +59,22 @@ type AnalyticsTrendPoint struct { } type AnalyticsModelRow struct { - Model string `json:"model"` - Capability string `json:"capability"` - Tasks int `json:"tasks"` - Requests int `json:"requests"` - UniqueUsers int `json:"uniqueUsers"` - TaskSuccessRate float64 `json:"taskSuccessRate"` - RequestSuccessRate float64 `json:"requestSuccessRate"` - P50DurationMs int64 `json:"p50DurationMs"` - P95DurationMs int64 `json:"p95DurationMs"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - MediaCount int `json:"mediaCount"` - VideoSeconds int `json:"videoSeconds"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency"` + Model string `json:"model"` + Capability string `json:"capability"` + Tasks int `json:"tasks"` + Requests int `json:"requests"` + UniqueUsers int `json:"uniqueUsers"` + TaskSuccessRate float64 `json:"taskSuccessRate"` + RequestSuccessRate float64 `json:"requestSuccessRate"` + P50DurationMs int64 `json:"p50DurationMs"` + P95DurationMs int64 `json:"p95DurationMs"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + MediaCount int `json:"mediaCount"` + VideoSeconds int `json:"videoSeconds"` + Finance AnalyticsFinance `json:"finance"` } type AnalyticsUserRow struct { @@ -182,6 +178,11 @@ func (s *Service) AdminAnalytics(actor *model.User, query AnalyticsQuery) (*Anal return nil, err } result := buildAnalyticsOverview(filter, tasks, rollingTasks, rollingLogs, logs, activities, users) + records, err := s.analyticsFinancialRecords(logs) + if err != nil { + return nil, err + } + applyAnalyticsFinance(result, records) result.KPI.CurrentQueuedTasks = queued return result, nil } @@ -448,14 +449,22 @@ func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([] if err != nil { return nil, err } + records, err := s.analyticsFinancialRecords(logs) + if err != nil { + return nil, err + } var buffer bytes.Buffer buffer.WriteString("\xEF\xBB\xBF") writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "估算费用(微单位)", "币种", "错误类型"}) + _ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "收入(微积分)", "成本(微积分)", "利润(微积分)", "错误类型"}) for _, log := range logs { - cost := "" - if log.CostAvailable { - cost = strconv.FormatInt(log.EstimatedCostMicros, 10) + revenue, cost, profit := "", "", "" + if record, exists := records[log.ID]; exists { + revenue = strconv.FormatInt(record.Revenue, 10) + if record.Cost != nil { + cost = strconv.FormatInt(*record.Cost, 10) + profit = strconv.FormatInt(record.Revenue-*record.Cost, 10) + } } inputTokens, outputTokens, cachedTokens := "", "", "" if log.UsageAvailable { @@ -463,7 +472,7 @@ func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([] outputTokens = strconv.FormatInt(log.OutputTokens, 10) cachedTokens = strconv.FormatInt(log.CachedTokens, 10) } - _ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), cost, log.Currency, classifyAPICallError(log)}) + _ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), revenue, cost, profit, classifyAPICallError(log)}) } writer.Flush() return buffer.Bytes(), writer.Error() @@ -598,16 +607,9 @@ func buildAnalyticsOverview(filter repository.AnalyticsFilter, tasks []model.Tas result.KPI.DAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -1), filter.To) result.KPI.WAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -7), filter.To) result.KPI.MAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -30), filter.To) - currency := "" for _, log := range logs { durations = append(durations, log.DurationMs) - if log.CostAvailable { - result.KPI.CostAvailable = true - result.KPI.EstimatedCostMicros += log.EstimatedCostMicros - currency = mergeCurrency(currency, log.Currency) - } } - result.KPI.Currency = currency result.KPI.P95DurationMs = percentile(durations, 0.95) result.Trend = buildAnalyticsTrend(filter, tasks, logs, activities) result.Models = buildAnalyticsModels(tasks, logs) @@ -724,11 +726,6 @@ func buildAnalyticsModels(tasks []model.Task, logs []model.ApiCallLog) []Analyti } item.row.MediaCount += log.MediaCount item.row.VideoSeconds += log.VideoSeconds - if log.CostAvailable { - item.row.CostAvailable = true - item.row.EstimatedCostMicros += log.EstimatedCostMicros - item.row.Currency = mergeCurrency(item.row.Currency, log.Currency) - } } result := make([]AnalyticsModelRow, 0, len(items)) for _, item := range items { @@ -917,16 +914,6 @@ func percentile(values []int64, quantile float64) int64 { return items[index] } -func mergeCurrency(current string, next string) string { - if next == "" { - return current - } - if current == "" || current == next { - return next - } - return "MIXED" -} - func capabilityFromTaskType(taskType string) string { value := strings.ToLower(taskType) for _, capability := range []string{"video", "image", "audio", "text"} { diff --git a/backend/internal/app/analytics_finance.go b/backend/internal/app/analytics_finance.go new file mode 100644 index 000000000..8043d08c8 --- /dev/null +++ b/backend/internal/app/analytics_finance.go @@ -0,0 +1,90 @@ +package app + +import "infinite-canvas/backend/internal/model" + +type AnalyticsFinance struct { + SettledOrders int `json:"settledOrders"` + CostedOrders int `json:"costedOrders"` + RevenueMicrocredits int64 `json:"revenueMicrocredits"` + CostMicrocredits int64 `json:"costMicrocredits"` + ProfitMicrocredits *int64 `json:"profitMicrocredits"` + ProfitMargin *float64 `json:"profitMargin"` +} + +type analyticsFinancialRecord struct { + Log model.ApiCallLog + Revenue int64 + Cost *int64 +} + +// 一个订单的重试、轮询不能重复计入收入;只归属实际结算渠道。 +// 历史订单使用下单价格快照,缺失成本时不反查当前价格。 +func (s *Service) analyticsFinancialRecords(logs []model.ApiCallLog) (map[string]analyticsFinancialRecord, error) { + ids := make([]string, 0, len(logs)) + for _, log := range logs { + if log.Billable && log.BillingOrderID != "" { + ids = append(ids, log.BillingOrderID) + } + } + orders, err := s.repo.BillingOrdersByIDs(uniqueNonEmpty(ids)) + if err != nil { + return nil, err + } + byOrder := make(map[string]model.ApiCallLog) + for _, log := range logs { + order, exists := orders[log.BillingOrderID] + if !log.Billable || !exists || order.UserID != log.UserID || order.ChannelID != log.ChannelID || order.Status != model.BillingStatusSettled { + continue + } + switch log.RequestKind { + case "poll", "download", "upload", "workflow-schema", "cancel-query", "cancel": + continue + } + previous, exists := byOrder[order.ID] + if !exists || log.CreatedAt.Before(previous.CreatedAt) || (log.CreatedAt.Equal(previous.CreatedAt) && log.ID < previous.ID) { + byOrder[order.ID] = log + } + } + records := make(map[string]analyticsFinancialRecord, len(byOrder)) + for id, log := range byOrder { + order := orders[id] + cost, err := billingCreditCost(order) + if err != nil { + return nil, err + } + records[log.ID] = analyticsFinancialRecord{Log: log, Revenue: order.ActualAmountMicrocredits, Cost: cost} + } + return records, nil +} + +func (finance *AnalyticsFinance) add(record analyticsFinancialRecord) { + finance.SettledOrders++ + finance.RevenueMicrocredits += record.Revenue + if record.Cost != nil { + finance.CostedOrders++ + finance.CostMicrocredits += *record.Cost + } + finance.ProfitMicrocredits, finance.ProfitMargin = nil, nil + if finance.CostedOrders == finance.SettledOrders { + profit := finance.RevenueMicrocredits - finance.CostMicrocredits + finance.ProfitMicrocredits = &profit + if finance.RevenueMicrocredits > 0 { + margin := float64(profit) * 100 / float64(finance.RevenueMicrocredits) + finance.ProfitMargin = &margin + } + } +} + +func applyAnalyticsFinance(overview *AnalyticsOverview, records map[string]analyticsFinancialRecord) { + rows := make(map[[2]string]*AnalyticsFinance, len(overview.Models)) + for index := range overview.Models { + row := &overview.Models[index] + rows[[2]string{row.Model, row.Capability}] = &row.Finance + } + for _, record := range records { + overview.KPI.Finance.add(record) + if row := rows[[2]string{firstNonEmpty(record.Log.Model, "未识别"), record.Log.Capability}]; row != nil { + row.add(record) + } + } +} diff --git a/backend/internal/app/analytics_finance_test.go b/backend/internal/app/analytics_finance_test.go new file mode 100644 index 000000000..16c4b1195 --- /dev/null +++ b/backend/internal/app/analytics_finance_test.go @@ -0,0 +1,215 @@ +package app + +import ( + "encoding/csv" + "strconv" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAnalyticsFinanceAggregatesKnownAndUnknownCosts(t *testing.T) { + cost := int64(300_000) + zero := int64(0) + for _, test := range []struct { + name string + records []analyticsFinancialRecord + costed int + revenue int64 + cost int64 + profit *int64 + }{ + {name: "no orders"}, + {name: "explicit zero", records: []analyticsFinancialRecord{{Cost: &zero}}, costed: 1, profit: &zero}, + {name: "break even", records: []analyticsFinancialRecord{{Revenue: 300_000, Cost: &cost}}, costed: 1, revenue: 300_000, cost: 300_000, profit: &zero}, + {name: "partial cost last", records: []analyticsFinancialRecord{{Revenue: 900_000, Cost: &cost}, {Revenue: 400_000}}, costed: 1, revenue: 1_300_000, cost: 300_000}, + {name: "partial cost first", records: []analyticsFinancialRecord{{Revenue: 400_000}, {Revenue: 900_000, Cost: &cost}}, costed: 1, revenue: 1_300_000, cost: 300_000}, + } { + t.Run(test.name, func(t *testing.T) { + var finance AnalyticsFinance + for _, record := range test.records { + finance.add(record) + } + if finance.SettledOrders != len(test.records) || finance.CostedOrders != test.costed || finance.RevenueMicrocredits != test.revenue || finance.CostMicrocredits != test.cost { + t.Fatalf("finance = %+v", finance) + } + if test.profit == nil { + if finance.ProfitMicrocredits != nil || finance.ProfitMargin != nil { + t.Fatalf("unknown profit = %+v", finance) + } + return + } + if finance.ProfitMicrocredits == nil || *finance.ProfitMicrocredits != *test.profit { + t.Fatalf("profit = %+v", finance) + } + if test.revenue == 0 { + if finance.ProfitMargin != nil { + t.Fatalf("zero revenue margin = %v", *finance.ProfitMargin) + } + } else if finance.ProfitMargin == nil || *finance.ProfitMargin != float64(*test.profit)*100/float64(test.revenue) { + t.Fatalf("margin = %+v", finance) + } + }) + } +} + +func TestAdminAnalyticsFinanceUsesSettledSnapshotsAndDeduplicates(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.BillingOrder{}, &model.ApiCallLog{}, &model.Task{}, &model.UserDailyActivity{}, &model.User{}); err != nil { + t.Fatal(err) + } + svc := &Service{repo: repository.New(db)} + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + now := time.Now().UTC() + orders := []model.BillingOrder{ + {ID: "paid", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 900_000, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "fixed_request", CostQuantity: 1, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 300_000}}}, + {ID: "free", Status: model.BillingStatusSettled, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "fixed_request", CostQuantity: 1, CostPricing: model.CreditCostPricing{Configured: true}}}, + {ID: "unknown", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 400_000}, + {ID: "reserved", Status: model.BillingStatusReserved, ReservedAmountMicrocredits: 1_000_000}, + {ID: "refunded", Status: model.BillingStatusRefunded, ActualAmountMicrocredits: 800_000}, + {ID: "loss", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 100_000, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "per_second", CostQuantity: 3, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 100_000}}}, + } + logs := make([]model.ApiCallLog, 0) + for index := range orders { + order := &orders[index] + order.UserID, order.ChannelID, order.IdempotencyKey = "user", "channel", order.ID + logs = append(logs, model.ApiCallLog{ID: order.ID, BillingOrderID: order.ID, UserID: "user", ChannelID: "channel", Model: order.ID, Capability: "text", Billable: true, RequestKind: "create", Status: model.ApiCallStatusSucceeded, CreatedAt: now}) + } + for _, id := range []string{"retry", "poll", "download", "upload", "workflow-schema", "cancel-query", "cancel", "wrong-user", "old-channel"} { + item := logs[0] + item.ID = id + item.CreatedAt = now.Add(time.Second) + switch id { + case "poll", "download", "upload", "workflow-schema", "cancel-query", "cancel": + item.RequestKind = id + item.Model = "auxiliary" + item.CreatedAt = now.Add(-time.Second) + case "wrong-user": + item.UserID = "other" + case "old-channel": + item.ChannelID = "previous" + } + logs = append(logs, item) + } + if err := db.Create(&orders).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&logs).Error; err != nil { + t.Fatal(err) + } + query := AnalyticsQuery{From: now.Add(-time.Hour).Format(time.RFC3339), To: now.Add(time.Hour).Format(time.RFC3339)} + result, err := svc.AdminAnalytics(admin, query) + if err != nil { + t.Fatal(err) + } + finance := result.KPI.Finance + if finance.SettledOrders != 4 || finance.CostedOrders != 3 || finance.RevenueMicrocredits != 1_400_000 || finance.CostMicrocredits != 600_000 || finance.ProfitMicrocredits != nil || finance.ProfitMargin != nil { + t.Fatalf("partial finance = %+v", finance) + } + for _, row := range result.Models { + f := row.Finance + switch row.Model { + case "paid": + if f.SettledOrders != 1 || f.ProfitMicrocredits == nil || *f.ProfitMicrocredits != 600_000 || f.ProfitMargin == nil || *f.ProfitMargin < 66.6 || *f.ProfitMargin > 66.7 { + t.Fatalf("paid = %+v", f) + } + case "free": + if f.CostedOrders != 1 || f.ProfitMicrocredits == nil || *f.ProfitMicrocredits != 0 || f.ProfitMargin != nil { + t.Fatalf("free = %+v", f) + } + case "loss": + if f.ProfitMicrocredits == nil || *f.ProfitMicrocredits != -200_000 || f.ProfitMargin == nil || *f.ProfitMargin != -200 { + t.Fatalf("loss = %+v", f) + } + } + } + data, err := svc.AdminAnalyticsCSV(admin, query) + if err != nil { + t.Fatal(err) + } + rows, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\xEF\xBB\xBF"))).ReadAll() + if err != nil { + t.Fatal(err) + } + var revenue, cost int64 + for _, row := range rows[1:] { + if row[6] == "auxiliary" && (row[15] != "" || row[16] != "" || row[17] != "") { + t.Fatalf("auxiliary request must not carry order finances: %v", row) + } + for index, target := range map[int]*int64{15: &revenue, 16: &cost} { + if row[index] != "" { + value, err := strconv.ParseInt(row[index], 10, 64) + if err != nil { + t.Fatal(err) + } + *target += value + } + } + } + if revenue != finance.RevenueMicrocredits || cost != finance.CostMicrocredits { + t.Fatalf("CSV mismatch: revenue=%d cost=%d", revenue, cost) + } + query.Model = "paid" + filtered, err := svc.AdminAnalytics(admin, query) + if err != nil || filtered.KPI.Finance.SettledOrders != 1 || filtered.KPI.Finance.ProfitMicrocredits == nil || *filtered.KPI.Finance.ProfitMicrocredits != 600_000 { + t.Fatalf("model filter = %+v, %v", filtered, err) + } + query.ChannelID = "previous" + filtered, err = svc.AdminAnalytics(admin, query) + if err != nil || filtered.KPI.Finance.SettledOrders != 0 { + t.Fatalf("previous channel finance = %+v, %v", filtered, err) + } + query.Model, query.ChannelID = "auxiliary", "" + filtered, err = svc.AdminAnalytics(admin, query) + if err != nil || filtered.KPI.Finance.SettledOrders != 0 { + t.Fatalf("auxiliary request finance = %+v, %v", filtered, err) + } + if _, err := svc.AdminAnalytics(&model.User{Role: model.UserRoleUser}, query); err == nil { + t.Fatal("non-admin must not read costs") + } +} + +func TestAdminReferencesIncludeExistingDisplayNameGroups(t *testing.T) { + svc, db := newChannelModelTestService(t) + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatal(err) + } + channels := []model.ModelChannel{ + {ID: "one", Scope: model.ChannelScopeSystem, Enabled: true}, + {ID: "two", Scope: model.ChannelScopeSystem, Enabled: false}, + } + if err := db.Create(&channels).Error; err != nil { + t.Fatal(err) + } + items := []model.ChannelModel{ + {ID: "a", ChannelID: "one", ModelKey: "a", DisplayName: "Shared", Enabled: true}, + {ID: "b", ChannelID: "one", ModelKey: "b", DisplayName: "Shared", Enabled: false}, + {ID: "c", ChannelID: "two", ModelKey: "c", DisplayName: "Other", Enabled: false}, + {ID: "d", ChannelID: "two", ModelKey: "d"}, + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + result, err := svc.AdminReferences(&model.User{Role: model.UserRoleAdmin}) + if err != nil { + t.Fatal(err) + } + groups := map[string]int{} + for _, channel := range result.Channels { + for _, name := range channel.ModelDisplayNames { + groups[name]++ + } + } + if len(groups) != 3 || groups["Shared"] != 1 || groups["Other"] != 1 || groups["d"] != 1 { + t.Fatalf("groups = %#v", groups) + } +} diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 37303b19f..5bf8d98a8 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -23,6 +23,8 @@ description: 信封、错误码与 OpenAPI 同一产品在不同渠道中使用相同的模型展示名即可归组,`modelKey` 可以不同;相同 `modelKey` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。 +`GET /admin/references` 的 `channels[]` 同时返回 `modelDisplayNames`,包含系统渠道已创建模型的一级分组名称(含停用模型,空展示名使用产品模型标识)。编辑器“模型展示名(一级目录)”汇总去重后提供可搜索下拉,并允许输入新分组;保存后刷新引用数据。 + 以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 | 接口 | 请求 | @@ -44,6 +46,12 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 成本字段仅通过经过管理员权限校验的接口显式返回;普通用户的模型目录、会话模型信息、报价、任务与账单 JSON 均不包含成本配置、成本快照或成本金额。底层价格档和账单成本字段禁止 JSON 序列化,不以页面隐藏代替接口隔离。 +数据概览不再提供独立的“模型价格配置”入口。`GET /admin/analytics/overview` 的 `kpi`、`models[]` 使用 `finance` 返回 `settledOrders`、`costedOrders`、`revenueMicrocredits`、`costMicrocredits`、可空的 `profitMicrocredits`、`profitMargin`,取代旧的费用/币种字段。按当前请求日志时间及筛选范围关联已结算订单,以订单 ID 去重并核对用户与实际结算渠道;轮询、下载、未结算、退款和无关联账单请求不计入财务汇总。收入为实际结算积分,成本复用系统模型价格档的订单快照与计费用量,利润为收入减成本,利润率为利润占收入百分比。已知成本可显示部分合计;只要有已结算订单缺失成本,利润和利润率返回 `null`,不把未知成本视为零。无订单时利润为 `null`;零收入时利润率为 `null`。历史无成本快照的订单不按当前价回填。此处为已结算生成订单的积分毛利,不含充值收入或失败请求的上游损耗。 + +`GET /admin/analytics/export.csv` 沿用相同口径,CSV 输出收入、成本、利润(整数微积分);重复请求只在该筛选范围内选定的一行记录金额,未知值留空。 + +上传、工作流结构查询、取消查询和取消请求也不参与财务归属,即使日志被标记为可计费;它们不能代替生成请求承载订单金额。订单是否已结算以账单状态为准,保留人工核账后结算订单的统计能力。 + ## 画布保存、历史与恢复 画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 9189e1625..5b3f3f47a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,20 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 数据概览财务统计与模型一级分组 + +已实现:移除数据概览的独立价格编辑入口;按系统模型订单快照汇总已结算收入、成本、利润与利润率,并同步 CSV。“模型展示名(一级目录)”支持搜索、选择跨系统渠道已有分组及输入新分组。 + +自动化验证:后台 UI 回归测试 17 项通过,前端类型检查与生产构建通过;关闭 CGO 后,财务汇总、实际用量成本和无效成本价格三项专项测试通过。SQLite 集成测试受本机 GCC/CGO 对象解析错误阻断,尚未完成;以下真实页面与数据链路仍待验收。 + +缺失字段修复验证:新增 4 项测试直接执行模型表格财务列的渲染函数,覆盖未返回或空财务对象、零金额、部分成本和亏损;与后台 UI 回归测试共 21 项通过,前端类型检查及生产构建通过。 + +- [ ] 在真实数据概览切换时间、模型、渠道和用户,核对订单去重、部分成本提示及 CSV 金额;确认退款、未结算订单不计收入。 +- [ ] 工作流上传、结构查询、轮询、下载和取消相关日志不承载订单金额;核对 CSV 与概览一致。 +- [ ] 后端响应缺少财务统计时,页面应保留其他统计并显示提示,金额为 `--`,不误报零费用或暂无订单;后端更新并重启后刷新,确认财务数据恢复。模型分组列表未返回时编辑器仍可输入名称。 +- [ ] 检查浅色、深色与窄屏下的费用区域、模型表格横向滚动及长金额换行。 +- [ ] 新建或编辑模型,在“模型展示名(一级目录)”选择其他渠道已有分组;输入新分组保存后再次编辑,确认下拉更新且创作端归组正确。 + ## 画布素材链路与工作条改造(2026-09-19) 已实现: diff --git a/web/src/pages/admin/components/analytics-finance.ts b/web/src/pages/admin/components/analytics-finance.ts new file mode 100644 index 000000000..4c7f6b34b --- /dev/null +++ b/web/src/pages/admin/components/analytics-finance.ts @@ -0,0 +1,22 @@ +import type { ColumnsType } from "antd/es/table"; +import type { AdminAnalytics, AnalyticsFinance } from "@/services/api/auth"; + +export function formatCredits(value: number | null | undefined) { + return value == null ? "--" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 6 }).format(value / 1_000_000); +} + +export function formatFinanceCost(finance: AnalyticsFinance | null | undefined) { + if (!finance?.costedOrders) return "--"; + return `${formatCredits(finance.costMicrocredits)}${finance.costedOrders < finance.settledOrders ? "(部分)" : ""}`; +} + +export function formatFinanceMargin(value: number | null | undefined) { + return value == null ? "--" : `${value.toFixed(1)}%`; +} + +export const analyticsFinanceColumns: ColumnsType = [ + { title: "收入(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.revenueMicrocredits) }, + { title: "成本(积分)", width: 150, render: (_, row) => formatFinanceCost(row.finance) }, + { title: "利润(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.profitMicrocredits) }, + { title: "利润率", width: 100, render: (_, row) => formatFinanceMargin(row.finance?.profitMargin) }, +]; diff --git a/web/src/pages/admin/components/analytics-panel.tsx b/web/src/pages/admin/components/analytics-panel.tsx index 398f2014a..0f4e7a8c8 100644 --- a/web/src/pages/admin/components/analytics-panel.tsx +++ b/web/src/pages/admin/components/analytics-panel.tsx @@ -1,47 +1,30 @@ -import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd"; +import { Alert, App, Button, DatePicker, Select, Tabs, Tag } from "antd"; import { Tooltip } from "@/pages/admin/ui/controls"; import { useCountUp } from "@/hooks/use-count-up"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import type { ColumnsType } from "antd/es/table"; import dayjs, { type Dayjs } from "dayjs"; -import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, Pencil, Plus, RefreshCw, Settings2, Trash2, UsersRound, Workflow } from "lucide-react"; +import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, RefreshCw, UsersRound, Workflow } from "lucide-react"; import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts"; import { useSearchParams } from "react-router"; -import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui"; +import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui"; import { - createAdminModelPricing, - deleteAdminModelPricing, exportAdminAnalytics, getAdminAnalytics, listAdminUsers, - listAdminModelPricings, - updateAdminModelPricing, type AdminReferenceData, type AdminAnalytics, type AnalyticsFilters, - type ModelPricing, } from "@/services/api/auth"; +import { analyticsFinanceColumns, formatCredits, formatFinanceCost, formatFinanceMargin } from "./analytics-finance"; type Props = { users: AdminReferenceData["users"]; channels: AdminReferenceData["channels"]; }; -type PricingFormValues = { - channelId?: string; - model: string; - capability: ModelPricing["capability"]; - currency: string; - inputPerMillion?: string; - outputPerMillion?: string; - cachedPerMillion?: string; - perRequest?: string; - perMedia?: string; - perVideoSecond?: string; -}; - type TrendMetric = "volume" | "quality" | "activity"; type AnalysisTab = "models" | "users" | "failures"; type RangePreset = "7d" | "30d" | "60d"; @@ -63,23 +46,14 @@ export default function AnalyticsPanel({ users, channels }: Props) { const [channelId, setChannelId] = useState(searchParams.get("channelId") || undefined); const [capability, setCapability] = useState(searchParams.get("capability") || undefined); const [data, setData] = useState(null); - const [pricings, setPricings] = useState([]); const [loading, setLoading] = useState(false); - const [pricingModalOpen, setPricingModalOpen] = useState(false); - const [editingPricing, setEditingPricing] = useState(null); - const [savingPricing, setSavingPricing] = useState(false); const [userOptions, setUserOptions] = useState(users); const [searchingUsers, setSearchingUsers] = useState(false); const [modelPage, setModelPage] = useState(1); const [userPage, setUserPage] = useState(1); const [failurePage, setFailurePage] = useState(1); - const [pricingPage, setPricingPage] = useState(1); const [trendMetric, setTrendMetric] = useState("volume"); const [analysisTab, setAnalysisTab] = useState("models"); - const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false); - const [pendingPricing, setPendingPricing] = useState(undefined); - const [form] = Form.useForm(); - const pricingChannelId = Form.useWatch("channelId", form); const analyticsPageSize = 20; const filters = useMemo( @@ -97,9 +71,8 @@ export default function AnalyticsPanel({ users, channels }: Props) { const reload = useCallback(async () => { setLoading(true); try { - const [analytics, pricingData] = await Promise.all([getAdminAnalytics(filters), listAdminModelPricings()]); + const analytics = await getAdminAnalytics(filters); setData(analytics); - setPricings(pricingData.pricings); } catch (error) { message.error(error instanceof Error ? error.message : "读取统计数据失败"); } finally { @@ -123,7 +96,6 @@ export default function AnalyticsPanel({ users, channels }: Props) { setModelPage(1); setUserPage(1); setFailurePage(1); - setPricingPage(1); }, [filters]); useEffect(() => { @@ -149,105 +121,6 @@ export default function AnalyticsPanel({ users, channels }: Props) { return [...names].sort().map((name) => ({ label: name, value: name })); }, [channels, data?.models]); - const pricingModelOptions = useMemo(() => { - const names = new Set(); - const sourceChannels = channels.filter((channel) => channel.enabled !== false && (!pricingChannelId || channel.id === pricingChannelId)); - sourceChannels.forEach((channel) => channel.models?.forEach((name) => names.add(name))); - if (editingPricing?.model && (!pricingChannelId || editingPricing.channelId === pricingChannelId)) { - names.add(editingPricing.model); - } - return [...names].sort().map((name) => ({ label: name, value: name })); - }, [channels, editingPricing?.channelId, editingPricing?.model, pricingChannelId]); - - const preparePricingForm = (pricing: ModelPricing | null) => { - setEditingPricing(pricing); - form.resetFields(); - form.setFieldsValue( - pricing - ? { - channelId: pricing.channelId || undefined, - model: pricing.model, - capability: pricing.capability, - currency: pricing.currency, - inputPerMillion: formatPriceInput(pricing.inputPerMillionMicros), - outputPerMillion: formatPriceInput(pricing.outputPerMillionMicros), - cachedPerMillion: formatPriceInput(pricing.cachedPerMillionMicros), - perRequest: formatPriceInput(pricing.perRequestMicros), - perMedia: formatPriceInput(pricing.perMediaMicros), - perVideoSecond: formatPriceInput(pricing.perVideoSecondMicros), - } - : { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: "", outputPerMillion: "", cachedPerMillion: "", perRequest: "", perMedia: "", perVideoSecond: "" }, - ); - setPricingModalOpen(true); - }; - - const openPricing = (pricing?: ModelPricing) => { - const nextPricing = pricing || null; - if (pricingWorkspaceOpen) { - setPendingPricing(nextPricing); - setPricingWorkspaceOpen(false); - } else { - preparePricingForm(nextPricing); - } - }; - - const handlePricingValuesChange = (changedValues: Partial, values: PricingFormValues) => { - if (Object.prototype.hasOwnProperty.call(changedValues, "channelId")) { - const nextModels = channels.filter((channel) => channel.enabled !== false && (!values.channelId || channel.id === values.channelId)).flatMap((channel) => channel.models || []); - if (values.model && !nextModels.includes(values.model)) form.setFieldValue("model", undefined); - return; - } - - if (!Object.prototype.hasOwnProperty.call(changedValues, "model") || !values.model) return; - const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(values.model)); - if (values.channelId && matchingChannels.some((channel) => channel.id === values.channelId)) return; - if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id); - }; - - const handlePricingModelChange = (modelName: string) => { - const currentChannelId = form.getFieldValue("channelId") as string | undefined; - const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(modelName)); - const matchingChannel = matchingChannels.find((channel) => channel.id === currentChannelId) || matchingChannels[0]; - form.setFieldsValue({ model: modelName, channelId: matchingChannel?.id || currentChannelId }); - }; - - const savePricing = async () => { - const values = await form.validateFields(); - const payload = { - channelId: values.channelId || "", - model: values.model.trim(), - capability: values.capability, - currency: values.currency.trim().toUpperCase(), - inputPerMillionMicros: toMicros(values.inputPerMillion), - outputPerMillionMicros: toMicros(values.outputPerMillion), - cachedPerMillionMicros: toMicros(values.cachedPerMillion), - perRequestMicros: toMicros(values.perRequest), - perMediaMicros: toMicros(values.perMedia), - perVideoSecondMicros: toMicros(values.perVideoSecond), - }; - setSavingPricing(true); - try { - const result = editingPricing ? await updateAdminModelPricing(editingPricing.id, payload) : await createAdminModelPricing(payload); - setPricings((items) => (editingPricing ? items.map((item) => (item.id === result.pricing.id ? result.pricing : item)) : [...items, result.pricing])); - setPricingModalOpen(false); - message.success("模型价格已保存,后续调用将按新价格记录费用快照"); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存价格失败"); - } finally { - setSavingPricing(false); - } - }; - - const removePricing = async (id: string) => { - try { - await deleteAdminModelPricing(id); - setPricings((items) => items.filter((item) => item.id !== id)); - message.success("价格配置已删除"); - } catch (error) { - message.error(error instanceof Error ? error.message : "删除价格失败"); - } - }; - const modelColumns: ColumnsType = [ { title: "模型", @@ -270,7 +143,7 @@ export default function AnalyticsPanel({ users, channels }: Props) { { title: "P50 / P95", width: 145, render: (_, row) => `${formatDuration(row.p50DurationMs)} / ${formatDuration(row.p95DurationMs)}` }, { title: "Token(入 / 出 / 缓存)", width: 190, render: (_, row) => (row.usageAvailable ? `${formatNumber(row.inputTokens)} / ${formatNumber(row.outputTokens)} / ${formatNumber(row.cachedTokens)}` : "--") }, { title: "媒体 / 视频秒", width: 125, render: (_, row) => `${row.mediaCount} / ${row.videoSeconds}` }, - { title: "估算费用", width: 120, render: (_, row) => formatCost(row.estimatedCostMicros, row.currency, row.costAvailable) }, + ...analyticsFinanceColumns, ]; const userColumns: ColumnsType = [ @@ -301,60 +174,16 @@ export default function AnalyticsPanel({ users, channels }: Props) { { title: "最近发生", dataIndex: "lastSeenAt", width: 170, render: (value) => dayjs(value).format("YYYY-MM-DD HH:mm") }, ]; - const pricingColumns: ColumnsType = [ - { - title: "模型", - dataIndex: "model", - width: 210, - render: (value, row) => ( -
-
{value}
-
{row.channelId ? channels.find((channel) => channel.id === row.channelId)?.name || row.channelId : "全部渠道"}
-
- ), - }, - { title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel }, - { - title: "输入 / 输出 / 缓存(每百万 Token)", - width: 250, - render: (_, row) => `${formatMoney(fromMicros(row.inputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.outputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.cachedPerMillionMicros), row.currency)}`, - }, - { - title: "每请求 / 每媒体 / 每视频秒", - width: 220, - render: (_, row) => `${formatMoney(fromMicros(row.perRequestMicros), row.currency)} / ${formatMoney(fromMicros(row.perMediaMicros), row.currency)} / ${formatMoney(fromMicros(row.perVideoSecondMicros), row.currency)}`, - }, - { - title: "操作", - width: 170, - render: (_, row) => ( - , onClick: () => openPricing(row) }} - actions={[ - { - key: "delete", - label: "删除", - icon: , - danger: true, - confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" }, - onClick: () => removePricing(row.id), - }, - ]} - /> - ), - }, - ]; - const trend = data?.trend || []; const currentTrend = trend[trend.length - 1]; const previousTrend = trend[trend.length - 2]; const modelRows = data?.models || []; const userRows = data?.users || []; const failureRows = data?.failures || []; - const pricingRows = pricings; const failureTotal = failureRows.reduce((sum, item) => sum + item.count, 0); const topFailure = failureRows.reduce((current, item) => (!current || item.count > current.count ? item : current), undefined); - const pricedModelCount = modelRows.filter((item) => item.costAvailable).length; + const finance = data?.kpi.finance; + const financeUnavailable = data !== null && (!finance || modelRows.some((row) => !row.finance)); const trendHasData = trendMetric === "volume" ? trend.some((item) => item.tasks > 0 || item.requests > 0) : trendMetric === "quality" ? trend.some((item) => item.requests > 0) : trend.some((item) => item.activeUsers > 0); const trendTitle = trendMetric === "volume" ? "任务与请求趋势" : trendMetric === "quality" ? "请求质量趋势" : "用户活跃趋势"; const pageRows = (rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize); @@ -443,6 +272,8 @@ export default function AnalyticsPanel({ users, channels }: Props) { + {financeUnavailable && } +
} @@ -468,10 +299,14 @@ export default function AnalyticsPanel({ users, channels }: Props) { /> } - label="估算费用" - value={data ? (data.kpi.costAvailable ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, true) : "待配置") : "--"} - detail={data ? (data.kpi.costAvailable ? `${pricedModelCount}/${modelRows.length} 个模型可估算` : "价格未完整配置,暂不能汇总") : undefined} - tone={data?.kpi.costAvailable ? "neutral" : "warning"} + label="费用统计(积分)" + value={finance ?
+
收入
{formatCredits(finance.revenueMicrocredits)}
+
成本
{formatFinanceCost(finance)}
+
利润
{formatCredits(finance.profitMicrocredits)}
+
: "--"} + detail={finance ? `已结算 ${finance.settledOrders} 笔 · 利润率 ${formatFinanceMargin(finance.profitMargin)}` : undefined} + tone={finance && (finance.costedOrders < finance.settledOrders || (finance.profitMicrocredits ?? 0) < 0) ? "warning" : "neutral"} />
@@ -553,11 +388,11 @@ export default function AnalyticsPanel({ users, channels }: Props) { /> } - label="费用可估算模型" - value={data ? `${pricedModelCount}/${modelRows.length}` : "--"} - description={data?.kpi.costAvailable ? "当前范围费用可以汇总" : "仍有调用无法完成费用估算"} - tone={data?.kpi.costAvailable ? "success" : "warning"} - onClick={() => setPricingWorkspaceOpen(true)} + label="成本覆盖订单" + value={finance ? `${finance.costedOrders}/${finance.settledOrders}` : "--"} + description={!finance ? "财务统计尚未返回" : finance.settledOrders ? (finance.costedOrders === finance.settledOrders ? "已结算订单成本完整" : `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`) : "当前范围暂无已结算订单"} + tone={finance && finance.costedOrders < finance.settledOrders ? "warning" : "neutral"} + onClick={() => openAnalysis("models")} /> @@ -569,9 +404,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {

深度分析

按模型、用户或异常类型继续核对当前统计范围。

- `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }} + table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1600 } }} empty={} - skeletonColumns={9} + skeletonColumns={12} footer={ setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />} /> ), @@ -618,76 +450,6 @@ export default function AnalyticsPanel({ users, channels }: Props) { /> - setPricingWorkspaceOpen(false)} - afterOpenChange={(open) => { - if (open || pendingPricing === undefined) return; - const nextPricing = pendingPricing; - setPendingPricing(undefined); - preparePricingForm(nextPricing); - }} - extra={ - - } - > -

价格只影响后续调用的费用快照,历史统计不会被重新计算。

- } - skeletonColumns={5} - footer={ setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />} - /> -
- - setPricingModalOpen(false)} - onOk={() => void savePricing()} - confirmLoading={savingPricing} - okText="保存" - cancelText="取消" - width={760} - zIndex={1200} - destroyOnHidden - > -
-
- - channel.enabled !== false).map((channel) => ({ label: channel.name, value: channel.id }))} /> - - - - - - - - - - -
-
-
); } @@ -765,20 +527,6 @@ function FilterSelect({ ); } -function PriceInput({ name, label }: { name: keyof PricingFormValues; label: string }) { - return ( - - - - ); -} - -function validatePriceInput(_: unknown, value?: string) { - const normalized = value?.trim() || ""; - if (!normalized || /^(?:\d+(?:\.\d{0,6})?|\.\d{1,6})$/.test(normalized)) return Promise.resolve(); - return Promise.reject(new Error("请输入非负价格,最多 6 位小数")); -} - function capabilityLabel(value: string) { return capabilityOptions.find((item) => item.value === value)?.label || "未分类"; } @@ -811,32 +559,6 @@ function formatRateDelta(current?: number, previous?: number) { return { value: `较前一日 ${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) }; } -function formatCost(micros: number, currency?: string, available?: boolean) { - return available ? formatMoney(fromMicros(micros), currency || "USD") : "--"; -} - -function formatMoney(value: number, currency = "USD") { - if (currency === "MIXED") return `${value.toFixed(6)}(混合币种)`; - try { - return new Intl.NumberFormat("zh-CN", { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(value); - } catch { - return `${currency} ${value.toFixed(6)}`; - } -} - -function fromMicros(value: number) { - return value / 1_000_000; -} - -function formatPriceInput(micros: number) { - return fromMicros(micros).toFixed(6); -} - -function toMicros(value?: string | number) { - const parsed = typeof value === "number" ? value : Number(value?.trim() || 0); - return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 1_000_000) : 0; -} - function filterDate(value: string | null, fallback: Dayjs) { if (!value) return fallback; const parsed = dayjs(value); diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index 1f9951b63..5ab5d384a 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; -import { Alert, App, Button, Form, Input, Segmented, Switch, Tabs } from "antd"; +import { Alert, App, AutoComplete, Button, Form, Input, Segmented, Switch, Tabs } from "antd"; +import { useAdminContext } from "../admin-context"; import { AdminModal } from "@/pages/admin/ui/overlays"; import { FlaskConical, Plus } from "lucide-react"; import { ModelIconPicker } from "@/components/model-logo"; @@ -34,6 +35,10 @@ export function ChannelModelEditor({ onSaved: () => Promise; }) { const { message, modal } = App.useApp(); + const { references } = useAdminContext(); + const displayNameOptions = [...new Set(references.channels.flatMap((item) => item.modelDisplayNames ?? []))] + .sort((a, b) => a.localeCompare(b, "zh-CN")) + .map((value) => ({ value })); const [form] = Form.useForm(); const [initialValues] = useState(() => initialChannelModelValues(editing, protocols)); const [activeSection, setActiveSection] = useState("identity"); @@ -248,7 +253,12 @@ export function ChannelModelEditor({ - + Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))} + placeholder="选择已有分组或输入新名称" + allowClear + /> diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 5f8233105..b381e9139 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -160,7 +160,16 @@ export type AnalyticsFilters = { export type AdminReferenceData = { users: Array<{ id: string; username: string; displayName: string }>; - channels: Array<{ id: string; name: string; enabled: boolean; models: string[] }>; + channels: Array<{ id: string; name: string; enabled: boolean; models: string[]; modelDisplayNames?: string[] }>; +}; + +export type AnalyticsFinance = { + settledOrders: number; + costedOrders: number; + revenueMicrocredits: number; + costMicrocredits: number; + profitMicrocredits: number | null; + profitMargin: number | null; }; export type AdminAnalytics = { @@ -176,9 +185,7 @@ export type AdminAnalytics = { successRate: number; p95DurationMs: number; currentQueuedTasks: number; - estimatedCostMicros: number; - costAvailable: boolean; - currency?: string; + finance?: AnalyticsFinance | null; }; trend: Array<{ day: string; tasks: number; requests: number; activeUsers: number; requestSuccessRate: number }>; models: Array<{ @@ -197,9 +204,7 @@ export type AdminAnalytics = { usageAvailable: boolean; mediaCount: number; videoSeconds: number; - estimatedCostMicros: number; - costAvailable: boolean; - currency?: string; + finance?: AnalyticsFinance | null; }>; users: Array<{ userId: string; name: string; activeDays: number; tasks: number; agentMessages: number; canvasDays: number; assets: number; resources: number; commonModel?: string }>; failures: Array<{ type: string; model: string; count: number; lastError?: string; lastSeenAt: string }>; diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css index 167bff352..dab9a530d 100644 --- a/web/src/styles/admin-ui.css +++ b/web/src/styles/admin-ui.css @@ -8485,21 +8485,6 @@ width: 246px; } -.admin-analytics-pricing-modal .admin-analytics-price-field, -.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control, -.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control-input, -.admin-analytics-pricing-modal .admin-analytics-price-input, -.admin-analytics-pricing-modal .admin-analytics-price-input input { - width: 100%; - min-width: 0; - pointer-events: auto; - user-select: text; -} - -.admin-analytics-pricing-modal .admin-analytics-price-input { - display: block; -} - .admin-analytics-toolbar .admin-list-toolbar-actions { align-items: center; } @@ -8590,6 +8575,33 @@ line-height: 16px; } +.admin-analytics-finance-values { + display: grid; + gap: 6px; + margin: 0; + font-size: 14px; + letter-spacing: 0; + line-height: 1.4; + white-space: normal; +} + +.admin-analytics-finance-values > div { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; +} + +.admin-analytics-finance-values dt { + color: var(--admin-text-muted); + font-weight: 400; +} + +.admin-analytics-finance-values dd { + margin: 0; + text-align: right; + overflow-wrap: anywhere; +} + .admin-analytics-overview-grid { display: grid; min-width: 0; @@ -8824,21 +8836,6 @@ padding-block: 10px; } -.admin-analytics-pricing-description { - margin-bottom: 14px; - border: 1px solid var(--admin-divider); - border-radius: 8px; - background: var(--admin-layer-2); - padding: 10px 12px; - color: var(--admin-text-secondary); - font-size: 11px; - line-height: 17px; -} - -.admin-analytics-pricing-drawer .admin-table-frame { - min-height: min(620px, calc(100vh - 190px)); -} - @media (min-width: 1280px) { .admin-analytics-overview-grid { grid-template-columns: minmax(0, 2fr) minmax(300px, 0.82fr); diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts index 401179ecf..147f0dbae 100644 --- a/web/test/admin-ui-regressions.test.ts +++ b/web/test/admin-ui-regressions.test.ts @@ -115,33 +115,20 @@ test("channel model manager supports bounded atomic batch deletion", async () => expect(component).toContain("批量删除"); }); -test("analytics keeps fixed range presets distinct and uses enabled channel models for pricing", async () => { +test("analytics keeps range presets and uses order finances without a separate pricing editor", async () => { const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text()); expect(source).toContain('type RangePreset = "7d" | "30d" | "60d"'); expect(source).toContain('["60d", "60 天"]'); expect(source).toContain('next.set("rangePreset", rangePreset)'); expect(source).toContain("setRangePreset(undefined)"); - expect(source).toContain('placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}'); - expect(source).toContain("onValuesChange={handlePricingValuesChange}"); - expect(source).toContain("onChange={handlePricingModelChange}"); - expect(source).toContain('hasOwnProperty.call(changedValues, "model")'); - expect(source).toContain('if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id)'); - expect(source).toContain("const sourceChannels = channels.filter("); - expect(source).toContain('Form.useWatch("channelId", form)'); - expect(source).toContain("pricingChannelId"); - expect(source).toContain("channel.id === pricingChannelId"); - expect(source).toContain('inputMode="decimal"'); - expect(source).toContain('className="admin-analytics-price-input"'); - expect(source).toContain('className="admin-analytics-price-field"'); - expect(source).toContain('rootClassName="admin-modal-root admin-analytics-pricing-modal"'); - expect(source).toContain("zIndex={1200}"); - expect(source).toContain("setPricingWorkspaceOpen(false)"); - expect(source).toContain("validator: validatePriceInput"); - expect(source).toContain("请输入非负价格,最多 6 位小数"); - expect(source).toContain("function formatPriceInput(micros: number)"); - expect(source).toContain("function toMicros(value?: string | number)"); - expect(source).not.toContain(" { diff --git a/web/test/analytics-finance.test.ts b/web/test/analytics-finance.test.ts new file mode 100644 index 000000000..4e50c32ed --- /dev/null +++ b/web/test/analytics-finance.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import type { AdminAnalytics, AnalyticsFinance } from "../src/services/api/auth"; +import { analyticsFinanceColumns } from "../src/pages/admin/components/analytics-finance"; + +function renderFinanceCells(finance?: AnalyticsFinance | null) { + const row: AdminAnalytics["models"][number] = { + model: "example", capability: "text", tasks: 1, requests: 1, uniqueUsers: 1, + taskSuccessRate: 100, requestSuccessRate: 100, p50DurationMs: 100, p95DurationMs: 100, + inputTokens: 10, outputTokens: 10, cachedTokens: 0, usageAvailable: true, + mediaCount: 0, videoSeconds: 0, ...(finance === undefined ? {} : { finance }), + }; + return analyticsFinanceColumns.map((column) => "render" in column ? column.render?.(undefined, row, 0) : undefined); +} + +test("model finance cells tolerate missing and null backend finance", () => { + expect(renderFinanceCells()).toEqual(["--", "--", "--", "--"]); + expect(renderFinanceCells(null)).toEqual(["--", "--", "--", "--"]); +}); + +test("model finance cells preserve real zero values and unknown margins", () => { + expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 0, costMicrocredits: 0, profitMicrocredits: 0, profitMargin: null })) + .toEqual(["0", "0", "0", "--"]); +}); + +test("model finance cells show partial costs without inventing profits", () => { + expect(renderFinanceCells({ settledOrders: 2, costedOrders: 1, revenueMicrocredits: 1500000, costMicrocredits: 250000, profitMicrocredits: null, profitMargin: null })) + .toEqual(["1.5", "0.25(部分)", "--", "--"]); +}); + +test("model finance cells display known losses", () => { + expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 1000000, costMicrocredits: 1500000, profitMicrocredits: -500000, profitMargin: -50 })) + .toEqual(["1", "1.5", "-0.5", "-50.0%"]); +});