Commit 2a5ef6d3 authored by shaw's avatar shaw
Browse files

Merge PR #279: feat(计费): 账号计费倍率快照与账号口径费用统计

parents 99cbfa15 ec24a3c3
......@@ -1196,7 +1196,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
......@@ -1212,6 +1212,11 @@
/>
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
......@@ -1832,6 +1837,7 @@ const form = reactive({
proxy_id: null as number | null,
concurrency: 10,
priority: 1,
rate_multiplier: 1,
group_ids: [] as number[],
expires_at: null as number | null
})
......@@ -2119,6 +2125,7 @@ const resetForm = () => {
form.proxy_id = null
form.concurrency = 10
form.priority = 1
form.rate_multiplier = 1
form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based'
......@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
......@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => {
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
......
......@@ -549,7 +549,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
......@@ -564,6 +564,11 @@
data-tour="account-form-priority"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
......@@ -807,6 +812,7 @@ const form = reactive({
proxy_id: null as number | null,
concurrency: 1,
priority: 1,
rate_multiplier: 1,
status: 'active' as 'active' | 'inactive',
group_ids: [] as number[],
expires_at: null as number | null
......@@ -834,6 +840,7 @@ watch(
form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency
form.priority = newAccount.priority
form.rate_multiplier = newAccount.rate_multiplier ?? 1
form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null
......
......@@ -15,7 +15,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U ${{ formatUserCost }}
</span>
</div>
</div>
......@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
return t.toString()
})
const formatCost = computed(() => {
const formatAccountCost = computed(() => {
if (!props.windowStats) return '0.00'
return props.windowStats.cost.toFixed(2)
})
const formatUserCost = computed(() => {
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
return props.windowStats.user_cost.toFixed(2)
})
</script>
......@@ -61,11 +61,12 @@
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500"
>({{ t('admin.accounts.stats.standardCost') }}: ${{
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost)
}})</span
>
}})
</span>
</p>
</div>
......@@ -114,6 +115,9 @@
days: stats.summary.actual_days_used
})
}}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p>
</div>
......@@ -164,13 +168,17 @@
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
......@@ -210,13 +218,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
......@@ -260,13 +272,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
......@@ -485,14 +501,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label),
datasets: [
{
label: t('admin.accounts.stats.cost') + ' (USD)',
data: stats.value.history.map((h) => h.cost),
label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{
label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests),
......@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({
},
title: {
display: true,
text: t('admin.accounts.stats.cost') + ' (USD)',
text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6',
font: {
size: 11
......
......@@ -27,9 +27,18 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
<p class="text-xs text-gray-400">
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
<p class="text-xl font-bold text-green-600">
${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
{{ t('usage.userBilled') }}:
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
· {{ t('usage.standardCost') }}:
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
<p class="text-xs text-gray-400" v-else>
{{ t('usage.standardCost') }}:
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
</div>
</div>
......
......@@ -81,7 +81,8 @@
</template>
<template #cell-cost="{ row }">
<div class="flex items-center gap-1.5 text-sm">
<div class="text-sm">
<div class="flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<div
......@@ -94,6 +95,10 @@
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
</div>
</div>
</template>
<template #cell-first_token="{ row }">
......@@ -202,14 +207,24 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
</span>
</div>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
......
......@@ -429,6 +429,9 @@ export default {
totalCost: 'Total Cost',
standardCost: 'Standard',
actualCost: 'Actual',
userBilled: 'User billed',
accountBilled: 'Account billed',
accountMultiplier: 'Account rate',
avgDuration: 'Avg Duration',
inSelectedRange: 'in selected range',
perRequest: 'per request',
......@@ -1059,6 +1062,7 @@ export default {
concurrencyStatus: 'Concurrency',
notes: 'Notes',
priority: 'Priority',
billingRateMultiplier: 'Billing Rate',
weight: 'Weight',
status: 'Status',
schedulable: 'Schedulable',
......@@ -1226,6 +1230,8 @@ export default {
concurrency: 'Concurrency',
priority: 'Priority',
priorityHint: 'Lower value accounts are used first',
billingRateMultiplier: 'Billing Rate Multiplier',
billingRateMultiplierHint: '>=0, 0 means free. Affects account billing only',
expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority',
......
......@@ -426,6 +426,9 @@ export default {
totalCost: '总消费',
standardCost: '标准',
actualCost: '实际',
userBilled: '用户扣费',
accountBilled: '账号计费',
accountMultiplier: '账号倍率',
avgDuration: '平均耗时',
inSelectedRange: '所选范围内',
perRequest: '每次请求',
......@@ -1109,6 +1112,7 @@ export default {
concurrencyStatus: '并发',
notes: '备注',
priority: '优先级',
billingRateMultiplier: '账号倍率',
weight: '权重',
status: '状态',
schedulable: '调度',
......@@ -1360,6 +1364,8 @@ export default {
concurrency: '并发数',
priority: '优先级',
priorityHint: '优先级越小的账号优先使用',
billingRateMultiplier: '账号计费倍率',
billingRateMultiplierHint: '>=0,0 表示该账号计费为 0;仅影响账号计费口径',
expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高',
......
......@@ -428,6 +428,7 @@ export interface Account {
concurrency: number
current_concurrency?: number // Real-time concurrency count from Redis
priority: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
status: 'active' | 'inactive' | 'error'
error_message: string | null
last_used_at: string | null
......@@ -457,7 +458,9 @@ export interface Account {
export interface WindowStats {
requests: number
tokens: number
cost: number
cost: number // Account cost (account multiplier)
standard_cost?: number
user_cost?: number
}
export interface UsageProgress {
......@@ -522,6 +525,7 @@ export interface CreateAccountRequest {
proxy_id?: number | null
concurrency?: number
priority?: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
......@@ -537,6 +541,7 @@ export interface UpdateAccountRequest {
proxy_id?: number | null
concurrency?: number
priority?: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
schedulable?: boolean
status?: 'active' | 'inactive'
group_ids?: number[]
......@@ -593,6 +598,7 @@ export interface UsageLog {
total_cost: number
actual_cost: number
rate_multiplier: number
account_rate_multiplier?: number | null
stream: boolean
duration_ms: number
......@@ -852,23 +858,27 @@ export interface AccountUsageHistory {
requests: number
tokens: number
cost: number
actual_cost: number
actual_cost: number // Account cost (account multiplier)
user_cost: number // User/API key billed cost (group multiplier)
}
export interface AccountUsageSummary {
days: number
actual_days_used: number
total_cost: number
total_cost: number // Account cost (account multiplier)
total_user_cost: number
total_standard_cost: number
total_requests: number
total_tokens: number
avg_daily_cost: number
avg_daily_cost: number // Account cost
avg_daily_user_cost: number
avg_daily_requests: number
avg_daily_tokens: number
avg_duration_ms: number
today: {
date: string
cost: number
user_cost: number
requests: number
tokens: number
} | null
......@@ -876,6 +886,7 @@ export interface AccountUsageSummary {
date: string
label: string
cost: number
user_cost: number
requests: number
} | null
highest_request_day: {
......@@ -883,6 +894,7 @@ export interface AccountUsageSummary {
label: string
requests: number
cost: number
user_cost: number
} | null
}
......
......@@ -61,6 +61,11 @@
<template #cell-usage="{ row }">
<AccountUsageCell :account="row" />
</template>
<template #cell-rate_multiplier="{ row }">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
</span>
</template>
<template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template>
......@@ -193,6 +198,7 @@ const cols = computed(() => {
c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
......
......@@ -94,7 +94,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
]
......@@ -115,8 +115,10 @@ const exportToExcel = async () => {
log.cache_read_cost?.toFixed(6) || '0.000000',
log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00',
(log.account_rate_multiplier ?? 1).toFixed(2),
log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6),
log.first_token_ms ?? '',
log.duration_ms,
log.request_id || '',
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment