feat(admin): 管理统计 - 合并 PR #545 财务统计与分组选择
Merge pull request #545 from Rou325. Preserve the original author and current main behavior.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
90
backend/internal/app/analytics_finance.go
Normal file
90
backend/internal/app/analytics_finance.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
215
backend/internal/app/analytics_finance_test.go
Normal file
215
backend/internal/app/analytics_finance_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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。客户端不能获取新版号后直接重发旧内容。
|
||||
|
||||
@@ -5,6 +5,20 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 数据概览财务统计与模型一级分组
|
||||
|
||||
已实现:移除数据概览的独立价格编辑入口;按系统模型订单快照汇总已结算收入、成本、利润与利润率,并同步 CSV。“模型展示名(一级目录)”支持搜索、选择跨系统渠道已有分组及输入新分组。
|
||||
|
||||
自动化验证:后台 UI 回归测试 17 项通过,前端类型检查与生产构建通过;关闭 CGO 后,财务汇总、实际用量成本和无效成本价格三项专项测试通过。SQLite 集成测试受本机 GCC/CGO 对象解析错误阻断,尚未完成;以下真实页面与数据链路仍待验收。
|
||||
|
||||
缺失字段修复验证:新增 4 项测试直接执行模型表格财务列的渲染函数,覆盖未返回或空财务对象、零金额、部分成本和亏损;与后台 UI 回归测试共 21 项通过,前端类型检查及生产构建通过。
|
||||
|
||||
- [ ] 在真实数据概览切换时间、模型、渠道和用户,核对订单去重、部分成本提示及 CSV 金额;确认退款、未结算订单不计收入。
|
||||
- [ ] 工作流上传、结构查询、轮询、下载和取消相关日志不承载订单金额;核对 CSV 与概览一致。
|
||||
- [ ] 后端响应缺少财务统计时,页面应保留其他统计并显示提示,金额为 `--`,不误报零费用或暂无订单;后端更新并重启后刷新,确认财务数据恢复。模型分组列表未返回时编辑器仍可输入名称。
|
||||
- [ ] 检查浅色、深色与窄屏下的费用区域、模型表格横向滚动及长金额换行。
|
||||
- [ ] 新建或编辑模型,在“模型展示名(一级目录)”选择其他渠道已有分组;输入新分组保存后再次编辑,确认下拉更新且创作端归组正确。
|
||||
|
||||
## 画布素材链路与工作条改造(2026-09-19)
|
||||
|
||||
已实现:
|
||||
|
||||
22
web/src/pages/admin/components/analytics-finance.ts
Normal file
22
web/src/pages/admin/components/analytics-finance.ts
Normal file
@@ -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<AdminAnalytics["models"][number]> = [
|
||||
{ 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) },
|
||||
];
|
||||
@@ -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<AdminAnalytics | null>(null);
|
||||
const [pricings, setPricings] = useState<ModelPricing[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pricingModalOpen, setPricingModalOpen] = useState(false);
|
||||
const [editingPricing, setEditingPricing] = useState<ModelPricing | null>(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<TrendMetric>("volume");
|
||||
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("models");
|
||||
const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false);
|
||||
const [pendingPricing, setPendingPricing] = useState<ModelPricing | null | undefined>(undefined);
|
||||
const [form] = Form.useForm<PricingFormValues>();
|
||||
const pricingChannelId = Form.useWatch("channelId", form);
|
||||
const analyticsPageSize = 20;
|
||||
|
||||
const filters = useMemo<AnalyticsFilters>(
|
||||
@@ -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<string>();
|
||||
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<PricingFormValues>, 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<AdminAnalytics["models"][number]> = [
|
||||
{
|
||||
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<AdminAnalytics["users"][number]> = [
|
||||
@@ -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<ModelPricing> = [
|
||||
{
|
||||
title: "模型",
|
||||
dataIndex: "model",
|
||||
width: 210,
|
||||
render: (value, row) => (
|
||||
<div>
|
||||
<div className="font-medium">{value}</div>
|
||||
<div className="text-xs text-foreground/45">{row.channelId ? channels.find((channel) => channel.id === row.channelId)?.name || row.channelId : "全部渠道"}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ 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) => (
|
||||
<AdminRowActions
|
||||
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openPricing(row) }}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
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<AdminAnalytics["failures"][number] | undefined>((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 = <T,>(rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize);
|
||||
@@ -443,6 +272,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
</div>
|
||||
</ListToolbar>
|
||||
|
||||
{financeUnavailable && <Alert type="warning" showIcon title="后端未返回完整财务统计,缺失金额显示为 --。请确认后端已更新并重启后刷新。" />}
|
||||
|
||||
<section className="admin-analytics-health-grid" aria-label="运营健康指标">
|
||||
<AnalyticsHealthCard
|
||||
icon={<UsersRound className="size-4" />}
|
||||
@@ -468,10 +299,14 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
/>
|
||||
<AnalyticsHealthCard
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
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 ? <dl className="admin-analytics-finance-values">
|
||||
<div><dt>收入</dt><dd>{formatCredits(finance.revenueMicrocredits)}</dd></div>
|
||||
<div><dt>成本</dt><dd>{formatFinanceCost(finance)}</dd></div>
|
||||
<div><dt>利润</dt><dd>{formatCredits(finance.profitMicrocredits)}</dd></div>
|
||||
</dl> : "--"}
|
||||
detail={finance ? `已结算 ${finance.settledOrders} 笔 · 利润率 ${formatFinanceMargin(finance.profitMargin)}` : undefined}
|
||||
tone={finance && (finance.costedOrders < finance.settledOrders || (finance.profitMicrocredits ?? 0) < 0) ? "warning" : "neutral"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -553,11 +388,11 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
/>
|
||||
<AnalyticsAttentionItem
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
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")}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -569,9 +404,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
<h2>深度分析</h2>
|
||||
<p>按模型、用户或异常类型继续核对当前统计范围。</p>
|
||||
</div>
|
||||
<Button icon={<Settings2 className="size-4" />} onClick={() => setPricingWorkspaceOpen(true)}>
|
||||
模型价格配置
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={analysisTab}
|
||||
@@ -583,9 +415,9 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
label: "模型分析",
|
||||
children: (
|
||||
<AdminDataTable
|
||||
table={{ rowKey: (row) => `${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={<AdminTableEmpty />}
|
||||
skeletonColumns={9}
|
||||
skeletonColumns={12}
|
||||
footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />}
|
||||
/>
|
||||
),
|
||||
@@ -618,76 +450,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Drawer
|
||||
rootClassName="admin-secondary-drawer admin-analytics-pricing-drawer"
|
||||
title="模型价格配置"
|
||||
open={pricingWorkspaceOpen}
|
||||
width="min(1120px, calc(100vw - 48px))"
|
||||
onClose={() => setPricingWorkspaceOpen(false)}
|
||||
afterOpenChange={(open) => {
|
||||
if (open || pendingPricing === undefined) return;
|
||||
const nextPricing = pendingPricing;
|
||||
setPendingPricing(undefined);
|
||||
preparePricingForm(nextPricing);
|
||||
}}
|
||||
extra={
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openPricing()}>
|
||||
新增价格
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="admin-analytics-pricing-description">价格只影响后续调用的费用快照,历史统计不会被重新计算。</p>
|
||||
<AdminDataTable
|
||||
table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }}
|
||||
empty={<AdminTableEmpty title="暂无模型价格" description="新增价格后,后续调用才能形成可汇总的费用快照。" />}
|
||||
skeletonColumns={5}
|
||||
footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
rootClassName="admin-modal-root admin-analytics-pricing-modal"
|
||||
title={editingPricing ? "编辑模型价格" : "新增模型价格"}
|
||||
open={pricingModalOpen}
|
||||
onCancel={() => setPricingModalOpen(false)}
|
||||
onOk={() => void savePricing()}
|
||||
confirmLoading={savingPricing}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={760}
|
||||
zIndex={1200}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handlePricingValuesChange}>
|
||||
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
|
||||
<Form.Item name="model" label="模型" rules={[{ required: true, message: "请选择模型" }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}
|
||||
options={pricingModelOptions}
|
||||
disabled={!pricingModelOptions.length}
|
||||
onChange={handlePricingModelChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="channelId" label="渠道范围">
|
||||
<Select allowClear placeholder="全部渠道" options={channels.filter((channel) => channel.enabled !== false).map((channel) => ({ label: channel.name, value: channel.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="capability" label="能力类型" rules={[{ required: true }]}>
|
||||
<Select options={capabilityOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
|
||||
<Input maxLength={12} />
|
||||
</Form.Item>
|
||||
<PriceInput name="inputPerMillion" label="每百万输入 Token" />
|
||||
<PriceInput name="outputPerMillion" label="每百万输出 Token" />
|
||||
<PriceInput name="cachedPerMillion" label="每百万缓存 Token" />
|
||||
<PriceInput name="perRequest" label="每次请求" />
|
||||
<PriceInput name="perMedia" label="每个输出媒体" />
|
||||
<PriceInput name="perVideoSecond" label="每视频秒" />
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -765,20 +527,6 @@ function FilterSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function PriceInput({ name, label }: { name: keyof PricingFormValues; label: string }) {
|
||||
return (
|
||||
<Form.Item className="admin-analytics-price-field" name={name} label={`${label}(币种单位)`} rules={[{ validator: validatePriceInput }]}>
|
||||
<Input aria-label={`${label}价格`} disabled={false} readOnly={false} inputMode="decimal" maxLength={30} className="admin-analytics-price-input" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
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<FormValues>();
|
||||
const [initialValues] = useState(() => initialChannelModelValues(editing, protocols));
|
||||
const [activeSection, setActiveSection] = useState<EditorSection>("identity");
|
||||
@@ -248,7 +253,12 @@ export function ChannelModelEditor({
|
||||
<Input placeholder="留空则使用产品模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。">
|
||||
<Input placeholder="不填则使用模型标识" />
|
||||
<AutoComplete
|
||||
options={displayNameOptions}
|
||||
filterOption={(input, option) => Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))}
|
||||
placeholder="选择已有分组或输入新名称"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
|
||||
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("<InputNumber");
|
||||
expect(source).not.toContain("模型价格配置");
|
||||
expect(source).not.toContain("listAdminModelPricings");
|
||||
expect(source).toContain("finance.revenueMicrocredits");
|
||||
expect(source).toContain("finance.profitMicrocredits");
|
||||
expect(source).toContain("finance.costedOrders < finance.settledOrders");
|
||||
expect(source).toContain("...analyticsFinanceColumns");
|
||||
expect(source).toContain("后端未返回完整财务统计");
|
||||
});
|
||||
|
||||
test("storage settings keep generic S3 controls and connection validation", async () => {
|
||||
|
||||
33
web/test/analytics-finance.test.ts
Normal file
33
web/test/analytics-finance.test.ts
Normal file
@@ -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%"]);
|
||||
});
|
||||
Reference in New Issue
Block a user