Sfoglia il codice sorgente

feat: 使用日志计费详情展开与 completion_tokens 修复

前端新增 UsageLogExpandedDetail 点击展开面板和 BillingDetailSummary
hover 计费详情,后端修复 per_1m_tokens 任务结算日志的 completion_tokens
从零值改为写入 total_tokens 实际值。新增 taskBillingLogMerge 合并
任务预扣/结算日志。

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 2 settimane fa
parent
commit
84d364d76a
18 ha cambiato i file con 1585 aggiunte e 88 eliminazioni
  1. +5
    -1
      controller/relay.go
  2. +23
    -21
      model/log.go
  3. +21
    -1
      relay/relay_task.go
  4. +19
    -0
      relay/relay_task_test.go
  5. +70
    -14
      service/task_billing.go
  6. +98
    -7
      service/task_billing_test.go
  7. +24
    -2
      web/src/components/table/task-logs/modals/ContentModal.jsx
  8. +207
    -0
      web/src/components/table/usage-logs/BillingDetailSummary.jsx
  9. +109
    -0
      web/src/components/table/usage-logs/BillingDetailSummary.test.jsx
  10. +142
    -0
      web/src/components/table/usage-logs/BillingDetailSummaryData.js
  11. +80
    -0
      web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx
  12. +96
    -20
      web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  13. +6
    -3
      web/src/components/table/usage-logs/UsageLogsTable.jsx
  14. +25
    -3
      web/src/helpers/utils.jsx
  15. +60
    -4
      web/src/helpers/utils.test.js
  16. +168
    -0
      web/src/hooks/usage-logs/taskBillingLogMerge.js
  17. +143
    -0
      web/src/hooks/usage-logs/taskBillingLogMerge.test.js
  18. +289
    -12
      web/src/hooks/usage-logs/useUsageLogsData.jsx

+ 5
- 1
controller/relay.go Vedi File

@@ -607,7 +607,11 @@ func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingCont
bc.BillingUnit = decision.BillingUnit bc.BillingUnit = decision.BillingUnit
bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD
bc.PerCallBilling = decision.PerCallBilling 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.GroupRatio = decision.GroupRatioInfo.GroupRatio
bc.OtherRatios = types.CloneRatios(decision.OtherRatios) bc.OtherRatios = types.CloneRatios(decision.OtherRatios)
} }


+ 23
- 21
model/log.go Vedi File

@@ -211,15 +211,16 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
} }


type RecordTaskBillingLogParams struct { 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) { func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
@@ -234,18 +235,19 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
} }
} }
log := &Log{ 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 err := LOG_DB.Create(log).Error
if err != nil { if err != nil {


+ 21
- 1
relay/relay_task.go Vedi File

@@ -521,6 +521,26 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto {
Progress: task.Progress, Progress: task.Progress,
Properties: task.Properties, Properties: task.Properties,
Username: task.Username, 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
}

+ 19
- 0
relay/relay_task_test.go Vedi File

@@ -200,6 +200,25 @@ func TestTaskModel2DtoDoesNotExposeUpstreamRequestSnapshot(t *testing.T) {
require.NotContains(t, string(data), "hidden") 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 { func newRelayTaskRemixContext(t *testing.T, originTaskID string) *gin.Context {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)


+ 70
- 14
service/task_billing.go Vedi File

@@ -2,6 +2,7 @@ package service


import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"


@@ -11,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "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["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice other["model_price"] = info.PriceData.ModelPrice
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio 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 { if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
} }
@@ -146,6 +163,35 @@ func taskModelName(task *model.Task) string {
return task.Properties.OriginModelName 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 统一的任务失败退款逻辑。 // RefundTaskQuota 统一的任务失败退款逻辑。
// 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。 // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { 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. 记录日志 // 3. 记录日志
other := taskBillingOther(task) other := taskBillingOther(task)
other["task_id"] = task.TaskID other["task_id"] = task.TaskID
other["billing_phase"] = "refund"
other["reason"] = reason other["reason"] = reason
other["pre_consumed_quota"] = quota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId, UserId: task.UserId,
LogType: model.LogTypeRefund, LogType: model.LogTypeRefund,
@@ -193,6 +241,18 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
if quotaDelta == 0 { if quotaDelta == 0 {
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)", logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)",
task.TaskID, logger.LogQuota(actualQuota), reason)) 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 return
} }


@@ -226,21 +286,17 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
logType = model.LogTypeRefund logType = model.LogTypeRefund
logQuota = -quotaDelta 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{ 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),
}) })
} }




+ 98
- 7
service/task_billing_test.go Vedi File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"testing" "testing"
"time" "time"
@@ -11,6 +12,8 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common" 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/glebarez/sqlite"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -125,8 +128,8 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc
SubscriptionId: subscriptionId, SubscriptionId: subscriptionId,
TokenId: tokenId, TokenId: tokenId,
BillingContext: &model.TaskBillingContext{ BillingContext: &model.TaskBillingContext{
ModelPrice: 0.02,
GroupRatio: 1.0,
ModelPrice: 0.02,
GroupRatio: 1.0,
OriginModelName: "test-model", OriginModelName: "test-model",
}, },
}, },
@@ -175,6 +178,14 @@ func getLastLog(t *testing.T) *model.Log {
return &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 { func countLogs(t *testing.T) int64 {
t.Helper() t.Helper()
var count int64 var count int64
@@ -217,6 +228,61 @@ func TestRefundTaskQuota_Wallet(t *testing.T) {
assert.Equal(t, "test-model", log.ModelName) 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) { func TestRefundTaskQuota_Subscription(t *testing.T) {
truncate(t) truncate(t)
ctx := context.Background() ctx := context.Background()
@@ -244,6 +310,11 @@ func TestRefundTaskQuota_Subscription(t *testing.T) {
log := getLastLog(t) log := getLastLog(t)
require.NotNil(t, log) require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type) 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) { func TestRefundTaskQuota_ZeroQuota(t *testing.T) {
@@ -322,6 +393,13 @@ func TestRecalculate_PositiveDelta(t *testing.T) {
require.NotNil(t, log) require.NotNil(t, log)
assert.Equal(t, model.LogTypeConsume, log.Type) assert.Equal(t, model.LogTypeConsume, log.Type)
assert.Equal(t, actualQuota-preConsumed, log.Quota) 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) { func TestRecalculate_NegativeDelta(t *testing.T) {
@@ -363,18 +441,29 @@ func TestRecalculate_ZeroDelta(t *testing.T) {


const userID = 12 const userID = 12
const initQuota, preConsumed = 10000, 3000 const initQuota, preConsumed = 10000, 3000
const totalTokens = 48400


seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)


task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0) 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") RecalculateTaskQuota(ctx, task, preConsumed, "exact match")


// No change to user quota // No change to user quota
assert.Equal(t, initQuota, getUserQuota(t, userID)) 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) { func TestRecalculate_ActualQuotaZero(t *testing.T) {
@@ -615,9 +704,11 @@ type mockAdaptor struct {
adjustReturn int 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 { func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
return m.adjustReturn return m.adjustReturn
} }


+ 24
- 2
web/src/components/table/task-logs/modals/ContentModal.jsx Vedi File

@@ -162,7 +162,7 @@ const ContentModal = ({
height: isVideo ? '70vh' : '400px', height: isVideo ? '70vh' : '400px',
maxHeight: '80vh', maxHeight: '80vh',
overflow: 'auto', overflow: 'auto',
padding: isVideo && videoError ? '0' : '24px',
padding: isVideo && videoError ? '0' : '18px 20px 20px',
}} }}
width={isVideo ? '90vw' : 800} width={isVideo ? '90vw' : 800}
style={isVideo ? { maxWidth: 960 } : undefined} style={isVideo ? { maxWidth: 960 } : undefined}
@@ -170,7 +170,29 @@ const ContentModal = ({
{isVideo ? ( {isVideo ? (
renderVideoContent() 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> </Modal>
); );


+ 207
- 0
web/src/components/table/usage-logs/BillingDetailSummary.jsx Vedi File

@@ -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;

+ 109
- 0
web/src/components/table/usage-logs/BillingDetailSummary.test.jsx Vedi File

@@ -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',
});
});
});

+ 142
- 0
web/src/components/table/usage-logs/BillingDetailSummaryData.js Vedi File

@@ -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,
};
};

+ 80
- 0
web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx Vedi File

@@ -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;

+ 96
- 20
web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx Vedi File

@@ -25,7 +25,7 @@ import {
Tooltip, Tooltip,
Popover, Popover,
Typography, Typography,
Button
Button,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
timestamp2string, timestamp2string,
@@ -43,6 +43,7 @@ import {
} from '../../../helpers'; } from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons'; import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Route, Sparkles } from 'lucide-react'; import { Route, Sparkles } from 'lucide-react';
import BillingDetailSummary from './BillingDetailSummary';


const colors = [ const colors = [
'amber', 'amber',
@@ -101,7 +102,53 @@ function buildChannelAffinityTooltip(affinity, t) {
} }


// Render functions // 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) { switch (type) {
case 1: case 1:
return ( return (
@@ -374,7 +421,10 @@ export const getLogsColumns = ({
} }


return isAdminUser && 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> <Space>
<span style={{ position: 'relative', display: 'inline-block' }}> <span style={{ position: 'relative', display: 'inline-block' }}>
<Tooltip content={record.channel_name || t('未知渠道')}> <Tooltip content={record.channel_name || t('未知渠道')}>
@@ -465,7 +515,10 @@ export const getLogsColumns = ({
title: t('令牌'), title: t('令牌'),
dataIndex: 'token_name', dataIndex: 'token_name',
render: (text, record, index) => { 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> <div>
<Tag <Tag
color='grey' color='grey'
@@ -522,7 +575,7 @@ export const getLogsColumns = ({
title: t('类型'), title: t('类型'),
dataIndex: 'type', dataIndex: 'type',
render: (text, record, index) => { render: (text, record, index) => {
return <>{renderType(text, t)}</>;
return <>{renderType(text, t, record)}</>;
}, },
}, },
{ {
@@ -530,7 +583,10 @@ export const getLogsColumns = ({
title: t('模型'), title: t('模型'),
dataIndex: 'model_name', dataIndex: 'model_name',
render: (text, record, index) => { 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)}</> <>{renderModelName(record, copyText, t)}</>
) : ( ) : (
<></> <></>
@@ -597,7 +653,10 @@ export const getLogsColumns = ({
cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`; 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 <div
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
@@ -631,7 +690,10 @@ export const getLogsColumns = ({
dataIndex: 'completion_tokens', dataIndex: 'completion_tokens',
render: (text, record, index) => { render: (text, record, index) => {
return parseInt(text) > 0 && 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>}</> <>{<span> {text} </span>}</>
) : ( ) : (
<></> <></>
@@ -643,7 +705,14 @@ export const getLogsColumns = ({
title: t('花费'), title: t('花费'),
dataIndex: 'quota', dataIndex: 'quota',
render: (text, record, index) => { 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 <></>; return <></>;
} }
const other = getLogOther(record.other); const other = getLogOther(record.other);
@@ -710,9 +779,9 @@ export const getLogsColumns = ({
} }
if (other.admin_info !== undefined) { if (other.admin_info !== undefined) {
if ( 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 useChannel = other.admin_info.use_channel;
let useChannelStr = useChannel.join('->'); 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 let content = other?.claude
? renderModelPriceSimple( ? renderModelPriceSimple(
other.model_ratio, other.model_ratio,
@@ -830,14 +906,14 @@ export const getLogsColumns = ({
'openai', 'openai',
); );
return ( 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>
); );
}, },
}, },


+ 6
- 3
web/src/components/table/usage-logs/UsageLogsTable.jsx Vedi File

@@ -18,13 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/ */


import React, { useMemo } from 'react'; 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 CardTable from '../../common/ui/CardTable';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark, IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations'; } from '@douyinfe/semi-illustrations';
import { getLogsColumns } from './UsageLogsColumnDefs'; import { getLogsColumns } from './UsageLogsColumnDefs';
import UsageLogExpandedDetail from './UsageLogExpandedDetail';


const LogsTable = (logsData) => { const LogsTable = (logsData) => {
const { const {
@@ -82,7 +83,7 @@ const LogsTable = (logsData) => {
}, [compactMode, visibleColumnsList]); }, [compactMode, visibleColumnsList]);


const expandRowRender = (record, index) => { const expandRowRender = (record, index) => {
return <Descriptions data={expandData[record.key]} />;
return <UsageLogExpandedDetail detail={expandData[record.key]} />;
}; };


return ( return (
@@ -92,7 +93,9 @@ const LogsTable = (logsData) => {
expandedRowRender: expandRowRender, expandedRowRender: expandRowRender,
expandRowByClick: true, expandRowByClick: true,
rowExpandable: (record) => 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} dataSource={logs}
rowKey='key' rowKey='key'


+ 25
- 3
web/src/helpers/utils.jsx Vedi File

@@ -693,6 +693,26 @@ export const summarizeMatrixPricing = ({
return { return {
price: min === max ? minText : `${minText} - ${maxText}`, price: min === max ? minText : `${minText} - ${maxText}`,
unit: getMatrixPricingUnit(cfg, tokenUnit), 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, precision = 4,
}) => { }) => {
// 1. 选择实际使用的分组 // 1. 选择实际使用的分组
const ratios = groupRatio || {};
let usedGroup = selectedGroup; let usedGroup = selectedGroup;
let usedGroupRatio = groupRatio[selectedGroup];
let usedGroupRatio = ratios[selectedGroup];


if (selectedGroup === 'all' || usedGroupRatio === undefined) { if (selectedGroup === 'all' || usedGroupRatio === undefined) {
// 在模型可用分组中选择倍率最小的分组,若无则使用 1 // 在模型可用分组中选择倍率最小的分组,若无则使用 1
@@ -717,7 +738,7 @@ export const calculateModelPrice = ({
record.enable_groups.length > 0 record.enable_groups.length > 0
) { ) {
record.enable_groups.forEach((g) => { record.enable_groups.forEach((g) => {
const r = groupRatio[g];
const r = ratios[g];
if (r !== undefined && r < minRatio) { if (r !== undefined && r < minRatio) {
minRatio = r; minRatio = r;
usedGroup = g; usedGroup = g;
@@ -839,7 +860,8 @@ export const formatPriceInfo = (priceData, t) => {
if (priceData.isPerToken) { if (priceData.isPerToken) {
return ( return (
<span style={{ color: 'var(--semi-color-text-1)' }}> <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> </span>
); );
} }


+ 60
- 4
web/src/helpers/utils.test.js Vedi File

@@ -15,8 +15,12 @@ globalThis.localStorage = {
getItem: () => null, 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)}`; const displayPrice = (value) => `$${Number(value).toFixed(3)}`;


@@ -27,8 +31,18 @@ describe('model pricing helpers', () => {
pricing_config: { pricing_config: {
billing_unit: 'per_1m_tokens', billing_unit: 'per_1m_tokens',
table: [ 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', unit: '1M tokens',
usedGroupRatio: 0.5, 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', () => { it('formats per 1K matrix token price from per 1M price', () => {
@@ -63,4 +95,28 @@ describe('model pricing helpers', () => {
}), }),
).toBe('$0.0280'); ).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,
});
});
}); });

+ 168
- 0
web/src/hooks/usage-logs/taskBillingLogMerge.js Vedi File

@@ -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);
};

+ 143
- 0
web/src/hooks/usage-logs/taskBillingLogMerge.test.js Vedi File

@@ -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',
});
});
});

+ 289
- 12
web/src/hooks/usage-logs/useUsageLogsData.jsx Vedi File

@@ -38,6 +38,7 @@ import {
} from '../../helpers'; } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode'; import { useTableCompactMode } from '../common/useTableCompactMode';
import { mergeTaskBillingLogs } from './taskBillingLogMerge';


export const useLogsData = () => { export const useLogsData = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -248,8 +249,12 @@ export const useLogsData = () => {
logType: formLogType, logType: formLogType,
} = getFormValues(); } = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType; 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 }); const params = new URLSearchParams({ type: currentLogType });
if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp);
if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp);
@@ -278,8 +283,12 @@ export const useLogsData = () => {
logType: formLogType, logType: formLogType,
} = getFormValues(); } = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType; 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 }); const params = new URLSearchParams({ type: currentLogType });
if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp);
if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp);
@@ -340,6 +349,208 @@ export const useLogsData = () => {


// Format logs data // Format logs data
const setLogsFormat = (logs) => { 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 requestConversionDisplayValue = (conversionChain) => {
const chain = Array.isArray(conversionChain) const chain = Array.isArray(conversionChain)
? conversionChain.filter(Boolean) ? conversionChain.filter(Boolean)
@@ -357,7 +568,10 @@ export const useLogsData = () => {
let other = getLogOther(logs[i].other); let other = getLogOther(logs[i].other);
let expandDataLocal = []; 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({ expandDataLocal.push({
key: t('渠道信息'), key: t('渠道信息'),
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`, value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
@@ -571,13 +785,63 @@ export const useLogsData = () => {
expandDataLocal.push({ expandDataLocal.push({
key: t('失败原因'), key: t('失败原因'),
value: ( 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} {other.reason}
</div> </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) { if (other?.request_path) {
expandDataLocal.push({ expandDataLocal.push({
key: t('请求路径'), key: t('请求路径'),
@@ -651,7 +915,11 @@ export const useLogsData = () => {
value: localCountMode, value: localCountMode,
}); });
} }
expandDatesLocal[logs[i].key] = expandDataLocal;
expandDatesLocal[logs[i].key] = buildSectionedExpandData(
expandDataLocal,
logs[i],
other,
);
} }


setExpandData(expandDatesLocal); setExpandData(expandDatesLocal);
@@ -684,8 +952,12 @@ export const useLogsData = () => {
? formLogType ? formLogType
: logType; : 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({ const params = new URLSearchParams({
p: startIdx, p: startIdx,
@@ -778,9 +1050,14 @@ export const useLogsData = () => {


// Check if any record has expandable content // Check if any record has expandable content
const hasExpandableRows = () => { 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 { return {


Caricamento…
Annulla
Salva