Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/components/admin/usage/UsageFilters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ const billingModeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingModes') },
{ value: 'token', label: t('admin.usage.billingModeToken') },
{ value: 'per_request', label: t('admin.usage.billingModePerRequest') },
{ value: 'image', label: t('admin.usage.billingModeImage') }
{ value: 'image', label: t('admin.usage.billingModeImage') },
{ value: 'video', label: t('admin.usage.billingModeVideo') }
])

const emitChange = () => emit('change')
Expand Down
27 changes: 26 additions & 1 deletion frontend/src/components/admin/usage/UsageTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ formatImageBillingSize(row, t) }})</span>
</div>
<!-- 视频生成请求(按视频时长计费) -->
<div v-else-if="isVideoUsage(row)" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 6h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ formatVideoDuration(row.video_duration_seconds) }}</span>
</div>
<!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5">
<div class="space-y-1 text-sm">
Expand Down Expand Up @@ -301,7 +308,7 @@
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
<template v-if="tooltipData && !isImageUsage(tooltipData) && !isVideoUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
Expand All @@ -315,6 +322,20 @@
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<template v-else-if="tooltipData && isVideoUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.videoDuration') }}</span>
<span class="font-medium text-white">{{ formatVideoDuration(tooltipData.video_duration_seconds) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.videoUnitPrice') }}</span>
<span class="font-medium text-amber-300">${{ videoUnitPrice(tooltipData).toFixed(6) }} {{ t('usage.videoUnitSuffix') }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.videoTotalPrice') }}</span>
<span class="font-medium text-white">${{ videoTotalCost(tooltipData).toFixed(6) }}</span>
</div>
</template>
<template v-else-if="tooltipData && isImageUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
Expand Down Expand Up @@ -414,8 +435,12 @@ import {
getBillingModeLabel,
getBillingModeBadgeClass,
isImageUsage,
isVideoUsage,
getDisplayBillingMode,
imageUnitPrice,
videoUnitPrice,
videoTotalCost,
formatVideoDuration,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per Request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.billingModeVideo': 'Per Second (Video)',
'admin.usage.group': 'Group',
'admin.usage.allGroups': 'All Groups',
'common.refresh': 'Refresh',
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ const messages: Record<string, string> = {
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'usage.videoDuration': 'Video duration',
'usage.videoUnitPrice': 'Per-second price',
'usage.videoTotalPrice': 'Video total price',
'usage.videoUnitSuffix': '/ second',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.billingModeVideo': 'Per Second (Video)',
}

vi.mock('vue-i18n', async () => {
Expand Down Expand Up @@ -321,6 +326,70 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})

it('shows video usage duration and per-second pricing instead of token pricing', async () => {
const wrapper = mount(UsageTable, {
props: {
data: [
{
request_id: 'req-admin-video',
model: 'grok-imagine-video',
actual_cost: 0.1,
total_cost: 0.1,
account_rate_multiplier: 1,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
cache_ttl_overridden: false,
billing_mode: 'video',
image_count: 0,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
image_output_tokens: 0,
image_output_cost: 0,
video_duration_seconds: 5,
video_unit_price: 0.02,
video_cost: 0.1,
},
],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})

await wrapper.find('.group.relative').trigger('mouseenter')
await nextTick()

const text = wrapper.text()
expect(text).toContain('Per Second (Video)')
expect(text).toContain('5s')
expect(text).toContain('Video duration')
expect(text).toContain('Per-second price')
expect(text).toContain('Video total price')
expect(text).toContain('$0.020000 / second')
expect(text).toContain('$0.100000')
expect(text).not.toContain('/ 1M tokens')
})
})

// A DataTable stub that also renders cell-user, so the deleted badge can be asserted.
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/constants/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ export type ChannelStatus = typeof CHANNEL_STATUS_ACTIVE | typeof CHANNEL_STATUS
export const BILLING_MODE_TOKEN = 'token' as const
export const BILLING_MODE_PER_REQUEST = 'per_request' as const
export const BILLING_MODE_IMAGE = 'image' as const
export const BILLING_MODE_VIDEO = 'video' as const
export type BillingMode =
| typeof BILLING_MODE_TOKEN
| typeof BILLING_MODE_PER_REQUEST
| typeof BILLING_MODE_IMAGE
| typeof BILLING_MODE_VIDEO

/** Billing-model-source values (must match service.BillingModelSource* constants in Go). */
export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,11 @@ export default {
unitPrice: 'Per-request price',
imageUnitPrice: 'Per-image price',
imageTotalPrice: 'Image total price',
videoUnitPrice: 'Per-second price',
videoTotalPrice: 'Video total price',
videoDuration: 'Video duration',
videoUnit: 's',
videoUnitSuffix: '/ second',
imageCount: 'Image count',
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
Expand Down Expand Up @@ -1097,6 +1102,7 @@ export default {
billingModeToken: 'Per Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Per Image',
billingModeVideo: 'Per Second (Video)',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
Expand Down Expand Up @@ -2379,6 +2385,7 @@ export default {
billingModeToken: 'Per Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Per Image',
billingModeVideo: 'Per Second (Video)',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
Expand Down Expand Up @@ -4695,6 +4702,7 @@ export default {
billingModeToken: 'Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Image',
billingModeVideo: 'Per Second (Video)',
allBillingModes: 'All Billing Modes',
ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history',
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,11 @@ export default {
unitPrice: '单次价格',
imageUnitPrice: '单张价格',
imageTotalPrice: '图片总价',
videoUnitPrice: '视频单价',
videoTotalPrice: '视频总价',
videoDuration: '视频时长',
videoUnit: '秒',
videoUnitSuffix: '/ 秒',
imageCount: '图片张数',
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
Expand Down Expand Up @@ -1101,6 +1106,7 @@ export default {
billingModeToken: '按 Token',
billingModePerRequest: '按次',
billingModeImage: '按图片',
billingModeVideo: '按秒(视频)',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
Expand Down Expand Up @@ -2455,6 +2461,7 @@ export default {
billingModeToken: '按 Token',
billingModePerRequest: '按次',
billingModeImage: '按图片',
billingModeVideo: '按秒(视频)',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
Expand Down Expand Up @@ -4848,6 +4855,7 @@ export default {
billingModeToken: '按量',
billingModePerRequest: '按次',
billingModeImage: '按次(图片)',
billingModeVideo: '按秒(视频)',
allBillingModes: '全部计费模式',
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,11 @@ export interface UsageLog {
image_output_tokens: number
image_output_cost: number

// 视频生成字段
video_duration_seconds: number
video_unit_price: number | null
video_cost: number

// User-Agent
user_agent: string | null

Expand Down
52 changes: 47 additions & 5 deletions frontend/src/utils/billingMode.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export const BILLING_MODE_TOKEN = 'token'
export const BILLING_MODE_PER_REQUEST = 'per_request'
export const BILLING_MODE_IMAGE = 'image'
export const BILLING_MODE_VIDEO = 'video'

export function getBillingModeLabel(mode: string | null | undefined, t: (key: string) => string): string {
switch (mode) {
case BILLING_MODE_PER_REQUEST: return t('admin.usage.billingModePerRequest')
case BILLING_MODE_IMAGE: return t('admin.usage.billingModeImage')
case BILLING_MODE_VIDEO: return t('admin.usage.billingModeVideo')
default: return t('admin.usage.billingModeToken')
}
}
Expand All @@ -14,30 +16,70 @@ export function getBillingModeBadgeClass(mode: string | null | undefined): strin
switch (mode) {
case BILLING_MODE_PER_REQUEST: return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
case BILLING_MODE_IMAGE: return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
case BILLING_MODE_VIDEO: return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
}

interface ImageBillingRow {
interface MediaBillingRow {
image_count: number
billing_mode?: string | null
total_cost: number
video_duration_seconds?: number | null
video_unit_price?: number | null
video_cost?: number | null
}

export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing_mode'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN
export function isImageUsage(row: Pick<MediaBillingRow, 'image_count' | 'billing_mode'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN && row?.billing_mode !== BILLING_MODE_VIDEO
}

export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
export function isVideoUsage(row: Pick<MediaBillingRow, 'billing_mode' | 'video_duration_seconds' | 'video_cost'> | null | undefined): boolean {
return row?.billing_mode === BILLING_MODE_VIDEO || ((row?.video_duration_seconds ?? 0) > 0 || (row?.video_cost ?? 0) > 0) && row?.billing_mode !== BILLING_MODE_TOKEN
}

export function getDisplayBillingMode(row: Pick<MediaBillingRow, 'billing_mode' | 'image_count' | 'video_duration_seconds' | 'video_cost'> | null | undefined): string | null | undefined {
if (isVideoUsage(row)) {
return BILLING_MODE_VIDEO
}
if ((row?.image_count ?? 0) > 0 && !row?.billing_mode) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}

export function imageUnitPrice(row: Pick<ImageBillingRow, 'image_count' | 'total_cost'> | null): number {
export function imageUnitPrice(row: Pick<MediaBillingRow, 'image_count' | 'total_cost'> | null): number {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}

export function videoUnitPrice(row: Pick<MediaBillingRow, 'video_duration_seconds' | 'video_unit_price' | 'video_cost' | 'total_cost'> | null): number {
if (!row) return 0
if (row.video_unit_price != null) return row.video_unit_price
const duration = row.video_duration_seconds ?? 0
if (duration <= 0) return 0
const total = row.video_cost ?? row.total_cost ?? 0
const price = total / duration
return Number.isFinite(price) ? price : 0
}

export function videoTotalCost(row: Pick<MediaBillingRow, 'video_cost' | 'total_cost'> | null): number {
if (!row) return 0
return row.video_cost ?? row.total_cost ?? 0
}

export function formatVideoDuration(seconds: number | null | undefined): string {
const totalSeconds = Math.max(0, Math.round(seconds ?? 0))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const remainingSeconds = totalSeconds % 60

const parts: string[] = []
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`)

return parts.join(' ')
}
Loading