diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index 51a11ea778..c4d05b6098 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -609,6 +609,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSize: l.ImageSize,
ImageInputSize: l.ImageInputSize,
ImageOutputSize: l.ImageOutputSize,
+ ImageOutputTokens: l.ImageOutputTokens,
+ ImageOutputCost: l.ImageOutputCost,
ImageSizeSource: l.ImageSizeSource,
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType,
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index b1841c622e..92d0e7dcd5 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -464,6 +464,8 @@ type UsageLog struct {
ImageSize *string `json:"image_size"`
ImageInputSize *string `json:"image_input_size"`
ImageOutputSize *string `json:"image_output_size"`
+ ImageOutputTokens int `json:"image_output_tokens"`
+ ImageOutputCost float64 `json:"image_output_cost"`
ImageSizeSource *string `json:"image_size_source"`
ImageSizeBreakdown map[string]int `json:"image_size_breakdown"`
MediaType *string `json:"media_type"`
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue
index 65ac1548f9..af9ab466b6 100644
--- a/frontend/src/components/admin/usage/UsageTable.vue
+++ b/frontend/src/components/admin/usage/UsageTable.vue
@@ -125,6 +125,12 @@
R
+
+
+
+
{{ row.image_output_tokens.toLocaleString() }}
+
+
{{ t('admin.usage.inputTokens') }}
{{ tokenTooltipData.input_tokens.toLocaleString() }}
-
+
{{ t('admin.usage.outputTokens') }}
{{ tokenTooltipData.output_tokens.toLocaleString() }}
+
+ {{ t('admin.usage.outputTokens') }}
+ {{ textOutputTokens(tokenTooltipData).toLocaleString() }}
+
+
+ {{ t('usage.imageOutputTokens') }}
+ {{ tokenTooltipData.image_output_tokens.toLocaleString() }}
+
@@ -279,8 +293,26 @@
{{ t('admin.usage.outputCost') }}
${{ tooltipData.output_cost.toFixed(6) }}
+
+ {{ t('usage.imageOutputCost') }}
+ ${{ tooltipData.image_output_cost.toFixed(6) }}
+
-
+
+
+ {{ t('usage.inputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}
+
+
+ {{ t('usage.outputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.output_cost, textOutputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}
+
+
+ {{ t('usage.imageOutputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}
+
+
+
{{ t('usage.imageCount') }}
{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}
@@ -314,16 +346,6 @@
${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
-
-
- {{ t('usage.inputTokenPrice') }}
- {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}
-
-
- {{ t('usage.outputTokenPrice') }}
- {{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}
-
-
{{ t('usage.unitPrice') }}
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
@@ -384,13 +406,23 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
-import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode'
+import {
+ BILLING_MODE_TOKEN,
+ getBillingModeLabel,
+ getBillingModeBadgeClass,
+ isImageUsage,
+ getDisplayBillingMode,
+ imageUnitPrice,
+} from '@/utils/billingMode'
import {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
+ hasImageOutputTokens,
+ textOutputTokens,
+ hasImageOutputCost,
} from '@/utils/imageUsage'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
@@ -400,23 +432,6 @@ function accountBilled(row: { total_cost?: number | null; account_stats_cost?: n
return Number.isNaN(result) ? 0 : result
}
-function imageUnitPrice(row: AdminUsageLog | 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
-}
-
-function isImageUsage(row: Pick
| null | undefined): boolean {
- return (row?.image_count ?? 0) > 0
-}
-
-function getDisplayBillingMode(row: Pick | null | undefined): string | null | undefined {
- if (isImageUsage(row)) {
- return BILLING_MODE_IMAGE
- }
- return row?.billing_mode
-}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 6735029c7c..f68fcf3aac 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -906,6 +906,9 @@ export default {
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
imageOutputSize: 'Output size',
+ imageOutputTokens: 'Image Output Tokens',
+ imageOutputTokenPrice: 'Image Output Price',
+ imageOutputCost: 'Image Output Cost',
imageSizeSource: 'Size source',
imageSizeBreakdown: 'Size breakdown',
imageSizeSourceOutput: 'Upstream output',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index abb8dff730..2850c8ce6a 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -910,6 +910,9 @@ export default {
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
imageOutputSize: '输出尺寸',
+ imageOutputTokens: '图片输出 Token',
+ imageOutputTokenPrice: '图片输出单价',
+ imageOutputCost: '图片输出成本',
imageSizeSource: '尺寸来源',
imageSizeBreakdown: '尺寸明细',
imageSizeSourceOutput: '上游输出',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index c21361693a..1c1ab98491 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -232,6 +232,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
+ service_quota_enabled: boolean
affiliate_enabled: boolean
}
@@ -1234,6 +1235,8 @@ export interface UsageLog {
image_output_size: string | null
image_size_source: ImageSizeSource | null
image_size_breakdown: ImageSizeBreakdown | null
+ image_output_tokens: number
+ image_output_cost: number
// User-Agent
user_agent: string | null
@@ -1408,6 +1411,8 @@ export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
total_cache_tokens: number
+ total_cache_read_tokens: number
+ total_cache_creation_tokens: number
total_tokens: number
total_cost: number // 标准计费
total_actual_cost: number // 实际扣除
diff --git a/frontend/src/utils/billingMode.ts b/frontend/src/utils/billingMode.ts
index 152dadc45d..485c09244b 100644
--- a/frontend/src/utils/billingMode.ts
+++ b/frontend/src/utils/billingMode.ts
@@ -17,3 +17,24 @@ export function getBillingModeBadgeClass(mode: string | null | undefined): strin
default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
}
+
+interface ImageBillingRow {
+ image_count: number
+ billing_mode?: string | null
+ total_cost: number
+}
+
+export function isImageUsage(row: Pick | null | undefined): boolean {
+ return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN
+}
+
+export function getDisplayBillingMode(row: Pick | null | undefined): string | null | undefined {
+ return row?.billing_mode
+}
+
+export function imageUnitPrice(row: Pick | 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
+}
diff --git a/frontend/src/utils/imageUsage.ts b/frontend/src/utils/imageUsage.ts
index 91b7b90c29..069c532f1e 100644
--- a/frontend/src/utils/imageUsage.ts
+++ b/frontend/src/utils/imageUsage.ts
@@ -2,6 +2,28 @@ import type { UsageLog } from '@/types'
type Translate = (key: string) => string
+// --- Image output token / cost helpers ---
+
+type ImageOutputTokenRow = Pick
+type ImageOutputCostRow = Pick
+
+/** Whether the row contains any image-output tokens. */
+export const hasImageOutputTokens = (row: ImageOutputTokenRow | null | undefined): boolean =>
+ (row?.image_output_tokens ?? 0) > 0
+
+/**
+ * Text-only output tokens (total output minus image-output).
+ * Returns 0 when no text tokens exist.
+ */
+export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): number =>
+ Math.max(0, (row?.output_tokens ?? 0) - (row?.image_output_tokens ?? 0))
+
+/** Whether the row has a non-zero image-output cost. */
+export const hasImageOutputCost = (row: ImageOutputCostRow | null | undefined): boolean =>
+ (row?.image_output_cost ?? 0) > 0
+
+// --- Image size / billing helpers ---
+
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
const knownImageBillingSizes = new Set(['1K', '2K', '4K', 'mixed'])
diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue
index d6807aaf59..9e64b56e9e 100644
--- a/frontend/src/views/user/UsageView.vue
+++ b/frontend/src/views/user/UsageView.vue
@@ -29,7 +29,7 @@
-
+
{{ t('usage.totalTokens') }}
@@ -37,8 +37,23 @@
{{ formatTokens(usageStats?.total_tokens || 0) }}
- {{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} /
- {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}
+ {{ t('usage.in') }} {{ formatTokens(usageStats?.total_input_tokens || 0) }}
+ ·
+ {{ t('usage.out') }} {{ formatTokens(usageStats?.total_output_tokens || 0) }}
+ ·
+ {{ t('usage.cacheHit') }} {{ formatTokens(usageStats?.total_cache_read_tokens || 0) }}
+ ·
+ {{ t('usage.cacheCreate') }} {{ formatTokens(usageStats?.total_cache_creation_tokens || 0) }}
+
+
+ {{ t('usage.cacheHitRate') }}:
+
+ {{ formatTokens(cacheStats.cacheRead) }}
+ /
+ {{ formatTokens(cacheStats.totalInput) }}
+ {{ cacheStats.ratePercent }}
+
+ -
@@ -257,6 +272,12 @@
R
+
+
+
+
{{ row.image_output_tokens.toLocaleString() }}
+
+
{{ t('admin.usage.inputTokens') }}
{{ tokenTooltipData.input_tokens.toLocaleString() }}
-
+
{{ t('admin.usage.outputTokens') }}
{{ tokenTooltipData.output_tokens.toLocaleString() }}
+
+ {{ t('admin.usage.outputTokens') }}
+ {{ textOutputTokens(tokenTooltipData).toLocaleString() }}
+
+
+ {{ t('usage.imageOutputTokens') }}
+ {{ tokenTooltipData.image_output_tokens.toLocaleString() }}
+
@@ -447,8 +476,27 @@
{{ t('admin.usage.outputCost') }}
${{ tooltipData.output_cost.toFixed(6) }}
+
+ {{ t('usage.imageOutputCost') }}
+ ${{ tooltipData.image_output_cost.toFixed(6) }}
+
+
+
+
+ {{ t('usage.inputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}
+
+
+ {{ t('usage.outputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.output_cost, textOutputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}
+
+
+ {{ t('usage.imageOutputTokenPrice') }}
+ {{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}
+
+
-
+
{{ t('usage.imageCount') }}
{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}
@@ -482,17 +530,6 @@
${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
-
-
-
- {{ t('usage.inputTokenPrice') }}
- {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}
-
-
- {{ t('usage.outputTokenPrice') }}
- {{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}
-
-
{{ t('usage.unitPrice') }}
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
@@ -559,10 +596,12 @@ import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import {
- BILLING_MODE_IMAGE,
BILLING_MODE_TOKEN,
getBillingModeBadgeClass,
getBillingModeLabel,
+ isImageUsage,
+ getDisplayBillingMode,
+ imageUnitPrice,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
@@ -570,6 +609,9 @@ import {
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
+ hasImageOutputTokens,
+ textOutputTokens,
+ hasImageOutputCost,
} from '@/utils/imageUsage'
const { t } = useI18n()
@@ -590,6 +632,19 @@ const tokenTooltipData = ref(null)
// Usage stats from API
const usageStats = ref(null)
+// 缓存命中率 = cache_read / (input + cache_read)
+// 分母为 0(无任何输入)时显示 '-'
+const cacheStats = computed(() => {
+ // 总输入 token = 普通输入 + 缓存写入 + 缓存读取(命中)
+ // 缓存命中率 = 缓存读取 / 总输入;总输入为 0 时返回零值,模板按 '-' 渲染。
+ const cacheRead = usageStats.value?.total_cache_read_tokens || 0
+ const cacheCreate = usageStats.value?.total_cache_creation_tokens || 0
+ const input = usageStats.value?.total_input_tokens || 0
+ const totalInput = input + cacheCreate + cacheRead
+ const ratePercent = totalInput > 0 ? `${((cacheRead / totalInput) * 100).toFixed(1)}%` : '-'
+ return { cacheRead, totalInput, ratePercent }
+})
+
const columns = computed(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
@@ -671,23 +726,6 @@ const formatDuration = (ms: number): string => {
return `${(ms / 1000).toFixed(2)}s`
}
-const imageUnitPrice = (row: UsageLog | 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
-}
-
-const isImageUsage = (row: Pick | null | undefined): boolean => {
- return (row?.image_count ?? 0) > 0
-}
-
-const getDisplayBillingMode = (row: Pick | null | undefined): string | null | undefined => {
- if (isImageUsage(row)) {
- return BILLING_MODE_IMAGE
- }
- return row?.billing_mode
-}
const formatUserAgent = (ua: string): string => {
return ua