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
429f38d0
Commit
429f38d0
authored
Dec 26, 2025
by
shaw
Browse files
Merge PR #37: Add Gemini OAuth and Messages Compat Support
parents
2d89f366
2714be99
Changes
165
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/account/AccountTodayStatsCell.vue
View file @
429f38d0
...
...
@@ -2,9 +2,9 @@
<div>
<!-- Loading state -->
<div
v-if=
"loading"
class=
"space-y-0.5"
>
<div
class=
"h-3 w-12 bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"h-3 w-16 bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"h-3 w-10 bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"h-3 w-12
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-16
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-10
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
<!-- Error state -->
...
...
@@ -17,24 +17,28 @@
<!-- Requests -->
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Req:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatNumber
(
stats
.
requests
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatNumber
(
stats
.
requests
)
}}
</span>
</div>
<!-- Tokens -->
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Tok:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatTokens
(
stats
.
tokens
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatTokens
(
stats
.
tokens
)
}}
</span>
</div>
<!-- Cost -->
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Cost:
</span>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
}}
</span>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
}}
</span>
</div>
</div>
<!-- No data -->
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
</div>
</
template
>
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
429f38d0
<
template
>
<div
v-if=
"showUsageWindows"
>
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
<template
v-if=
"account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')"
>
<template
v-if=
"
account.platform === 'anthropic' &&
(account.type === 'oauth' || account.type === 'setup-token')
"
>
<!-- Loading state -->
<div
v-if=
"loading"
class=
"space-y-1.5"
>
<!-- OAuth: 3 rows, Setup Token: 1 row -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
w-8
h-1.5 bg-gray-200 dark:bg-gray-700
rounded-full animate-pulse
"
></div>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-1.5
w-8 animate-pulse rounded-full
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
<template
v-if=
"account.type === 'oauth'"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
w-8
h-1.5 bg-gray-200 dark:bg-gray-700
rounded-full animate-pulse
"
></div>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-1.5
w-8 animate-pulse rounded-full
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
<div
class=
"flex items-center gap-1"
>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
w-8
h-1.5 bg-gray-200 dark:bg-gray-700
rounded-full animate-pulse
"
></div>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-1.5
w-8 animate-pulse rounded-full
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
</
template
>
</div>
...
...
@@ -61,9 +66,7 @@
</div>
<!-- No data yet -->
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
</template>
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
...
...
@@ -97,9 +100,7 @@
</div>
<!-- Non-OAuth/Setup-Token accounts -->
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
</template>
<
script
setup
lang=
"ts"
>
...
...
@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
const
usageInfo
=
ref
<
AccountUsageInfo
|
null
>
(
null
)
// Show usage windows for OAuth and Setup Token accounts
const
showUsageWindows
=
computed
(
()
=>
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
const
showUsageWindows
=
computed
(
()
=>
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
// OpenAI Codex usage computed properties
const
hasCodexUsage
=
computed
(()
=>
{
const
extra
=
props
.
account
.
extra
return
extra
&&
(
return
(
extra
&&
// Check for new canonical fields first
extra
.
codex_5h_used_percent
!==
undefined
||
extra
.
codex_7d_used_percent
!==
undefined
||
// Fallback to legacy fields
extra
.
codex_primary_used_percent
!==
undefined
||
extra
.
codex_secondary_used_percent
!==
undefined
(
extra
.
codex_5h_used_percent
!==
undefined
||
extra
.
codex_7d_used_percent
!==
undefined
||
// Fallback to legacy fields
extra
.
codex_primary_used_percent
!==
undefined
||
extra
.
codex_secondary_used_percent
!==
undefined
)
)
})
...
...
@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
return
extra
.
codex_primary_used_percent
??
null
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
return
extra
.
codex_secondary_used_percent
??
null
}
...
...
@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
if
(
extra
.
codex_primary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_primary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
}
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
if
(
extra
.
codex_secondary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_secondary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
...
...
@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
return
extra
.
codex_primary_used_percent
??
null
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
return
extra
.
codex_secondary_used_percent
??
null
}
...
...
@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_primary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_primary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
}
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_secondary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_secondary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
429f38d0
...
...
@@ -2,9 +2,9 @@
<Modal
:show=
"show"
:title=
"t('admin.accounts.bulkEdit.title')"
size=
"lg"
@
close=
"handleClose"
>
<form
class=
"space-y-5"
@
submit.prevent=
"handleSubmit"
>
<!-- Info -->
<div
class=
"rounded-lg bg-blue-50 dark:bg-blue-900/20
p-4
"
>
<div
class=
"rounded-lg bg-blue-50
p-4
dark:bg-blue-900/20"
>
<p
class=
"text-sm text-blue-700 dark:text-blue-400"
>
<svg
class=
"
w-5 h-
5 inline
mr-1.
5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<svg
class=
"
mr-1.
5 inline
h-5 w-
5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
...
...
@@ -17,8 +17,8 @@
<
/div
>
<!--
Base
URL
(
API
Key
only
)
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableBaseUrl
"
...
...
@@ -31,7 +31,7 @@
type
=
"
text
"
:
disabled
=
"
!enableBaseUrl
"
class
=
"
input
"
:
class
=
"
!enableBaseUrl && '
opacity-50
cursor-not-allowed'
"
:
class
=
"
!enableBaseUrl && 'cursor-not-allowed
opacity-50
'
"
:
placeholder
=
"
t('admin.accounts.bulkEdit.baseUrlPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
...
...
@@ -40,8 +40,8 @@
<
/div
>
<!--
Model
restriction
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableModelRestriction
"
...
...
@@ -50,21 +50,21 @@
/>
<
/div
>
<
div
:
class
=
"
!enableModelRestriction && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableModelRestriction && 'pointer-events-none
opacity-50
'
"
>
<!--
Mode
Toggle
-->
<
div
class
=
"
flex gap-2
mb-4
"
>
<
div
class
=
"
mb-4
flex gap-2
"
>
<
button
type
=
"
button
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
,
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
>
<
svg
class
=
"
w-4 h-4
inline
mr-1.5
"
class
=
"
mr-1.5
inline
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -84,12 +84,12 @@
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
,
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
>
<
svg
class
=
"
w-4 h-4
inline
mr-1.5
"
class
=
"
mr-1.5
inline
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -107,10 +107,10 @@
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
div
class
=
"
mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20
p-3
"
>
<
div
class
=
"
mb-3 rounded-lg bg-blue-50
p-3
dark:bg-blue-900/20
"
>
<
p
class
=
"
text-xs text-blue-700 dark:text-blue-400
"
>
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -127,7 +127,7 @@
<
/div
>
<!--
Model
Checkbox
List
-->
<
div
class
=
"
grid grid-cols-2 gap-2
mb-3
"
>
<
div
class
=
"
mb-3
grid grid-cols-2 gap-2
"
>
<
label
v
-
for
=
"
model in allModels
"
:
key
=
"
model.value
"
...
...
@@ -158,10 +158,10 @@
<!--
Mapping
Mode
-->
<
div
v
-
else
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20
p-3
"
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50
p-3
dark:bg-purple-900/20
"
>
<
p
class
=
"
text-xs text-purple-700 dark:text-purple-400
"
>
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -178,7 +178,7 @@
<
/div
>
<!--
Model
Mapping
List
-->
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
space-y-2
mb-3
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3
space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
...
...
@@ -191,7 +191,7 @@
:
placeholder
=
"
t('admin.accounts.requestModel')
"
/>
<
svg
class
=
"
w-4 h-4 text-gray-400 flex-shrink-
0
"
class
=
"
h-4 w-4 flex-shrink-0 text-gray-40
0
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -211,10 +211,10 @@
/>
<
button
type
=
"
button
"
class
=
"
p-2 text-red-500 hover:
text
-red-
60
0 hover:
bg
-red-
5
0 dark:hover:bg-red-900/20
rounded-lg transition-colors
"
class
=
"
rounded-lg
p-2 text-red-500
transition-colors
hover:
bg
-red-
5
0 hover:
text
-red-
60
0 dark:hover:bg-red-900/20
"
@
click
=
"
removeModelMapping(index)
"
>
<
svg
class
=
"
w
-4
h
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
class
=
"
h
-4
w
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
...
...
@@ -228,11 +228,11 @@
<
button
type
=
"
button
"
class
=
"
w-full rounded-lg border-2 border-dashed border-gray-300
dark:border-dark-500
px-4 py-2 text-gray-600
dark:text-gray-400
transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300
mb-3
"
class
=
"
mb-3
w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700
dark:border-dark-500 dark:text-gray-400
dark:hover:border-dark-400 dark:hover:text-gray-300
"
@
click
=
"
addModelMapping
"
>
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -264,11 +264,11 @@
<
/div
>
<!--
Custom
error
codes
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.customErrorCodes
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400
mt-1
"
>
<
p
class
=
"
mt-1
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
<
/p
>
<
/div
>
...
...
@@ -280,10 +280,10 @@
<
/div
>
<
div
v
-
if
=
"
enableCustomErrorCodes
"
class
=
"
space-y-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50 dark:bg-amber-900/20
p-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50
p-3
dark:bg-amber-900/20
"
>
<
p
class
=
"
text-xs text-amber-700 dark:text-amber-400
"
>
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
...
...
@@ -308,8 +308,8 @@
:
class
=
"
[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
ring-1 ring-red-500
'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
,
? 'bg-red-100 text-red-700
ring-1 ring-red-500
dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
@
click
=
"
toggleErrorCode(code.value)
"
>
...
...
@@ -329,7 +329,7 @@
@
keyup
.
enter
=
"
addCustomErrorCode
"
/>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary px-3
"
@
click
=
"
addCustomErrorCode
"
>
<
svg
class
=
"
w
-4
h
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
class
=
"
h
-4
w
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
...
...
@@ -345,7 +345,7 @@
<
span
v
-
for
=
"
code in selectedErrorCodes.sort((a, b) => a - b)
"
:
key
=
"
code
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100
dark:bg-red-900/30
px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700
dark:bg-red-900/30
dark:text-red-400
"
>
{{
code
}}
<
button
...
...
@@ -353,7 +353,7 @@
class
=
"
hover:text-red-900 dark:hover:text-red-300
"
@
click
=
"
removeErrorCode(code)
"
>
<
svg
class
=
"
w
-3.5
h
-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
class
=
"
h
-3.5
w
-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
...
...
@@ -371,13 +371,13 @@
<
/div
>
<!--
Intercept
warmup
requests
(
Anthropic
only
)
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
class
=
"
flex-1 pr-4
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400
mt-1
"
>
<
p
class
=
"
mt-1
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
<
/p
>
<
/div
>
...
...
@@ -392,14 +392,14 @@
type
=
"
button
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
,
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
@
click
=
"
interceptWarmupRequests = !interceptWarmupRequests
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
,
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
...
...
@@ -407,8 +407,8 @@
<
/div
>
<!--
Proxy
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableProxy
"
...
...
@@ -416,15 +416,15 @@
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
div
:
class
=
"
!enableProxy && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableProxy && 'pointer-events-none
opacity-50
'
"
>
<
ProxySelector
v
-
model
=
"
proxyId
"
:
proxies
=
"
proxies
"
/>
<
/div
>
<
/div
>
<!--
Concurrency
&
Priority
-->
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableConcurrency
"
...
...
@@ -438,11 +438,11 @@
min
=
"
1
"
:
disabled
=
"
!enableConcurrency
"
class
=
"
input
"
:
class
=
"
!enableConcurrency && '
opacity-50
cursor-not-allowed'
"
:
class
=
"
!enableConcurrency && 'cursor-not-allowed
opacity-50
'
"
/>
<
/div
>
<
div
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enablePriority
"
...
...
@@ -456,14 +456,14 @@
min
=
"
1
"
:
disabled
=
"
!enablePriority
"
class
=
"
input
"
:
class
=
"
!enablePriority && '
opacity-50
cursor-not-allowed'
"
:
class
=
"
!enablePriority && 'cursor-not-allowed
opacity-50
'
"
/>
<
/div
>
<
/div
>
<!--
Status
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableStatus
"
...
...
@@ -471,14 +471,14 @@
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
div
:
class
=
"
!enableStatus && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableStatus && 'pointer-events-none
opacity-50
'
"
>
<
Select
v
-
model
=
"
status
"
:
options
=
"
statusOptions
"
/>
<
/div
>
<
/div
>
<!--
Groups
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
nav.groups
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableGroups
"
...
...
@@ -486,7 +486,7 @@
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
div
:
class
=
"
!enableGroups && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableGroups && 'pointer-events-none
opacity-50
'
"
>
<
GroupSelector
v
-
model
=
"
groupIds
"
:
groups
=
"
groups
"
/>
<
/div
>
<
/div
>
...
...
@@ -499,7 +499,7 @@
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
animate-spin
-ml-1 mr-2 h-4 w-4
"
class
=
"
-ml-1 mr-2 h-4 w-4
animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
...
...
@@ -601,7 +601,7 @@ const allModels = [
{
value
:
'
gpt-5.1-codex
'
,
label
:
'
GPT-5.1 Codex
'
}
,
{
value
:
'
gpt-5.1-2025-11-13
'
,
label
:
'
GPT-5.1
'
}
,
{
value
:
'
gpt-5.1-codex-mini
'
,
label
:
'
GPT-5.1 Codex Mini
'
}
,
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
,
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
]
// Preset mappings (combined Anthropic + OpenAI)
...
...
@@ -610,48 +610,46 @@ const presetMappings = [
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
,
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
,
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
,
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
,
}
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
}
]
// Common HTTP error codes
...
...
@@ -662,12 +660,12 @@ const commonErrorCodes = [
{
value
:
500
,
label
:
'
Server Error
'
}
,
{
value
:
502
,
label
:
'
Bad Gateway
'
}
,
{
value
:
503
,
label
:
'
Unavailable
'
}
,
{
value
:
529
,
label
:
'
Overloaded
'
}
,
{
value
:
529
,
label
:
'
Overloaded
'
}
]
const
statusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
common.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common.inactive
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common.inactive
'
)
}
])
// Model mapping helpers
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
429f38d0
<
template
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.createAccount')"
size=
"lg"
@
close=
"handleClose"
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.createAccount')"
size=
"lg"
@
close=
"handleClose"
>
<!-- Step Indicator for OAuth accounts -->
<div
v-if=
"isOAuthFlow"
class=
"mb-6 flex items-center justify-center"
>
<div
class=
"flex items-center space-x-4"
>
...
...
@@ -17,7 +12,9 @@
>
1
</div>
<span
class=
"ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.oauth.authMethod
'
)
}}
</span>
<span
class=
"ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.oauth.authMethod
'
)
}}
</span>
</div>
<div
class=
"h-0.5 w-8 bg-gray-300 dark:bg-dark-600"
/>
<div
class=
"flex items-center"
>
...
...
@@ -29,7 +26,9 @@
>
2
</div>
<span
class=
"ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.oauth.title
'
)
}}
</span>
<span
class=
"ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
oauthStepTitle
}}
</span>
</div>
</div>
</div>
...
...
@@ -50,19 +49,29 @@
<!-- Platform Selection - Segmented Control Style -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.platform
'
)
}}
</label>
<div
class=
"flex rounded-lg bg-gray-100 dark:bg-dark-700
p-1 mt-2
"
>
<div
class=
"
mt-2
flex rounded-lg bg-gray-100
p-1
dark:bg-dark-700"
>
<button
type=
"button"
@
click=
"form.platform = 'anthropic'"
:class=
"[
'flex
-1
flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
'flex flex
-1
items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'anthropic'
? 'bg-white dark:bg-dark-600
text-orange-600
dark:text-orange-400
shadow-sm
'
: 'text-gray-600
dark
:text-gray-
4
00
hover
:text-gray-
9
00 dark:hover:text-gray-200'
? 'bg-white
text-orange-600 shadow-sm
dark:bg-dark-600 dark:text-orange-400'
: 'text-gray-600
hover
:text-gray-
9
00
dark
:text-gray-
4
00 dark:hover:text-gray-200'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
</svg>
Anthropic
</button>
...
...
@@ -70,47 +79,98 @@
type=
"button"
@
click=
"form.platform = 'openai'"
:class=
"[
'flex
-1
flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
'flex flex
-1
items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'openai'
? 'bg-white dark:bg-dark-600
text-green-600
dark:text-green-400
shadow-sm
'
: 'text-gray-600
dark
:text-gray-
4
00
hover
:text-gray-
9
00 dark:hover:text-gray-200'
? 'bg-white
text-green-600 shadow-sm
dark:bg-dark-600 dark:text-green-400'
: 'text-gray-600
hover
:text-gray-
9
00
dark
:text-gray-
4
00 dark:hover:text-gray-200'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
OpenAI
</button>
<button
type=
"button"
@
click=
"form.platform = 'gemini'"
:class=
"[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'gemini'
? 'bg-white text-blue-600 shadow-sm dark:bg-dark-600 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 2l1.5 6.5L20 10l-6.5 1.5L12 18l-1.5-6.5L4 10l6.5-1.5L12 2z"
/>
</svg>
Gemini
</button>
</div>
</div>
<!-- Account Type Selection (Anthropic) -->
<div
v-if=
"form.platform === 'anthropic'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"grid grid-cols-2 gap-3
mt-2
"
>
<div
class=
"
mt-2
grid grid-cols-2 gap-3"
>
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all
text-left
',
'flex items-center gap-3 rounded-lg border-2 p-3
text-left
transition-all',
accountCategory === 'oauth-based'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20'
: 'border-gray-200
dark:border-dark-600
hover:border-orange-300 dark:hover:border-orange-700'
: 'border-gray-200 hover:border-orange-3
00 dark:border-dark-6
00 dark:hover:border-orange-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeCode
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauthSetupToken
'
)
}}
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeCode
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauthSetupToken
'
)
}}
</span>
</div>
</button>
...
...
@@ -118,25 +178,41 @@
type=
"button"
@
click=
"accountCategory = 'apikey'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all
text-left
',
'flex items-center gap-3 rounded-lg border-2 p-3
text-left
transition-all',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200
dark:border-dark-600
hover:border-purple-300 dark:hover:border-purple-700'
: 'border-gray-200 hover:border-purple-3
00 dark:border-dark-6
00 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeConsole
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.apiKey
'
)
}}
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeConsole
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.apiKey
'
)
}}
</span>
</div>
</button>
</div>
...
...
@@ -145,25 +221,37 @@
<!-- Account Type Selection (OpenAI) -->
<div
v-if=
"form.platform === 'openai'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"grid grid-cols-2 gap-3
mt-2
"
>
<div
class=
"
mt-2
grid grid-cols-2 gap-3"
>
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all
text-left
',
'flex items-center gap-3 rounded-lg border-2 p-3
text-left
transition-all',
accountCategory === 'oauth-based'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200
dark
:border-
dark-600 hover
:border-
green-3
00 dark:hover:border-green-700'
: 'border-gray-200
hover
:border-
green-300 dark
:border-
dark-6
00 dark:hover:border-green-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-green-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
...
...
@@ -176,20 +264,32 @@
type=
"button"
@
click=
"accountCategory = 'apikey'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all
text-left
',
'flex items-center gap-3 rounded-lg border-2 p-3
text-left
transition-all',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200
dark:border-dark-600
hover:border-purple-300 dark:hover:border-purple-700'
: 'border-gray-200 hover:border-purple-3
00 dark:border-dark-6
00 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
...
...
@@ -200,10 +300,187 @@
</div>
</div>
<!-- Account Type Selection (Gemini) -->
<div
v-if=
"form.platform === 'gemini'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
>
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
Google OAuth
</span>
</div>
</button>
<button
type=
"button"
@
click=
"accountCategory = 'apikey'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1721.75 8.25z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
API Key
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
AI Studio API Key
</span>
</div>
</button>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
<div
v-if=
"accountCategory === 'oauth-based'"
class=
"mt-4"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('code_assist')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Code Assist
</span>
<span
class=
"block text-xs font-medium text-blue-600 dark:text-blue-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectId
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectIdDesc
'
)
}}
</span>
</div>
</button>
<div
class=
"group relative"
>
<button
type=
"button"
:disabled=
"!geminiAIStudioOAuthEnabled"
@
click=
"handleSelectGeminiOAuthType('ai_studio')"
:class=
"[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
AI Studio
</span>
<span
class=
"block text-xs font-medium text-purple-600 dark:text-purple-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.noProjectIdNeeded
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.noProjectIdNeededDesc
'
)
}}
</span>
</div>
<span
v-if=
"!geminiAIStudioOAuthEnabled"
class=
"ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredShort
'
)
}}
</span>
</button>
<div
v-if=
"!geminiAIStudioOAuthEnabled"
class=
"pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredTip
'
)
}}
</div>
</div>
</div>
</div>
</div>
<!-- Add Method (only for Anthropic OAuth-based type) -->
<div
v-if=
"form.platform === 'anthropic' && isOAuthFlow"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.addMethod
'
)
}}
</label>
<div
class=
"flex gap-4
mt-2
"
>
<div
class=
"
mt-2
flex gap-4"
>
<label
class=
"flex cursor-pointer items-center"
>
<input
v-model=
"addMethod"
...
...
@@ -220,7 +497,9 @@
value=
"setup-token"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.setupTokenLongLived
'
)
}}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.setupTokenLongLived
'
)
}}
</span>
</label>
</div>
</div>
...
...
@@ -233,7 +512,13 @@
v-model=
"apiKeyBaseUrl"
type=
"text"
class=
"input"
:placeholder=
"form.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
:placeholder=
"
form.platform === 'openai'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.baseUrlHint
'
)
}}
</p>
</div>
...
...
@@ -244,17 +529,29 @@
type=
"password"
required
class=
"input font-mono"
:placeholder=
"form.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
:placeholder=
"
form.platform === 'openai'
? 'sk-proj-...'
: form.platform === 'gemini'
? 'AIza...'
: 'sk-ant-...'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.apiKeyHint
'
)
}}
</p>
<p
class=
"input-hint"
>
{{
form
.
platform
===
'
gemini
'
?
t
(
'
admin.accounts.gemini.apiKeyHint
'
)
:
t
(
'
admin.accounts.apiKeyHint
'
)
}}
</p>
</div>
<!-- Model Restriction Section -->
<div
class=
"border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<!-- Model Restriction Section
(不适用于 Gemini)
-->
<div
v-if=
"form.platform !== 'gemini'"
class=
"border-t border-gray-200
pt-4
dark:border-dark-600"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
</label>
<!-- Mode Toggle -->
<div
class=
"flex gap-2
mb-4
"
>
<div
class=
"
mb-4
flex gap-2"
>
<button
type=
"button"
@
click=
"modelRestrictionMode = 'whitelist'"
...
...
@@ -265,8 +562,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg
class=
"w-4 h-4 inline mr-1.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
class=
"mr-1.5 inline h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
t
(
'
admin.accounts.modelWhitelist
'
)
}}
</button>
...
...
@@ -280,8 +587,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg
class=
"w-4 h-4 inline mr-1.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
<svg
class=
"mr-1.5 inline h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
{{
t
(
'
admin.accounts.modelMapping
'
)
}}
</button>
...
...
@@ -289,22 +606,36 @@
<!-- Whitelist Mode -->
<div
v-if=
"modelRestrictionMode === 'whitelist'"
>
<div
class=
"mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20
p-3
"
>
<div
class=
"mb-3 rounded-lg bg-blue-50
p-3
dark:bg-blue-900/20"
>
<p
class=
"text-xs text-blue-700 dark:text-blue-400"
>
<svg
class=
"w-4 h-4 inline mr-1"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
class=
"mr-1 inline h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
t
(
'
admin.accounts.selectAllowedModels
'
)
}}
</p>
</div>
<!-- Model Checkbox List -->
<div
class=
"grid grid-cols-2 gap-2
mb-3
"
>
<div
class=
"
mb-3
grid grid-cols-2 gap-2"
>
<label
v-for=
"model in commonModels"
:key=
"model.value"
class=
"flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class=
"allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
:class=
"
allowedModels.includes(model.value)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200'
"
>
<input
type=
"checkbox"
...
...
@@ -318,23 +649,35 @@
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.selectedModels
'
,
{
count
:
allowedModels
.
length
}
)
}}
<
span
v
-
if
=
"
allowedModels.length === 0
"
>
{{
t
(
'
admin.accounts.supportsAllModels
'
)
}}
<
/span
>
<
span
v
-
if
=
"
allowedModels.length === 0
"
>
{{
t
(
'
admin.accounts.supportsAllModels
'
)
}}
<
/span
>
<
/p
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20
p-3
"
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50
p-3
dark:bg-purple-900/20
"
>
<
p
class
=
"
text-xs text-purple-700 dark:text-purple-400
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.mapRequestModels
'
)
}}
<
/p
>
<
/div
>
<!--
Model
Mapping
List
-->
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
space-y-2
mb-3
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3
space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
...
...
@@ -346,8 +689,18 @@
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.requestModel')
"
/>
<
svg
class
=
"
w-4 h-4 text-gray-400 flex-shrink-0
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14 5l7 7m0 0l-7 7m7-7H3
"
/>
<
svg
class
=
"
h-4 w-4 flex-shrink-0 text-gray-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14 5l7 7m0 0l-7 7m7-7H3
"
/>
<
/svg
>
<
input
v
-
model
=
"
mapping.to
"
...
...
@@ -358,10 +711,15 @@
<
button
type
=
"
button
"
@
click
=
"
removeModelMapping(index)
"
class
=
"
p-2 text-red-500 hover:
text
-red-
60
0 hover:
bg
-red-
5
0 dark:hover:bg-red-900/20
rounded-lg transition-colors
"
class
=
"
rounded-lg
p-2 text-red-500
transition-colors
hover:
bg
-red-
5
0 hover:
text
-red-
60
0 dark:hover:bg-red-900/20
"
>
<
svg
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/>
<
/svg
>
<
/button
>
<
/div
>
...
...
@@ -370,10 +728,20 @@
<
button
type
=
"
button
"
@
click
=
"
addModelMapping
"
class
=
"
w-full rounded-lg border-2 border-dashed border-gray-300
dark:border-dark-500
px-4 py-2 text-gray-600
dark:text-gray-400
transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300
mb-3
"
class
=
"
mb-3
w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700
dark:border-dark-500 dark:text-gray-400
dark:hover:border-dark-400 dark:hover:text-gray-300
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
...
...
@@ -385,10 +753,7 @@
:
key
=
"
preset.label
"
type
=
"
button
"
@
click
=
"
addPresetMapping(preset.from, preset.to)
"
:
class
=
"
[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]
"
:
class
=
"
['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]
"
>
+
{{
preset
.
label
}}
<
/button
>
...
...
@@ -397,11 +762,16 @@
<
/div
>
<!--
Custom
Error
Codes
Section
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<
div
class
=
"
flex items-center justify-between mb-3
"
>
<
div
v
-
if
=
"
form.platform !== 'gemini'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.customErrorCodes
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400 mt-1
"
>
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
...
...
@@ -421,10 +791,20 @@
<
/div
>
<
div
v
-
if
=
"
customErrorCodesEnabled
"
class
=
"
space-y-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50 dark:bg-amber-900/20
p-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50
p-3
dark:bg-amber-900/20
"
>
<
p
class
=
"
text-xs text-amber-700 dark:text-amber-400
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.customErrorCodesWarning
'
)
}}
<
/p
>
...
...
@@ -440,7 +820,7 @@
:
class
=
"
[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
ring-1 ring-red-500
'
? 'bg-red-100 text-red-700
ring-1 ring-red-500
dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
...
...
@@ -459,13 +839,14 @@
:
placeholder
=
"
t('admin.accounts.enterErrorCode')
"
@
keyup
.
enter
=
"
addCustomErrorCode
"
/>
<
button
type
=
"
button
"
@
click
=
"
addCustomErrorCode
"
class
=
"
btn btn-secondary px-3
"
>
<
svg
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
button
type
=
"
button
"
@
click
=
"
addCustomErrorCode
"
class
=
"
btn btn-secondary px-3
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
/svg
>
<
/button
>
<
/div
>
...
...
@@ -475,7 +856,7 @@
<
span
v
-
for
=
"
code in selectedErrorCodes.sort((a, b) => a - b)
"
:
key
=
"
code
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100
dark:bg-red-900/30
px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700
dark:bg-red-900/30
dark:text-red-400
"
>
{{
code
}}
<
button
...
...
@@ -483,8 +864,13 @@
@
click
=
"
removeErrorCode(code)
"
class
=
"
hover:text-red-900 dark:hover:text-red-300
"
>
<
svg
class
=
"
w-3.5 h-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M6 18L18 6M6 6l12 12
"
/>
<
svg
class
=
"
h-3.5 w-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M6 18L18 6M6 6l12 12
"
/>
<
/svg
>
<
/button
>
<
/span
>
...
...
@@ -494,14 +880,50 @@
<
/div
>
<
/div
>
<
/div
>
<!--
Gemini
模型说明
-->
<
div
v
-
if
=
"
form.platform === 'gemini'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
svg
class
=
"
h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
div
>
<
p
class
=
"
text-sm font-medium text-blue-800 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.gemini.modelPassthrough
'
)
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-blue-700 dark:text-blue-400
"
>
{{
t
(
'
admin.accounts.gemini.modelPassthroughDesc
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Intercept
Warmup
Requests
(
Anthropic
only
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<
div
v
-
if
=
"
form.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400 mt-1
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
<
/p
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
...
...
@@ -523,64 +945,56 @@
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.priorityHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<!--
Group
Selection
-->
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
form.platform
"
/>
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
form.platform
"
/>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
button
@
click
=
"
handleClose
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
handleClose
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
animate-spin
-ml-1 mr-2 h-4 w-4
"
class
=
"
-ml-1 mr-2 h-4 w-4
animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
{{
isOAuthFlow
?
t
(
'
common.next
'
)
:
(
submitting
?
t
(
'
admin.accounts.creating
'
)
:
t
(
'
common.create
'
))
}}
{{
isOAuthFlow
?
t
(
'
common.next
'
)
:
submitting
?
t
(
'
admin.accounts.creating
'
)
:
t
(
'
common.create
'
)
}}
<
/button
>
<
/div
>
<
/form
>
...
...
@@ -589,26 +1003,23 @@
<
div
v
-
else
class
=
"
space-y-5
"
>
<
OAuthAuthorizationFlow
ref
=
"
oauthFlowRef
"
:
add
-
method
=
"
form.platform === '
openai' ? 'oauth' : addMethod
"
:
add
-
method
=
"
form.platform === '
anthropic' ? addMethod : 'oauth'
"
:
auth
-
url
=
"
currentAuthUrl
"
:
session
-
id
=
"
currentSessionId
"
:
loading
=
"
currentOAuthLoading
"
:
error
=
"
currentOAuthError
"
:
show
-
help
=
"
form.platform
!
== '
openai
'
"
:
show
-
proxy
-
warning
=
"
!!form.proxy_id
"
:
allow
-
multiple
=
"
form.platform
!
== '
openai
'
"
:
show
-
cookie
-
option
=
"
form.platform
!
== '
openai
'
"
:
show
-
help
=
"
form.platform
=
== '
anthropic
'
"
:
show
-
proxy
-
warning
=
"
form.platform !== 'openai' &&
!!form.proxy_id
"
:
allow
-
multiple
=
"
form.platform
=
== '
anthropic
'
"
:
show
-
cookie
-
option
=
"
form.platform
=
== '
anthropic
'
"
:
platform
=
"
form.platform
"
:
show
-
project
-
id
=
"
geminiOAuthType === 'code_assist'
"
@
generate
-
url
=
"
handleGenerateUrl
"
@
cookie
-
auth
=
"
handleCookieAuth
"
/>
<
div
class
=
"
flex justify-between gap-3 pt-4
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary
"
@
click
=
"
goBackToBasicInfo
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary
"
@
click
=
"
goBackToBasicInfo
"
>
{{
t
(
'
common.back
'
)
}}
<
/button
>
<
button
...
...
@@ -620,14 +1031,29 @@
>
<
svg
v
-
if
=
"
currentOAuthLoading
"
class
=
"
animate-spin
-ml-1 mr-2 h-4 w-4
"
class
=
"
-ml-1 mr-2 h-4 w-4
animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
{{
currentOAuthLoading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
{{
currentOAuthLoading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
<
/button
>
<
/div
>
<
/div
>
...
...
@@ -639,8 +1065,13 @@ import { ref, reactive, computed, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
useGeminiOAuth
}
from
'
@/composables/useGeminiOAuth
'
import
type
{
Proxy
,
Group
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
Modal
from
'
@/components/common/Modal.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
...
@@ -651,6 +1082,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface
OAuthFlowExposed
{
authCode
:
string
oauthState
:
string
projectId
:
string
sessionKey
:
string
inputMethod
:
AuthInputMethod
reset
:
()
=>
void
...
...
@@ -658,6 +1091,12 @@ interface OAuthFlowExposed {
const
{
t
}
=
useI18n
()
const
oauthStepTitle
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
)
return
t
(
'
admin.accounts.oauth.openai.title
'
)
if
(
form
.
platform
===
'
gemini
'
)
return
t
(
'
admin.accounts.oauth.gemini.title
'
)
return
t
(
'
admin.accounts.oauth.title
'
)
}
)
interface
Props
{
show
:
boolean
proxies
:
Proxy
[]
...
...
@@ -673,24 +1112,33 @@ const emit = defineEmits<{
const
appStore
=
useAppStore
()
// OAuth composables
const
oauth
=
useAccountOAuth
()
// For Anthropic OAuth
const
openaiOAuth
=
useOpenAIOAuth
()
// For OpenAI OAuth
const
oauth
=
useAccountOAuth
()
// For Anthropic OAuth
const
openaiOAuth
=
useOpenAIOAuth
()
// For OpenAI OAuth
const
geminiOAuth
=
useGeminiOAuth
()
// For Gemini OAuth
// Computed: current OAuth state for template binding
const
currentAuthUrl
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
authUrl
.
value
:
oauth
.
authUrl
.
value
if
(
form
.
platform
===
'
openai
'
)
return
openaiOAuth
.
authUrl
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
authUrl
.
value
return
oauth
.
authUrl
.
value
}
)
const
currentSessionId
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
sessionId
.
value
:
oauth
.
sessionId
.
value
if
(
form
.
platform
===
'
openai
'
)
return
openaiOAuth
.
sessionId
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
sessionId
.
value
return
oauth
.
sessionId
.
value
}
)
const
currentOAuthLoading
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
loading
.
value
:
oauth
.
loading
.
value
if
(
form
.
platform
===
'
openai
'
)
return
openaiOAuth
.
loading
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
loading
.
value
return
oauth
.
loading
.
value
}
)
const
currentOAuthError
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
error
.
value
:
oauth
.
error
.
value
if
(
form
.
platform
===
'
openai
'
)
return
openaiOAuth
.
error
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
error
.
value
return
oauth
.
error
.
value
}
)
// Refs
...
...
@@ -716,6 +1164,8 @@ const customErrorCodesEnabled = ref(false)
const
selectedErrorCodes
=
ref
<
number
[]
>
([])
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
interceptWarmupRequests
=
ref
(
false
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
ai_studio
'
>
(
'
code_assist
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
// Common models for whitelist - Anthropic
const
anthropicModels
=
[
...
...
@@ -740,34 +1190,143 @@ const openaiModels = [
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
]
// Common models for whitelist - Gemini
const
geminiModels
=
[
{
value
:
'
gemini-2.0-flash
'
,
label
:
'
Gemini 2.0 Flash
'
}
,
{
value
:
'
gemini-2.0-flash-lite
'
,
label
:
'
Gemini 2.0 Flash Lite
'
}
,
{
value
:
'
gemini-1.5-pro
'
,
label
:
'
Gemini 1.5 Pro
'
}
,
{
value
:
'
gemini-1.5-flash
'
,
label
:
'
Gemini 1.5 Flash
'
}
]
// Computed: current models based on platform
const
commonModels
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiModels
:
anthropicModels
if
(
form
.
platform
===
'
openai
'
)
return
openaiModels
if
(
form
.
platform
===
'
gemini
'
)
return
geminiModels
return
anthropicModels
}
)
// Preset mappings for quick add - Anthropic
const
anthropicPresetMappings
=
[
{
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Haiku 3.5
'
,
from
:
'
claude-3-5-haiku-20241022
'
,
to
:
'
claude-3-5-haiku-20241022
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
Haiku 4.5
'
,
from
:
'
claude-haiku-4-5-20251001
'
,
to
:
'
claude-haiku-4-5-20251001
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
{
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Haiku 3.5
'
,
from
:
'
claude-3-5-haiku-20241022
'
,
to
:
'
claude-3-5-haiku-20241022
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
Haiku 4.5
'
,
from
:
'
claude-haiku-4-5-20251001
'
,
to
:
'
claude-haiku-4-5-20251001
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Preset mappings for quick add - OpenAI
const
openaiPresetMappings
=
[
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
GPT-5.1 Codex
'
,
from
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Codex Max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex-max
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Codex Mini
'
,
from
:
'
gpt-5.1-codex-mini
'
,
to
:
'
gpt-5.1-codex-mini
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
GPT-5.1 Codex
'
,
from
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Codex Max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex-max
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Codex Mini
'
,
from
:
'
gpt-5.1-codex-mini
'
,
to
:
'
gpt-5.1-codex-mini
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Preset mappings for quick add - Gemini
const
geminiPresetMappings
=
[
{
label
:
'
Flash
'
,
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
:
'
Flash Lite
'
,
from
:
'
gemini-2.0-flash-lite
'
,
to
:
'
gemini-2.0-flash-lite
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
1.5 Pro
'
,
from
:
'
gemini-1.5-pro
'
,
to
:
'
gemini-1.5-pro
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
1.5 Flash
'
,
from
:
'
gemini-1.5-flash
'
,
to
:
'
gemini-1.5-flash
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
]
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiPresetMappings
:
anthropicPresetMappings
if
(
form
.
platform
===
'
openai
'
)
return
openaiPresetMappings
if
(
form
.
platform
===
'
gemini
'
)
return
geminiPresetMappings
return
anthropicPresetMappings
}
)
// Common HTTP error codes for quick selection
...
...
@@ -804,38 +1363,84 @@ const canExchangeCode = computed(() => {
if
(
form
.
platform
===
'
openai
'
)
{
return
authCode
.
trim
()
&&
openaiOAuth
.
sessionId
.
value
&&
!
openaiOAuth
.
loading
.
value
}
if
(
form
.
platform
===
'
gemini
'
)
{
return
authCode
.
trim
()
&&
geminiOAuth
.
sessionId
.
value
&&
!
geminiOAuth
.
loading
.
value
}
return
authCode
.
trim
()
&&
oauth
.
sessionId
.
value
&&
!
oauth
.
loading
.
value
}
)
// Watchers
watch
(()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
!
newVal
)
{
resetForm
()
watch
(
()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
!
newVal
)
{
resetForm
()
}
}
}
)
)
// Sync form.type based on accountCategory and addMethod
watch
([
accountCategory
,
addMethod
],
([
category
,
method
])
=>
{
if
(
category
===
'
oauth-based
'
)
{
form
.
type
=
method
as
AccountType
// 'oauth' or 'setup-token'
}
else
{
form
.
type
=
'
apikey
'
}
}
,
{
immediate
:
true
}
)
watch
(
[
accountCategory
,
addMethod
],
([
category
,
method
])
=>
{
if
(
category
===
'
oauth-based
'
)
{
form
.
type
=
method
as
AccountType
// 'oauth' or 'setup-token'
}
else
{
form
.
type
=
'
apikey
'
}
}
,
{
immediate
:
true
}
)
// Reset platform-specific settings when platform changes
watch
(()
=>
form
.
platform
,
(
newPlatform
)
=>
{
// Reset base URL based on platform
apiKeyBaseUrl
.
value
=
newPlatform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
// Clear model-related settings
allowedModels
.
value
=
[]
modelMappings
.
value
=
[]
// Reset OAuth states
oauth
.
resetState
()
openaiOAuth
.
resetState
()
}
)
watch
(
()
=>
form
.
platform
,
(
newPlatform
)
=>
{
// Reset base URL based on platform
apiKeyBaseUrl
.
value
=
newPlatform
===
'
openai
'
?
'
https://api.openai.com
'
:
newPlatform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
:
'
https://api.anthropic.com
'
// Clear model-related settings
allowedModels
.
value
=
[]
modelMappings
.
value
=
[]
// Reset Anthropic-specific settings when switching to other platforms
if
(
newPlatform
!==
'
anthropic
'
)
{
interceptWarmupRequests
.
value
=
false
}
// Reset OAuth states
oauth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
}
)
// Gemini AI Studio OAuth availability (requires operator-configured OAuth client)
watch
(
[()
=>
props
.
show
,
()
=>
form
.
platform
,
accountCategory
],
async
([
show
,
platform
,
category
])
=>
{
if
(
!
show
||
platform
!==
'
gemini
'
||
category
!==
'
oauth-based
'
)
{
geminiAIStudioOAuthEnabled
.
value
=
false
return
}
const
caps
=
await
geminiOAuth
.
getCapabilities
()
geminiAIStudioOAuthEnabled
.
value
=
!!
caps
?.
ai_studio_oauth_enabled
if
(
!
geminiAIStudioOAuthEnabled
.
value
&&
geminiOAuthType
.
value
===
'
ai_studio
'
)
{
geminiOAuthType
.
value
=
'
code_assist
'
}
}
,
{
immediate
:
true
}
)
const
handleSelectGeminiOAuthType
=
(
oauthType
:
'
code_assist
'
|
'
ai_studio
'
)
=>
{
if
(
oauthType
===
'
ai_studio
'
&&
!
geminiAIStudioOAuthEnabled
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfigured
'
))
return
}
geminiOAuthType
.
value
=
oauthType
}
// Model mapping helpers
const
addModelMapping
=
()
=>
{
...
...
@@ -848,7 +1453,7 @@ const removeModelMapping = (index: number) => {
const
addPresetMapping
=
(
from
:
string
,
to
:
string
)
=>
{
// Check if mapping already exists
const
exists
=
modelMappings
.
value
.
some
(
m
=>
m
.
from
===
from
)
const
exists
=
modelMappings
.
value
.
some
(
(
m
)
=>
m
.
from
===
from
)
if
(
exists
)
{
appStore
.
showInfo
(
t
(
'
admin.accounts.mappingExists
'
,
{
model
:
from
}
))
return
...
...
@@ -933,8 +1538,10 @@ const resetForm = () => {
selectedErrorCodes
.
value
=
[]
customErrorCodeInput
.
value
=
null
interceptWarmupRequests
.
value
=
false
geminiOAuthType
.
value
=
'
code_assist
'
oauth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
...
...
@@ -960,9 +1567,12 @@ const handleSubmit = async () => {
}
// Determine default base URL based on platform
const
defaultBaseUrl
=
form
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
const
defaultBaseUrl
=
form
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
form
.
platform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
:
'
https://api.anthropic.com
'
// Build credentials with optional model mapping
const
credentials
:
Record
<
string
,
unknown
>
=
{
...
...
@@ -1009,12 +1619,15 @@ const goBackToBasicInfo = () => {
step
.
value
=
1
oauth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
const
handleGenerateUrl
=
async
()
=>
{
if
(
form
.
platform
===
'
openai
'
)
{
await
openaiOAuth
.
generateAuthUrl
(
form
.
proxy_id
)
}
else
if
(
form
.
platform
===
'
gemini
'
)
{
await
geminiOAuth
.
generateAuthUrl
(
form
.
proxy_id
,
oauthFlowRef
.
value
?.
projectId
,
geminiOAuthType
.
value
)
}
else
{
await
oauth
.
generateAuthUrl
(
addMethod
.
value
,
form
.
proxy_id
)
}
...
...
@@ -1044,10 +1657,7 @@ const handleExchangeCode = async () => {
const
credentials
=
openaiOAuth
.
buildCredentials
(
tokenInfo
)
const
extra
=
openaiOAuth
.
buildExtraInfo
(
tokenInfo
)
// Merge interceptWarmupRequests into credentials
if
(
interceptWarmupRequests
.
value
)
{
credentials
.
intercept_warmup_requests
=
true
}
// Note: intercept_warmup_requests is Anthropic-only, not applicable to OpenAI
await
adminAPI
.
accounts
.
create
({
name
:
form
.
name
,
...
...
@@ -1073,6 +1683,58 @@ const handleExchangeCode = async () => {
return
}
// For Gemini
if
(
form
.
platform
===
'
gemini
'
)
{
if
(
!
authCode
.
trim
()
||
!
geminiOAuth
.
sessionId
.
value
)
return
geminiOAuth
.
loading
.
value
=
true
geminiOAuth
.
error
.
value
=
''
try
{
const
stateFromInput
=
oauthFlowRef
.
value
?.
oauthState
||
''
const
stateToUse
=
stateFromInput
||
geminiOAuth
.
state
.
value
if
(
!
stateToUse
)
{
geminiOAuth
.
error
.
value
=
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
geminiOAuth
.
error
.
value
)
return
}
const
tokenInfo
=
await
geminiOAuth
.
exchangeAuthCode
({
code
:
authCode
.
trim
(),
sessionId
:
geminiOAuth
.
sessionId
.
value
,
state
:
stateToUse
,
proxyId
:
form
.
proxy_id
,
oauthType
:
geminiOAuthType
.
value
}
)
if
(
!
tokenInfo
)
return
const
credentials
=
geminiOAuth
.
buildCredentials
(
tokenInfo
)
// Note: intercept_warmup_requests is Anthropic-only, not applicable to Gemini
await
adminAPI
.
accounts
.
create
({
name
:
form
.
name
,
platform
:
'
gemini
'
,
type
:
'
oauth
'
,
credentials
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
priority
:
form
.
priority
,
group_ids
:
form
.
group_ids
}
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
emit
(
'
created
'
)
handleClose
()
}
catch
(
error
:
any
)
{
geminiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
geminiOAuth
.
error
.
value
)
}
finally
{
geminiOAuth
.
loading
.
value
=
false
}
return
}
// For Anthropic
if
(
!
authCode
.
trim
()
||
!
oauth
.
sessionId
.
value
)
return
...
...
@@ -1081,9 +1743,10 @@ const handleExchangeCode = async () => {
try
{
const
proxyConfig
=
form
.
proxy_id
?
{
proxy_id
:
form
.
proxy_id
}
:
{
}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
oauth
.
sessionId
.
value
,
...
...
@@ -1135,9 +1798,10 @@ const handleCookieAuth = async (sessionKey: string) => {
return
}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
let
successCount
=
0
let
failedCount
=
0
...
...
@@ -1174,7 +1838,12 @@ const handleCookieAuth = async (sessionKey: string) => {
successCount
++
}
catch
(
error
:
any
)
{
failedCount
++
errors
.
push
(
t
(
'
admin.accounts.oauth.keyAuthFailed
'
,
{
index
:
i
+
1
,
error
:
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
}
))
errors
.
push
(
t
(
'
admin.accounts.oauth.keyAuthFailed
'
,
{
index
:
i
+
1
,
error
:
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
}
)
)
}
}
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
429f38d0
<
template
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.editAccount')"
size=
"lg"
@
close=
"handleClose"
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.editAccount')"
size=
"lg"
@
close=
"handleClose"
>
<form
v-if=
"account"
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
common.name
'
)
}}
</label>
<input
v-model=
"form.name"
type=
"text"
required
class=
"input"
/>
<input
v-model=
"form.name"
type=
"text"
required
class=
"input"
/>
</div>
<!-- API Key fields (only for apikey type) -->
...
...
@@ -24,7 +14,13 @@
v-model=
"editBaseUrl"
type=
"text"
class=
"input"
:placeholder=
"account.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
:placeholder=
"
account.platform === 'openai'
? 'https://api.openai.com'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.baseUrlHint
'
)
}}
</p>
</div>
...
...
@@ -34,17 +30,23 @@
v-model=
"editApiKey"
type=
"password"
class=
"input font-mono"
:placeholder=
"account.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
:placeholder=
"
account.platform === 'openai'
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
: 'sk-ant-...'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.leaveEmptyToKeep
'
)
}}
</p>
</div>
<!-- Model Restriction Section -->
<div
class=
"border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<!-- Model Restriction Section
(不适用于 Gemini)
-->
<div
v-if=
"account.platform !== 'gemini'"
class=
"border-t border-gray-200
pt-4
dark:border-dark-600"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
</label>
<!-- Mode Toggle -->
<div
class=
"flex gap-2
mb-4
"
>
<div
class=
"
mb-4
flex gap-2"
>
<button
type=
"button"
@
click=
"modelRestrictionMode = 'whitelist'"
...
...
@@ -55,8 +57,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg
class=
"w-4 h-4 inline mr-1.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
class=
"mr-1.5 inline h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
t
(
'
admin.accounts.modelWhitelist
'
)
}}
</button>
...
...
@@ -70,8 +82,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg
class=
"w-4 h-4 inline mr-1.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
<svg
class=
"mr-1.5 inline h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
{{
t
(
'
admin.accounts.modelMapping
'
)
}}
</button>
...
...
@@ -79,22 +101,36 @@
<!-- Whitelist Mode -->
<div
v-if=
"modelRestrictionMode === 'whitelist'"
>
<div
class=
"mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20
p-3
"
>
<div
class=
"mb-3 rounded-lg bg-blue-50
p-3
dark:bg-blue-900/20"
>
<p
class=
"text-xs text-blue-700 dark:text-blue-400"
>
<svg
class=
"w-4 h-4 inline mr-1"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
class=
"mr-1 inline h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
t
(
'
admin.accounts.selectAllowedModels
'
)
}}
</p>
</div>
<!-- Model Checkbox List -->
<div
class=
"grid grid-cols-2 gap-2
mb-3
"
>
<div
class=
"
mb-3
grid grid-cols-2 gap-2"
>
<label
v-for=
"model in commonModels"
:key=
"model.value"
class=
"flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class=
"allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
:class=
"
allowedModels.includes(model.value)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200'
"
>
<input
type=
"checkbox"
...
...
@@ -108,23 +144,35 @@
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.selectedModels
'
,
{
count
:
allowedModels
.
length
}
)
}}
<
span
v
-
if
=
"
allowedModels.length === 0
"
>
{{
t
(
'
admin.accounts.supportsAllModels
'
)
}}
<
/span
>
<
span
v
-
if
=
"
allowedModels.length === 0
"
>
{{
t
(
'
admin.accounts.supportsAllModels
'
)
}}
<
/span
>
<
/p
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20
p-3
"
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50
p-3
dark:bg-purple-900/20
"
>
<
p
class
=
"
text-xs text-purple-700 dark:text-purple-400
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.mapRequestModels
'
)
}}
<
/p
>
<
/div
>
<!--
Model
Mapping
List
-->
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
space-y-2
mb-3
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3
space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
...
...
@@ -136,8 +184,18 @@
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.requestModel')
"
/>
<
svg
class
=
"
w-4 h-4 text-gray-400 flex-shrink-0
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14 5l7 7m0 0l-7 7m7-7H3
"
/>
<
svg
class
=
"
h-4 w-4 flex-shrink-0 text-gray-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14 5l7 7m0 0l-7 7m7-7H3
"
/>
<
/svg
>
<
input
v
-
model
=
"
mapping.to
"
...
...
@@ -148,10 +206,15 @@
<
button
type
=
"
button
"
@
click
=
"
removeModelMapping(index)
"
class
=
"
p-2 text-red-500 hover:
text
-red-
60
0 hover:
bg
-red-
5
0 dark:hover:bg-red-900/20
rounded-lg transition-colors
"
class
=
"
rounded-lg
p-2 text-red-500
transition-colors
hover:
bg
-red-
5
0 hover:
text
-red-
60
0 dark:hover:bg-red-900/20
"
>
<
svg
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/>
<
/svg
>
<
/button
>
<
/div
>
...
...
@@ -160,10 +223,20 @@
<
button
type
=
"
button
"
@
click
=
"
addModelMapping
"
class
=
"
w-full rounded-lg border-2 border-dashed border-gray-300
dark:border-dark-500
px-4 py-2 text-gray-600
dark:text-gray-400
transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300
mb-3
"
class
=
"
mb-3
w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700
dark:border-dark-500 dark:text-gray-400
dark:hover:border-dark-400 dark:hover:text-gray-300
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
...
...
@@ -175,10 +248,7 @@
:
key
=
"
preset.label
"
type
=
"
button
"
@
click
=
"
addPresetMapping(preset.from, preset.to)
"
:
class
=
"
[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]
"
:
class
=
"
['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]
"
>
+
{{
preset
.
label
}}
<
/button
>
...
...
@@ -187,11 +257,13 @@
<
/div
>
<!--
Custom
Error
Codes
Section
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.customErrorCodes
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400 mt-1
"
>
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
...
...
@@ -211,10 +283,20 @@
<
/div
>
<
div
v
-
if
=
"
customErrorCodesEnabled
"
class
=
"
space-y-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50 dark:bg-amber-900/20
p-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50
p-3
dark:bg-amber-900/20
"
>
<
p
class
=
"
text-xs text-amber-700 dark:text-amber-400
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.customErrorCodesWarning
'
)
}}
<
/p
>
...
...
@@ -230,7 +312,7 @@
:
class
=
"
[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
ring-1 ring-red-500
'
? 'bg-red-100 text-red-700
ring-1 ring-red-500
dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
...
...
@@ -249,13 +331,14 @@
:
placeholder
=
"
t('admin.accounts.enterErrorCode')
"
@
keyup
.
enter
=
"
addCustomErrorCode
"
/>
<
button
type
=
"
button
"
@
click
=
"
addCustomErrorCode
"
class
=
"
btn btn-secondary px-3
"
>
<
svg
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
button
type
=
"
button
"
@
click
=
"
addCustomErrorCode
"
class
=
"
btn btn-secondary px-3
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 4v16m8-8H4
"
/>
<
/svg
>
<
/button
>
<
/div
>
...
...
@@ -265,7 +348,7 @@
<
span
v
-
for
=
"
code in selectedErrorCodes.sort((a, b) => a - b)
"
:
key
=
"
code
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100
dark:bg-red-900/30
px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700
dark:bg-red-900/30
dark:text-red-400
"
>
{{
code
}}
<
button
...
...
@@ -273,8 +356,13 @@
@
click
=
"
removeErrorCode(code)
"
class
=
"
hover:text-red-900 dark:hover:text-red-300
"
>
<
svg
class
=
"
w-3.5 h-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M6 18L18 6M6 6l12 12
"
/>
<
svg
class
=
"
h-3.5 w-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M6 18L18 6M6 6l12 12
"
/>
<
/svg
>
<
/button
>
<
/span
>
...
...
@@ -284,14 +372,50 @@
<
/div
>
<
/div
>
<
/div
>
<!--
Gemini
模型说明
-->
<
div
v
-
if
=
"
account.platform === 'gemini'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
svg
class
=
"
h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
div
>
<
p
class
=
"
text-sm font-medium text-blue-800 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.gemini.modelPassthrough
'
)
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-blue-700 dark:text-blue-400
"
>
{{
t
(
'
admin.accounts.gemini.modelPassthroughDesc
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Intercept
Warmup
Requests
(
Anthropic
only
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<
div
v
-
if
=
"
account?.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400 mt-1
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
<
/p
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
...
...
@@ -313,69 +437,52 @@
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
/div
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
form.status
"
:
options
=
"
statusOptions
"
/>
<
Select
v
-
model
=
"
form.status
"
:
options
=
"
statusOptions
"
/>
<
/div
>
<!--
Group
Selection
-->
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
account?.platform
"
/>
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
account?.platform
"
/>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
button
@
click
=
"
handleClose
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
handleClose
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
animate-spin
-ml-1 mr-2 h-4 w-4
"
class
=
"
-ml-1 mr-2 h-4 w-4
animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
{{
submitting
?
t
(
'
admin.accounts.updating
'
)
:
t
(
'
common.update
'
)
}}
<
/button
>
...
...
@@ -452,39 +559,150 @@ const openaiModels = [
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
]
// Common models for whitelist - Gemini
const
geminiModels
=
[
{
value
:
'
gemini-2.0-flash
'
,
label
:
'
Gemini 2.0 Flash
'
}
,
{
value
:
'
gemini-2.0-flash-lite
'
,
label
:
'
Gemini 2.0 Flash Lite
'
}
,
{
value
:
'
gemini-1.5-pro
'
,
label
:
'
Gemini 1.5 Pro
'
}
,
{
value
:
'
gemini-1.5-flash
'
,
label
:
'
Gemini 1.5 Flash
'
}
]
// Computed: current models based on platform
const
commonModels
=
computed
(()
=>
{
return
props
.
account
?.
platform
===
'
openai
'
?
openaiModels
:
anthropicModels
if
(
props
.
account
?.
platform
===
'
openai
'
)
return
openaiModels
if
(
props
.
account
?.
platform
===
'
gemini
'
)
return
geminiModels
return
anthropicModels
}
)
// Preset mappings for quick add - Anthropic
const
anthropicPresetMappings
=
[
{
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Haiku 3.5
'
,
from
:
'
claude-3-5-haiku-20241022
'
,
to
:
'
claude-3-5-haiku-20241022
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
Haiku 4.5
'
,
from
:
'
claude-haiku-4-5-20251001
'
,
to
:
'
claude-haiku-4-5-20251001
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
{
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Haiku 3.5
'
,
from
:
'
claude-3-5-haiku-20241022
'
,
to
:
'
claude-3-5-haiku-20241022
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
Haiku 4.5
'
,
from
:
'
claude-haiku-4-5-20251001
'
,
to
:
'
claude-haiku-4-5-20251001
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Preset mappings for quick add - OpenAI
const
openaiPresetMappings
=
[
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
GPT-5.1 Codex
'
,
from
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Codex Max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex-max
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Codex Mini
'
,
from
:
'
gpt-5.1-codex-mini
'
,
to
:
'
gpt-5.1-codex-mini
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
GPT-5.1 Codex
'
,
from
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Codex Max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex-max
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Codex Mini
'
,
from
:
'
gpt-5.1-codex-mini
'
,
to
:
'
gpt-5.1-codex-mini
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Preset mappings for quick add - Gemini
const
geminiPresetMappings
=
[
{
label
:
'
Flash
'
,
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
:
'
Flash Lite
'
,
from
:
'
gemini-2.0-flash-lite
'
,
to
:
'
gemini-2.0-flash-lite
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
1.5 Pro
'
,
from
:
'
gemini-1.5-pro
'
,
to
:
'
gemini-1.5-pro
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
1.5 Flash
'
,
from
:
'
gemini-1.5-flash
'
,
to
:
'
gemini-1.5-flash
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
]
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
{
return
props
.
account
?.
platform
===
'
openai
'
?
openaiPresetMappings
:
anthropicPresetMappings
if
(
props
.
account
?.
platform
===
'
openai
'
)
return
openaiPresetMappings
if
(
props
.
account
?.
platform
===
'
gemini
'
)
return
geminiPresetMappings
return
anthropicPresetMappings
}
)
// Computed: default base URL based on platform
const
defaultBaseUrl
=
computed
(()
=>
{
return
props
.
account
?.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
if
(
props
.
account
?.
platform
===
'
openai
'
)
return
'
https://api.openai.com
'
if
(
props
.
account
?.
platform
===
'
gemini
'
)
return
'
https://generativelanguage.googleapis.com
'
return
'
https://api.anthropic.com
'
}
)
// Common HTTP error codes for quick selection
...
...
@@ -513,71 +731,85 @@ const statusOptions = computed(() => [
])
// Watchers
watch
(()
=>
props
.
account
,
(
newAccount
)
=>
{
if
(
newAccount
)
{
form
.
name
=
newAccount
.
name
form
.
proxy_id
=
newAccount
.
proxy_id
form
.
concurrency
=
newAccount
.
concurrency
form
.
priority
=
newAccount
.
priority
form
.
status
=
newAccount
.
status
as
'
active
'
|
'
inactive
'
form
.
group_ids
=
newAccount
.
group_ids
||
[]
// Load intercept warmup requests setting (applies to all account types)
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
|
undefined
interceptWarmupRequests
.
value
=
credentials
?.
intercept_warmup_requests
===
true
// Initialize API Key fields for apikey type
if
(
newAccount
.
type
===
'
apikey
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
editBaseUrl
.
value
=
credentials
.
base_url
as
string
||
platformDefaultUrl
// Load model mappings and detect mode
const
existingMappings
=
credentials
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
if
(
existingMappings
&&
typeof
existingMappings
===
'
object
'
)
{
const
entries
=
Object
.
entries
(
existingMappings
)
// Detect if this is whitelist mode (all from === to) or mapping mode
const
isWhitelistMode
=
entries
.
length
>
0
&&
entries
.
every
(([
from
,
to
])
=>
from
===
to
)
if
(
isWhitelistMode
)
{
// Whitelist mode: populate allowedModels
watch
(
()
=>
props
.
account
,
(
newAccount
)
=>
{
if
(
newAccount
)
{
form
.
name
=
newAccount
.
name
form
.
proxy_id
=
newAccount
.
proxy_id
form
.
concurrency
=
newAccount
.
concurrency
form
.
priority
=
newAccount
.
priority
form
.
status
=
newAccount
.
status
as
'
active
'
|
'
inactive
'
form
.
group_ids
=
newAccount
.
group_ids
||
[]
// Load intercept warmup requests setting (applies to all account types)
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
|
undefined
interceptWarmupRequests
.
value
=
credentials
?.
intercept_warmup_requests
===
true
// Initialize API Key fields for apikey type
if
(
newAccount
.
type
===
'
apikey
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
newAccount
.
platform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
:
'
https://api.anthropic.com
'
editBaseUrl
.
value
=
(
credentials
.
base_url
as
string
)
||
platformDefaultUrl
// Load model mappings and detect mode
const
existingMappings
=
credentials
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
if
(
existingMappings
&&
typeof
existingMappings
===
'
object
'
)
{
const
entries
=
Object
.
entries
(
existingMappings
)
// Detect if this is whitelist mode (all from === to) or mapping mode
const
isWhitelistMode
=
entries
.
length
>
0
&&
entries
.
every
(([
from
,
to
])
=>
from
===
to
)
if
(
isWhitelistMode
)
{
// Whitelist mode: populate allowedModels
modelRestrictionMode
.
value
=
'
whitelist
'
allowedModels
.
value
=
entries
.
map
(([
from
])
=>
from
)
modelMappings
.
value
=
[]
}
else
{
// Mapping mode: populate modelMappings
modelRestrictionMode
.
value
=
'
mapping
'
modelMappings
.
value
=
entries
.
map
(([
from
,
to
])
=>
({
from
,
to
}
))
allowedModels
.
value
=
[]
}
}
else
{
// No mappings: default to whitelist mode with empty selection (allow all)
modelRestrictionMode
.
value
=
'
whitelist
'
allowedModels
.
value
=
entries
.
map
(([
from
])
=>
from
)
modelMappings
.
value
=
[]
}
else
{
// Mapping mode: populate modelMappings
modelRestrictionMode
.
value
=
'
mapping
'
modelMappings
.
value
=
entries
.
map
(([
from
,
to
])
=>
({
from
,
to
}
))
allowedModels
.
value
=
[]
}
// Load custom error codes
customErrorCodesEnabled
.
value
=
credentials
.
custom_error_codes_enabled
===
true
const
existingErrorCodes
=
credentials
.
custom_error_codes
as
number
[]
|
undefined
if
(
existingErrorCodes
&&
Array
.
isArray
(
existingErrorCodes
))
{
selectedErrorCodes
.
value
=
[...
existingErrorCodes
]
}
else
{
selectedErrorCodes
.
value
=
[]
}
}
else
{
// No mappings: default to whitelist mode with empty selection (allow all)
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
newAccount
.
platform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
:
'
https://api.anthropic.com
'
editBaseUrl
.
value
=
platformDefaultUrl
modelRestrictionMode
.
value
=
'
whitelist
'
modelMappings
.
value
=
[]
allowedModels
.
value
=
[]
}
// Load custom error codes
customErrorCodesEnabled
.
value
=
credentials
.
custom_error_codes_enabled
===
true
const
existingErrorCodes
=
credentials
.
custom_error_codes
as
number
[]
|
undefined
if
(
existingErrorCodes
&&
Array
.
isArray
(
existingErrorCodes
))
{
selectedErrorCodes
.
value
=
[...
existingErrorCodes
]
}
else
{
customErrorCodesEnabled
.
value
=
false
selectedErrorCodes
.
value
=
[]
}
}
else
{
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
editBaseUrl
.
value
=
platformDefaultUrl
modelRestrictionMode
.
value
=
'
whitelist
'
modelMappings
.
value
=
[]
allowedModels
.
value
=
[]
customErrorCodesEnabled
.
value
=
false
selectedErrorCodes
.
value
=
[]
editApiKey
.
value
=
''
}
editApiKey
.
value
=
''
}
}
,
{
immediate
:
true
}
)
}
,
{
immediate
:
true
}
)
// Model mapping helpers
const
addModelMapping
=
()
=>
{
...
...
@@ -589,7 +821,7 @@ const removeModelMapping = (index: number) => {
}
const
addPresetMapping
=
(
from
:
string
,
to
:
string
)
=>
{
const
exists
=
modelMappings
.
value
.
some
(
m
=>
m
.
from
===
from
)
const
exists
=
modelMappings
.
value
.
some
(
(
m
)
=>
m
.
from
===
from
)
if
(
exists
)
{
appStore
.
showInfo
(
t
(
'
admin.accounts.mappingExists
'
,
{
model
:
from
}
))
return
...
...
@@ -666,7 +898,7 @@ const handleSubmit = async () => {
// For apikey type, handle credentials update
if
(
props
.
account
.
type
===
'
apikey
'
)
{
const
currentCredentials
=
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
||
{
}
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newBaseUrl
=
editBaseUrl
.
value
.
trim
()
||
defaultBaseUrl
.
value
const
modelMapping
=
buildModelMappingObject
()
...
...
@@ -707,7 +939,7 @@ const handleSubmit = async () => {
updatePayload
.
credentials
=
newCredentials
}
else
{
// For oauth/setup-token types, only update intercept_warmup_requests if changed
const
currentCredentials
=
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
||
{
}
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newCredentials
:
Record
<
string
,
unknown
>
=
{
...
currentCredentials
}
if
(
interceptWarmupRequests
.
value
)
{
...
...
frontend/src/components/account/OAuthAuthorizationFlow.vue
View file @
429f38d0
<
template
>
<div
class=
"rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30 p-6"
>
<div
class=
"rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30"
>
<div
class=
"flex items-start gap-4"
>
<div
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
</div>
<div
class=
"flex-1"
>
...
...
@@ -22,7 +34,9 @@
value=
"manual"
class=
"text-blue-600 focus:ring-blue-500"
/>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.manualAuth
'
)
}}
</span>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.manualAuth
'
)
}}
</span>
</label>
<label
class=
"flex cursor-pointer items-center gap-2"
>
<input
...
...
@@ -31,23 +45,39 @@
value=
"cookie"
class=
"text-blue-600 focus:ring-blue-500"
/>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.cookieAutoAuth
'
)
}}
</span>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.cookieAutoAuth
'
)
}}
</span>
</label>
</div>
</div>
<!-- Cookie Auto-Auth Form -->
<div
v-if=
"inputMethod === 'cookie'"
class=
"space-y-4"
>
<div
class=
"rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4"
>
<div
class=
"rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p
class=
"mb-3 text-sm text-blue-700 dark:text-blue-300"
>
{{
t
(
'
admin.accounts.oauth.cookieAutoAuthDesc
'
)
}}
</p>
<!-- sessionKey Input -->
<div
class=
"mb-4"
>
<label
class=
"mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<svg
class=
"w-4 h-4 text-blue-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
<label
class=
"mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<svg
class=
"h-4 w-4 text-blue-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
{{
t
(
'
admin.accounts.oauth.sessionKey
'
)
}}
<span
...
...
@@ -62,16 +92,30 @@
class
=
"
text-blue-500 hover:text-blue-600
"
@
click
=
"
showHelpDialog = !showHelpDialog
"
>
<
svg
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z
"
/>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z
"
/>
<
/svg
>
<
/button
>
<
/label
>
<
textarea
v
-
model
=
"
sessionKeyInput
"
rows
=
"
3
"
class
=
"
input w-full font-mono text-sm resize-y
"
:
placeholder
=
"
allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
"
class
=
"
input w-full resize-y font-mono text-sm
"
:
placeholder
=
"
allowMultiple
? t('admin.accounts.oauth.sessionKeyPlaceholder')
: t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
"
><
/textarea
>
<
p
v
-
if
=
"
parsedKeyCount > 1 && allowMultiple
"
...
...
@@ -84,12 +128,14 @@
<!--
Help
Section
-->
<
div
v
-
if
=
"
showHelpDialog && showHelp
"
class
=
"
mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30
p-3
"
class
=
"
mb-4 rounded-lg border border-amber-200 bg-amber-50
p-3
dark:border-amber-700 dark:bg-amber-900/30
"
>
<
h5
class
=
"
mb-2 font-semibold text-amber-800 dark:text-amber-200
"
>
{{
t
(
'
admin.accounts.oauth.howToGetSessionKey
'
)
}}
<
/h5
>
<
ol
class
=
"
list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300
"
>
<
ol
class
=
"
list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300
"
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step1')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step2')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step3')
"
><
/li
>
...
...
@@ -97,15 +143,18 @@
<
li
v
-
html
=
"
t('admin.accounts.oauth.step5')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step6')
"
><
/li
>
<
/ol
>
<
p
class
=
"
mt-2 text-xs text-amber-600 dark:text-amber-400
"
v
-
html
=
"
t('admin.accounts.oauth.sessionKeyFormat')
"
><
/p
>
<
p
class
=
"
mt-2 text-xs text-amber-600 dark:text-amber-400
"
v
-
html
=
"
t('admin.accounts.oauth.sessionKeyFormat')
"
><
/p
>
<
/div
>
<!--
Error
Message
-->
<
div
v
-
if
=
"
error
"
class
=
"
mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30
p-3
"
class
=
"
mb-4 rounded-lg border border-red-200 bg-red-50
p-3
dark:border-red-700 dark:bg-red-900/30
"
>
<
p
class
=
"
text-sm text-red-600 dark:text-red-400
whitespace-pre-line
"
>
<
p
class
=
"
whitespace-pre-line
text-sm text-red-600 dark:text-red-400
"
>
{{
error
}}
<
/p
>
<
/div
>
...
...
@@ -117,14 +166,45 @@
:
disabled
=
"
loading || !sessionKeyInput.trim()
"
@
click
=
"
handleCookieAuth
"
>
<
svg
v
-
if
=
"
loading
"
class
=
"
animate-spin -ml-1 mr-2 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
svg
v
-
if
=
"
loading
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
<
svg
v
-
else
class
=
"
w-4 h-4 mr-2
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z
"
/>
<
svg
v
-
else
class
=
"
mr-2 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z
"
/>
<
/svg
>
{{
loading
?
t
(
'
admin.accounts.oauth.authorizing
'
)
:
t
(
'
admin.accounts.oauth.startAutoAuth
'
)
}}
{{
loading
?
t
(
'
admin.accounts.oauth.authorizing
'
)
:
t
(
'
admin.accounts.oauth.startAutoAuth
'
)
}}
<
/button
>
<
/div
>
<
/div
>
...
...
@@ -136,15 +216,44 @@
<
/p
>
<!--
Step
1
:
Generate
Auth
URL
-->
<
div
class
=
"
rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4
"
>
<
div
class
=
"
rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
1
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
oauthStep1GenerateUrl
}}
<
/p
>
<
div
v
-
if
=
"
showProjectId && platform === 'gemini'
"
class
=
"
mb-3
"
>
<
label
class
=
"
input-label flex items-center gap-2
"
>
{{
t
(
'
admin.accounts.oauth.gemini.projectIdLabel
'
)
}}
<
a
href
=
"
https://console.cloud.google.com/
"
target
=
"
_blank
"
rel
=
"
noopener noreferrer
"
class
=
"
inline-flex items-center gap-1 text-xs font-normal text-blue-500 hover:text-blue-600 dark:text-blue-400
"
>
<
svg
class
=
"
h-3 w-3
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.oauth.gemini.howToGetProjectId
'
)
}}
<
/a
>
<
/label
>
<
input
v
-
model
=
"
projectId
"
type
=
"
text
"
class
=
"
input w-full font-mono text-sm
"
:
placeholder
=
"
t('admin.accounts.oauth.gemini.projectIdPlaceholder')
"
/>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.oauth.gemini.projectIdHint
'
)
}}
<
/p
>
<
/div
>
<
button
v
-
if
=
"
!authUrl
"
type
=
"
button
"
...
...
@@ -152,12 +261,39 @@
class
=
"
btn btn-primary text-sm
"
@
click
=
"
handleGenerateUrl
"
>
<
svg
v
-
if
=
"
loading
"
class
=
"
animate-spin -ml-1 mr-2 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
svg
v
-
if
=
"
loading
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
<
svg
v
-
else
class
=
"
w-4 h-4 mr-2
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
<
svg
v
-
else
class
=
"
mr-2 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
<
/svg
>
{{
loading
?
t
(
'
admin.accounts.oauth.generating
'
)
:
oauthGenerateAuthUrl
}}
<
/button
>
...
...
@@ -167,7 +303,7 @@
:
value
=
"
authUrl
"
readonly
type
=
"
text
"
class
=
"
input flex-1 bg-gray-50
dark:bg-gray-700
font-mono text-xs
"
class
=
"
input flex-1 bg-gray-50 font-mono text-xs
dark:bg-gray-700
"
/>
<
button
type
=
"
button
"
...
...
@@ -175,11 +311,33 @@
title
=
"
Copy URL
"
@
click
=
"
handleCopyUrl
"
>
<
svg
v
-
if
=
"
!copied
"
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184
"
/>
<
svg
v
-
if
=
"
!copied
"
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184
"
/>
<
/svg
>
<
svg
v
-
else
class
=
"
w-4 h-4 text-green-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M4.5 12.75l6 6 9-13.5
"
/>
<
svg
v
-
else
class
=
"
h-4 w-4 text-green-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M4.5 12.75l6 6 9-13.5
"
/>
<
/svg
>
<
/button
>
<
/div
>
...
...
@@ -188,8 +346,18 @@
class
=
"
text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400
"
@
click
=
"
handleRegenerate
"
>
<
svg
class
=
"
w-3 h-3 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
/>
<
svg
class
=
"
mr-1 inline h-3 w-3
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.oauth.regenerate
'
)
}}
<
/button
>
...
...
@@ -199,9 +367,13 @@
<
/div
>
<!--
Step
2
:
Open
URL
and
authorize
-->
<
div
class
=
"
rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4
"
>
<
div
class
=
"
rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
2
<
/div
>
<
div
class
=
"
flex-1
"
>
...
...
@@ -212,58 +384,120 @@
{{
oauthOpenUrlDesc
}}
<
/p
>
<!--
OpenAI
Important
Notice
-->
<
div
v
-
if
=
"
isOpenAI
"
class
=
"
mt-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 p-3
"
>
<
p
class
=
"
text-xs text-amber-800 dark:text-amber-300
"
v
-
html
=
"
oauthImportantNotice
"
>
<
/p
>
<
div
v
-
if
=
"
isOpenAI
"
class
=
"
mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30
"
>
<
p
class
=
"
text-xs text-amber-800 dark:text-amber-300
"
v
-
html
=
"
oauthImportantNotice
"
><
/p
>
<
/div
>
<!--
Proxy
Warning
(
for
non
-
OpenAI
)
-->
<
div
v
-
else
-
if
=
"
showProxyWarning
"
class
=
"
mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3
"
>
<
p
class
=
"
text-xs text-yellow-800 dark:text-yellow-300
"
v
-
html
=
"
t('admin.accounts.oauth.proxyWarning')
"
>
<
/p
>
<
div
v
-
else
-
if
=
"
showProxyWarning
"
class
=
"
mt-2 rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30
"
>
<
p
class
=
"
text-xs text-yellow-800 dark:text-yellow-300
"
v
-
html
=
"
t('admin.accounts.oauth.proxyWarning')
"
><
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Step
3
:
Enter
authorization
code
-->
<
div
class
=
"
rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4
"
>
<
div
class
=
"
rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
3
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
oauthStep3EnterCode
}}
<
/p
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
v
-
html
=
"
oauthAuthCodeDesc
"
>
<
/p
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
v
-
html
=
"
oauthAuthCodeDesc
"
><
/p
>
<
div
>
<
label
class
=
"
input-label
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1 text-blue-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z
"
/>
<
svg
class
=
"
mr-1 inline h-4 w-4 text-blue-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z
"
/>
<
/svg
>
{{
oauthAuthCode
}}
<
/label
>
<
textarea
v
-
model
=
"
authCodeInput
"
rows
=
"
3
"
class
=
"
input w-full font-mono text-sm
resize-none
"
class
=
"
input w-full
resize-none
font-mono text-sm
"
:
placeholder
=
"
oauthAuthCodePlaceholder
"
><
/textarea
>
<
p
class
=
"
mt-2 text-xs text-gray-500 dark:text-gray-400
"
>
<
svg
class
=
"
w-3 h-3 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z
"
/>
<
svg
class
=
"
mr-1 inline h-3 w-3
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z
"
/>
<
/svg
>
{{
oauthAuthCodeHint
}}
<
/p
>
<!--
Gemini
-
specific
state
parameter
warning
-->
<
div
v
-
if
=
"
platform === 'gemini'
"
class
=
"
mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30
"
>
<
div
class
=
"
flex items-start gap-2
"
>
<
svg
class
=
"
h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z
"
/>
<
/svg
>
<
div
class
=
"
text-sm text-amber-800 dark:text-amber-300
"
>
<
p
class
=
"
font-semibold
"
>
{{
$t
(
'
admin.accounts.oauth.gemini.stateWarningTitle
'
)
}}
<
/p
>
<
p
class
=
"
mt-1
"
>
{{
$t
(
'
admin.accounts.oauth.gemini.stateWarningDesc
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Error
Message
-->
<
div
v
-
if
=
"
error
"
class
=
"
mt-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30
p-3
"
class
=
"
mt-3 rounded-lg border border-red-200 bg-red-50
p-3
dark:border-red-700 dark:bg-red-900/30
"
>
<
p
class
=
"
text-sm text-red-600 dark:text-red-400
whitespace-pre-line
"
>
<
p
class
=
"
whitespace-pre-line
text-sm text-red-600 dark:text-red-400
"
>
{{
error
}}
<
/p
>
<
/div
>
...
...
@@ -293,7 +527,8 @@ interface Props {
allowMultiple
?:
boolean
methodLabel
?:
string
showCookieOption
?:
boolean
// Whether to show cookie auto-auth option
platform
?:
'
anthropic
'
|
'
openai
'
// Platform type for different UI/text
platform
?:
'
anthropic
'
|
'
openai
'
|
'
gemini
'
// Platform type for different UI/text
showProjectId
?:
boolean
// New prop to control project ID visibility
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
...
...
@@ -306,7 +541,8 @@ const props = withDefaults(defineProps<Props>(), {
allowMultiple
:
false
,
methodLabel
:
'
Authorization Method
'
,
showCookieOption
:
true
,
platform
:
'
anthropic
'
platform
:
'
anthropic
'
,
showProjectId
:
true
}
)
const
emit
=
defineEmits
<
{
...
...
@@ -318,16 +554,12 @@ const emit = defineEmits<{
const
{
t
}
=
useI18n
()
// Platform-specific translation helpers
const
isOpenAI
=
computed
(()
=>
props
.
platform
===
'
openai
'
)
// Get translation key based on platform
const
getOAuthKey
=
(
key
:
string
)
=>
{
if
(
isOpenAI
.
value
)
{
// Try OpenAI-specific key first
const
openaiKey
=
`admin.accounts.oauth.openai.${key
}
`
return
openaiKey
}
if
(
props
.
platform
===
'
openai
'
)
return
`admin.accounts.oauth.openai.${key
}
`
if
(
props
.
platform
===
'
gemini
'
)
return
`admin.accounts.oauth.gemini.${key
}
`
return
`admin.accounts.oauth.${key
}
`
}
...
...
@@ -343,20 +575,27 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
const
oauthAuthCode
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCode
'
)))
const
oauthAuthCodePlaceholder
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodePlaceholder
'
)))
const
oauthAuthCodeHint
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodeHint
'
)))
const
oauthImportantNotice
=
computed
(()
=>
isOpenAI
.
value
?
t
(
'
admin.accounts.oauth.openai.importantNotice
'
)
:
''
)
const
oauthImportantNotice
=
computed
(()
=>
props
.
platform
===
'
openai
'
?
t
(
'
admin.accounts.oauth.openai.importantNotice
'
)
:
''
)
// Local state
const
inputMethod
=
ref
<
AuthInputMethod
>
(
props
.
showCookieOption
?
'
manual
'
:
'
manual
'
)
const
authCodeInput
=
ref
(
''
)
const
sessionKeyInput
=
ref
(
''
)
const
showHelpDialog
=
ref
(
false
)
const
oauthState
=
ref
(
''
)
const
projectId
=
ref
(
''
)
// Clipboard
const
{
copied
,
copyToClipboard
}
=
useClipboard
()
// Computed
const
parsedKeyCount
=
computed
(()
=>
{
return
sessionKeyInput
.
value
.
split
(
'
\n
'
).
map
(
k
=>
k
.
trim
()).
filter
(
k
=>
k
).
length
return
sessionKeyInput
.
value
.
split
(
'
\n
'
)
.
map
((
k
)
=>
k
.
trim
())
.
filter
((
k
)
=>
k
).
length
}
)
// Watchers
...
...
@@ -367,7 +606,7 @@ watch(inputMethod, (newVal) => {
// Auto-extract code from OpenAI callback URL
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
watch
(
authCodeInput
,
(
newVal
)
=>
{
if
(
!
isOpenAI
.
value
)
return
if
(
props
.
platform
!==
'
openai
'
&&
props
.
platform
!==
'
gemini
'
)
return
const
trimmed
=
newVal
.
trim
()
// Check if it looks like a URL with code parameter
...
...
@@ -376,6 +615,10 @@ watch(authCodeInput, (newVal) => {
// Try to parse as URL
const
url
=
new
URL
(
trimmed
)
const
code
=
url
.
searchParams
.
get
(
'
code
'
)
const
stateParam
=
url
.
searchParams
.
get
(
'
state
'
)
if
(
props
.
platform
===
'
gemini
'
&&
stateParam
)
{
oauthState
.
value
=
stateParam
}
if
(
code
&&
code
!==
trimmed
)
{
// Replace the input with just the code
authCodeInput
.
value
=
code
...
...
@@ -383,6 +626,10 @@ watch(authCodeInput, (newVal) => {
}
catch
{
// If URL parsing fails, try regex extraction
const
match
=
trimmed
.
match
(
/
[
?&
]
code=
([^
&
]
+
)
/
)
const
stateMatch
=
trimmed
.
match
(
/
[
?&
]
state=
([^
&
]
+
)
/
)
if
(
props
.
platform
===
'
gemini
'
&&
stateMatch
&&
stateMatch
[
1
])
{
oauthState
.
value
=
stateMatch
[
1
]
}
if
(
match
&&
match
[
1
]
&&
match
[
1
]
!==
trimmed
)
{
authCodeInput
.
value
=
match
[
1
]
}
...
...
@@ -415,10 +662,14 @@ const handleCookieAuth = () => {
// Expose methods and state
defineExpose
({
authCode
:
authCodeInput
,
oauthState
,
projectId
,
sessionKey
:
sessionKeyInput
,
inputMethod
,
reset
:
()
=>
{
authCodeInput
.
value
=
''
oauthState
.
value
=
''
projectId
.
value
=
''
sessionKeyInput
.
value
=
''
inputMethod
.
value
=
'
manual
'
showHelpDialog
.
value
=
false
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
429f38d0
...
...
@@ -7,29 +7,55 @@
>
<div
v-if=
"account"
class=
"space-y-5"
>
<!-- Account Info -->
<div
class=
"rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4"
>
<div
class=
"rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<div
class=
"flex items-center gap-3"
>
<div
:class=
"[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600'
]"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
<div
:class=
"[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: 'from-orange-500 to-orange-600'
]"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span
class=
"block font-semibold text-gray-900 dark:text-white"
>
{{
account
.
name
}}
</span>
<span
class=
"block font-semibold text-gray-900 dark:text-white"
>
{{
account
.
name
}}
</span>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
isOpenAI
?
t
(
'
admin.accounts.openaiAccount
'
)
:
t
(
'
admin.accounts.claudeCodeAccount
'
)
}}
{{
isOpenAI
?
t
(
'
admin.accounts.openaiAccount
'
)
:
isGemini
?
t
(
'
admin.accounts.geminiAccount
'
)
:
t
(
'
admin.accounts.claudeCodeAccount
'
)
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<div
v-if=
"
!
is
OpenAI
"
>
<div
v-if=
"is
Anthropic
"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.authMethod
'
)
}}
</label>
<div
class=
"flex gap-4
mt-2
"
>
<div
class=
"
mt-2
flex gap-4"
>
<label
class=
"flex cursor-pointer items-center"
>
<input
v-model=
"addMethod"
...
...
@@ -46,12 +72,119 @@
value=
"setup-token"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.setupTokenLongLived
'
)
}}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.setupTokenLongLived
'
)
}}
</span>
</label>
</div>
</div>
<!-- OAuth Authorization Section -->
<!-- Gemini OAuth Type Selection -->
<div
v-if=
"isGemini"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('code_assist')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Code Assist
</span>
<span
class=
"block text-xs font-medium text-blue-600 dark:text-blue-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectId
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectIdDesc
'
)
}}
</span>
</div>
</button>
<button
type=
"button"
:disabled=
"!geminiAIStudioOAuthEnabled"
@
click=
"handleSelectGeminiOAuthType('ai_studio')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
AI Studio
</span>
<span
class=
"block text-xs font-medium text-purple-600 dark:text-purple-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.noProjectIdNeeded
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.noProjectIdNeededDesc
'
)
}}
</span>
<div
v-if=
"!geminiAIStudioOAuthEnabled"
class=
"group relative mt-1 inline-block"
>
<span
class=
"rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredShort
'
)
}}
</span>
<div
class=
"pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredTip
'
)
}}
</div>
</div>
</div>
</button>
</div>
</div>
<OAuthAuthorizationFlow
ref=
"oauthFlowRef"
:add-method=
"addMethod"
...
...
@@ -59,22 +192,19 @@
:session-id=
"currentSessionId"
:loading=
"currentLoading"
:error=
"currentError"
:show-help=
"
!
is
OpenAI
"
:show-proxy-warning=
"
!
is
OpenAI
"
:show-cookie-option=
"
!
is
OpenAI
"
:show-help=
"is
Anthropic
"
:show-proxy-warning=
"is
Anthropic
"
:show-cookie-option=
"is
Anthropic
"
:allow-multiple=
"false"
:method-label=
"t('admin.accounts.inputMethod')"
:platform=
"isOpenAI ? 'openai' : 'anthropic'"
:platform=
"isOpenAI ? 'openai' : isGemini ? 'gemini' : 'anthropic'"
:show-project-id=
"isGemini && geminiOAuthType === 'code_assist'"
@
generate-url=
"handleGenerateUrl"
@
cookie-auth=
"handleCookieAuth"
/>
<div
class=
"flex justify-between gap-3 pt-4"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
...
...
@@ -86,14 +216,29 @@
>
<svg
v-if=
"currentLoading"
class=
"
animate-spin
-ml-1 mr-2 h-4 w-4"
class=
"-ml-1 mr-2 h-4 w-4
animate-spin
"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
currentLoading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
{{
currentLoading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
</button>
</div>
</div>
...
...
@@ -105,8 +250,13 @@ import { ref, computed, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
useGeminiOAuth
}
from
'
@/composables/useGeminiOAuth
'
import
type
{
Account
}
from
'
@/types
'
import
Modal
from
'
@/components/common/Modal.vue
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
...
...
@@ -115,6 +265,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface
OAuthFlowExposed
{
authCode
:
string
oauthState
:
string
projectId
:
string
sessionKey
:
string
inputMethod
:
AuthInputMethod
reset
:
()
=>
void
...
...
@@ -137,55 +289,113 @@ const { t } = useI18n()
// OAuth composables - use both Claude and OpenAI
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
()
const
geminiOAuth
=
useGeminiOAuth
()
// Refs
const
oauthFlowRef
=
ref
<
OAuthFlowExposed
|
null
>
(
null
)
// State
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
ai_studio
'
>
(
'
code_assist
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
// Computed - check if this is an OpenAI account
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
const
isGemini
=
computed
(()
=>
props
.
account
?.
platform
===
'
gemini
'
)
const
isAnthropic
=
computed
(()
=>
props
.
account
?.
platform
===
'
anthropic
'
)
// Computed - current OAuth state based on platform
const
currentAuthUrl
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
authUrl
.
value
:
claudeOAuth
.
authUrl
.
value
)
const
currentSessionId
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
sessionId
.
value
:
claudeOAuth
.
sessionId
.
value
)
const
currentLoading
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
loading
.
value
:
claudeOAuth
.
loading
.
value
)
const
currentError
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
error
.
value
:
claudeOAuth
.
error
.
value
)
const
currentAuthUrl
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
authUrl
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
authUrl
.
value
return
claudeOAuth
.
authUrl
.
value
})
const
currentSessionId
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
sessionId
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
sessionId
.
value
return
claudeOAuth
.
sessionId
.
value
})
const
currentLoading
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
loading
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
loading
.
value
return
claudeOAuth
.
loading
.
value
})
const
currentError
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
error
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
error
.
value
return
claudeOAuth
.
error
.
value
})
// Computed
const
isManualInputMethod
=
computed
(()
=>
{
// OpenAI always uses manual input (no cookie auth option)
return
isOpenAI
.
value
||
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
return
isOpenAI
.
value
||
isGemini
.
value
||
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
})
const
canExchangeCode
=
computed
(()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
const
sessionId
=
isOpenAI
.
value
?
openaiOAuth
.
sessionId
.
value
:
claudeOAuth
.
sessionId
.
value
const
loading
=
isOpenAI
.
value
?
openaiOAuth
.
loading
.
value
:
claudeOAuth
.
loading
.
value
const
sessionId
=
isOpenAI
.
value
?
openaiOAuth
.
sessionId
.
value
:
isGemini
.
value
?
geminiOAuth
.
sessionId
.
value
:
claudeOAuth
.
sessionId
.
value
const
loading
=
isOpenAI
.
value
?
openaiOAuth
.
loading
.
value
:
isGemini
.
value
?
geminiOAuth
.
loading
.
value
:
claudeOAuth
.
loading
.
value
return
authCode
.
trim
()
&&
sessionId
&&
!
loading
})
// Watchers
watch
(()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
// Initialize addMethod based on current account type (Claude only)
if
(
!
isOpenAI
.
value
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
))
{
addMethod
.
value
=
props
.
account
.
type
as
AddMethod
watch
(
()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
// Initialize addMethod based on current account type (Claude only)
if
(
isAnthropic
.
value
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
{
addMethod
.
value
=
props
.
account
.
type
as
AddMethod
}
if
(
isGemini
.
value
)
{
const
creds
=
(
props
.
account
.
credentials
||
{})
as
Record
<
string
,
unknown
>
geminiOAuthType
.
value
=
creds
.
oauth_type
===
'
ai_studio
'
?
'
ai_studio
'
:
'
code_assist
'
}
if
(
isGemini
.
value
)
{
geminiOAuth
.
getCapabilities
().
then
((
caps
)
=>
{
geminiAIStudioOAuthEnabled
.
value
=
!!
caps
?.
ai_studio_oauth_enabled
if
(
!
geminiAIStudioOAuthEnabled
.
value
&&
geminiOAuthType
.
value
===
'
ai_studio
'
)
{
geminiOAuthType
.
value
=
'
code_assist
'
}
})
}
}
else
{
resetState
()
}
}
else
{
resetState
()
}
}
)
)
// Methods
const
resetState
=
()
=>
{
addMethod
.
value
=
'
oauth
'
geminiOAuthType
.
value
=
'
code_assist
'
geminiAIStudioOAuthEnabled
.
value
=
false
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
const
handleSelectGeminiOAuthType
=
(
oauthType
:
'
code_assist
'
|
'
ai_studio
'
)
=>
{
if
(
oauthType
===
'
ai_studio
'
&&
!
geminiAIStudioOAuthEnabled
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfigured
'
))
return
}
geminiOAuthType
.
value
=
oauthType
}
const
handleClose
=
()
=>
{
emit
(
'
close
'
)
}
...
...
@@ -195,6 +405,9 @@ const handleGenerateUrl = async () => {
if
(
isOpenAI
.
value
)
{
await
openaiOAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
}
else
if
(
isGemini
.
value
)
{
const
projectId
=
geminiOAuthType
.
value
===
'
code_assist
'
?
oauthFlowRef
.
value
?.
projectId
:
undefined
await
geminiOAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
,
projectId
,
geminiOAuthType
.
value
)
}
else
{
await
claudeOAuth
.
generateAuthUrl
(
addMethod
.
value
,
props
.
account
.
proxy_id
)
}
...
...
@@ -211,7 +424,11 @@ const handleExchangeCode = async () => {
const
sessionId
=
openaiOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
const
tokenInfo
=
await
openaiOAuth
.
exchangeAuthCode
(
authCode
.
trim
(),
sessionId
,
props
.
account
.
proxy_id
)
const
tokenInfo
=
await
openaiOAuth
.
exchangeAuthCode
(
authCode
.
trim
(),
sessionId
,
props
.
account
.
proxy_id
)
if
(
!
tokenInfo
)
return
// Build credentials and extra info
...
...
@@ -236,6 +453,38 @@ const handleExchangeCode = async () => {
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
openaiOAuth
.
error
.
value
)
}
}
else
if
(
isGemini
.
value
)
{
const
sessionId
=
geminiOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
const
stateFromInput
=
oauthFlowRef
.
value
?.
oauthState
||
''
const
stateToUse
=
stateFromInput
||
geminiOAuth
.
state
.
value
if
(
!
stateToUse
)
return
const
tokenInfo
=
await
geminiOAuth
.
exchangeAuthCode
({
code
:
authCode
.
trim
(),
sessionId
,
state
:
stateToUse
,
proxyId
:
props
.
account
.
proxy_id
,
oauthType
:
geminiOAuthType
.
value
})
if
(
!
tokenInfo
)
return
const
credentials
=
geminiOAuth
.
buildCredentials
(
tokenInfo
)
try
{
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
'
oauth
'
,
credentials
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
geminiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
geminiOAuth
.
error
.
value
)
}
}
else
{
// Claude OAuth flow
const
sessionId
=
claudeOAuth
.
sessionId
.
value
...
...
@@ -246,9 +495,10 @@ const handleExchangeCode = async () => {
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
sessionId
,
...
...
@@ -288,9 +538,10 @@ const handleCookieAuth = async (sessionKey: string) => {
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
''
,
...
...
@@ -314,7 +565,8 @@ const handleCookieAuth = async (sessionKey: string) => {
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.cookieAuthFailed
'
)
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.cookieAuthFailed
'
)
}
finally
{
claudeOAuth
.
loading
.
value
=
false
}
...
...
frontend/src/components/account/SyncFromCrsModal.vue
View file @
429f38d0
...
...
@@ -10,10 +10,15 @@
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.syncFromCrsDesc
'
)
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3"
>
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
<div
class=
"rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
</div>
<div
class=
"text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"
>
<div
class=
"rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{
t
(
'
admin.accounts.crsVersionRequirement
'
)
}}
</div>
...
...
@@ -31,12 +36,7 @@
<div
class=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsUsername
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
autocomplete=
"username"
/>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
autocomplete=
"username"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsPassword
'
)
}}
</label>
...
...
@@ -50,12 +50,19 @@
</div>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300"
>
<input
v-model=
"form.sync_proxies"
type=
"checkbox"
class=
"rounded border-gray-300 dark:border-dark-600"
/>
<input
v-model=
"form.sync_proxies"
type=
"checkbox"
class=
"rounded border-gray-300 dark:border-dark-600"
/>
{{
t
(
'
admin.accounts.syncProxies
'
)
}}
</label>
</div>
<div
v-if=
"result"
class=
"rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2"
>
<div
v-if=
"result"
class=
"space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.syncResult
'
)
}}
</div>
...
...
@@ -67,9 +74,12 @@
<div
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
{{
t
(
'
admin.accounts.syncErrors
'
)
}}
</div>
<div
class=
"mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono"
>
<div
class=
"mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div
v-for=
"(item, idx) in errorItems"
:key=
"idx"
class=
"whitespace-pre-wrap"
>
{{
item
.
kind
}}
{{
item
.
crs_account_id
}}
—
{{
item
.
action
}}{{
item
.
error
?
`: ${item.error
}
`
:
''
}}
{{
item
.
kind
}}
{{
item
.
crs_account_id
}}
—
{{
item
.
action
}}{{
item
.
error
?
`: ${item.error
}
`
:
''
}}
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/components/account/UsageProgressBar.vue
View file @
429f38d0
<
template
>
<div>
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
<div
v-if=
"windowStats"
class=
"flex items-center justify-between mb-0.5"
:title=
"`5h 窗口用量统计`"
>
<div
class=
"flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help"
>
<span
class=
"px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"
>
<div
v-if=
"windowStats"
class=
"mb-0.5 flex items-center justify-between"
:title=
"`5h 窗口用量统计`"
>
<div
class=
"flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatRequests
}}
req
</span>
<span
class=
"
px-1.5 py-0.5
rounded bg-gray-100 dark:bg-gray-800"
>
<span
class=
"rounded bg-gray-100
px-1.5 py-0.5
dark:bg-gray-800"
>
{{
formatTokens
}}
</span>
<span
class=
"px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"
>
$
{{
formatCost
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
$
{{
formatCost
}}
</span>
</div>
</div>
...
...
@@ -19,16 +23,13 @@
<div
class=
"flex items-center gap-1"
>
<!-- Label badge (fixed width for alignment) -->
<span
:class=
"[
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
:class=
"['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
>
{{
label
}}
</span>
<!-- Progress bar container -->
<div
class=
"
w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-
0"
>
<div
class=
"
h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-70
0"
>
<div
:class=
"['h-full transition-all duration-300', barClass]"
:style=
"
{ width: barWidth }"
...
...
@@ -36,12 +37,12 @@
</div>
<!-- Percentage -->
<span
:class=
"['
text-[10px] font-medium w-[32px] text-right shrink-0
', textClass]"
>
<span
:class=
"['
w-[32px] shrink-0 text-right text-[10px] font-medium
', textClass]"
>
{{
displayPercent
}}
</span>
<!-- Reset time -->
<span
v-if=
"resetsAt"
class=
"text-[10px] text-gray-400
shrink-0
"
>
<span
v-if=
"resetsAt"
class=
"
shrink-0
text-[10px] text-gray-400"
>
{{
formatResetTime
}}
</span>
</div>
...
...
@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
const
props
=
defineProps
<
{
label
:
string
utilization
:
number
// Percentage (0-100+)
utilization
:
number
// Percentage (0-100+)
resetsAt
?:
string
|
null
color
:
'
indigo
'
|
'
emerald
'
|
'
purple
'
windowStats
?:
WindowStats
|
null
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
429f38d0
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white mb-4"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
</h3>
<div
v-if=
"loading"
class=
"flex items-center justify-center h-48"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
</h3>
<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
class=
"
w
-48
h
-48"
>
<div
class=
"
h
-48
w
-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
<div
class=
"
flex-1
max-h-48 overflow-y-auto"
>
<div
class=
"max-h-48
flex-1
overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"text-left
pb-2
"
>
{{
t
(
'
admin.dashboard.model
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
<th
class=
"
pb-2
text-left"
>
{{
t
(
'
admin.dashboard.model
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"model in modelStats"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
<tr
v-for=
"model in modelStats"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class=
"flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm"
>
<div
v-else
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
</div>
...
...
@@ -40,12 +62,7 @@
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
type
{
ModelStat
}
from
'
@/types
'
...
...
@@ -60,20 +77,30 @@ const props = defineProps<{
}
>
()
const
chartColors
=
[
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
]
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
modelStats
?.
length
)
return
null
return
{
labels
:
props
.
modelStats
.
map
(
m
=>
m
.
model
),
datasets
:
[{
data
:
props
.
modelStats
.
map
(
m
=>
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
modelStats
.
length
),
borderWidth
:
0
,
}],
labels
:
props
.
modelStats
.
map
((
m
)
=>
m
.
model
),
datasets
:
[
{
data
:
props
.
modelStats
.
map
((
m
)
=>
m
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
modelStats
.
length
),
borderWidth
:
0
}
]
}
})
...
...
@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
false
,
display
:
false
},
tooltip
:
{
callbacks
:
{
...
...
@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
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
formatTokens
=
(
value
:
number
):
string
=>
{
...
...
frontend/src/components/charts/TokenUsageTrend.vue
View file @
429f38d0
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white mb-4"
>
{{
t
(
'
admin.dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
v-if=
"loading"
class=
"flex items-center justify-center h-48"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"trendData.length > 0 && chartData"
class=
"h-48"
>
<Line
:data=
"chartData"
:options=
"lineOptions"
/>
</div>
<div
v-else
class=
"flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm"
>
<div
v-else
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
</div>
...
...
@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
,
input
:
'
#3b82f6
'
,
output
:
'
#10b981
'
,
cache
:
'
#f59e0b
'
,
cache
:
'
#f59e0b
'
}))
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
trendData
?.
length
)
return
null
return
{
labels
:
props
.
trendData
.
map
(
d
=>
d
.
date
),
labels
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
date
),
datasets
:
[
{
label
:
'
Input
'
,
data
:
props
.
trendData
.
map
(
d
=>
d
.
input_tokens
),
data
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
input_tokens
),
borderColor
:
chartColors
.
value
.
input
,
backgroundColor
:
`
${
chartColors
.
value
.
input
}
20`
,
fill
:
true
,
tension
:
0.3
,
tension
:
0.3
},
{
label
:
'
Output
'
,
data
:
props
.
trendData
.
map
(
d
=>
d
.
output_tokens
),
data
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
output_tokens
),
borderColor
:
chartColors
.
value
.
output
,
backgroundColor
:
`
${
chartColors
.
value
.
output
}
20`
,
fill
:
true
,
tension
:
0.3
,
tension
:
0.3
},
{
label
:
'
Cache
'
,
data
:
props
.
trendData
.
map
(
d
=>
d
.
cache_tokens
),
data
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
cache_tokens
),
borderColor
:
chartColors
.
value
.
cache
,
backgroundColor
:
`
${
chartColors
.
value
.
cache
}
20`
,
fill
:
true
,
tension
:
0.3
,
}
,
]
,
tension
:
0.3
}
]
}
})
...
...
@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
maintainAspectRatio
:
false
,
interaction
:
{
intersect
:
false
,
mode
:
'
index
'
as
const
,
mode
:
'
index
'
as
const
},
plugins
:
{
legend
:
{
...
...
@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
pointStyle
:
'
circle
'
,
padding
:
15
,
font
:
{
size
:
11
,
}
,
}
,
size
:
11
}
}
},
tooltip
:
{
callbacks
:
{
...
...
@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
return
`Actual: $
${
formatCost
(
data
.
actual_cost
)}
| Standard: $
${
formatCost
(
data
.
cost
)}
`
}
return
''
}
,
}
,
}
,
}
}
}
},
scales
:
{
x
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
},
ticks
:
{
color
:
chartColors
.
value
.
text
,
font
:
{
size
:
10
,
}
,
}
,
size
:
10
}
}
},
y
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
},
ticks
:
{
color
:
chartColors
.
value
.
text
,
font
:
{
size
:
10
,
size
:
10
},
callback
:
(
value
:
string
|
number
)
=>
formatTokens
(
Number
(
value
))
,
}
,
}
,
}
,
callback
:
(
value
:
string
|
number
)
=>
formatTokens
(
Number
(
value
))
}
}
}
}))
const
formatTokens
=
(
value
:
number
):
string
=>
{
...
...
frontend/src/components/common/ConfirmDialog.vue
View file @
429f38d0
...
...
@@ -9,7 +9,7 @@
<button
@
click=
"handleCancel"
type=
"button"
class=
"px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-200 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
focus:outline-none focus:ring-2 focus:ring-offset-2
dark:focus:ring-offset-dark-800
focus:ring-primary-500
"
class=
"
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
20
0 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
>
{{
cancelText
}}
</button>
...
...
@@ -17,7 +17,7 @@
@
click=
"handleConfirm"
type=
"button"
:class=
"[
'px-4 py-2 text-sm font-medium text-white
rounded-md
focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
'
rounded-md
px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
...
...
frontend/src/components/common/DataTable.vue
View file @
429f38d0
...
...
@@ -7,7 +7,7 @@
v-for=
"column in columns"
:key=
"column.key"
scope=
"col"
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400
uppercase tracking-wider
"
class=
"px-6 py-3 text-left text-xs font-medium
uppercase tracking-wider
text-gray-500 dark:text-dark-400"
:class=
"
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable
&&
handleSort(column.key)"
>
...
...
@@ -16,8 +16,8 @@
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<svg
v-if=
"sortKey === column.key"
class=
"
w
-4
h
-4"
:class=
"
{ '
transform
rotate-180': sortOrder === 'desc' }"
class=
"
h
-4
w
-4"
:class=
"
{ 'rotate-180
transform
': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
...
...
@@ -27,7 +27,7 @@
clip-rule=
"evenodd"
/>
</svg>
<svg
v-else
class=
"
w
-4
h
-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<svg
v-else
class=
"
h
-4
w
-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
d=
"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
...
...
@@ -37,22 +37,30 @@
</th>
</tr>
</thead>
<tbody
class=
"
bg-white dark:bg-dark-900
divide-y divide-gray-200 dark:divide-dark-700"
>
<tbody
class=
"divide-y divide-gray-200
bg-white
dark:divide-dark-700
dark:bg-dark-900
"
>
<!-- Loading skeleton -->
<tr
v-if=
"loading"
v-for=
"i in 5"
:key=
"i"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"
px-6 py-4
whitespace-nowrap"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"whitespace-nowrap
px-6 py-4
"
>
<div
class=
"animate-pulse"
>
<div
class=
"h-4 bg-gray-200 dark:bg-dark-700
rounded w-3/4
"
></div>
<div
class=
"h-4
w-3/4 rounded
bg-gray-200 dark:bg-dark-700"
></div>
</div>
</td>
</tr>
<!-- Empty state -->
<tr
v-else-if=
"!data || data.length === 0"
>
<td
:colspan=
"columns.length"
class=
"px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<td
:colspan=
"columns.length"
class=
"px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<slot
name=
"empty"
>
<div
class=
"flex flex-col items-center"
>
<svg
class=
"w-12 h-12 text-gray-400 dark:text-dark-500 mb-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<svg
class=
"mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
...
...
@@ -60,18 +68,25 @@
d=
"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p
class=
"text-lg font-medium text-gray-900 dark:text-gray-100"
>
{{
t
(
'
empty.noData
'
)
}}
</p>
<p
class=
"text-lg font-medium text-gray-900 dark:text-gray-100"
>
{{
t
(
'
empty.noData
'
)
}}
</p>
</div>
</slot>
</td>
</tr>
<!-- Data rows -->
<tr
v-else
v-for=
"(row, index) in sortedData"
:key=
"index"
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
>
<tr
v-else
v-for=
"(row, index) in sortedData"
:key=
"index"
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"
px-6 py-4
whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
class=
"whitespace-nowrap
px-6 py-4
text-sm text-gray-900 dark:text-gray-100"
>
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]">
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
...
...
frontend/src/components/common/DateRangePicker.vue
View file @
429f38d0
...
...
@@ -3,14 +3,21 @@
<button
type=
"button"
@
click=
"toggle"
:class=
"[
'date-picker-trigger',
isOpen && 'date-picker-trigger-open'
]"
:class=
"['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
>
<span
class=
"date-picker-icon"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
</span>
<span
class=
"date-picker-value"
>
...
...
@@ -18,7 +25,7 @@
</span>
<span
class=
"date-picker-chevron"
>
<svg
:class=
"['
w
-4
h
-4 transition-transform duration-200', isOpen && 'rotate-180']"
:class=
"['
h
-4
w
-4 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -30,20 +37,14 @@
</button>
<Transition
name=
"date-picker-dropdown"
>
<div
v-if=
"isOpen"
class=
"date-picker-dropdown"
>
<div
v-if=
"isOpen"
class=
"date-picker-dropdown"
>
<!-- Quick presets -->
<div
class=
"date-picker-presets"
>
<button
v-for=
"preset in presets"
:key=
"preset.value"
@
click=
"selectPreset(preset)"
:class=
"[
'date-picker-preset',
isPresetActive(preset) && 'date-picker-preset-active'
]"
:class=
"['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
>
{{
t
(
preset
.
labelKey
)
}}
</button>
...
...
@@ -64,8 +65,18 @@
/>
</div>
<div
class=
"date-picker-separator"
>
<svg
class=
"w-4 h-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
<svg
class=
"h-4 w-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
</svg>
</div>
<div
class=
"date-picker-field"
>
...
...
@@ -83,10 +94,7 @@
<!-- Apply button -->
<div
class=
"date-picker-actions"
>
<button
@
click=
"apply"
class=
"date-picker-apply"
>
<button
@
click=
"apply"
class=
"date-picker-apply"
>
{{
t
(
'
dates.apply
'
)
}}
</button>
</div>
...
...
@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
const
displayValue
=
computed
(()
=>
{
if
(
activePreset
.
value
)
{
const
preset
=
presets
.
find
(
p
=>
p
.
value
===
activePreset
.
value
)
const
preset
=
presets
.
find
(
(
p
)
=>
p
.
value
===
activePreset
.
value
)
if
(
preset
)
return
t
(
preset
.
labelKey
)
}
...
...
@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
}
// Sync local state with props
watch
(()
=>
props
.
startDate
,
(
val
)
=>
{
localStartDate
.
value
=
val
onDateChange
()
})
watch
(
()
=>
props
.
startDate
,
(
val
)
=>
{
localStartDate
.
value
=
val
onDateChange
()
}
)
watch
(()
=>
props
.
endDate
,
(
val
)
=>
{
localEndDate
.
value
=
val
onDateChange
()
})
watch
(
()
=>
props
.
endDate
,
(
val
)
=>
{
localEndDate
.
value
=
val
onDateChange
()
}
)
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
...
...
@@ -301,18 +315,18 @@ onUnmounted(() => {
<
style
scoped
>
.date-picker-trigger
{
@apply
flex
items-center
gap-2;
@apply
px-3
py-2
rounded-lg
text-sm;
@apply
rounded-lg
px-3
py-2
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
focus
:
border-primary-500
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
}
.date-picker-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500
;
@apply
border-primary-500
ring-2
ring-primary-500/30;
}
.date-picker-icon
{
...
...
@@ -328,7 +342,7 @@ onUnmounted(() => {
}
.date-picker-dropdown
{
@apply
absolute
z-[100]
mt-2
left-0
;
@apply
absolute
left-0
z-[100]
mt-2;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
...
...
@@ -342,7 +356,7 @@ onUnmounted(() => {
}
.date-picker-preset
{
@apply
px-3
py-1.5
text-xs
font-medium
rounded-md
;
@apply
rounded-md
px-3
py-1.5
text-xs
font-medium;
@apply
text-gray-600
dark
:
text-gray-400
;
@apply
hover
:
bg-gray-100
dark
:
hover
:
bg-dark-700
;
@apply
transition-colors
duration-150;
...
...
@@ -366,15 +380,15 @@ onUnmounted(() => {
}
.date-picker-label
{
@apply
block
text-xs
font-medium
text-gray-500
dark
:
text-gray-400
mb-1
;
@apply
mb-1
block
text-xs
font-medium
text-gray-500
dark
:
text-gray-400
;
}
.date-picker-input
{
@apply
w-full
px-2
py-1.5
text-sm
rounded-md
;
@apply
w-full
rounded-md
px-2
py-1.5
text-sm;
@apply
bg-gray-50
dark
:
bg-dark-700
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
focus
:
border-primary-500
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
;
}
.date-picker-input
::-webkit-calendar-picker-indicator
{
...
...
@@ -395,7 +409,7 @@ onUnmounted(() => {
}
.date-picker-apply
{
@apply
px-4
py-1.5
text-sm
font-medium
rounded-lg
;
@apply
rounded-lg
px-4
py-1.5
text-sm
font-medium;
@apply
bg-primary-600
text-white;
@apply
hover
:
bg-primary-700
;
@apply
transition-colors
duration-150;
...
...
frontend/src/components/common/EmptyState.vue
View file @
429f38d0
<
template
>
<div
class=
"empty-state"
>
<!-- Icon -->
<div
class=
"w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center"
>
<div
class=
"mb-5 flex h-20 w-20 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
>
<slot
name=
"icon"
>
<component
v-if=
"icon"
:is=
"icon"
class=
"empty-state-icon w-10 h-10"
aria-hidden=
"true"
/>
<component
v-if=
"icon"
:is=
"icon"
class=
"empty-state-icon h-10 w-10"
aria-hidden=
"true"
/>
<svg
v-else
class=
"empty-state-icon
w
-10
h
-10"
class=
"empty-state-icon
h
-10
w
-10"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -48,17 +45,13 @@
>
<svg
v-if=
"actionIcon"
class=
"
w-5
h-5
mr-2
"
class=
"
mr-2
h-5
w-5
"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
actionText
}}
</component>
...
...
frontend/src/components/common/GroupBadge.vue
View file @
429f38d0
<
template
>
<span
:class=
"[
'inline-flex items-center gap-1.5 px-2 py-0.5
rounded-md
text-xs font-medium transition-colors',
'inline-flex items-center gap-1.5
rounded-md
px-2 py-0.5 text-xs font-medium transition-colors',
badgeClass
]"
>
...
...
@@ -10,10 +10,7 @@
<!-- Group name -->
<span
class=
"truncate"
>
{{
name
}}
</span>
<!-- Right side label -->
<span
v-if=
"showLabel"
:class=
"labelClass"
>
<span
v-if=
"showLabel"
:class=
"labelClass"
>
{{
labelText
}}
</span>
</span>
...
...
@@ -31,7 +28,7 @@ interface Props {
subscriptionType
?:
SubscriptionType
rateMultiplier
?:
number
showRate
?:
boolean
daysRemaining
?:
number
|
null
// 剩余天数(订阅类型时使用)
daysRemaining
?:
number
|
null
// 剩余天数(订阅类型时使用)
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
...
...
@@ -97,6 +94,9 @@ const labelClass = computed(() => {
if
(
props
.
platform
===
'
openai
'
)
{
return
`
${
base
}
bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
}
if
(
props
.
platform
===
'
gemini
'
)
{
return
`
${
base
}
bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
return
`
${
base
}
bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
...
...
@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
?
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
:
'
bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400
'
}
if
(
props
.
platform
===
'
gemini
'
)
{
return
isSubscription
.
value
?
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
:
'
bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400
'
}
// Fallback: original colors
return
isSubscription
.
value
?
'
bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400
'
...
...
frontend/src/components/common/GroupSelector.vue
View file @
429f38d0
...
...
@@ -2,15 +2,15 @@
<div>
<label
class=
"input-label"
>
Groups
<span
class=
"text-gray-400
font-normal
"
>
(
{{
modelValue
.
length
}}
selected)
</span>
<span
class=
"
font-normal
text-gray-400"
>
(
{{
modelValue
.
length
}}
selected)
</span>
</label>
<div
class=
"grid grid-cols-2 gap-1
max-h-32
overflow-y-auto
p-2
border border-gray-200 dark:border-dark-600
rounded-lg bg-gray-50
dark:bg-dark-800"
class=
"grid
max-h-32
grid-cols-2 gap-1 overflow-y-auto
rounded-lg
border border-gray-200
bg-gray-50 p-2
dark:border-dark-600 dark:bg-dark-800"
>
<label
v-for=
"group in filteredGroups"
:key=
"group.id"
class=
"flex items-center gap-2 px-2 py-1.5
rounded
hover:bg-white dark:hover:bg-dark-700
cursor-pointer transition-colors
"
class=
"flex
cursor-pointer
items-center gap-2
rounded
px-2 py-1.5
transition-colors
hover:bg-white dark:hover:bg-dark-700"
:title=
"`$
{group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
>
<input
...
...
@@ -18,19 +18,19 @@
:value=
"group.id"
:checked=
"modelValue.includes(group.id)"
@
change=
"handleChange(group.id, ($event.target as HTMLInputElement).checked)"
class=
"
w
-3.5
h
-3.5
text-primary-500
border-gray-300
dark:border-d
ar
k
-500
rounded
focus:ring-primary-500
shrink-
0"
class=
"
h
-3.5
w
-3.5
shrink-0 rounded
border-gray-300
text-prim
ar
y
-500 focus:ring-primary-500
dark:border-dark-50
0"
/>
<GroupBadge
:name=
"group.name"
:subscription-type=
"group.subscription_type"
:rate-multiplier=
"group.rate_multiplier"
class=
"
flex-1
min-w-0"
class=
"min-w-0
flex-1
"
/>
<span
class=
"text-xs text-gray-400
shrink-0
"
>
{{
group
.
account_count
||
0
}}
</span>
<span
class=
"
shrink-0
text-xs text-gray-400"
>
{{
group
.
account_count
||
0
}}
</span>
</label>
<div
v-if=
"filteredGroups.length === 0"
class=
"col-span-2 text-center text-sm text-gray-500 dark:text-gray-400
py-2
"
class=
"col-span-2
py-2
text-center text-sm text-gray-500 dark:text-gray-400"
>
No groups available
</div>
...
...
@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
if
(
!
props
.
platform
)
{
return
props
.
groups
}
return
props
.
groups
.
filter
(
g
=>
g
.
platform
===
props
.
platform
)
return
props
.
groups
.
filter
(
(
g
)
=>
g
.
platform
===
props
.
platform
)
})
const
handleChange
=
(
groupId
:
number
,
checked
:
boolean
)
=>
{
const
newValue
=
checked
?
[...
props
.
modelValue
,
groupId
]
:
props
.
modelValue
.
filter
(
id
=>
id
!==
groupId
)
:
props
.
modelValue
.
filter
(
(
id
)
=>
id
!==
groupId
)
emit
(
'
update:modelValue
'
,
newValue
)
}
</
script
>
frontend/src/components/common/LocaleSwitcher.vue
View file @
429f38d0
...
...
@@ -2,13 +2,13 @@
<div
class=
"relative"
ref=
"dropdownRef"
>
<button
@
click=
"toggleDropdown"
class=
"flex items-center gap-1.5 px-2 py-1.5
rounded-lg
text-sm font-medium text-gray-600
dark:text-gray-300
hover:bg-gray-100 dark:
hover:bg-dark-700 transition-colors
"
class=
"flex items-center gap-1.5
rounded-lg
px-2 py-1.5 text-sm font-medium text-gray-600
transition-colors
hover:bg-gray-100 dark:
text-gray-300 dark:hover:bg-dark-700
"
:title=
"currentLocale?.name"
>
<span
class=
"text-base"
>
{{
currentLocale
?.
flag
}}
</span>
<span
class=
"hidden sm:inline"
>
{{
currentLocale
?.
code
.
toUpperCase
()
}}
</span>
<svg
class=
"
w
-3.5
h
-3.5 text-gray-400 transition-transform duration-200"
class=
"
h
-3.5
w
-3.5 text-gray-400 transition-transform duration-200"
:class=
"
{ 'rotate-180': isOpen }"
fill="none"
viewBox="0 0 24 24"
...
...
@@ -22,20 +22,23 @@
<transition
name=
"dropdown"
>
<div
v-if=
"isOpen"
class=
"absolute right-0 mt-1 w-32
rounded-lg bg-white dark:bg-dark-800 shadow
-lg border border-gray-200 dark:border-dark-700
overflow-hidden z-5
0"
class=
"absolute right-0
z-50
mt-1 w-32
overflow-hidden rounded
-lg border border-gray-200
bg-white shadow-lg
dark:border-dark-700
dark:bg-dark-80
0"
>
<button
v-for=
"locale in availableLocales"
:key=
"locale.code"
@
click=
"selectLocale(locale.code)"
class=
"w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class=
"
{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
class=
"flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class=
"
{
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
locale.code === currentLocaleCode
}"
>
<span
class=
"text-base"
>
{{
locale
.
flag
}}
</span>
<span>
{{
locale
.
name
}}
</span>
<svg
v-if=
"locale.code === currentLocaleCode"
class=
"
w-4 h-4 ml-auto
text-primary-500"
class=
"
ml-auto h-4 w-4
text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
...
...
@@ -60,7 +63,7 @@ const isOpen = ref(false)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
(
l
=>
l
.
code
===
locale
.
value
))
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
(
(
l
)
=>
l
.
code
===
locale
.
value
))
function
toggleDropdown
()
{
isOpen
.
value
=
!
isOpen
.
value
...
...
frontend/src/components/common/Modal.vue
View file @
429f38d0
...
...
@@ -9,24 +9,24 @@
@
click.self=
"handleClose"
>
<!-- Modal panel -->
<div
:class=
"['modal-content', sizeClasses]"
@
click.stop
>
<div
:class=
"['modal-content', sizeClasses]"
@
click.stop
>
<!-- Header -->
<div
class=
"modal-header"
>
<h3
id=
"modal-title"
class=
"modal-title"
>
<h3
id=
"modal-title"
class=
"modal-title"
>
{{
title
}}
</h3>
<button
@
click=
"emit('close')"
class=
"
p-2
-mr-2 rounded-xl text-gray-400
dark:text-dark-5
00 hover:text-gray-600 dark:
hover:
text-dark-
3
00 hover:bg-
gray-1
00 dark:hover:
bg
-dark-
7
00
transition-colors
"
class=
"-mr-2 rounded-xl
p-2
text-gray-400
transition-colors hover:bg-gray-1
00 hover:text-gray-600 dark:text-dark-
5
00
dark:
hover:bg-
dark-7
00 dark:hover:
text
-dark-
3
00"
aria-label=
"Close modal"
>
<svg
class=
"w-5 h-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
...
...
@@ -38,10 +38,7 @@
</div>
<!-- Footer -->
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<slot
name=
"footer"
></slot>
</div>
</div>
...
...
frontend/src/components/common/Pagination.vue
View file @
429f38d0
<
template
>
<div
class=
"flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6"
>
<div
class=
"flex items-center justify-between flex-1 sm:hidden"
>
<div
class=
"flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
>
<div
class=
"flex flex-1 items-center justify-between sm:hidden"
>
<!-- Mobile pagination -->
<button
@
click=
"goToPage(page - 1)"
:disabled=
"page === 1"
class=
"relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-200 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class=
"relative inline-flex items-center
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
20
0 dark:hover:bg-dark-600"
>
{{
t
(
'
pagination.previous
'
)
}}
</button>
...
...
@@ -15,7 +17,7 @@
<
button
@
click
=
"
goToPage(page + 1)
"
:
disabled
=
"
page === totalPages
"
class
=
"
relative inline-flex items-center px-4 py-2
ml-3
text-sm font-medium text-gray-700
dark:text-gray-200 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class
=
"
relative
ml-3
inline-flex items-center
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
20
0 dark:hover:bg-dark-600
"
>
{{
t
(
'
pagination.next
'
)
}}
<
/button
>
...
...
@@ -36,8 +38,10 @@
<!--
Page
size
selector
-->
<
div
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/span
>
<
div
class
=
"
w-20 page-size-select
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/spa
n
>
<
div
class
=
"
page-size-select w-20
"
>
<
Select
:
model
-
value
=
"
pageSize
"
:
options
=
"
pageSizeSelectOptions
"
...
...
@@ -56,10 +60,10 @@
<
button
@
click
=
"
goToPage(page - 1)
"
:
disabled
=
"
page === 1
"
class
=
"
relative inline-flex items-center
px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-l-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class
=
"
relative inline-flex items-center
rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
40
0 dark:hover:bg-dark-600
"
:
aria
-
label
=
"
t('pagination.previous')
"
>
<
svg
class
=
"
w
-5
h
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
svg
class
=
"
h
-5
w
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
path
fill
-
rule
=
"
evenodd
"
d
=
"
M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z
"
...
...
@@ -75,13 +79,15 @@
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
class
=
"
[
'relative inline-flex items-center px-4 py-2 text-sm font-medium
border
',
'relative inline-flex items-center
border
px-4 py-2 text-sm font-medium',
pageNum === page
? 'z-10 b
g
-primary-50
dark:
bg-primary-
900/30 border
-primary-
5
00
text
-primary-
60
0 dark:text-primary-400'
: 'b
g-white dark:bg-dark-700 border
-gray-
30
0 dark:border-dark-600
text-gray
-700 dark:text-gray-300
hover:bg-gray-50
dark:hover:bg-dark-600',
? 'z-10 b
order
-primary-50
0
bg-primary-
50 text
-primary-
6
00
dark:bg
-primary-
900/3
0 dark:text-primary-400'
: 'b
order-gray-300 bg-white text-gray-700 hover:bg
-gray-
5
0 dark:border-dark-600
dark:bg-dark
-700 dark:text-gray-300 dark:hover:bg-dark-600',
typeof pageNum !== 'number' && 'cursor-default'
]
"
:
aria
-
label
=
"
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum
}
) : undefined
"
:
aria
-
label
=
"
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum
}
) : undefined
"
:
aria
-
current
=
"
pageNum === page ? 'page' : undefined
"
>
{{
pageNum
}}
...
...
@@ -91,10 +97,10 @@
<
button
@
click
=
"
goToPage(page + 1)
"
:
disabled
=
"
page === totalPages
"
class
=
"
relative inline-flex items-center
px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-r-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class
=
"
relative inline-flex items-center
rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
40
0 dark:hover:bg-dark-600
"
:
aria
-
label
=
"
t('pagination.next')
"
>
<
svg
class
=
"
w
-5
h
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
svg
class
=
"
h
-5
w
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
path
fill
-
rule
=
"
evenodd
"
d
=
"
M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z
"
...
...
@@ -145,7 +151,7 @@ const toItem = computed(() => {
}
)
const
pageSizeSelectOptions
=
computed
(()
=>
{
return
props
.
pageSizeOptions
.
map
(
size
=>
({
return
props
.
pageSizeOptions
.
map
(
(
size
)
=>
({
value
:
size
,
label
:
String
(
size
)
}
))
...
...
@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
<
style
scoped
>
.
page
-
size
-
select
:
deep
(.
select
-
trigger
)
{
@
apply
py
-
1.5
px
-
3
text
-
sm
;
@
apply
p
x
-
3
p
y
-
1.5
text
-
sm
;
}
<
/style
>
Prev
1
2
3
4
5
6
7
8
9
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