merge: sync upstream/main before PR

This commit is contained in:
Wang Lvyuan
2026-03-19 16:37:28 +08:00
107 changed files with 2973 additions and 341 deletions

View File

@@ -82,6 +82,7 @@
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
:show-now-when-idle="true"
color="indigo"
/>
<UsageProgressBar
@@ -90,6 +91,7 @@
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
:window-stats="usageInfo.seven_day.window_stats"
:show-now-when-idle="true"
color="emerald"
/>
</div>

View File

@@ -48,7 +48,7 @@
</span>
<!-- Reset time -->
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
<span v-if="shouldShowResetTime" class="shrink-0 text-[10px] text-gray-400">
{{ formatResetTime }}
</span>
</div>
@@ -68,6 +68,7 @@ const props = defineProps<{
resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple' | 'amber'
windowStats?: WindowStats | null
showNowWhenIdle?: boolean
}>()
const { t } = useI18n()
@@ -139,9 +140,20 @@ const displayPercent = computed(() => {
return percent > 999 ? '>999%' : `${percent}%`
})
const shouldShowResetTime = computed(() => {
if (props.resetsAt) return true
return Boolean(props.showNowWhenIdle && props.utilization <= 0)
})
// Format reset time
const formatResetTime = computed(() => {
// For rolling windows, when utilization is 0%, treat as immediately available.
if (props.showNowWhenIdle && props.utilization <= 0) {
return '现在'
}
if (!props.resetsAt) return '-'
const date = new Date(props.resetsAt)
const diffMs = date.getTime() - now.value.getTime()

View File

@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UsageProgressBar from '../UsageProgressBar.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
describe('UsageProgressBar', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-17T00:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('showNowWhenIdle=true 且利用率为 0 时显示“现在”', () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: '5h',
utilization: 0,
resetsAt: '2026-03-17T02:30:00Z',
showNowWhenIdle: true,
color: 'indigo'
}
})
expect(wrapper.text()).toContain('现在')
expect(wrapper.text()).not.toContain('2h 30m')
})
it('showNowWhenIdle=true 但利用率大于 0 时显示倒计时', () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: '7d',
utilization: 12,
resetsAt: '2026-03-17T02:30:00Z',
showNowWhenIdle: true,
color: 'emerald'
}
})
expect(wrapper.text()).toContain('2h 30m')
expect(wrapper.text()).not.toContain('现在')
})
it('showNowWhenIdle=false 时保持原有倒计时行为', () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: '1d',
utilization: 0,
resetsAt: '2026-03-17T02:30:00Z',
showNowWhenIdle: false,
color: 'indigo'
}
})
expect(wrapper.text()).toContain('2h 30m')
expect(wrapper.text()).not.toContain('现在')
})
})

View File

@@ -25,8 +25,16 @@
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
</template>
<template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<template #cell-model="{ row }">
<div v-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5"></span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
</template>
<template #cell-reasoning_effort="{ row }">

View File

@@ -1,10 +1,10 @@
<template>
<div class="card p-4">
<div class="mb-4 flex items-start justify-between gap-3">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ title || t('usage.endpointDistribution') }}
</h3>
<div class="flex flex-col items-end gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<div
v-if="showSourceToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"

View File

@@ -6,7 +6,42 @@
? t('admin.dashboard.modelDistribution')
: t('admin.dashboard.spendingRankingTitle') }}
</h3>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<div
v-if="showSourceToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'requested'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'requested')"
>
{{ t('usage.requestedModel') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'upstream'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'upstream')"
>
{{ t('usage.upstreamModel') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="source === 'mapping'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:source', 'mapping')"
>
{{ t('usage.mapping') }}
</button>
</div>
<div
v-if="showMetricToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
@@ -215,9 +250,13 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
type DistributionMetric = 'tokens' | 'actual_cost'
type ModelSource = 'requested' | 'upstream' | 'mapping'
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
const props = withDefaults(defineProps<{
modelStats: ModelStat[]
upstreamModelStats?: ModelStat[]
mappingModelStats?: ModelStat[]
source?: ModelSource
enableRankingView?: boolean
rankingItems?: UserSpendingRankingItem[]
rankingTotalActualCost?: number
@@ -225,12 +264,16 @@ const props = withDefaults(defineProps<{
rankingTotalTokens?: number
loading?: boolean
metric?: DistributionMetric
showSourceToggle?: boolean
showMetricToggle?: boolean
rankingLoading?: boolean
rankingError?: boolean
startDate?: string
endDate?: string
}>(), {
upstreamModelStats: () => [],
mappingModelStats: () => [],
source: 'requested',
enableRankingView: false,
rankingItems: () => [],
rankingTotalActualCost: 0,
@@ -238,6 +281,7 @@ const props = withDefaults(defineProps<{
rankingTotalTokens: 0,
loading: false,
metric: 'tokens',
showSourceToggle: false,
showMetricToggle: false,
rankingLoading: false,
rankingError: false
@@ -261,6 +305,7 @@ const toggleBreakdown = async (type: string, id: string) => {
start_date: props.startDate,
end_date: props.endDate,
model: id,
model_source: props.source,
})
breakdownItems.value = res.users || []
} catch {
@@ -272,6 +317,7 @@ const toggleBreakdown = async (type: string, id: string) => {
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
'update:source': [value: ModelSource]
'ranking-click': [item: UserSpendingRankingItem]
}>()
@@ -294,14 +340,19 @@ const chartColors = [
]
const displayModelStats = computed(() => {
if (!props.modelStats?.length) return []
const sourceStats = props.source === 'upstream'
? props.upstreamModelStats
: props.source === 'mapping'
? props.mappingModelStats
: props.modelStats
if (!sourceStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
})
const chartData = computed(() => {
if (!props.modelStats?.length) return null
if (!displayModelStats.value.length) return null
return {
labels: displayModelStats.value.map((m) => m.model),

View File

@@ -0,0 +1,84 @@
<template>
<div class="flex flex-col gap-1">
<!-- 并发槽位 -->
<div class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(concurrencyUsed, concurrencyMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ concurrencyUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ concurrencyMax }}</span>
</span>
</div>
<!-- 会话数 -->
<div v-if="sessionsMax > 0" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(sessionsUsed, sessionsMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span class="font-mono">{{ sessionsUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ sessionsMax }}</span>
</span>
</div>
<!-- RPM -->
<div v-if="rpmMax > 0" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(rpmUsed, rpmMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="font-mono">{{ rpmUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ rpmMax }}</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
concurrencyUsed: number
concurrencyMax: number
sessionsUsed: number
sessionsMax: number
rpmUsed: number
rpmMax: number
}
withDefaults(defineProps<Props>(), {
concurrencyUsed: 0,
concurrencyMax: 0,
sessionsUsed: 0,
sessionsMax: 0,
rpmUsed: 0,
rpmMax: 0
})
function capacityClass(used: number, max: number): string {
if (max > 0 && used >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (used > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}
</script>