Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
b764d3b8
Commit
b764d3b8
authored
Mar 12, 2026
by
ius
Browse files
Merge remote-tracking branch 'origin/main' into feat/billing-ledger-decouple-usage-log-20260312
parents
611fd884
826090e0
Changes
76
Show whitespace changes
Inline
Side-by-side
frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
View file @
b764d3b8
...
...
@@ -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
'
...
...
frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
View file @
b764d3b8
...
...
@@ -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
{
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
b764d3b8
...
...
@@ -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
)
{
...
...
frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts
0 → 100644
View file @
b764d3b8
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
'
)
})
})
frontend/src/components/charts/GroupDistributionChart.vue
View file @
b764d3b8
<
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=
"
g
roupStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
v-else-if=
"
displayG
roupStats.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
g
roupStats"
v-for=
"group in
displayG
roupStats"
: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
.
g
roupStats
.
map
((
g
)
=>
g
.
group_name
||
String
(
g
.
group_id
)),
labels
:
displayG
roupStats
.
value
.
map
((
g
)
=>
g
.
group_name
||
String
(
g
.
group_id
)),
datasets
:
[
{
data
:
props
.
g
roupStats
.
map
((
g
)
=>
g
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
g
roupStats
.
length
),
data
:
displayG
roupStats
.
value
.
map
((
g
)
=>
props
.
metric
===
'
actual_cost
'
?
g
.
actual_cost
:
g
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayG
roupStats
.
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
}
%)`
}
}
}
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
b764d3b8
<
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=
"
m
odelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
v-else-if=
"
displayM
odelStats.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
m
odelStats"
v-for=
"model in
displayM
odelStats"
: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
.
m
odelStats
.
map
((
m
)
=>
m
.
model
),
labels
:
displayM
odelStats
.
value
.
map
((
m
)
=>
m
.
model
),
datasets
:
[
{
data
:
props
.
m
odelStats
.
map
((
m
)
=>
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
m
odelStats
.
length
),
data
:
displayM
odelStats
.
value
.
map
((
m
)
=>
props
.
metric
===
'
actual_cost
'
?
m
.
actual_cost
:
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayM
odelStats
.
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
}
%)`
}
}
}
...
...
frontend/src/components/charts/__tests__/GroupDistributionChart.spec.ts
0 → 100644
View file @
b764d3b8
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%)
'
)
})
})
frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts
0 → 100644
View file @
b764d3b8
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%)
'
)
})
})
frontend/src/components/keys/UseKeyModal.vue
View file @
b764d3b8
...
...
@@ -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
:
{
...
...
frontend/src/composables/__tests__/useModelWhitelist.spec.ts
View file @
b764d3b8
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
({
...
...
frontend/src/composables/useModelWhitelist.ts
View file @
b764d3b8
...
...
@@ -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
'
},
// 精确映射
...
...
frontend/src/i18n/locales/en.ts
View file @
b764d3b8
...
...
@@ -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
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
b764d3b8
...
...
@@ -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 账号能力
'
,
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
b764d3b8
...
...
@@ -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
()
...
...
frontend/src/views/admin/UsageView.vue
View file @
b764d3b8
...
...
@@ -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
:
''
})
...
...
frontend/src/views/admin/__tests__/UsageView.spec.ts
0 → 100644
View file @
b764d3b8
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
)
})
})
Prev
1
2
3
4
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment