Files
chaos-api/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
T
wans10 938dc9522b fix(web): 修复阶梯计费 Base64 解码失败与标签不匹配导致的显示错误 (#4530)
* fix(web): 修复阶梯计费表达式解析与匹配逻辑

- 优化 Base64 解码逻辑:引入 UTF-8 感知的解码方法(使用 TextDecoder/Uint8Array),替换原有的简单 `atob`,修复包含非拉丁字符时解码失败的问题。
- 增强阶梯标签匹配机制:新增标签规范化处理(移除空格、统一大小写、转换 `<`/`≤`/`<=` 等符号),确保日志记录中的标签能够与配置中的标签准确匹配。
- 将上述修复同步应用于 default 和 classic 两套前端主题。

* refactor(web): 完善 Base64 解码函数的类型声明

- 根据 CodeRabbitAI 的代码审查建议,将 `decodeBillingExprB64` 方法中 `Array.prototype.map` 回调函数的参数类型由 `any` 替换为更精确的 `number`。
- 提高了代码的类型安全性与可读性。

* fix(web): 修复动态价格明细表中阶梯高亮未能正确匹配的问题

- 在 default 主题的 `DynamicPricingBreakdown` 组件中,引入 `normalizeTierLabel` 函数。
- 替换原有对 `matchedTierLabel` 的严格相等判定,确保在包含全半角符号(如 `≤`/`<=`)或存在空格等格式不一致的场景下,日志详情中的表格依然能准确高亮(Matched)当前命中的对应计费阶梯。

* refactor(web): 移除阶梯计费标签不匹配时的强制兜底逻辑

- 在 default 和 classic 主题中,修改 `resolveMatchedTier` 和相关的阶梯匹配方法,当日志中 `matched_tier` 无法与表达式中的阶梯标签严格对应时,直接返回 `null` 而不再默认退化展示第一阶梯(`tiers[0]`)的价格。
- 遵循“数据准确性优先”的计费展示准则,防止因匹配失败而向用户展示猜测出的单价,避免产生账单误导及客诉风险。
- 在 Classic 主题账单卡片中,对于无法匹配的异常账单明确展示“未匹配到对应阶梯”的提示。

* fix(web): 修复阶梯计费标签正则匹配的短路问题

- 根据 CodeRabbitAI 的代码审查反馈,修正了 `normalizeLabel`(以及 `normalizeTierLabel`)函数中的正则表达式分支顺序。
- 将原本的 `/<|≤|<=/` 调整为 `/<=|≤|</`,以修复 JavaScript 正则引擎从左到右匹配时,会将 `<=` 中的 `<` 优先短路匹配,导致残留 `=` 号的问题。
- 确保了双字符操作符(如 `<=`、`>=`)现在能够被正确完整地替换为单字符(`<`、`>`),保证了计费阶梯日志匹配的准确性。

* fix(web): 完善阶梯计费未匹配展示

---------

Co-authored-by: CaIon <i@caion.me>
2026-04-30 20:26:58 +08:00

788 lines
26 KiB
TypeScript
Vendored

import { useState } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { CircleAlert, Sparkles, KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import {
formatUseTime,
formatLogQuota,
formatTimestampToDate,
} from '@/lib/format'
import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import {
StatusBadge,
type StatusBadgeProps,
} from '@/components/status-badge'
import type { UsageLog } from '../../data/schema'
import {
formatModelName,
getFirstResponseTimeColor,
getResponseTimeColor,
getTieredBillingSummary,
hasAnyCacheTokens,
parseLogOther,
isViolationFeeLog,
} from '../../lib/format'
import { getLogAvatarStyle } from '../../lib/avatar-color'
import {
isDisplayableLogType,
isTimingLogType,
getLogTypeConfig,
isPerCallBilling,
} from '../../lib/utils'
import type { LogOtherData } from '../../types'
import { DetailsDialog } from '../dialogs/details-dialog'
import { ModelBadge } from '../model-badge'
import { useUsageLogsContext } from '../usage-logs-provider'
interface DetailSegment {
text: string
muted?: boolean
danger?: boolean
}
function formatRatioCompact(ratio: number | undefined): string {
if (ratio == null || !Number.isFinite(ratio)) return '-'
return ratio % 1 === 0
? String(ratio)
: ratio.toFixed(4).replace(/\.?0+$/, '')
}
function getGroupRatioText(other: LogOtherData | null): string | null {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
return `${formatRatioCompact(userGroupRatio)}x`
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
return `${formatRatioCompact(groupRatio)}x`
}
return null
}
function buildDetailSegments(
log: UsageLog,
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] {
if (log.type === 6) {
return [{ text: t('Async task refund') }]
}
if (log.type !== 2) return []
const isViolation = isViolationFeeLog(other)
if (isViolation) {
const segments: DetailSegment[] = []
segments.push({ text: t('Violation Fee'), danger: true })
if (other?.violation_fee_code) {
segments.push({
text: other.violation_fee_code,
muted: true,
})
}
segments.push({
text: `${t('Fee')}: ${formatLogQuota(other?.fee_quota ?? log.quota)}`,
muted: true,
})
return segments
}
if (!other) return []
const segments: DetailSegment[] = []
const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false }
const formatPrice = (price: number) =>
`${formatBillingCurrencyFromUSD(price, priceOpts)}/M`
const formatPriceCompact = (price: number) =>
formatBillingCurrencyFromUSD(price, priceOpts)
const formatPriceList = (prices: string[], showUnit: boolean) => {
const text = prices.join(' / ')
return showUnit ? `${text}/M` : text
}
const isTieredExpr = other.billing_mode === 'tiered_expr'
const tieredSummary = getTieredBillingSummary(other)
if (isTieredExpr) {
if (tieredSummary) {
const baseEntries = tieredSummary.priceEntries
.filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
.map((entry) => formatPriceCompact(entry.price))
if (baseEntries.length > 0) {
const tierLabel = tieredSummary.tier.label || t('Default')
segments.push({
text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`,
})
}
const cacheEntries = tieredSummary.priceEntries
.filter((entry) =>
[
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => {
return formatPriceCompact(entry.price)
})
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
const otherEntries = tieredSummary.priceEntries
.filter(
(entry) =>
![
'inputPrice',
'outputPrice',
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`)
if (otherEntries.length > 0) {
segments.push({
text: otherEntries.join(' · '),
muted: true,
})
}
} else {
segments.push({
text: `${t('Dynamic Pricing')} · ${t('No matching results')}`,
muted: true,
})
}
} else {
const isPerCall = isPerCallBilling(other.model_price)
if (isPerCall) {
segments.push({
text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`,
})
} else if (other.model_ratio != null) {
const inputPriceUSD = other.model_ratio * 2.0
const baseEntries = [formatPriceCompact(inputPriceUSD)]
if (other.completion_ratio != null) {
baseEntries.push(
formatPriceCompact(inputPriceUSD * other.completion_ratio)
)
}
segments.push({
text: `${t('Standard')} · ${formatPriceList(baseEntries, true)}`,
})
if (hasAnyCacheTokens(other)) {
const cacheEntries = [
other.cache_ratio != null && other.cache_ratio !== 1
? formatPriceCompact(inputPriceUSD * other.cache_ratio)
: null,
other.cache_creation_ratio != null &&
other.cache_creation_ratio !== 1
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio)
: null,
other.cache_creation_ratio_1h != null &&
other.cache_creation_ratio_1h !== 0
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio_1h)
: null,
].filter(Boolean) as string[]
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
}
} else {
const userGroupRatio = other.user_group_ratio
const groupRatio = other.group_ratio
const isUserGroup =
userGroupRatio != null &&
Number.isFinite(userGroupRatio) &&
userGroupRatio !== -1
const effectiveRatio = isUserGroup ? userGroupRatio : groupRatio
const ratioLabel = isUserGroup
? t('User Exclusive Ratio')
: t('Group Ratio')
if (effectiveRatio != null && Number.isFinite(effectiveRatio)) {
segments.push({
text: `${ratioLabel} ${formatRatioCompact(effectiveRatio)}x`,
})
}
}
}
if (other.is_system_prompt_overwritten) {
segments.push({
text: t('System Prompt Override'),
danger: true,
})
}
return segments
}
export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<UsageLog>[] = [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Time')} />
),
cell: ({ row }) => {
const log = row.original
const timestamp = row.getValue('created_at') as number
const config = getLogTypeConfig(log.type)
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp)}
</span>
<StatusBadge
label={t(config.label)}
variant={config.color as StatusBadgeProps['variant']}
size='sm'
copyable={false}
/>
</div>
)
},
filterFn: (row, _id, value) => {
if (!value || value.length === 0) return true
return value.includes(String(row.original.type))
},
enableHiding: false,
meta: { label: t('Time') },
},
]
if (isAdmin) {
columns.push(
{
id: 'channel',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Channel')} />
),
cell: function ChannelCell({ row }) {
const {
sensitiveVisible,
setAffinityTarget,
setAffinityDialogOpen,
} = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
const affinity = other?.admin_info?.channel_affinity
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0
? useChannel.join(' → ')
: undefined
const channelDisplay = log.channel_name
? `${log.channel_name} #${log.channel}`
: `#${log.channel}`
const channelIdDisplay = `#${log.channel}`
const channelName = sensitiveVisible ? log.channel_name : '••••'
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='flex max-w-[160px] flex-col gap-0.5'>
<div className='relative inline-flex w-fit'>
<StatusBadge
label={channelIdDisplay}
autoColor={String(log.channel)}
copyText={String(log.channel)}
size='sm'
className='font-mono'
/>
{affinity && (
<button
type='button'
className='absolute -top-1 -right-1 leading-none text-amber-500'
onClick={(e) => {
e.stopPropagation()
setAffinityTarget({
rule_name: affinity.rule_name || '',
using_group:
affinity.using_group ||
affinity.selected_group ||
'',
key_hint: affinity.key_hint || '',
key_fp: affinity.key_fp || '',
})
setAffinityDialogOpen(true)
}}
>
<Sparkles className='size-3 fill-current' />
</button>
)}
</div>
{log.channel_name && (
<span className='text-muted-foreground/70 truncate text-[11px]'>
{channelName}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1'>
<p>{sensitiveVisible ? channelDisplay : channelIdDisplay}</p>
{channelChain && (
<p className='text-muted-foreground text-xs'>
{t('Chain')}: {channelChain}
</p>
)}
{affinity && (
<div className='border-t pt-1 text-xs'>
<p className='font-medium'>{t('Channel Affinity')}</p>
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p>
<p>
{t('Group')}:{' '}
{sensitiveVisible
? affinity.using_group ||
affinity.selected_group ||
'-'
: '••••'}
</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
},
meta: { label: t('Channel'), mobileHidden: true },
},
{
id: 'user',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
cell: function UserCell({ row }) {
const {
sensitiveVisible,
setSelectedUserId,
setUserInfoDialogOpen,
} = useUsageLogsContext()
const log = row.original
if (!log.username) return null
return (
<button
type='button'
className='flex items-center gap-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='size-6 ring-1 ring-border/60'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible
? getLogAvatarStyle(log.username)
: undefined
}
>
{sensitiveVisible
? log.username.charAt(0).toUpperCase()
: '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? log.username : '••••'}
</span>
</button>
)
},
meta: { label: t('User'), mobileHidden: true },
}
)
}
columns.push({
accessorKey: 'token_name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Token')} />
),
cell: function TokenNameCell({ row }) {
const { sensitiveVisible } = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const tokenName = log.token_name
if (!tokenName) return null
const other = parseLogOther(log.other)
const displayName = sensitiveVisible ? tokenName : '••••'
let group = log.group
if (!group) group = other?.group || ''
const metaParts: string[] = []
const groupRatioText = getGroupRatioText(other)
if (group) {
metaParts.push(sensitiveVisible ? group : '••••')
}
if (groupRatioText) metaParts.push(groupRatioText)
return (
<div className='flex max-w-[150px] flex-col gap-0.5'>
<StatusBadge
label={displayName}
icon={KeyRound}
copyText={sensitiveVisible ? tokenName : undefined}
size='sm'
showDot={false}
className='max-w-full overflow-hidden rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono text-foreground'
/>
{metaParts.length > 0 && (
<span className='text-muted-foreground/60 truncate text-[11px]'>
{metaParts.join(' · ')}
</span>
)}
</div>
)
},
meta: { label: t('Token') },
size: 130,
})
columns.push(
{
accessorKey: 'model_name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model')} />
),
cell: function ModelCell({ row }) {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const modelInfo = formatModelName(log)
return (
<div className='flex max-w-[220px] flex-col gap-0.5'>
<ModelBadge
modelName={modelInfo.name}
actualModel={modelInfo.actualModel}
/>
</div>
)
},
meta: { label: t('Model'), mobileTitle: true },
},
{
accessorKey: 'use_time',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Timing')} />
),
cell: ({ row }) => {
const log = row.original
if (!isTimingLogType(log.type)) return null
const useTime = row.getValue('use_time') as number
const other = parseLogOther(log.other)
const frt = other?.frt
const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
const timeVariant = getResponseTimeColor(
useTime,
log.completion_tokens
)
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
const pillBg: Record<string, string> = {
success:
'border border-emerald-200/40 bg-emerald-50/35 dark:border-emerald-900/40 dark:bg-emerald-950/15',
warning:
'border border-amber-200/45 bg-amber-50/35 dark:border-amber-900/40 dark:bg-amber-950/15',
danger:
'border border-rose-200/50 bg-rose-50/35 dark:border-rose-900/40 dark:bg-rose-950/15',
}
const pillText: Record<string, string> = {
success: 'text-emerald-700/85 dark:text-emerald-400/85',
warning: 'text-amber-700/85 dark:text-amber-400/85',
danger: 'text-rose-700/85 dark:text-rose-400/85',
}
const pillDot: Record<string, string> = {
success: 'bg-emerald-500/80',
warning: 'bg-amber-500/80',
danger: 'bg-rose-500/80',
}
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-1.5'>
<span
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
pillBg[timeVariant],
pillText[timeVariant]
)}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
pillDot[timeVariant]
)}
aria-hidden='true'
/>
{formatUseTime(useTime)}
</span>
{log.is_stream && (frt != null && frt > 0 ? (
<span
className={cn(
'inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
pillBg[frtVariant!],
pillText[frtVariant!]
)}
>
{formatUseTime(frt / 1000)}
</span>
) : (
<span className='inline-flex items-center rounded-md border border-border/60 px-1.5 py-0.5 text-[11px] text-muted-foreground/50'>
N/A
</span>
))}
</div>
<div className='flex items-center gap-1 text-[11px]'>
<span className='text-muted-foreground/60'>
{log.is_stream ? t('Stream') : t('Non-stream')}
{tokensPerSecond != null && (
<>
{' · '}
<span className='font-mono tabular-nums'>
{Math.round(tokensPerSecond)}
</span>
{' t/s'}
</>
)}
</span>
{log.is_stream &&
other?.stream_status &&
other.stream_status.status !== 'ok' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CircleAlert className='size-3 text-red-500' />
</TooltipTrigger>
<TooltipContent>
<div className='space-y-0.5 text-xs'>
<p>
{t('Stream Status')}: {t('Error')}
</p>
<p>{other.stream_status.end_reason || 'unknown'}</p>
{(other.stream_status.error_count ?? 0) > 0 && (
<p>
{t('Soft Errors')}:{' '}
{other.stream_status.error_count}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
)
},
meta: { label: t('Timing'), mobileHidden: true },
},
{
accessorKey: 'prompt_tokens',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Tokens' />
),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
if (promptTokens === 0 && completionTokens === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const cacheReadTokens = other?.cache_tokens || 0
const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium tabular-nums'>
{promptTokens.toLocaleString()} / {completionTokens.toLocaleString()}
</span>
{(cacheReadTokens > 0 || cacheWriteTokens > 0) && (
<div className='flex items-center gap-1 text-[11px]'>
{cacheReadTokens > 0 && (
<span className='text-muted-foreground/60'>
{t('Cache')} {cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span className='text-muted-foreground/60'>
{cacheWriteTokens.toLocaleString()}
</span>
)}
</div>
)}
</div>
)
},
meta: { label: 'Tokens', mobileHidden: true },
},
{
accessorKey: 'quota',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Cost')} />
),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const quota = row.getValue('quota') as number
const other = parseLogOther(log.other)
const isSubscription = other?.billing_source === 'subscription'
if (isSubscription) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300'>
<span className='size-1.5 rounded-full bg-emerald-500' aria-hidden='true' />
{t('Subscription')}
</span>
</TooltipTrigger>
<TooltipContent>
<span>
{t('Deducted by subscription')}: {formatLogQuota(quota)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const quotaStr = formatLogQuota(quota)
return (
<div className='flex flex-col gap-0.5'>
<span className='border-border/80 inline-flex w-fit items-center rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-xs font-semibold tabular-nums'>
{quotaStr}
</span>
</div>
)
},
meta: { label: t('Cost') },
},
{
accessorKey: 'content',
header: t('Details'),
cell: function DetailsCell({ row }) {
const [dialogOpen, setDialogOpen] = useState(false)
const log = row.original
const other = parseLogOther(log.other)
const segments = buildDetailSegments(log, other, t)
const primary = segments[0]
const hasMore = segments.length > 1
return (
<>
<button
type='button'
className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view full details')}
>
{primary ? (
<span
className={cn(
'truncate leading-snug group-hover:underline',
primary.muted
? 'text-muted-foreground/60'
: primary.danger
? 'text-red-600 dark:text-red-400'
: 'text-foreground'
)}
>
{primary.text}
{hasMore && (
<span className='text-muted-foreground/40 ml-0.5'>
+{segments.length - 1}
</span>
)}
</span>
) : log.content ? (
<span className='text-muted-foreground truncate group-hover:underline'>
{log.content}
</span>
) : (
<span className='text-muted-foreground/40'></span>
)}
</button>
<DetailsDialog
log={log}
isAdmin={isAdmin}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
meta: { label: t('Details') },
size: 180,
maxSize: 200,
}
)
return columns
}