前端新增 UsageLogExpandedDetail 点击展开面板和 BillingDetailSummary hover 计费详情,后端修复 per_1m_tokens 任务结算日志的 completion_tokens 从零值改为写入 total_tokens 实际值。新增 taskBillingLogMerge 合并 任务预扣/结算日志。 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -607,7 +607,11 @@ func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingCont | |||
| bc.BillingUnit = decision.BillingUnit | |||
| bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD | |||
| bc.PerCallBilling = decision.PerCallBilling | |||
| bc.ModelPrice = decision.PriceUSD | |||
| if decision.BillingUnit == types.BillingUnitPer1MTokens { | |||
| bc.ModelPrice = decision.TokenUnitPriceUSD | |||
| } else { | |||
| bc.ModelPrice = decision.PriceUSD | |||
| } | |||
| bc.GroupRatio = decision.GroupRatioInfo.GroupRatio | |||
| bc.OtherRatios = types.CloneRatios(decision.OtherRatios) | |||
| } | |||
| @@ -211,15 +211,16 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) | |||
| } | |||
| type RecordTaskBillingLogParams struct { | |||
| UserId int | |||
| LogType int | |||
| Content string | |||
| ChannelId int | |||
| ModelName string | |||
| Quota int | |||
| TokenId int | |||
| Group string | |||
| Other map[string]interface{} | |||
| UserId int | |||
| LogType int | |||
| Content string | |||
| ChannelId int | |||
| ModelName string | |||
| Quota int | |||
| TokenId int | |||
| Group string | |||
| CompletionTokens int | |||
| Other map[string]interface{} | |||
| } | |||
| func RecordTaskBillingLog(params RecordTaskBillingLogParams) { | |||
| @@ -234,18 +235,19 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { | |||
| } | |||
| } | |||
| log := &Log{ | |||
| UserId: params.UserId, | |||
| Username: username, | |||
| CreatedAt: common.GetTimestamp(), | |||
| Type: params.LogType, | |||
| Content: params.Content, | |||
| TokenName: tokenName, | |||
| ModelName: params.ModelName, | |||
| Quota: params.Quota, | |||
| ChannelId: params.ChannelId, | |||
| TokenId: params.TokenId, | |||
| Group: params.Group, | |||
| Other: common.MapToJsonStr(params.Other), | |||
| UserId: params.UserId, | |||
| Username: username, | |||
| CreatedAt: common.GetTimestamp(), | |||
| Type: params.LogType, | |||
| Content: params.Content, | |||
| TokenName: tokenName, | |||
| ModelName: params.ModelName, | |||
| Quota: params.Quota, | |||
| ChannelId: params.ChannelId, | |||
| TokenId: params.TokenId, | |||
| Group: params.Group, | |||
| CompletionTokens: params.CompletionTokens, | |||
| Other: common.MapToJsonStr(params.Other), | |||
| } | |||
| err := LOG_DB.Create(log).Error | |||
| if err != nil { | |||
| @@ -521,6 +521,26 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto { | |||
| Progress: task.Progress, | |||
| Properties: task.Properties, | |||
| Username: task.Username, | |||
| Data: task.Data, | |||
| Data: sanitizeTaskDtoData(task), | |||
| } | |||
| } | |||
| func sanitizeTaskDtoData(task *model.Task) []byte { | |||
| if task == nil || len(task.Data) == 0 { | |||
| return nil | |||
| } | |||
| payload := map[string]any{} | |||
| if err := common.Unmarshal(task.Data, &payload); err != nil { | |||
| return task.Data | |||
| } | |||
| if _, ok := payload["aiping_id"]; !ok { | |||
| return task.Data | |||
| } | |||
| delete(payload, "aiping_id") | |||
| payload["id"] = task.TaskID | |||
| data, err := common.Marshal(payload) | |||
| if err != nil { | |||
| return task.Data | |||
| } | |||
| return data | |||
| } | |||
| @@ -200,6 +200,25 @@ func TestTaskModel2DtoDoesNotExposeUpstreamRequestSnapshot(t *testing.T) { | |||
| require.NotContains(t, string(data), "hidden") | |||
| } | |||
| func TestTaskModel2DtoSanitizesAipingInternalIDFromData(t *testing.T) { | |||
| task := &model.Task{ | |||
| TaskID: "task_public", | |||
| Data: []byte(`{ | |||
| "id":"cgt-upstream", | |||
| "aiping_id":"b7ee658f-acac-433d-897d-5c9ed55ef402", | |||
| "status":"succeeded" | |||
| }`), | |||
| } | |||
| dtoTask := TaskModel2Dto(task) | |||
| data, err := common.Marshal(dtoTask) | |||
| require.NoError(t, err) | |||
| require.NotContains(t, string(data), "aiping_id") | |||
| require.NotContains(t, string(data), "b7ee658f-acac-433d-897d-5c9ed55ef402") | |||
| require.Contains(t, string(data), `"id":"task_public"`) | |||
| } | |||
| func newRelayTaskRemixContext(t *testing.T, originTaskID string) *gin.Context { | |||
| t.Helper() | |||
| gin.SetMode(gin.TestMode) | |||
| @@ -2,6 +2,7 @@ package service | |||
| import ( | |||
| "context" | |||
| "encoding/json" | |||
| "fmt" | |||
| "strings" | |||
| @@ -11,6 +12,7 @@ import ( | |||
| "github.com/QuantumNous/new-api/model" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| @@ -39,6 +41,21 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { | |||
| other["request_path"] = c.Request.URL.Path | |||
| other["model_price"] = info.PriceData.ModelPrice | |||
| other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio | |||
| other["billing_phase"] = "preconsume" | |||
| other["pre_consumed_quota"] = info.PriceData.Quota | |||
| if info.TaskRelayInfo != nil && info.TaskRelayInfo.PublicTaskID != "" { | |||
| other["task_id"] = info.TaskRelayInfo.PublicTaskID | |||
| } | |||
| if decision := info.PricingDecisionFrozen; decision != nil && decision.BillingMode == types.BillingModeMatrix { | |||
| other["billing_mode"] = decision.BillingMode | |||
| other["billing_unit"] = decision.BillingUnit | |||
| if decision.TokenUnitPriceUSD > 0 { | |||
| other["token_unit_price_usd"] = decision.TokenUnitPriceUSD | |||
| } | |||
| if decision.Snapshot != nil { | |||
| other["pricing_snapshot"] = types.CloneMapAny(decision.Snapshot) | |||
| } | |||
| } | |||
| if info.PriceData.GroupRatioInfo.HasSpecialRatio { | |||
| other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio | |||
| } | |||
| @@ -146,6 +163,35 @@ func taskModelName(task *model.Task) string { | |||
| return task.Properties.OriginModelName | |||
| } | |||
| func taskCompletionTokens(task *model.Task) int { | |||
| if bc := task.PrivateData.BillingContext; bc != nil && bc.PricingSnapshot != nil { | |||
| if tt, ok := bc.PricingSnapshot["total_tokens"]; ok { | |||
| switch v := tt.(type) { | |||
| case float64: | |||
| return int(v) | |||
| case int: | |||
| return v | |||
| case json.Number: | |||
| if n, err := v.Int64(); err == nil { | |||
| return int(n) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| return 0 | |||
| } | |||
| func taskSettlementOther(task *model.Task, reason string, preConsumedQuota, actualQuota, quotaDelta int) map[string]interface{} { | |||
| other := taskBillingOther(task) | |||
| other["task_id"] = task.TaskID | |||
| other["billing_phase"] = "settlement" | |||
| other["reason"] = reason | |||
| other["pre_consumed_quota"] = preConsumedQuota | |||
| other["actual_quota"] = actualQuota | |||
| other["quota_delta"] = quotaDelta | |||
| return other | |||
| } | |||
| // RefundTaskQuota 统一的任务失败退款逻辑。 | |||
| // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。 | |||
| func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { | |||
| @@ -166,7 +212,9 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { | |||
| // 3. 记录日志 | |||
| other := taskBillingOther(task) | |||
| other["task_id"] = task.TaskID | |||
| other["billing_phase"] = "refund" | |||
| other["reason"] = reason | |||
| other["pre_consumed_quota"] = quota | |||
| model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ | |||
| UserId: task.UserId, | |||
| LogType: model.LogTypeRefund, | |||
| @@ -193,6 +241,18 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int | |||
| if quotaDelta == 0 { | |||
| logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)", | |||
| task.TaskID, logger.LogQuota(actualQuota), reason)) | |||
| model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ | |||
| UserId: task.UserId, | |||
| LogType: model.LogTypeConsume, | |||
| Content: "", | |||
| ChannelId: task.ChannelId, | |||
| ModelName: taskModelName(task), | |||
| Quota: 0, | |||
| TokenId: task.PrivateData.TokenId, | |||
| Group: task.Group, | |||
| CompletionTokens: taskCompletionTokens(task), | |||
| Other: taskSettlementOther(task, reason, preConsumedQuota, actualQuota, quotaDelta), | |||
| }) | |||
| return | |||
| } | |||
| @@ -226,21 +286,17 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int | |||
| logType = model.LogTypeRefund | |||
| logQuota = -quotaDelta | |||
| } | |||
| other := taskBillingOther(task) | |||
| other["task_id"] = task.TaskID | |||
| other["reason"] = reason | |||
| other["pre_consumed_quota"] = preConsumedQuota | |||
| other["actual_quota"] = actualQuota | |||
| model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ | |||
| UserId: task.UserId, | |||
| LogType: logType, | |||
| Content: "", | |||
| ChannelId: task.ChannelId, | |||
| ModelName: taskModelName(task), | |||
| Quota: logQuota, | |||
| TokenId: task.PrivateData.TokenId, | |||
| Group: task.Group, | |||
| Other: other, | |||
| UserId: task.UserId, | |||
| LogType: logType, | |||
| Content: "", | |||
| ChannelId: task.ChannelId, | |||
| ModelName: taskModelName(task), | |||
| Quota: logQuota, | |||
| TokenId: task.PrivateData.TokenId, | |||
| Group: task.Group, | |||
| CompletionTokens: taskCompletionTokens(task), | |||
| Other: taskSettlementOther(task, reason, preConsumedQuota, actualQuota, quotaDelta), | |||
| }) | |||
| } | |||
| @@ -4,6 +4,7 @@ import ( | |||
| "context" | |||
| "encoding/json" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "os" | |||
| "testing" | |||
| "time" | |||
| @@ -11,6 +12,8 @@ import ( | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| @@ -125,8 +128,8 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc | |||
| SubscriptionId: subscriptionId, | |||
| TokenId: tokenId, | |||
| BillingContext: &model.TaskBillingContext{ | |||
| ModelPrice: 0.02, | |||
| GroupRatio: 1.0, | |||
| ModelPrice: 0.02, | |||
| GroupRatio: 1.0, | |||
| OriginModelName: "test-model", | |||
| }, | |||
| }, | |||
| @@ -175,6 +178,14 @@ func getLastLog(t *testing.T) *model.Log { | |||
| return &log | |||
| } | |||
| func getLogOtherMap(t *testing.T, log *model.Log) map[string]any { | |||
| t.Helper() | |||
| require.NotNil(t, log) | |||
| other := map[string]any{} | |||
| require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) | |||
| return other | |||
| } | |||
| func countLogs(t *testing.T) int64 { | |||
| t.Helper() | |||
| var count int64 | |||
| @@ -217,6 +228,61 @@ func TestRefundTaskQuota_Wallet(t *testing.T) { | |||
| assert.Equal(t, "test-model", log.ModelName) | |||
| } | |||
| func TestLogTaskConsumption_MatrixPreconsumeIncludesTaskBillingMetadata(t *testing.T) { | |||
| truncate(t) | |||
| gin.SetMode(gin.TestMode) | |||
| ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) | |||
| ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil) | |||
| ctx.Set("token_name", "task-token") | |||
| const userID, channelID = 40, 40 | |||
| seedUser(t, userID, 10000) | |||
| seedChannel(t, channelID) | |||
| info := &relaycommon.RelayInfo{ | |||
| UserId: userID, | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelId: channelID, | |||
| }, | |||
| TokenId: 400, | |||
| UsingGroup: "default", | |||
| OriginModelName: "matrix-model", | |||
| TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "submit", PublicTaskID: "task_public_123"}, | |||
| PriceData: types.PriceData{ | |||
| ModelPrice: 46, | |||
| Quota: 1, | |||
| GroupRatioInfo: types.GroupRatioInfo{ | |||
| GroupRatio: 1, | |||
| }, | |||
| }, | |||
| PricingDecisionFrozen: &types.PricingDecision{ | |||
| BillingMode: types.BillingModeMatrix, | |||
| BillingUnit: types.BillingUnitPer1MTokens, | |||
| TokenUnitPriceUSD: 46, | |||
| Snapshot: map[string]any{ | |||
| "billing_unit": types.BillingUnitPer1MTokens, | |||
| "resolution": "1080p", | |||
| }, | |||
| }, | |||
| } | |||
| LogTaskConsumption(ctx, info) | |||
| log := getLastLog(t) | |||
| require.NotNil(t, log) | |||
| assert.Equal(t, model.LogTypeConsume, log.Type) | |||
| assert.Equal(t, 1, log.Quota) | |||
| other := getLogOtherMap(t, log) | |||
| assert.Equal(t, "preconsume", other["billing_phase"]) | |||
| assert.Equal(t, "task_public_123", other["task_id"]) | |||
| assert.Equal(t, float64(1), other["pre_consumed_quota"]) | |||
| assert.Equal(t, types.BillingModeMatrix, other["billing_mode"]) | |||
| assert.Equal(t, types.BillingUnitPer1MTokens, other["billing_unit"]) | |||
| assert.Equal(t, float64(46), other["token_unit_price_usd"]) | |||
| require.IsType(t, map[string]any{}, other["pricing_snapshot"]) | |||
| } | |||
| func TestRefundTaskQuota_Subscription(t *testing.T) { | |||
| truncate(t) | |||
| ctx := context.Background() | |||
| @@ -244,6 +310,11 @@ func TestRefundTaskQuota_Subscription(t *testing.T) { | |||
| log := getLastLog(t) | |||
| require.NotNil(t, log) | |||
| assert.Equal(t, model.LogTypeRefund, log.Type) | |||
| other := getLogOtherMap(t, log) | |||
| assert.Equal(t, "refund", other["billing_phase"]) | |||
| assert.Equal(t, task.TaskID, other["task_id"]) | |||
| assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"]) | |||
| } | |||
| func TestRefundTaskQuota_ZeroQuota(t *testing.T) { | |||
| @@ -322,6 +393,13 @@ func TestRecalculate_PositiveDelta(t *testing.T) { | |||
| require.NotNil(t, log) | |||
| assert.Equal(t, model.LogTypeConsume, log.Type) | |||
| assert.Equal(t, actualQuota-preConsumed, log.Quota) | |||
| other := getLogOtherMap(t, log) | |||
| assert.Equal(t, "settlement", other["billing_phase"]) | |||
| assert.Equal(t, task.TaskID, other["task_id"]) | |||
| assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"]) | |||
| assert.Equal(t, float64(actualQuota), other["actual_quota"]) | |||
| assert.Equal(t, float64(actualQuota-preConsumed), other["quota_delta"]) | |||
| } | |||
| func TestRecalculate_NegativeDelta(t *testing.T) { | |||
| @@ -363,18 +441,29 @@ func TestRecalculate_ZeroDelta(t *testing.T) { | |||
| const userID = 12 | |||
| const initQuota, preConsumed = 10000, 3000 | |||
| const totalTokens = 48400 | |||
| seedUser(t, userID, initQuota) | |||
| task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0) | |||
| task.PrivateData.BillingContext.PricingSnapshot = map[string]any{ | |||
| "total_tokens": float64(totalTokens), | |||
| } | |||
| RecalculateTaskQuota(ctx, task, preConsumed, "exact match") | |||
| // No change to user quota | |||
| assert.Equal(t, initQuota, getUserQuota(t, userID)) | |||
| // No log created (delta is zero) | |||
| assert.Equal(t, int64(0), countLogs(t)) | |||
| log := getLastLog(t) | |||
| require.NotNil(t, log) | |||
| assert.Equal(t, model.LogTypeConsume, log.Type) | |||
| assert.Equal(t, 0, log.Quota) | |||
| assert.Equal(t, totalTokens, log.CompletionTokens) | |||
| other := getLogOtherMap(t, log) | |||
| assert.Equal(t, "settlement", other["billing_phase"]) | |||
| assert.Equal(t, float64(0), other["quota_delta"]) | |||
| } | |||
| func TestRecalculate_ActualQuotaZero(t *testing.T) { | |||
| @@ -615,9 +704,11 @@ type mockAdaptor struct { | |||
| adjustReturn int | |||
| } | |||
| func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} | |||
| func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { return nil, nil } | |||
| func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil } | |||
| func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} | |||
| func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { | |||
| return nil, nil | |||
| } | |||
| func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil } | |||
| func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int { | |||
| return m.adjustReturn | |||
| } | |||
| @@ -162,7 +162,7 @@ const ContentModal = ({ | |||
| height: isVideo ? '70vh' : '400px', | |||
| maxHeight: '80vh', | |||
| overflow: 'auto', | |||
| padding: isVideo && videoError ? '0' : '24px', | |||
| padding: isVideo && videoError ? '0' : '18px 20px 20px', | |||
| }} | |||
| width={isVideo ? '90vw' : 800} | |||
| style={isVideo ? { maxWidth: 960 } : undefined} | |||
| @@ -170,7 +170,29 @@ const ContentModal = ({ | |||
| {isVideo ? ( | |||
| renderVideoContent() | |||
| ) : ( | |||
| <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p> | |||
| <div | |||
| style={{ | |||
| minHeight: '100%', | |||
| padding: '16px 18px', | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-fill-0)', | |||
| }} | |||
| > | |||
| <pre | |||
| style={{ | |||
| margin: 0, | |||
| whiteSpace: 'pre-wrap', | |||
| wordBreak: 'break-word', | |||
| lineHeight: 1.7, | |||
| fontSize: 13, | |||
| fontFamily: | |||
| 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', | |||
| }} | |||
| > | |||
| {modalContent} | |||
| </pre> | |||
| </div> | |||
| )} | |||
| </Modal> | |||
| ); | |||
| @@ -0,0 +1,207 @@ | |||
| /* | |||
| Copyright (C) 2025 QuantumNous | |||
| This program is free software: you can redistribute it and/or modify | |||
| it under the terms of the GNU Affero General Public License as | |||
| published by the Free Software Foundation, either version 3 of the | |||
| License, or (at your option) any later version. | |||
| This program is distributed in the hope that it will be useful, | |||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
| GNU Affero General Public License for more details. | |||
| You should have received a copy of the GNU Affero General Public License | |||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React from 'react'; | |||
| import { Popover, Space, Tag, Typography } from '@douyinfe/semi-ui'; | |||
| import { getCurrencyConfig, renderQuota } from '../../../helpers'; | |||
| import { | |||
| buildMatrixBillingDetail, | |||
| formatBillingRatio, | |||
| } from './BillingDetailSummaryData'; | |||
| const DetailRow = ({ label, value }) => { | |||
| if (value === undefined || value === null || value === '') return null; | |||
| return ( | |||
| <div | |||
| style={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| gap: 14, | |||
| padding: '3px 0', | |||
| }} | |||
| > | |||
| <Typography.Text type='tertiary'>{label}</Typography.Text> | |||
| <Typography.Text style={{ textAlign: 'right', wordBreak: 'break-word' }}> | |||
| {value} | |||
| </Typography.Text> | |||
| </div> | |||
| ); | |||
| }; | |||
| const PopoverSection = ({ title, children }) => ( | |||
| <div | |||
| style={{ | |||
| padding: '10px 12px', | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-bg-1)', | |||
| }} | |||
| > | |||
| <Typography.Text strong>{title}</Typography.Text> | |||
| <div style={{ marginTop: 6 }}>{children}</div> | |||
| </div> | |||
| ); | |||
| const BillingDetailSummary = ({ other, record, t }) => { | |||
| const detail = buildMatrixBillingDetail({ | |||
| other, | |||
| t, | |||
| currencyConfig: getCurrencyConfig(), | |||
| }); | |||
| if (!detail) return null; | |||
| const actualQuotaText = | |||
| record?.quota !== undefined ? renderQuota(record.quota, 6) : ''; | |||
| const preConsumedQuotaText = | |||
| other?.pre_consumed_quota !== undefined | |||
| ? renderQuota(other.pre_consumed_quota, 6) | |||
| : ''; | |||
| const settledQuotaText = | |||
| other?.actual_quota !== undefined ? renderQuota(other.actual_quota, 6) : ''; | |||
| const popoverContent = ( | |||
| <div | |||
| style={{ | |||
| width: 420, | |||
| maxWidth: 'min(420px, calc(100vw - 48px))', | |||
| padding: 4, | |||
| lineHeight: 1.65, | |||
| }} | |||
| > | |||
| <Space wrap size={4} style={{ marginBottom: 8 }}> | |||
| <Tag color='blue' shape='circle'> | |||
| {detail.title} | |||
| </Tag> | |||
| {detail.isPerTokens ? ( | |||
| <Tag color='orange' shape='circle'> | |||
| {t('按完成 tokens 重算')} | |||
| </Tag> | |||
| ) : ( | |||
| <Tag color='green' shape='circle'> | |||
| {t('固定按次')} | |||
| </Tag> | |||
| )} | |||
| {detail.pricingMode !== '-' && ( | |||
| <Tag | |||
| color={detail.pricingMode === 'matched' ? 'green' : 'amber'} | |||
| shape='circle' | |||
| > | |||
| {detail.pricingMode} | |||
| </Tag> | |||
| )} | |||
| {detail.usesLegacyPriceField && ( | |||
| <Tag color='grey' shape='circle'> | |||
| {t('旧日志字段')} | |||
| </Tag> | |||
| )} | |||
| </Space> | |||
| <div | |||
| style={{ | |||
| marginBottom: 10, | |||
| padding: '10px 12px', | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-bg-1)', | |||
| }} | |||
| > | |||
| <Typography.Text strong>{t('价格计算')}</Typography.Text> | |||
| <DetailRow label={t('基础单价')} value={detail.unitPriceText} /> | |||
| <DetailRow | |||
| label={t('分组倍率')} | |||
| value={formatBillingRatio(detail.groupRatio)} | |||
| /> | |||
| <DetailRow label={t('实际单价')} value={detail.finalUnitPriceText} /> | |||
| <DetailRow label={t('实际用量')} value={detail.usageText} /> | |||
| <DetailRow label={t('计算扣费')} value={detail.costText} /> | |||
| </div> | |||
| {detail.dimensions.length > 0 && ( | |||
| <div | |||
| style={{ | |||
| marginBottom: 10, | |||
| padding: '10px 12px', | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-bg-1)', | |||
| }} | |||
| > | |||
| <Typography.Text strong>{t('命中维度')}</Typography.Text> | |||
| {detail.dimensions.map((item) => ( | |||
| <DetailRow key={item.key} label={item.key} value={item.value} /> | |||
| ))} | |||
| </div> | |||
| )} | |||
| <div | |||
| style={{ | |||
| padding: '10px 12px', | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-bg-1)', | |||
| }} | |||
| > | |||
| <Typography.Text strong>{t('结算信息')}</Typography.Text> | |||
| <DetailRow label={t('日志扣费')} value={actualQuotaText} /> | |||
| <DetailRow label={t('预扣')} value={preConsumedQuotaText} /> | |||
| <DetailRow label={t('实际')} value={settledQuotaText} /> | |||
| <DetailRow label={t('命中模式')} value={detail.pricingMode} /> | |||
| <DetailRow label='Fallback' value={detail.fallbackStrategy} /> | |||
| </div> | |||
| </div> | |||
| ); | |||
| return ( | |||
| <Popover | |||
| content={popoverContent} | |||
| trigger='hover' | |||
| position='leftTop' | |||
| spacing={10} | |||
| > | |||
| <div style={{ maxWidth: 280, cursor: 'default' }}> | |||
| <Space wrap size={4} style={{ marginBottom: 4 }}> | |||
| <Tag color='blue' shape='circle'> | |||
| {detail.isPerTokens ? t('每 1M tokens') : t('按次')} | |||
| </Tag> | |||
| {detail.pricingMode !== '-' && ( | |||
| <Tag | |||
| color={detail.pricingMode === 'matched' ? 'green' : 'amber'} | |||
| shape='circle' | |||
| > | |||
| {detail.pricingMode} | |||
| </Tag> | |||
| )} | |||
| </Space> | |||
| <div className='text-xs font-medium text-gray-900'> | |||
| {detail.unitPriceText} × {formatBillingRatio(detail.groupRatio)} | |||
| </div> | |||
| <div className='text-xs text-gray-600'> | |||
| {detail.usageText} → {detail.costText} | |||
| </div> | |||
| {detail.dimensionText && ( | |||
| <div className='text-xs text-gray-500 truncate'> | |||
| {detail.dimensionText} | |||
| </div> | |||
| )} | |||
| </div> | |||
| </Popover> | |||
| ); | |||
| }; | |||
| export default BillingDetailSummary; | |||
| @@ -0,0 +1,109 @@ | |||
| import { describe, expect, it } from 'vitest'; | |||
| import { buildMatrixBillingDetail } from './BillingDetailSummaryData.js'; | |||
| const t = (key) => | |||
| ({ | |||
| 矩阵计费: '矩阵计费', | |||
| '每 1M tokens': '每 1M tokens', | |||
| 按次: '按次', | |||
| 次: '次', | |||
| 单次计费: '单次计费', | |||
| '等待实际 tokens': '等待实际 tokens', | |||
| 等待完成后重算: '等待完成后重算', | |||
| 任意: '任意', | |||
| })[key] || key; | |||
| const cny = { symbol: '¥', rate: 7, type: 'CNY' }; | |||
| describe('BillingDetailSummary matrix billing detail', () => { | |||
| it('summarizes per_1m_tokens matrix usage with dimensions and token cost', () => { | |||
| const detail = buildMatrixBillingDetail({ | |||
| t, | |||
| currencyConfig: cny, | |||
| other: { | |||
| billing_mode: 'matrix', | |||
| billing_unit: 'per_1m_tokens', | |||
| token_unit_price_usd: 37 / 7, | |||
| group_ratio: 1, | |||
| pricing_snapshot: { | |||
| billing_unit: 'per_1m_tokens', | |||
| pricing_mode: 'matched', | |||
| price: 37 / 7, | |||
| resolution: '1080P', | |||
| duration: 10, | |||
| total_tokens: 2_000_000, | |||
| }, | |||
| }, | |||
| }); | |||
| expect(detail).toMatchObject({ | |||
| isPerTokens: true, | |||
| title: '矩阵计费 · 每 1M tokens', | |||
| unitPriceText: '¥37.000000 / 1M tokens', | |||
| finalUnitPriceText: '¥37.000000 / 1M tokens', | |||
| usageText: '2,000,000 tokens', | |||
| costText: '¥74.000000', | |||
| dimensionText: 'resolution=1080P · duration=10', | |||
| pricingMode: 'matched', | |||
| }); | |||
| }); | |||
| it('summarizes per_call matrix billing as fixed per-call cost', () => { | |||
| const detail = buildMatrixBillingDetail({ | |||
| t, | |||
| currencyConfig: cny, | |||
| other: { | |||
| billing_mode: 'matrix', | |||
| billing_unit: 'per_call', | |||
| model_price: 2, | |||
| group_ratio: 1.5, | |||
| pricing_snapshot: { | |||
| billing_unit: 'per_call', | |||
| pricing_mode: 'matched', | |||
| price: 2, | |||
| size: 'large', | |||
| }, | |||
| }, | |||
| }); | |||
| expect(detail).toMatchObject({ | |||
| isPerTokens: false, | |||
| title: '矩阵计费 · 按次', | |||
| unitPriceText: '¥14.000000 / 次', | |||
| finalUnitPriceText: '¥21.000000 / 次', | |||
| usageText: '单次计费', | |||
| costText: '¥21.000000', | |||
| dimensionText: 'size=large', | |||
| }); | |||
| }); | |||
| it('marks old per-token logs that only have model_price', () => { | |||
| const detail = buildMatrixBillingDetail({ | |||
| t, | |||
| currencyConfig: cny, | |||
| other: { | |||
| billing_mode: 'matrix', | |||
| billing_unit: 'per_1m_tokens', | |||
| model_price: 46, | |||
| group_ratio: 1, | |||
| pricing_snapshot: { | |||
| billing_unit: 'per_1m_tokens', | |||
| pricing_mode: 'fallback_max', | |||
| fallback_strategy: 'max', | |||
| price: 46, | |||
| resolution: '*', | |||
| }, | |||
| }, | |||
| }); | |||
| expect(detail).toMatchObject({ | |||
| usesLegacyPriceField: true, | |||
| unitPriceText: '¥322.000000 / 1M tokens', | |||
| usageText: '等待实际 tokens', | |||
| costText: '等待完成后重算', | |||
| dimensionText: 'resolution=任意', | |||
| pricingMode: 'fallback_max', | |||
| fallbackStrategy: 'max', | |||
| }); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,142 @@ | |||
| /* | |||
| Copyright (C) 2025 QuantumNous | |||
| This program is free software: you can redistribute it and/or modify | |||
| it under the terms of the GNU Affero General Public License as | |||
| published by the Free Software Foundation, either version 3 of the | |||
| License, or (at your option) any later version. | |||
| This program is distributed in the hope that it will be useful, | |||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
| GNU Affero General Public License for more details. | |||
| You should have received a copy of the GNU Affero General Public License | |||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| const SNAPSHOT_META_KEYS = new Set([ | |||
| 'price', | |||
| 'billing_unit', | |||
| 'pricing_mode', | |||
| 'fallback_strategy', | |||
| 'total_tokens', | |||
| ]); | |||
| export const formatBillingRatio = (ratio) => { | |||
| if (ratio === undefined || ratio === null) return '-'; | |||
| const value = Number(ratio); | |||
| if (!Number.isFinite(value)) return String(ratio); | |||
| return value.toFixed(4); | |||
| }; | |||
| const formatCurrency = (usdAmount, currencyConfig, digits = 6) => { | |||
| const value = Number(usdAmount || 0); | |||
| const rate = Number(currencyConfig?.rate || 1); | |||
| const symbol = currencyConfig?.symbol || '$'; | |||
| const displayValue = value * (Number.isFinite(rate) && rate > 0 ? rate : 1); | |||
| return `${symbol}${displayValue.toFixed(digits)}`; | |||
| }; | |||
| const formatTokenCount = (value) => { | |||
| const tokens = Number(value || 0); | |||
| if (!Number.isFinite(tokens) || tokens <= 0) return ''; | |||
| return Math.round(tokens).toLocaleString(); | |||
| }; | |||
| const getMatrixDimensions = (snapshot, t = (key) => key) => | |||
| Object.entries(snapshot || {}) | |||
| .filter(([key]) => !SNAPSHOT_META_KEYS.has(key)) | |||
| .map(([key, value]) => ({ | |||
| key, | |||
| value: value === '*' ? t('任意') : String(value), | |||
| })); | |||
| const getBillingUnitLabel = (billingUnit, t = (key) => key) => { | |||
| if (billingUnit === 'per_1m_tokens') return '1M tokens'; | |||
| if (billingUnit === 'per_call') return t('次'); | |||
| return billingUnit || '-'; | |||
| }; | |||
| const getMatrixUnitPrice = (other, billingUnit) => { | |||
| if (billingUnit === 'per_1m_tokens') { | |||
| if (other?.token_unit_price_usd !== undefined) { | |||
| return { | |||
| value: Number(other.token_unit_price_usd), | |||
| source: 'token_unit_price_usd', | |||
| }; | |||
| } | |||
| return { value: Number(other?.model_price), source: 'model_price' }; | |||
| } | |||
| return { value: Number(other?.model_price), source: 'model_price' }; | |||
| }; | |||
| export const buildMatrixBillingDetail = ({ | |||
| other, | |||
| t = (key) => key, | |||
| currencyConfig = { symbol: '$', rate: 1 }, | |||
| }) => { | |||
| if (!other || other.billing_mode !== 'matrix') return null; | |||
| const snapshot = other.pricing_snapshot || {}; | |||
| const billingUnit = other.billing_unit || snapshot.billing_unit || 'per_call'; | |||
| const { value: unitPriceUSD, source } = getMatrixUnitPrice( | |||
| other, | |||
| billingUnit, | |||
| ); | |||
| if (!Number.isFinite(unitPriceUSD) || unitPriceUSD <= 0) return null; | |||
| const groupRatio = Number(other.group_ratio ?? 1); | |||
| const effectiveGroupRatio = Number.isFinite(groupRatio) ? groupRatio : 1; | |||
| const finalUnitPriceUSD = unitPriceUSD * effectiveGroupRatio; | |||
| const unitLabel = getBillingUnitLabel(billingUnit, t); | |||
| const dimensions = getMatrixDimensions(snapshot, t); | |||
| const dimensionText = dimensions | |||
| .map((item) => `${item.key}=${item.value}`) | |||
| .join(' · '); | |||
| const totalTokens = snapshot.total_tokens ?? other.total_tokens; | |||
| const tokenText = formatTokenCount(totalTokens); | |||
| const pricingMode = snapshot.pricing_mode || '-'; | |||
| const fallbackStrategy = snapshot.fallback_strategy || ''; | |||
| const isPerTokens = billingUnit === 'per_1m_tokens'; | |||
| const estimatedCostUSD = | |||
| isPerTokens && tokenText | |||
| ? (finalUnitPriceUSD * Number(totalTokens || 0)) / 1_000_000 | |||
| : finalUnitPriceUSD; | |||
| const title = isPerTokens | |||
| ? `${t('矩阵计费')} · ${t('每 1M tokens')}` | |||
| : `${t('矩阵计费')} · ${t('按次')}`; | |||
| const unitPriceText = `${formatCurrency(unitPriceUSD, currencyConfig)} / ${unitLabel}`; | |||
| const finalUnitPriceText = `${formatCurrency(finalUnitPriceUSD, currencyConfig)} / ${unitLabel}`; | |||
| const usageText = isPerTokens | |||
| ? tokenText | |||
| ? `${tokenText} tokens` | |||
| : t('等待实际 tokens') | |||
| : t('单次计费'); | |||
| const costText = | |||
| isPerTokens && !tokenText | |||
| ? t('等待完成后重算') | |||
| : formatCurrency(estimatedCostUSD, currencyConfig); | |||
| return { | |||
| billingUnit, | |||
| isPerTokens, | |||
| title, | |||
| unitLabel, | |||
| source, | |||
| usesLegacyPriceField: isPerTokens && source === 'model_price', | |||
| groupRatio: effectiveGroupRatio, | |||
| unitPriceText, | |||
| finalUnitPriceText, | |||
| usageText, | |||
| costText, | |||
| tokenText, | |||
| dimensionText, | |||
| dimensions, | |||
| pricingMode, | |||
| fallbackStrategy, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,80 @@ | |||
| import React from 'react'; | |||
| import { Descriptions, Typography } from '@douyinfe/semi-ui'; | |||
| const sectionStyle = { | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-bg-1)', | |||
| overflow: 'hidden', | |||
| }; | |||
| const headerStyle = { | |||
| padding: '9px 12px', | |||
| borderBottom: '1px solid var(--semi-color-border)', | |||
| background: 'var(--semi-color-fill-0)', | |||
| }; | |||
| const bodyStyle = { | |||
| padding: '10px 12px 12px', | |||
| }; | |||
| const gridStyle = { | |||
| display: 'grid', | |||
| gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', | |||
| gap: '10px 16px', | |||
| }; | |||
| const itemStyle = { | |||
| minWidth: 0, | |||
| }; | |||
| const valueStyle = { | |||
| marginTop: 3, | |||
| lineHeight: 1.55, | |||
| wordBreak: 'break-word', | |||
| }; | |||
| const DetailItem = ({ item }) => ( | |||
| <div style={itemStyle}> | |||
| <Typography.Text type='tertiary' size='small'> | |||
| {item.key} | |||
| </Typography.Text> | |||
| <div style={valueStyle}>{item.value}</div> | |||
| </div> | |||
| ); | |||
| const DetailSection = ({ section }) => { | |||
| if (!section?.items?.length) return null; | |||
| return ( | |||
| <div style={sectionStyle}> | |||
| <div style={headerStyle}> | |||
| <Typography.Text strong>{section.title}</Typography.Text> | |||
| </div> | |||
| <div style={bodyStyle}> | |||
| <div style={gridStyle}> | |||
| {section.items.map((item, index) => ( | |||
| <DetailItem key={`${item.key}-${index}`} item={item} /> | |||
| ))} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }; | |||
| const UsageLogExpandedDetail = ({ detail }) => { | |||
| if (detail?.sections?.length) { | |||
| return ( | |||
| <div style={{ padding: '12px 16px' }}> | |||
| <div style={{ display: 'grid', gap: 12 }}> | |||
| {detail.sections.map((section) => ( | |||
| <DetailSection key={section.title} section={section} /> | |||
| ))} | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| return <Descriptions data={detail?.items || detail || []} />; | |||
| }; | |||
| export default UsageLogExpandedDetail; | |||
| @@ -25,7 +25,7 @@ import { | |||
| Tooltip, | |||
| Popover, | |||
| Typography, | |||
| Button | |||
| Button, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { | |||
| timestamp2string, | |||
| @@ -43,6 +43,7 @@ import { | |||
| } from '../../../helpers'; | |||
| import { IconHelpCircle } from '@douyinfe/semi-icons'; | |||
| import { Route, Sparkles } from 'lucide-react'; | |||
| import BillingDetailSummary from './BillingDetailSummary'; | |||
| const colors = [ | |||
| 'amber', | |||
| @@ -101,7 +102,53 @@ function buildChannelAffinityTooltip(affinity, t) { | |||
| } | |||
| // Render functions | |||
| function renderType(type, t) { | |||
| function renderType(type, t, record = null) { | |||
| const other = record ? getLogOther(record.other) : null; | |||
| if (other?.billing_mode === 'matrix') { | |||
| if (other?.task_billing_status === 'pending') { | |||
| return ( | |||
| <Tag color='amber' shape='circle'> | |||
| {t('待结算')} | |||
| </Tag> | |||
| ); | |||
| } | |||
| if (other?.task_billing_status === 'settled') { | |||
| return ( | |||
| <Tag color='green' shape='circle'> | |||
| {t('已结算')} | |||
| </Tag> | |||
| ); | |||
| } | |||
| if (other?.task_billing_status === 'refunded') { | |||
| return ( | |||
| <Tag color='teal' shape='circle'> | |||
| {t('已退还')} | |||
| </Tag> | |||
| ); | |||
| } | |||
| if (other?.billing_phase === 'preconsume') { | |||
| return ( | |||
| <Tag color='amber' shape='circle'> | |||
| {t('预扣')} | |||
| </Tag> | |||
| ); | |||
| } | |||
| if (other?.billing_phase === 'settlement') { | |||
| return ( | |||
| <Tag color={type === 6 ? 'teal' : 'green'} shape='circle'> | |||
| {type === 6 ? t('退还') : t('补扣')} | |||
| </Tag> | |||
| ); | |||
| } | |||
| if (other?.billing_phase === 'refund') { | |||
| return ( | |||
| <Tag color='teal' shape='circle'> | |||
| {t('退还')} | |||
| </Tag> | |||
| ); | |||
| } | |||
| } | |||
| switch (type) { | |||
| case 1: | |||
| return ( | |||
| @@ -374,7 +421,10 @@ export const getLogsColumns = ({ | |||
| } | |||
| return isAdminUser && | |||
| (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? ( | |||
| (record.type === 0 || | |||
| record.type === 2 || | |||
| record.type === 5 || | |||
| record.type === 6) ? ( | |||
| <Space> | |||
| <span style={{ position: 'relative', display: 'inline-block' }}> | |||
| <Tooltip content={record.channel_name || t('未知渠道')}> | |||
| @@ -465,7 +515,10 @@ export const getLogsColumns = ({ | |||
| title: t('令牌'), | |||
| dataIndex: 'token_name', | |||
| render: (text, record, index) => { | |||
| return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( | |||
| return record.type === 0 || | |||
| record.type === 2 || | |||
| record.type === 5 || | |||
| record.type === 6 ? ( | |||
| <div> | |||
| <Tag | |||
| color='grey' | |||
| @@ -522,7 +575,7 @@ export const getLogsColumns = ({ | |||
| title: t('类型'), | |||
| dataIndex: 'type', | |||
| render: (text, record, index) => { | |||
| return <>{renderType(text, t)}</>; | |||
| return <>{renderType(text, t, record)}</>; | |||
| }, | |||
| }, | |||
| { | |||
| @@ -530,7 +583,10 @@ export const getLogsColumns = ({ | |||
| title: t('模型'), | |||
| dataIndex: 'model_name', | |||
| render: (text, record, index) => { | |||
| return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( | |||
| return record.type === 0 || | |||
| record.type === 2 || | |||
| record.type === 5 || | |||
| record.type === 6 ? ( | |||
| <>{renderModelName(record, copyText, t)}</> | |||
| ) : ( | |||
| <></> | |||
| @@ -597,7 +653,10 @@ export const getLogsColumns = ({ | |||
| cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`; | |||
| } | |||
| return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( | |||
| return record.type === 0 || | |||
| record.type === 2 || | |||
| record.type === 5 || | |||
| record.type === 6 ? ( | |||
| <div | |||
| style={{ | |||
| display: 'inline-flex', | |||
| @@ -631,7 +690,10 @@ export const getLogsColumns = ({ | |||
| dataIndex: 'completion_tokens', | |||
| render: (text, record, index) => { | |||
| return parseInt(text) > 0 && | |||
| (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? ( | |||
| (record.type === 0 || | |||
| record.type === 2 || | |||
| record.type === 5 || | |||
| record.type === 6) ? ( | |||
| <>{<span> {text} </span>}</> | |||
| ) : ( | |||
| <></> | |||
| @@ -643,7 +705,14 @@ export const getLogsColumns = ({ | |||
| title: t('花费'), | |||
| dataIndex: 'quota', | |||
| render: (text, record, index) => { | |||
| if (!(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6)) { | |||
| if ( | |||
| !( | |||
| record.type === 0 || | |||
| record.type === 2 || | |||
| record.type === 5 || | |||
| record.type === 6 | |||
| ) | |||
| ) { | |||
| return <></>; | |||
| } | |||
| const other = getLogOther(record.other); | |||
| @@ -710,9 +779,9 @@ export const getLogsColumns = ({ | |||
| } | |||
| if (other.admin_info !== undefined) { | |||
| if ( | |||
| other.admin_info.use_channel !== null && | |||
| other.admin_info.use_channel !== undefined && | |||
| other.admin_info.use_channel !== '' | |||
| other.admin_info.use_channel !== null && | |||
| other.admin_info.use_channel !== undefined && | |||
| other.admin_info.use_channel !== '' | |||
| ) { | |||
| let useChannel = other.admin_info.use_channel; | |||
| let useChannelStr = useChannel.join('->'); | |||
| @@ -788,6 +857,13 @@ export const getLogsColumns = ({ | |||
| ); | |||
| } | |||
| const matrixPrice = Number( | |||
| other?.token_unit_price_usd ?? other?.model_price ?? 0, | |||
| ); | |||
| if (other?.billing_mode === 'matrix' && matrixPrice > 0) { | |||
| return <BillingDetailSummary other={other} record={record} t={t} />; | |||
| } | |||
| let content = other?.claude | |||
| ? renderModelPriceSimple( | |||
| other.model_ratio, | |||
| @@ -830,14 +906,14 @@ export const getLogsColumns = ({ | |||
| 'openai', | |||
| ); | |||
| return ( | |||
| <Typography.Paragraph | |||
| ellipsis={{ | |||
| rows: 3, | |||
| }} | |||
| style={{ maxWidth: 240, whiteSpace: 'pre-line' }} | |||
| > | |||
| {content} | |||
| </Typography.Paragraph> | |||
| <Typography.Paragraph | |||
| ellipsis={{ | |||
| rows: 3, | |||
| }} | |||
| style={{ maxWidth: 240, whiteSpace: 'pre-line' }} | |||
| > | |||
| {content} | |||
| </Typography.Paragraph> | |||
| ); | |||
| }, | |||
| }, | |||
| @@ -18,13 +18,14 @@ For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React, { useMemo } from 'react'; | |||
| import { Empty, Descriptions } from '@douyinfe/semi-ui'; | |||
| import { Empty } from '@douyinfe/semi-ui'; | |||
| import CardTable from '../../common/ui/CardTable'; | |||
| import { | |||
| IllustrationNoResult, | |||
| IllustrationNoResultDark, | |||
| } from '@douyinfe/semi-illustrations'; | |||
| import { getLogsColumns } from './UsageLogsColumnDefs'; | |||
| import UsageLogExpandedDetail from './UsageLogExpandedDetail'; | |||
| const LogsTable = (logsData) => { | |||
| const { | |||
| @@ -82,7 +83,7 @@ const LogsTable = (logsData) => { | |||
| }, [compactMode, visibleColumnsList]); | |||
| const expandRowRender = (record, index) => { | |||
| return <Descriptions data={expandData[record.key]} />; | |||
| return <UsageLogExpandedDetail detail={expandData[record.key]} />; | |||
| }; | |||
| return ( | |||
| @@ -92,7 +93,9 @@ const LogsTable = (logsData) => { | |||
| expandedRowRender: expandRowRender, | |||
| expandRowByClick: true, | |||
| rowExpandable: (record) => | |||
| expandData[record.key] && expandData[record.key].length > 0, | |||
| expandData[record.key]?.sections?.length > 0 || | |||
| expandData[record.key]?.items?.length > 0 || | |||
| expandData[record.key]?.length > 0, | |||
| })} | |||
| dataSource={logs} | |||
| rowKey='key' | |||
| @@ -693,6 +693,26 @@ export const summarizeMatrixPricing = ({ | |||
| return { | |||
| price: min === max ? minText : `${minText} - ${maxText}`, | |||
| unit: getMatrixPricingUnit(cfg, tokenUnit), | |||
| details: (cfg.table || []).slice(0, 3).map((row) => { | |||
| const dimensionText = Object.entries(row) | |||
| .filter(([key]) => key !== 'price' && key !== 'source') | |||
| .map( | |||
| ([key, value]) => `${key}=${value === '*' ? '任意' : String(value)}`, | |||
| ) | |||
| .join(' · '); | |||
| return { | |||
| dimensionText, | |||
| formattedPrice: formatMatrixPrice({ | |||
| price: Number(row.price || 0) * usedGroupRatio, | |||
| pricingConfig: cfg, | |||
| tokenUnit, | |||
| displayPrice, | |||
| currency, | |||
| precision, | |||
| }), | |||
| }; | |||
| }), | |||
| hiddenDetailCount: Math.max(0, (cfg.table || []).length - 3), | |||
| }; | |||
| }; | |||
| @@ -706,8 +726,9 @@ export const calculateModelPrice = ({ | |||
| precision = 4, | |||
| }) => { | |||
| // 1. 选择实际使用的分组 | |||
| const ratios = groupRatio || {}; | |||
| let usedGroup = selectedGroup; | |||
| let usedGroupRatio = groupRatio[selectedGroup]; | |||
| let usedGroupRatio = ratios[selectedGroup]; | |||
| if (selectedGroup === 'all' || usedGroupRatio === undefined) { | |||
| // 在模型可用分组中选择倍率最小的分组,若无则使用 1 | |||
| @@ -717,7 +738,7 @@ export const calculateModelPrice = ({ | |||
| record.enable_groups.length > 0 | |||
| ) { | |||
| record.enable_groups.forEach((g) => { | |||
| const r = groupRatio[g]; | |||
| const r = ratios[g]; | |||
| if (r !== undefined && r < minRatio) { | |||
| minRatio = r; | |||
| usedGroup = g; | |||
| @@ -839,7 +860,8 @@ export const formatPriceInfo = (priceData, t) => { | |||
| if (priceData.isPerToken) { | |||
| return ( | |||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | |||
| {t('输入')} {priceData.inputPrice} / {t('输出')} {priceData.completionPrice} / {priceData.unitLabel} tokens | |||
| {t('输入')} {priceData.inputPrice} / {t('输出')}{' '} | |||
| {priceData.completionPrice} / {priceData.unitLabel} tokens | |||
| </span> | |||
| ); | |||
| } | |||
| @@ -15,8 +15,12 @@ globalThis.localStorage = { | |||
| getItem: () => null, | |||
| }; | |||
| const { calculateModelPrice, formatMatrixPrice, hasMatrixPricing } = | |||
| await import('./utils.jsx'); | |||
| const { | |||
| calculateModelPrice, | |||
| formatMatrixPrice, | |||
| hasMatrixPricing, | |||
| summarizeMatrixPricing, | |||
| } = await import('./utils.jsx'); | |||
| const displayPrice = (value) => `$${Number(value).toFixed(3)}`; | |||
| @@ -27,8 +31,18 @@ describe('model pricing helpers', () => { | |||
| pricing_config: { | |||
| billing_unit: 'per_1m_tokens', | |||
| table: [ | |||
| { resolution: '480p', video_input: true, price: 28, source: 'manual' }, | |||
| { resolution: '480p', video_input: false, price: 46, source: 'manual' }, | |||
| { | |||
| resolution: '480p', | |||
| video_input: true, | |||
| price: 28, | |||
| source: 'manual', | |||
| }, | |||
| { | |||
| resolution: '480p', | |||
| video_input: false, | |||
| price: 46, | |||
| source: 'manual', | |||
| }, | |||
| ], | |||
| }, | |||
| }; | |||
| @@ -50,6 +64,24 @@ describe('model pricing helpers', () => { | |||
| unit: '1M tokens', | |||
| usedGroupRatio: 0.5, | |||
| }); | |||
| expect( | |||
| summarizeMatrixPricing({ | |||
| record, | |||
| usedGroupRatio: 0.5, | |||
| tokenUnit: 'M', | |||
| displayPrice, | |||
| currency: 'USD', | |||
| }).details, | |||
| ).toEqual([ | |||
| { | |||
| dimensionText: 'resolution=480p · video_input=true', | |||
| formattedPrice: '$14.0000', | |||
| }, | |||
| { | |||
| dimensionText: 'resolution=480p · video_input=false', | |||
| formattedPrice: '$23.0000', | |||
| }, | |||
| ]); | |||
| }); | |||
| it('formats per 1K matrix token price from per 1M price', () => { | |||
| @@ -63,4 +95,28 @@ describe('model pricing helpers', () => { | |||
| }), | |||
| ).toBe('$0.0280'); | |||
| }); | |||
| it('falls back to group ratio 1 when group ratios are not provided', () => { | |||
| const record = { | |||
| enable_groups: ['default'], | |||
| pricing_config: { | |||
| billing_unit: 'per_1m_tokens', | |||
| table: [{ resolution: '720p', price: 46, source: 'manual' }], | |||
| }, | |||
| }; | |||
| expect( | |||
| calculateModelPrice({ | |||
| record, | |||
| selectedGroup: 'default', | |||
| tokenUnit: 'M', | |||
| displayPrice, | |||
| currency: 'USD', | |||
| }), | |||
| ).toMatchObject({ | |||
| isMatrix: true, | |||
| price: '$46.0000', | |||
| usedGroupRatio: 1, | |||
| }); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,168 @@ | |||
| const MATRIX_BILLING_MODE = 'matrix'; | |||
| const PHASE_PRECONSUME = 'preconsume'; | |||
| const PHASE_SETTLEMENT = 'settlement'; | |||
| const PHASE_REFUND = 'refund'; | |||
| const parseOther = (other) => { | |||
| if (!other) return {}; | |||
| if (typeof other === 'object') return other; | |||
| try { | |||
| const parsed = JSON.parse(other); | |||
| return parsed && typeof parsed === 'object' ? parsed : {}; | |||
| } catch { | |||
| return {}; | |||
| } | |||
| }; | |||
| const numberOrFallback = (value, fallback) => { | |||
| const numberValue = Number(value); | |||
| return Number.isFinite(numberValue) ? numberValue : fallback; | |||
| }; | |||
| const getMergeKey = (log) => { | |||
| const other = parseOther(log?.other); | |||
| if (other.billing_mode !== MATRIX_BILLING_MODE) return ''; | |||
| if (!other.task_id || !other.billing_phase) return ''; | |||
| if ( | |||
| ![PHASE_PRECONSUME, PHASE_SETTLEMENT, PHASE_REFUND].includes( | |||
| other.billing_phase, | |||
| ) | |||
| ) { | |||
| return ''; | |||
| } | |||
| return other.task_id; | |||
| }; | |||
| const summarizeBillingLog = (log) => { | |||
| const other = parseOther(log.other); | |||
| return { | |||
| id: log.id, | |||
| type: log.type, | |||
| quota: log.quota, | |||
| created_at: log.created_at, | |||
| billing_phase: other.billing_phase, | |||
| pre_consumed_quota: other.pre_consumed_quota, | |||
| actual_quota: other.actual_quota, | |||
| quota_delta: other.quota_delta, | |||
| }; | |||
| }; | |||
| const pickMergedRecord = (group) => { | |||
| const refund = group.find( | |||
| (item) => item.other.billing_phase === PHASE_REFUND, | |||
| ); | |||
| if (refund) { | |||
| return { | |||
| item: refund, | |||
| status: 'refunded', | |||
| type: 6, | |||
| quota: refund.log.quota, | |||
| }; | |||
| } | |||
| const settlement = group.find( | |||
| (item) => item.other.billing_phase === PHASE_SETTLEMENT, | |||
| ); | |||
| if (settlement) { | |||
| const preConsumed = numberOrFallback( | |||
| settlement.other.pre_consumed_quota, | |||
| 0, | |||
| ); | |||
| const actualQuota = numberOrFallback( | |||
| settlement.other.actual_quota, | |||
| preConsumed + numberOrFallback(settlement.other.quota_delta, 0), | |||
| ); | |||
| return { | |||
| item: settlement, | |||
| status: 'settled', | |||
| type: 2, | |||
| quota: actualQuota, | |||
| }; | |||
| } | |||
| const preconsume = | |||
| group.find((item) => item.other.billing_phase === PHASE_PRECONSUME) || | |||
| group[0]; | |||
| return { | |||
| item: preconsume, | |||
| status: 'pending', | |||
| type: 2, | |||
| quota: preconsume.log.quota, | |||
| }; | |||
| }; | |||
| const buildMergedRecord = (taskId, group) => { | |||
| const picked = pickMergedRecord(group); | |||
| const preconsume = group.find( | |||
| (item) => item.other.billing_phase === PHASE_PRECONSUME, | |||
| ); | |||
| const settlement = group.find( | |||
| (item) => item.other.billing_phase === PHASE_SETTLEMENT, | |||
| ); | |||
| const refund = group.find( | |||
| (item) => item.other.billing_phase === PHASE_REFUND, | |||
| ); | |||
| const other = { | |||
| ...picked.item.other, | |||
| task_id: taskId, | |||
| task_billing_merged: true, | |||
| task_billing_status: picked.status, | |||
| task_billing_logs: group.map((item) => summarizeBillingLog(item.log)), | |||
| }; | |||
| if (preconsume) { | |||
| other.pre_consumed_quota = numberOrFallback( | |||
| preconsume.other.pre_consumed_quota, | |||
| preconsume.log.quota, | |||
| ); | |||
| } | |||
| if (settlement) { | |||
| other.actual_quota = numberOrFallback( | |||
| settlement.other.actual_quota, | |||
| picked.quota, | |||
| ); | |||
| other.quota_delta = numberOrFallback(settlement.other.quota_delta, 0); | |||
| } | |||
| if (refund) { | |||
| other.refund_quota = refund.log.quota; | |||
| } | |||
| return { | |||
| ...picked.item.log, | |||
| key: `task-billing-${taskId}`, | |||
| type: picked.type, | |||
| quota: picked.quota, | |||
| other: JSON.stringify(other), | |||
| }; | |||
| }; | |||
| export const mergeTaskBillingLogs = (logs = []) => { | |||
| const groups = new Map(); | |||
| const passthrough = []; | |||
| logs.forEach((log, index) => { | |||
| const taskId = getMergeKey(log); | |||
| if (!taskId) { | |||
| passthrough.push({ index, log }); | |||
| return; | |||
| } | |||
| if (!groups.has(taskId)) { | |||
| groups.set(taskId, { firstIndex: index, items: [] }); | |||
| } | |||
| groups.get(taskId).items.push({ | |||
| log, | |||
| other: parseOther(log.other), | |||
| index, | |||
| }); | |||
| }); | |||
| const merged = Array.from(groups.entries()).map(([taskId, group]) => ({ | |||
| index: group.firstIndex, | |||
| log: buildMergedRecord(taskId, group.items), | |||
| })); | |||
| return [...passthrough, ...merged] | |||
| .sort((a, b) => a.index - b.index) | |||
| .map((item) => item.log); | |||
| }; | |||
| @@ -0,0 +1,143 @@ | |||
| import { describe, expect, it } from 'vitest'; | |||
| import { mergeTaskBillingLogs } from './taskBillingLogMerge.js'; | |||
| const withOther = (log, other) => ({ | |||
| ...log, | |||
| other: JSON.stringify(other), | |||
| }); | |||
| const readOther = (log) => JSON.parse(log.other); | |||
| describe('mergeTaskBillingLogs', () => { | |||
| it('merges matrix preconsume and settlement logs into one final consumption row', () => { | |||
| const logs = [ | |||
| withOther( | |||
| { id: 11, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| billing_phase: 'preconsume', | |||
| task_id: 'task_1', | |||
| pre_consumed_quota: 1, | |||
| }, | |||
| ), | |||
| withOther( | |||
| { id: 12, type: 2, quota: 36, created_at: 200, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| billing_phase: 'settlement', | |||
| task_id: 'task_1', | |||
| pre_consumed_quota: 1, | |||
| actual_quota: 37, | |||
| quota_delta: 36, | |||
| }, | |||
| ), | |||
| ]; | |||
| const merged = mergeTaskBillingLogs(logs); | |||
| expect(merged).toHaveLength(1); | |||
| expect(merged[0]).toMatchObject({ | |||
| id: 12, | |||
| key: 'task-billing-task_1', | |||
| type: 2, | |||
| quota: 37, | |||
| }); | |||
| expect(readOther(merged[0])).toMatchObject({ | |||
| billing_mode: 'matrix', | |||
| billing_phase: 'settlement', | |||
| task_id: 'task_1', | |||
| pre_consumed_quota: 1, | |||
| actual_quota: 37, | |||
| quota_delta: 36, | |||
| task_billing_merged: true, | |||
| task_billing_status: 'settled', | |||
| }); | |||
| expect(readOther(merged[0]).task_billing_logs).toHaveLength(2); | |||
| }); | |||
| it('keeps pending preconsume rows when no settlement is present', () => { | |||
| const logs = [ | |||
| withOther( | |||
| { id: 21, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| billing_phase: 'preconsume', | |||
| task_id: 'task_2', | |||
| pre_consumed_quota: 1, | |||
| }, | |||
| ), | |||
| ]; | |||
| const merged = mergeTaskBillingLogs(logs); | |||
| expect(merged).toHaveLength(1); | |||
| expect(merged[0]).toMatchObject({ | |||
| id: 21, | |||
| key: 'task-billing-task_2', | |||
| type: 2, | |||
| quota: 1, | |||
| }); | |||
| expect(readOther(merged[0])).toMatchObject({ | |||
| task_billing_merged: true, | |||
| task_billing_status: 'pending', | |||
| }); | |||
| }); | |||
| it('does not merge old matrix logs that do not have billing phase metadata', () => { | |||
| const logs = [ | |||
| withOther( | |||
| { id: 31, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| task_id: 'task_3', | |||
| }, | |||
| ), | |||
| withOther( | |||
| { id: 32, type: 2, quota: 9, created_at: 200, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| task_id: 'task_3', | |||
| }, | |||
| ), | |||
| ]; | |||
| expect(mergeTaskBillingLogs(logs)).toHaveLength(2); | |||
| }); | |||
| it('merges preconsume and refund logs into one refund row', () => { | |||
| const logs = [ | |||
| withOther( | |||
| { id: 41, type: 2, quota: 20, created_at: 100, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| billing_phase: 'preconsume', | |||
| task_id: 'task_4', | |||
| pre_consumed_quota: 20, | |||
| }, | |||
| ), | |||
| withOther( | |||
| { id: 42, type: 6, quota: 20, created_at: 200, model_name: 'seedance' }, | |||
| { | |||
| billing_mode: 'matrix', | |||
| billing_phase: 'refund', | |||
| task_id: 'task_4', | |||
| pre_consumed_quota: 20, | |||
| }, | |||
| ), | |||
| ]; | |||
| const merged = mergeTaskBillingLogs(logs); | |||
| expect(merged).toHaveLength(1); | |||
| expect(merged[0]).toMatchObject({ | |||
| id: 42, | |||
| key: 'task-billing-task_4', | |||
| type: 6, | |||
| quota: 20, | |||
| }); | |||
| expect(readOther(merged[0])).toMatchObject({ | |||
| task_billing_merged: true, | |||
| task_billing_status: 'refunded', | |||
| }); | |||
| }); | |||
| }); | |||
| @@ -38,6 +38,7 @@ import { | |||
| } from '../../helpers'; | |||
| import { ITEMS_PER_PAGE } from '../../constants'; | |||
| import { useTableCompactMode } from '../common/useTableCompactMode'; | |||
| import { mergeTaskBillingLogs } from './taskBillingLogMerge'; | |||
| export const useLogsData = () => { | |||
| const { t } = useTranslation(); | |||
| @@ -248,8 +249,12 @@ export const useLogsData = () => { | |||
| logType: formLogType, | |||
| } = getFormValues(); | |||
| const currentLogType = formLogType !== undefined ? formLogType : logType; | |||
| let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; | |||
| let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; | |||
| let localStartTimestamp = start_timestamp | |||
| ? Date.parse(start_timestamp) / 1000 | |||
| : 0; | |||
| let localEndTimestamp = end_timestamp | |||
| ? Date.parse(end_timestamp) / 1000 | |||
| : 0; | |||
| const params = new URLSearchParams({ type: currentLogType }); | |||
| if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); | |||
| if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); | |||
| @@ -278,8 +283,12 @@ export const useLogsData = () => { | |||
| logType: formLogType, | |||
| } = getFormValues(); | |||
| const currentLogType = formLogType !== undefined ? formLogType : logType; | |||
| let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; | |||
| let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; | |||
| let localStartTimestamp = start_timestamp | |||
| ? Date.parse(start_timestamp) / 1000 | |||
| : 0; | |||
| let localEndTimestamp = end_timestamp | |||
| ? Date.parse(end_timestamp) / 1000 | |||
| : 0; | |||
| const params = new URLSearchParams({ type: currentLogType }); | |||
| if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); | |||
| if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); | |||
| @@ -340,6 +349,208 @@ export const useLogsData = () => { | |||
| // Format logs data | |||
| const setLogsFormat = (logs) => { | |||
| logs = mergeTaskBillingLogs(logs || []); | |||
| const pushDetail = (sections, sectionKey, item) => { | |||
| if ( | |||
| item?.value === undefined || | |||
| item?.value === null || | |||
| item?.value === '' | |||
| ) { | |||
| return; | |||
| } | |||
| if (!sections[sectionKey]) { | |||
| sections[sectionKey] = []; | |||
| } | |||
| sections[sectionKey].push(item); | |||
| }; | |||
| const compactJsonBlock = (value) => ( | |||
| <pre | |||
| style={{ | |||
| margin: 0, | |||
| maxHeight: 220, | |||
| overflow: 'auto', | |||
| whiteSpace: 'pre-wrap', | |||
| wordBreak: 'break-word', | |||
| lineHeight: 1.6, | |||
| fontSize: 12, | |||
| padding: '8px 10px', | |||
| border: '1px solid var(--semi-color-border)', | |||
| borderRadius: 6, | |||
| background: 'var(--semi-color-fill-0)', | |||
| }} | |||
| > | |||
| {typeof value === 'string' ? value : JSON.stringify(value, null, 2)} | |||
| </pre> | |||
| ); | |||
| const buildSectionedExpandData = (flatItems, log, other) => { | |||
| const sections = {}; | |||
| const knownKeys = new Set(); | |||
| const addKnown = (sectionKey, key, value) => { | |||
| knownKeys.add(key); | |||
| pushDetail(sections, sectionKey, { key, value }); | |||
| }; | |||
| addKnown('basic', t('时间'), log.timestamp2string); | |||
| addKnown('basic', t('模型'), log.model_name); | |||
| addKnown('basic', t('令牌'), log.token_name); | |||
| addKnown('basic', t('分组'), log.group); | |||
| if (isAdminUser) { | |||
| addKnown('basic', t('用户'), log.username); | |||
| } | |||
| if (other?.task_id) { | |||
| addKnown('basic', t('任务ID'), other.task_id); | |||
| } | |||
| if (other?.request_path) { | |||
| addKnown('basic', t('请求路径'), other.request_path); | |||
| } | |||
| addKnown('billing', t('花费'), renderQuota(log.quota, 6)); | |||
| addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); | |||
| addKnown('billing', t('Completion Tokens'), log.completion_tokens); | |||
| if (other?.model_price !== undefined) { | |||
| const quotaPerUnit = parseFloat( | |||
| localStorage.getItem('quota_per_unit') || '500000', | |||
| ); | |||
| addKnown( | |||
| 'billing', | |||
| t('模型价格'), | |||
| renderQuota(other.model_price * quotaPerUnit, 6), | |||
| ); | |||
| } | |||
| if (other?.token_unit_price_usd !== undefined) { | |||
| const quotaPerUnit = parseFloat( | |||
| localStorage.getItem('quota_per_unit') || '500000', | |||
| ); | |||
| addKnown( | |||
| 'billing', | |||
| t('Token 单价'), | |||
| renderQuota(other.token_unit_price_usd * quotaPerUnit, 6), | |||
| ); | |||
| } | |||
| if (other?.group_ratio !== undefined) { | |||
| addKnown('billing', t('分组倍率'), other.group_ratio); | |||
| } | |||
| if (other?.user_group_ratio !== undefined) { | |||
| addKnown('billing', t('用户分组倍率'), other.user_group_ratio); | |||
| } | |||
| if (other?.billing_mode) { | |||
| addKnown('billing', t('计费模式'), other.billing_mode); | |||
| } | |||
| if (other?.billing_unit) { | |||
| addKnown('billing', t('计费单位'), other.billing_unit); | |||
| } | |||
| if (other?.billing_phase) { | |||
| addKnown('billing', t('计费阶段'), other.billing_phase); | |||
| } | |||
| if (other?.pre_consumed_quota !== undefined) { | |||
| addKnown( | |||
| 'billing', | |||
| t('预扣金额'), | |||
| renderQuota(other.pre_consumed_quota, 6), | |||
| ); | |||
| } | |||
| if (other?.actual_quota !== undefined) { | |||
| addKnown('billing', t('实际消费'), renderQuota(other.actual_quota, 6)); | |||
| } | |||
| if (other?.quota_delta !== undefined) { | |||
| const delta = Number(other.quota_delta); | |||
| addKnown( | |||
| 'billing', | |||
| t('结算差额'), | |||
| `${delta > 0 ? '+' : ''}${renderQuota(delta, 6)}`, | |||
| ); | |||
| } | |||
| if (other?.refund_quota !== undefined) { | |||
| addKnown('billing', t('退还金额'), renderQuota(other.refund_quota, 6)); | |||
| } | |||
| if (other?.total_tokens !== undefined) { | |||
| addKnown('billing', t('总 Tokens'), other.total_tokens); | |||
| } | |||
| if (other?.pricing_snapshot) { | |||
| const snapshot = other.pricing_snapshot || {}; | |||
| const excluded = new Set([ | |||
| 'price', | |||
| 'billing_unit', | |||
| 'pricing_mode', | |||
| 'fallback_strategy', | |||
| 'total_tokens', | |||
| ]); | |||
| Object.entries(snapshot).forEach(([key, value]) => { | |||
| if (!excluded.has(key)) { | |||
| addKnown('matrix', key, String(value)); | |||
| } | |||
| }); | |||
| addKnown('matrix', t('命中价格 USD'), snapshot.price); | |||
| addKnown('matrix', t('命中模式'), snapshot.pricing_mode); | |||
| addKnown('matrix', 'Fallback', snapshot.fallback_strategy); | |||
| } | |||
| if (log.request_id) { | |||
| addKnown('context', 'Request ID', log.request_id); | |||
| } | |||
| if (isAdminUser && log.chat_id) { | |||
| addKnown('context', 'Chat ID', log.chat_id); | |||
| } | |||
| if (isAdminUser && log.upstream_id) { | |||
| addKnown('context', 'Upstream ID', log.upstream_id); | |||
| } | |||
| if (other?.request_conversion) { | |||
| addKnown( | |||
| 'context', | |||
| t('请求转换'), | |||
| requestConversionDisplayValue(other.request_conversion), | |||
| ); | |||
| } | |||
| if (other?.is_model_mapped) { | |||
| addKnown('context', t('请求并计费模型'), log.model_name); | |||
| addKnown('context', t('实际模型'), other.upstream_model_name); | |||
| } | |||
| if (other?.reason) { | |||
| addKnown( | |||
| 'state', | |||
| t('原因'), | |||
| <div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}> | |||
| {other.reason} | |||
| </div>, | |||
| ); | |||
| } | |||
| if (other?.reject_reason) { | |||
| addKnown('state', t('拦截原因'), other.reject_reason); | |||
| } | |||
| if (other?.task_billing_status) { | |||
| const statusMap = { | |||
| pending: t('待结算'), | |||
| settled: t('已结算'), | |||
| refunded: t('已退还'), | |||
| }; | |||
| addKnown( | |||
| 'state', | |||
| t('任务账单状态'), | |||
| statusMap[other.task_billing_status] || other.task_billing_status, | |||
| ); | |||
| } | |||
| const sectionOrder = [ | |||
| ['basic', t('基础信息')], | |||
| ['billing', t('计费信息')], | |||
| ['matrix', t('矩阵计费')], | |||
| ['context', t('请求上下文')], | |||
| ['state', t('状态与原因')], | |||
| ]; | |||
| return { | |||
| items: flatItems, | |||
| sections: sectionOrder | |||
| .filter(([key]) => sections[key]?.length) | |||
| .map(([key, title]) => ({ title, items: sections[key] })), | |||
| }; | |||
| }; | |||
| const requestConversionDisplayValue = (conversionChain) => { | |||
| const chain = Array.isArray(conversionChain) | |||
| ? conversionChain.filter(Boolean) | |||
| @@ -357,7 +568,10 @@ export const useLogsData = () => { | |||
| let other = getLogOther(logs[i].other); | |||
| let expandDataLocal = []; | |||
| if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) { | |||
| if ( | |||
| isAdminUser && | |||
| (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6) | |||
| ) { | |||
| expandDataLocal.push({ | |||
| key: t('渠道信息'), | |||
| value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`, | |||
| @@ -571,13 +785,63 @@ export const useLogsData = () => { | |||
| expandDataLocal.push({ | |||
| key: t('失败原因'), | |||
| value: ( | |||
| <div style={{ maxWidth: 600, whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: 1.6 }}> | |||
| <div | |||
| style={{ | |||
| maxWidth: 600, | |||
| whiteSpace: 'normal', | |||
| wordBreak: 'break-word', | |||
| lineHeight: 1.6, | |||
| }} | |||
| > | |||
| {other.reason} | |||
| </div> | |||
| ), | |||
| }); | |||
| } | |||
| } | |||
| if (other?.task_billing_merged) { | |||
| const statusMap = { | |||
| pending: t('待结算'), | |||
| settled: t('已结算'), | |||
| refunded: t('已退还'), | |||
| }; | |||
| expandDataLocal.push({ | |||
| key: t('任务账单状态'), | |||
| value: | |||
| statusMap[other.task_billing_status] || other.task_billing_status, | |||
| }); | |||
| if (other?.task_id && logs[i].type !== 6) { | |||
| expandDataLocal.push({ | |||
| key: t('任务ID'), | |||
| value: other.task_id, | |||
| }); | |||
| } | |||
| if (other?.pre_consumed_quota !== undefined) { | |||
| expandDataLocal.push({ | |||
| key: t('预扣金额'), | |||
| value: renderQuota(other.pre_consumed_quota, 6), | |||
| }); | |||
| } | |||
| if (other?.actual_quota !== undefined) { | |||
| expandDataLocal.push({ | |||
| key: t('实际消费'), | |||
| value: renderQuota(other.actual_quota, 6), | |||
| }); | |||
| } | |||
| if (other?.quota_delta !== undefined) { | |||
| const delta = Number(other.quota_delta); | |||
| expandDataLocal.push({ | |||
| key: t('结算差额'), | |||
| value: `${delta > 0 ? '+' : ''}${renderQuota(delta, 6)}`, | |||
| }); | |||
| } | |||
| if (other?.refund_quota !== undefined) { | |||
| expandDataLocal.push({ | |||
| key: t('退还金额'), | |||
| value: renderQuota(other.refund_quota, 6), | |||
| }); | |||
| } | |||
| } | |||
| if (other?.request_path) { | |||
| expandDataLocal.push({ | |||
| key: t('请求路径'), | |||
| @@ -651,7 +915,11 @@ export const useLogsData = () => { | |||
| value: localCountMode, | |||
| }); | |||
| } | |||
| expandDatesLocal[logs[i].key] = expandDataLocal; | |||
| expandDatesLocal[logs[i].key] = buildSectionedExpandData( | |||
| expandDataLocal, | |||
| logs[i], | |||
| other, | |||
| ); | |||
| } | |||
| setExpandData(expandDatesLocal); | |||
| @@ -684,8 +952,12 @@ export const useLogsData = () => { | |||
| ? formLogType | |||
| : logType; | |||
| let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; | |||
| let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; | |||
| let localStartTimestamp = start_timestamp | |||
| ? Date.parse(start_timestamp) / 1000 | |||
| : 0; | |||
| let localEndTimestamp = end_timestamp | |||
| ? Date.parse(end_timestamp) / 1000 | |||
| : 0; | |||
| const params = new URLSearchParams({ | |||
| p: startIdx, | |||
| @@ -778,9 +1050,14 @@ export const useLogsData = () => { | |||
| // Check if any record has expandable content | |||
| const hasExpandableRows = () => { | |||
| return logs.some( | |||
| (log) => expandData[log.key] && expandData[log.key].length > 0, | |||
| ); | |||
| return logs.some((log) => { | |||
| const detail = expandData[log.key]; | |||
| return ( | |||
| detail?.sections?.length > 0 || | |||
| detail?.items?.length > 0 || | |||
| detail?.length > 0 | |||
| ); | |||
| }); | |||
| }; | |||
| return { | |||