Commit b764d3b8 authored by ius's avatar ius
Browse files

Merge remote-tracking branch 'origin/main' into feat/billing-ledger-decouple-usage-log-20260312

parents 611fd884 826090e0
......@@ -32,6 +32,10 @@ describe('AccountUsageCell', () => {
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
'gemini-2.5-flash-image': {
utilization: 45,
reset_time: '2026-03-01T11:00:00Z'
},
'gemini-3.1-flash-image': {
utilization: 20,
reset_time: '2026-03-01T10:00:00Z'
......
......@@ -18,6 +18,10 @@ vi.mock('@/api/admin', () => ({
}
}))
vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn()
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
......
......@@ -61,6 +61,17 @@
{{ t('admin.accounts.soraTestHint') }}
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<!-- Terminal Output -->
<div class="group relative">
<div
......@@ -115,6 +126,27 @@
</button>
</div>
<div v-if="generatedImages.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.geminiImagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="(image, index) in generatedImages"
:key="`${image.url}-${index}`"
:href="image.url"
target="_blank"
rel="noopener noreferrer"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
>
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
......@@ -125,7 +157,13 @@
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
{{
isSoraAccount
? t('admin.accounts.soraTestMode')
: supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
</div>
</div>
......@@ -182,6 +220,7 @@ import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import TextArea from '@/components/common/TextArea.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
......@@ -195,6 +234,11 @@ interface OutputLine {
class: string
}
interface PreviewImage {
url: string
mimeType?: string
}
const props = defineProps<{
show: boolean
account: Account | null
......@@ -211,15 +255,37 @@ const streamingContent = ref('')
const errorMessage = ref('')
const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
const isSoraAccount = computed(() => props.account?.platform === 'sora')
const generatedImages = ref<PreviewImage[]>([])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
if (isSoraAccount.value) return false
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
return [...models].sort((a, b) => {
const aPriority = priorityMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bPriority = priorityMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (aPriority !== bPriority) return aPriority - bPriority
return 0
})
}
// Load available models when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
resetState()
await loadAvailableModels()
} else {
......@@ -228,6 +294,12 @@ watch(
}
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
}
})
const loadAvailableModels = async () => {
if (!props.account) return
if (props.account.platform === 'sora') {
......@@ -240,17 +312,14 @@ const loadAvailableModels = async () => {
loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading
try {
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
const models = await adminAPI.accounts.getAvailableModels(props.account.id)
availableModels.value = props.account.platform === 'gemini' || props.account.platform === 'antigravity'
? sortTestModels(models)
: models
// Default selection by platform
if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') {
const preferred =
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
selectedModelId.value = preferred?.id || availableModels.value[0].id
selectedModelId.value = availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
......@@ -272,6 +341,7 @@ const resetState = () => {
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
}
const handleClose = () => {
......@@ -325,7 +395,12 @@ const startTest = async () => {
'Content-Type': 'application/json'
},
body: JSON.stringify(
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
isSoraAccount.value
? {}
: {
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
}
)
})
......@@ -376,6 +451,8 @@ const handleEvent = (event: {
model?: string
success?: boolean
error?: string
image_url?: string
mime_type?: string
}) => {
switch (event.type) {
case 'test_start':
......@@ -384,7 +461,11 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
isSoraAccount.value
? t('admin.accounts.soraTestingFlow')
: supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
addLine('', 'text-gray-300')
......@@ -398,6 +479,16 @@ const handleEvent = (event: {
}
break
case 'image':
if (event.image_url) {
generatedImages.value.push({
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
......
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AccountTestModal from '../AccountTestModal.vue'
const { getAvailableModels, copyToClipboard } = vi.hoisted(() => ({
getAvailableModels: vi.fn(),
copyToClipboard: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
getAvailableModels
}
}
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
const messages: Record<string, string> = {
'admin.accounts.geminiImagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
}
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) => {
if (key === 'admin.accounts.geminiImageReceived' && params?.count) {
return `received-${params.count}`
}
return messages[key] || key
}
})
}
})
function createStreamResponse(lines: string[]) {
const encoder = new TextEncoder()
const chunks = lines.map((line) => encoder.encode(line))
let index = 0
return {
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockImplementation(async () => {
if (index < chunks.length) {
return { done: false, value: chunks[index++] }
}
return { done: true, value: undefined }
})
})
}
} as Response
}
function mountModal() {
return mount(AccountTestModal, {
props: {
show: false,
account: {
id: 42,
name: 'Gemini Image Test',
platform: 'gemini',
type: 'apikey',
status: 'active'
}
} as any,
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' },
Select: { template: '<div class="select-stub"></div>' },
TextArea: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: '<textarea class="textarea-stub" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
Icon: true
}
}
})
}
describe('AccountTestModal', () => {
beforeEach(() => {
getAvailableModels.mockResolvedValue([
{ id: 'gemini-2.0-flash', display_name: 'Gemini 2.0 Flash' },
{ id: 'gemini-2.5-flash-image', display_name: 'Gemini 2.5 Flash Image' },
{ id: 'gemini-3.1-flash-image', display_name: 'Gemini 3.1 Flash Image' }
])
copyToClipboard.mockReset()
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: vi.fn((key: string) => (key === 'auth_token' ? 'test-token' : null)),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
},
configurable: true
})
global.fetch = vi.fn().mockResolvedValue(
createStreamResponse([
'data: {"type":"test_start","model":"gemini-2.5-flash-image"}\n',
'data: {"type":"image","image_url":"data:image/png;base64,QUJD","mime_type":"image/png"}\n',
'data: {"type":"test_complete","success":true}\n'
])
) as any
})
afterEach(() => {
vi.restoreAllMocks()
})
it('gemini 图片模型测试会携带提示词并渲染图片预览', async () => {
const wrapper = mountModal()
await wrapper.setProps({ show: true })
await flushPromises()
const promptInput = wrapper.find('textarea.textarea-stub')
expect(promptInput.exists()).toBe(true)
await promptInput.setValue('draw a tiny orange cat astronaut')
const buttons = wrapper.findAll('button')
const startButton = buttons.find((button) => button.text().includes('admin.accounts.startTest'))
expect(startButton).toBeTruthy()
await startButton!.trigger('click')
await flushPromises()
await flushPromises()
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, request] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
model_id: 'gemini-3.1-flash-image',
prompt: 'draw a tiny orange cat astronaut'
})
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
expect(preview.exists()).toBe(true)
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
})
})
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.groupDistribution') }}
</h3>
<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"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'tokens'
? '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:metric', 'tokens')"
>
{{ t('admin.dashboard.metricTokens') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'actual_cost'
? '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:metric', 'actual_cost')"
>
{{ t('admin.dashboard.metricActualCost') }}
</button>
</div>
</div>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="groupStats.length > 0 && chartData" class="flex items-center gap-6">
<div v-else-if="displayGroupStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
......@@ -23,7 +50,7 @@
</thead>
<tbody>
<tr
v-for="group in groupStats"
v-for="group in displayGroupStats"
:key="group.group_id"
class="border-t border-gray-100 dark:border-gray-700"
>
......@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
const props = defineProps<{
type DistributionMetric = 'tokens' | 'actual_cost'
const props = withDefaults(defineProps<{
groupStats: GroupStat[]
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
}>(), {
loading: false,
metric: 'tokens',
showMetricToggle: false,
})
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
}>()
const chartColors = [
......@@ -89,15 +128,22 @@ const chartColors = [
'#84cc16'
]
const displayGroupStats = computed(() => {
if (!props.groupStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...props.groupStats].sort((a, b) => b[metricKey] - a[metricKey])
})
const chartData = computed(() => {
if (!props.groupStats?.length) return null
return {
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
labels: displayGroupStats.value.map((g) => g.group_name || String(g.group_id)),
datasets: [
{
data: props.groupStats.map((g) => g.total_tokens),
backgroundColor: chartColors.slice(0, props.groupStats.length),
data: displayGroupStats.value.map((g) => props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens),
backgroundColor: chartColors.slice(0, displayGroupStats.value.length),
borderWidth: 0
}
]
......@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
const formattedValue = props.metric === 'actual_cost'
? `$${formatCost(value)}`
: formatTokens(value)
return `${context.label}: ${formattedValue} (${percentage}%)`
}
}
}
......
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.modelDistribution') }}
</h3>
<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"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'tokens'
? '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:metric', 'tokens')"
>
{{ t('admin.dashboard.metricTokens') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'actual_cost'
? '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:metric', 'actual_cost')"
>
{{ t('admin.dashboard.metricActualCost') }}
</button>
</div>
</div>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
<div v-else-if="displayModelStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
......@@ -23,7 +50,7 @@
</thead>
<tbody>
<tr
v-for="model in modelStats"
v-for="model in displayModelStats"
:key="model.model"
class="border-t border-gray-100 dark:border-gray-700"
>
......@@ -71,9 +98,21 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
const props = defineProps<{
type DistributionMetric = 'tokens' | 'actual_cost'
const props = withDefaults(defineProps<{
modelStats: ModelStat[]
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
}>(), {
loading: false,
metric: 'tokens',
showMetricToggle: false,
})
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
}>()
const chartColors = [
......@@ -89,15 +128,22 @@ const chartColors = [
'#84cc16'
]
const displayModelStats = computed(() => {
if (!props.modelStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
})
const chartData = computed(() => {
if (!props.modelStats?.length) return null
return {
labels: props.modelStats.map((m) => m.model),
labels: displayModelStats.value.map((m) => m.model),
datasets: [
{
data: props.modelStats.map((m) => m.total_tokens),
backgroundColor: chartColors.slice(0, props.modelStats.length),
data: displayModelStats.value.map((m) => props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens),
backgroundColor: chartColors.slice(0, displayModelStats.value.length),
borderWidth: 0
}
]
......@@ -116,8 +162,11 @@ const doughnutOptions = computed(() => ({
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
const formattedValue = props.metric === 'actual_cost'
? `$${formatCost(value)}`
: formatTokens(value)
return `${context.label}: ${formattedValue} (${percentage}%)`
}
}
}
......
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import GroupDistributionChart from '../GroupDistributionChart.vue'
const messages: Record<string, string> = {
'admin.dashboard.groupDistribution': 'Group Distribution',
'admin.dashboard.group': 'Group',
'admin.dashboard.noGroup': 'No Group',
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
'admin.dashboard.noDataAvailable': 'No data available',
}
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
vi.mock('vue-chartjs', () => ({
Doughnut: {
props: ['data'],
template: '<div class="chart-data">{{ JSON.stringify(data) }}</div>',
},
}))
describe('GroupDistributionChart', () => {
const groupStats = [
{
group_id: 1,
group_name: 'group-a',
requests: 9,
total_tokens: 1200,
cost: 1.8,
actual_cost: 0.1,
},
{
group_id: 2,
group_name: 'group-b',
requests: 4,
total_tokens: 600,
cost: 0.7,
actual_cost: 0.9,
},
]
it('uses total_tokens and token ordering by default', () => {
const wrapper = mount(GroupDistributionChart, {
props: {
groupStats,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
const chartData = JSON.parse(wrapper.find('.chart-data').text())
expect(chartData.labels).toEqual(['group-a', 'group-b'])
expect(chartData.datasets[0].data).toEqual([1200, 600])
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('group-a')
expect(rows[1].text()).toContain('group-b')
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
const label = options.plugins.tooltip.callbacks.label({
label: 'group-a',
raw: 1200,
dataset: { data: [1200, 600] },
})
expect(label).toBe('group-a: 1.20K (66.7%)')
})
it('uses actual_cost and reorders rows in actual cost mode', () => {
const wrapper = mount(GroupDistributionChart, {
props: {
groupStats,
metric: 'actual_cost',
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
const chartData = JSON.parse(wrapper.find('.chart-data').text())
expect(chartData.labels).toEqual(['group-b', 'group-a'])
expect(chartData.datasets[0].data).toEqual([0.9, 0.1])
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('group-b')
expect(rows[1].text()).toContain('group-a')
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
const label = options.plugins.tooltip.callbacks.label({
label: 'group-b',
raw: 0.9,
dataset: { data: [0.9, 0.1] },
})
expect(label).toBe('group-b: $0.900 (90.0%)')
})
})
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ModelDistributionChart from '../ModelDistributionChart.vue'
const messages: Record<string, string> = {
'admin.dashboard.modelDistribution': 'Model Distribution',
'admin.dashboard.model': 'Model',
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
'admin.dashboard.noDataAvailable': 'No data available',
}
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
vi.mock('vue-chartjs', () => ({
Doughnut: {
props: ['data'],
template: '<div class="chart-data">{{ JSON.stringify(data) }}</div>',
},
}))
describe('ModelDistributionChart', () => {
const modelStats = [
{
model: 'model-a',
requests: 8,
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: 0,
cache_read_tokens: 0,
total_tokens: 1000,
cost: 1.5,
actual_cost: 0.2,
},
{
model: 'model-b',
requests: 3,
input_tokens: 40,
output_tokens: 20,
cache_creation_tokens: 0,
cache_read_tokens: 0,
total_tokens: 500,
cost: 0.5,
actual_cost: 1.4,
},
]
it('uses total_tokens and token ordering by default', () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
const chartData = JSON.parse(wrapper.find('.chart-data').text())
expect(chartData.labels).toEqual(['model-a', 'model-b'])
expect(chartData.datasets[0].data).toEqual([1000, 500])
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('model-a')
expect(rows[1].text()).toContain('model-b')
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
const label = options.plugins.tooltip.callbacks.label({
label: 'model-a',
raw: 1000,
dataset: { data: [1000, 500] },
})
expect(label).toBe('model-a: 1.00K (66.7%)')
})
it('uses actual_cost and reorders rows in actual cost mode', () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats,
metric: 'actual_cost',
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
const chartData = JSON.parse(wrapper.find('.chart-data').text())
expect(chartData.labels).toEqual(['model-b', 'model-a'])
expect(chartData.datasets[0].data).toEqual([1.4, 0.2])
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('model-b')
expect(rows[1].text()).toContain('model-a')
const options = (wrapper.vm as any).$?.setupState.doughnutOptions
const label = options.plugins.tooltip.callbacks.label({
label: 'model-b',
raw: 1.4,
dataset: { data: [1.4, 0.2] },
})
expect(label).toBe('model-b: $1.40 (87.5%)')
})
})
......@@ -959,6 +959,23 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
},
'gemini-2.5-flash-image': {
name: 'Gemini 2.5 Flash Image',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image'],
output: ['image']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3.1-flash-image': {
name: 'Gemini 3.1 Flash Image',
limit: {
......
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn()
}))
import { buildModelMappingObject, getModelsByPlatform } from '../useModelWhitelist'
describe('useModelWhitelist', () => {
......@@ -12,10 +17,27 @@ describe('useModelWhitelist', () => {
it('antigravity 模型列表包含图片模型兼容项', () => {
const models = getModelsByPlatform('antigravity')
expect(models).toContain('gemini-2.5-flash-image')
expect(models).toContain('gemini-3.1-flash-image')
expect(models).toContain('gemini-3-pro-image')
})
it('gemini 模型列表包含原生生图模型', () => {
const models = getModelsByPlatform('gemini')
expect(models).toContain('gemini-2.5-flash-image')
expect(models).toContain('gemini-3.1-flash-image')
expect(models.indexOf('gemini-3.1-flash-image')).toBeLessThan(models.indexOf('gemini-2.0-flash'))
expect(models.indexOf('gemini-2.5-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash'))
})
it('antigravity 模型列表会把新的 Gemini 图片模型排在前面', () => {
const models = getModelsByPlatform('antigravity')
expect(models.indexOf('gemini-3.1-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash'))
expect(models.indexOf('gemini-2.5-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash-lite'))
})
it('whitelist 模式会忽略通配符条目', () => {
const mapping = buildModelMappingObject('whitelist', ['claude-*', 'gemini-3.1-flash-image'], [])
expect(mapping).toEqual({
......
......@@ -51,6 +51,8 @@ export const claudeModels = [
const geminiModels = [
// Keep in sync with backend curated Gemini lists.
// This list is intentionally conservative (models commonly available across OAuth/API key).
'gemini-3.1-flash-image',
'gemini-2.5-flash-image',
'gemini-2.0-flash',
'gemini-2.5-flash',
'gemini-2.5-pro',
......@@ -85,6 +87,8 @@ const antigravityModels = [
'claude-sonnet-4-5',
'claude-sonnet-4-5-thinking',
// Gemini 2.5 系列
'gemini-3.1-flash-image',
'gemini-2.5-flash-image',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.5-flash-thinking',
......@@ -96,7 +100,6 @@ const antigravityModels = [
// Gemini 3.1 系列
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
'gemini-3.1-flash-image',
'gemini-3-pro-image',
// 其他
'gpt-oss-120b-medium',
......@@ -291,7 +294,9 @@ const soraPresetMappings: { label: string; from: string; to: string; color: stri
const geminiPresetMappings = [
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
{ label: '2.5 Image', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: '3.1 Image', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }
]
// Antigravity 预设映射(支持通配符)
......@@ -314,6 +319,9 @@ const antigravityPresetMappings = [
// Gemini 通配符映射
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '2.5-Flash-Image透传', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3.1-Flash-Image透传', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3-Pro-Image→3.1', from: 'gemini-3-pro-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
{ label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
// 精确映射
......
......@@ -950,6 +950,8 @@ export default {
hour: 'Hour',
modelDistribution: 'Model Distribution',
groupDistribution: 'Group Usage Distribution',
metricTokens: 'By Tokens',
metricActualCost: 'By Actual Cost',
tokenUsageTrend: 'Token Usage Trend',
userUsageTrend: 'User Usage Trend (Top 12)',
model: 'Model',
......@@ -1570,6 +1572,11 @@ export default {
adjust: 'Adjust',
adjusting: 'Adjusting...',
revoke: 'Revoke',
resetQuota: 'Reset Quota',
resetQuotaTitle: 'Reset Usage Quota',
resetQuotaConfirm: "Reset the daily and weekly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.",
quotaResetSuccess: 'Quota reset successfully',
failedToResetQuota: 'Failed to reset quota',
noSubscriptionsYet: 'No subscriptions yet',
assignFirstSubscription: 'Assign a subscription to get started.',
subscriptionAssigned: 'Subscription assigned successfully',
......@@ -2411,6 +2418,7 @@ export default {
connectedToApi: 'Connected to API',
usingModel: 'Using model: {model}',
sendingTestMessage: 'Sending test message: "hi"',
sendingGeminiImageRequest: 'Sending Gemini image generation test request...',
response: 'Response:',
startTest: 'Start Test',
testing: 'Testing...',
......@@ -2422,6 +2430,13 @@ export default {
selectTestModel: 'Select Test Model',
testModel: 'Test model',
testPrompt: 'Prompt: "hi"',
geminiImagePromptLabel: 'Image prompt',
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.',
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
......
......@@ -963,6 +963,8 @@ export default {
hour: '按小时',
modelDistribution: '模型分布',
groupDistribution: '分组使用分布',
metricTokens: '按 Token',
metricActualCost: '按实际消费',
tokenUsageTrend: 'Token 使用趋势',
noDataAvailable: '暂无数据',
model: '模型',
......@@ -1658,6 +1660,11 @@ export default {
adjust: '调整',
adjusting: '调整中...',
revoke: '撤销',
resetQuota: '重置配额',
resetQuotaTitle: '重置用量配额',
resetQuotaConfirm: "确定要重置 '{user}' 的每日和每周用量配额吗?用量将归零并从今天开始重新计算。",
quotaResetSuccess: '配额重置成功',
failedToResetQuota: '重置配额失败',
noSubscriptionsYet: '暂无订阅',
assignFirstSubscription: '分配一个订阅以开始使用。',
subscriptionAssigned: '订阅分配成功',
......@@ -2540,6 +2547,7 @@ export default {
connectedToApi: '已连接到 API',
usingModel: '使用模型:{model}',
sendingTestMessage: '发送测试消息:"hi"',
sendingGeminiImageRequest: '发送 Gemini 生图测试请求...',
response: '响应:',
startTest: '开始测试',
retry: '重试',
......@@ -2550,6 +2558,13 @@ export default {
selectTestModel: '选择测试模型',
testModel: '测试模型',
testPrompt: '提示词:"hi"',
geminiImagePromptLabel: '生图提示词',
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标:Sora 账号能力',
......
......@@ -370,6 +370,15 @@
<Icon name="calendar" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.adjust') }}</span>
</button>
<button
v-if="row.status === 'active'"
@click="handleResetQuota(row)"
:disabled="resettingQuota && resettingSubscription?.id === row.id"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon name="refresh" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.resetQuota') }}</span>
</button>
<button
v-if="row.status === 'active'"
@click="handleRevoke(row)"
......@@ -618,6 +627,17 @@
@confirm="confirmRevoke"
@cancel="showRevokeDialog = false"
/>
<!-- Reset Quota Confirmation Dialog -->
<ConfirmDialog
:show="showResetQuotaConfirm"
:title="t('admin.subscriptions.resetQuotaTitle')"
:message="t('admin.subscriptions.resetQuotaConfirm', { user: resettingSubscription?.user?.email })"
:confirm-text="t('admin.subscriptions.resetQuota')"
:cancel-text="t('common.cancel')"
@confirm="confirmResetQuota"
@cancel="showResetQuotaConfirm = false"
/>
</AppLayout>
</template>
......@@ -812,7 +832,10 @@ const pagination = reactive({
const showAssignModal = ref(false)
const showExtendModal = ref(false)
const showRevokeDialog = ref(false)
const showResetQuotaConfirm = ref(false)
const submitting = ref(false)
const resettingSubscription = ref<UserSubscription | null>(null)
const resettingQuota = ref(false)
const extendingSubscription = ref<UserSubscription | null>(null)
const revokingSubscription = ref<UserSubscription | null>(null)
......@@ -1121,6 +1144,29 @@ const confirmRevoke = async () => {
}
}
const handleResetQuota = (subscription: UserSubscription) => {
resettingSubscription.value = subscription
showResetQuotaConfirm.value = true
}
const confirmResetQuota = async () => {
if (!resettingSubscription.value) return
if (resettingQuota.value) return
resettingQuota.value = true
try {
await adminAPI.subscriptions.resetQuota(resettingSubscription.value.id, { daily: true, weekly: true })
appStore.showSuccess(t('admin.subscriptions.quotaResetSuccess'))
showResetQuotaConfirm.value = false
resettingSubscription.value = null
await loadSubscriptions()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToResetQuota'))
console.error('Error resetting quota:', error)
} finally {
resettingQuota.value = false
}
}
// Helper functions
const getDaysRemaining = (expiresAt: string): number | null => {
const now = new Date()
......
......@@ -13,8 +13,18 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
<GroupDistributionChart :group-stats="groupStats" :loading="chartsLoading" />
<ModelDistributionChart
v-model:metric="modelDistributionMetric"
:model-stats="modelStats"
:loading="chartsLoading"
:show-metric-toggle="true"
/>
<GroupDistributionChart
v-model:metric="groupDistributionMetric"
:group-stats="groupStats"
:loading="chartsLoading"
:show-metric-toggle="true"
/>
</div>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
......@@ -93,8 +103,12 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
const { t } = useI18n()
const appStore = useAppStore()
type DistributionMetric = 'tokens' | 'actual_cost'
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
const modelDistributionMetric = ref<DistributionMetric>('tokens')
const groupDistributionMetric = ref<DistributionMetric>('tokens')
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
let chartReqSeq = 0
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
......
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
})
return {
list: vi.fn(),
getStats: vi.fn(),
getSnapshotV2: vi.fn(),
getById: vi.fn(),
}
})
const messages: Record<string, string> = {
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
}
vi.mock('@/api/admin', () => ({
adminAPI: {
usage: {
list,
getStats,
},
dashboard: {
getSnapshotV2,
},
users: {
getById,
},
},
}))
vi.mock('@/api/admin/usage', () => ({
adminUsageAPI: {
list: vi.fn(),
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}),
}))
vi.mock('@/utils/format', () => ({
formatReasoningEffort: (value: string | null | undefined) => value ?? '-',
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const UsageFiltersStub = { template: '<div><slot name="after-reset" /></div>' }
const ModelDistributionChartStub = {
props: ['metric'],
emits: ['update:metric'],
template: `
<div data-test="model-chart">
<span class="metric">{{ metric }}</span>
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
</div>
`,
}
const GroupDistributionChartStub = {
props: ['metric'],
emits: ['update:metric'],
template: `
<div data-test="group-chart">
<span class="metric">{{ metric }}</span>
<button class="switch-metric" @click="$emit('update:metric', 'actual_cost')">switch</button>
</div>
`,
}
describe('admin UsageView distribution metric toggles', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getById.mockReset()
list.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStats.mockResolvedValue({
total_requests: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_tokens: 0,
total_tokens: 0,
total_cost: 0,
total_actual_cost: 0,
average_duration_ms: 0,
})
getSnapshotV2.mockResolvedValue({
trend: [],
models: [],
groups: [],
})
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps model and group metric toggles independent without refetching chart data', async () => {
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
UsageStatsCards: true,
UsageFilters: UsageFiltersStub,
UsageTable: true,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
Pagination: true,
Select: true,
Icon: true,
TokenUsageTrend: true,
ModelDistributionChart: ModelDistributionChartStub,
GroupDistributionChart: GroupDistributionChartStub,
},
},
})
vi.advanceTimersByTime(120)
await flushPromises()
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
const modelChart = wrapper.find('[data-test="model-chart"]')
const groupChart = wrapper.find('[data-test="group-chart"]')
expect(modelChart.find('.metric').text()).toBe('tokens')
expect(groupChart.find('.metric').text()).toBe('tokens')
await modelChart.find('.switch-metric').trigger('click')
await flushPromises()
expect(modelChart.find('.metric').text()).toBe('actual_cost')
expect(groupChart.find('.metric').text()).toBe('tokens')
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
await groupChart.find('.switch-metric').trigger('click')
await flushPromises()
expect(modelChart.find('.metric').text()).toBe('actual_cost')
expect(groupChart.find('.metric').text()).toBe('actual_cost')
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
})
})
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