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
8f0ea7a0
Commit
8f0ea7a0
authored
Mar 14, 2026
by
InCerry
Browse files
Merge branch 'main' into fix/enc_coot
parents
e4a4dfd0
a1dc0089
Changes
81
Show whitespace changes
Inline
Side-by-side
frontend/src/components/account/CreateAccountModal.vue
View file @
8f0ea7a0
...
...
@@ -323,35 +323,6 @@
</div>
</button>
<button
type=
"button"
@
click=
"accountCategory = 'bedrock-apikey'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'bedrock-apikey'
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-amber-300 dark:border-dark-600 dark:hover:border-amber-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'bedrock-apikey'
? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"key"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.bedrockApiKeyLabel
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.bedrockApiKeyDesc
'
)
}}
</span>
</div>
</button>
</div>
</div>
...
...
@@ -956,7 +927,7 @@
</div>
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
<div
v-if=
"form.type === 'apikey' && form.platform !== 'antigravity'
&& accountCategory !== 'bedrock-apikey'
"
class=
"space-y-4"
>
<div
v-if=
"form.type === 'apikey' && form.platform !== 'antigravity'"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
</label>
<input
...
...
@@ -1341,6 +1312,33 @@
<!--
Bedrock
credentials
(
only
for
Anthropic
Bedrock
type
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'bedrock'
"
class
=
"
space-y-4
"
>
<!--
Auth
Mode
Radio
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockAuthMode
'
)
}}
<
/label
>
<
div
class
=
"
mt-2 flex gap-4
"
>
<
label
class
=
"
flex cursor-pointer items-center
"
>
<
input
v
-
model
=
"
bedrockAuthMode
"
type
=
"
radio
"
value
=
"
sigv4
"
class
=
"
mr-2 text-primary-600 focus:ring-primary-500
"
/>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.accounts.bedrockAuthModeSigv4
'
)
}}
<
/span
>
<
/label
>
<
label
class
=
"
flex cursor-pointer items-center
"
>
<
input
v
-
model
=
"
bedrockAuthMode
"
type
=
"
radio
"
value
=
"
apikey
"
class
=
"
mr-2 text-primary-600 focus:ring-primary-500
"
/>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.accounts.bedrockAuthModeApikey
'
)
}}
<
/span
>
<
/label
>
<
/div
>
<
/div
>
<!--
SigV4
fields
-->
<
template
v
-
if
=
"
bedrockAuthMode === 'sigv4'
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockAccessKeyId
'
)
}}
<
/label
>
<
input
...
...
@@ -1369,6 +1367,20 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockSessionTokenHint
'
)
}}
<
/p
>
<
/div
>
<
/template
>
<!--
API
Key
field
-->
<
div
v
-
if
=
"
bedrockAuthMode === 'apikey'
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockApiKeyInput
'
)
}}
<
/label
>
<
input
v
-
model
=
"
bedrockApiKeyValue
"
type
=
"
password
"
required
class
=
"
input font-mono
"
/>
<
/div
>
<!--
Shared
:
Region
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockRegion
'
)
}}
<
/label
>
<
select
v
-
model
=
"
bedrockRegion
"
class
=
"
input
"
>
...
...
@@ -1408,6 +1420,8 @@
<
/select
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockRegionHint
'
)
}}
<
/p
>
<
/div
>
<!--
Shared
:
Force
Global
-->
<
div
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
...
...
@@ -1488,142 +1502,62 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Bedrock
API
Key
credentials
(
only
for
Anthropic
Bedrock
API
Key
type
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'bedrock-apikey'
"
class
=
"
space-y-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockApiKeyInput
'
)
}}
<
/label
>
<
input
v
-
model
=
"
bedrockApiKeyValue
"
type
=
"
password
"
required
class
=
"
input font-mono
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockRegion
'
)
}}
<
/label
>
<
select
v
-
model
=
"
bedrockApiKeyRegion
"
class
=
"
input
"
>
<
optgroup
label
=
"
US
"
>
<
option
value
=
"
us-east-1
"
>
us
-
east
-
1
(
N
.
Virginia
)
<
/option
>
<
option
value
=
"
us-east-2
"
>
us
-
east
-
2
(
Ohio
)
<
/option
>
<
option
value
=
"
us-west-1
"
>
us
-
west
-
1
(
N
.
California
)
<
/option
>
<
option
value
=
"
us-west-2
"
>
us
-
west
-
2
(
Oregon
)
<
/option
>
<
option
value
=
"
us-gov-east-1
"
>
us
-
gov
-
east
-
1
(
GovCloud
US
-
East
)
<
/option
>
<
option
value
=
"
us-gov-west-1
"
>
us
-
gov
-
west
-
1
(
GovCloud
US
-
West
)
<
/option
>
<
/optgroup
>
<
optgroup
label
=
"
Europe
"
>
<
option
value
=
"
eu-west-1
"
>
eu
-
west
-
1
(
Ireland
)
<
/option
>
<
option
value
=
"
eu-west-2
"
>
eu
-
west
-
2
(
London
)
<
/option
>
<
option
value
=
"
eu-west-3
"
>
eu
-
west
-
3
(
Paris
)
<
/option
>
<
option
value
=
"
eu-central-1
"
>
eu
-
central
-
1
(
Frankfurt
)
<
/option
>
<
option
value
=
"
eu-central-2
"
>
eu
-
central
-
2
(
Zurich
)
<
/option
>
<
option
value
=
"
eu-south-1
"
>
eu
-
south
-
1
(
Milan
)
<
/option
>
<
option
value
=
"
eu-south-2
"
>
eu
-
south
-
2
(
Spain
)
<
/option
>
<
option
value
=
"
eu-north-1
"
>
eu
-
north
-
1
(
Stockholm
)
<
/option
>
<
/optgroup
>
<
optgroup
label
=
"
Asia Pacific
"
>
<
option
value
=
"
ap-northeast-1
"
>
ap
-
northeast
-
1
(
Tokyo
)
<
/option
>
<
option
value
=
"
ap-northeast-2
"
>
ap
-
northeast
-
2
(
Seoul
)
<
/option
>
<
option
value
=
"
ap-northeast-3
"
>
ap
-
northeast
-
3
(
Osaka
)
<
/option
>
<
option
value
=
"
ap-south-1
"
>
ap
-
south
-
1
(
Mumbai
)
<
/option
>
<
option
value
=
"
ap-south-2
"
>
ap
-
south
-
2
(
Hyderabad
)
<
/option
>
<
option
value
=
"
ap-southeast-1
"
>
ap
-
southeast
-
1
(
Singapore
)
<
/option
>
<
option
value
=
"
ap-southeast-2
"
>
ap
-
southeast
-
2
(
Sydney
)
<
/option
>
<
/optgroup
>
<
optgroup
label
=
"
Canada
"
>
<
option
value
=
"
ca-central-1
"
>
ca
-
central
-
1
(
Canada
)
<
/option
>
<
/optgroup
>
<
optgroup
label
=
"
South America
"
>
<
option
value
=
"
sa-east-1
"
>
sa
-
east
-
1
(
São
Paulo
)
<
/option
>
<
/optgroup
>
<
/select
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockRegionHint
'
)
}}
<
/p
>
<
/div
>
<!--
Pool
Mode
Section
for
Bedrock
-->
<
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
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
v
-
model
=
"
bedrockApiKeyForceGlobal
"
type
=
"
checkbox
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500
"
/>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.accounts.bedrockForceGlobal
'
)
}}
<
/span
>
<
/label
>
<
p
class
=
"
input-hint mt-1
"
>
{{
t
(
'
admin.accounts.bedrockForceGlobalHint
'
)
}}
<
/p
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.poolMode
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.poolModeHint
'
)
}}
<
/p
>
<
/div
>
<!--
Model
Restriction
Section
for
Bedrock
API
Key
-->
<
div
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
=
"
mb-4 flex gap-2
"
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
@
click
=
"
poolModeEnabled = !poolModeEnabled
"
:
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'
'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',
poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
{{
t
(
'
admin.accounts.modelWhitelist
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
<
span
:
class
=
"
[
'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'
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
>
{{
t
(
'
admin.accounts.modelMapping
'
)
}}
/>
<
/button
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
platform
=
"
anthropic
"
/>
<
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
>
<
div
v
-
if
=
"
poolModeEnabled
"
class
=
"
rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20
"
>
<
p
class
=
"
text-xs text-blue-700 dark:text-blue-400
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
sm
"
class
=
"
mr-1 inline
"
:
stroke
-
width
=
"
2
"
/>
{{
t
(
'
admin.accounts.poolModeInfo
'
)
}}
<
/p
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.fromModel')
"
/>
<
span
class
=
"
text-gray-400
"
>
→
<
/span
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.toModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.splice(index, 1)
"
class
=
"
text-red-500 hover:text-red-700
"
>
<
Icon
name
=
"
trash
"
size
=
"
sm
"
/>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: '', to: ''
}
)
"
class
=
"
btn btn-secondary text-sm
"
>
+
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Bedrock
Preset
Mappings
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in bedrockPresets
"
:
key
=
"
preset.from
"
type
=
"
button
"
@
click
=
"
addPresetMapping(preset.from, preset.to)
"
:
class
=
"
['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]
"
>
+
{{
preset
.
label
}}
<
/button
>
<
/div
>
<
div
v
-
if
=
"
poolModeEnabled
"
class
=
"
mt-3
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.poolModeRetryCount
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
poolModeRetryCount
"
type
=
"
number
"
min
=
"
0
"
:
max
=
"
MAX_POOL_MODE_RETRY_COUNT
"
step
=
"
1
"
class
=
"
input
"
/>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.poolModeRetryCountHint
'
,
{
default
:
DEFAULT_POOL_MODE_RETRY_COUNT
,
max
:
MAX_POOL_MODE_RETRY_COUNT
}
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
API
Key
账号配额限制
-->
<
div
v
-
if
=
"
form.type === 'apikey'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<!--
API
Key
/
Bedrock
账号配额限制
-->
<
div
v
-
if
=
"
form.type === 'apikey'
|| form.type === 'bedrock'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaLimit
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
...
...
@@ -1634,9 +1568,21 @@
:
totalLimit
=
"
editQuotaLimit
"
:
dailyLimit
=
"
editQuotaDailyLimit
"
:
weeklyLimit
=
"
editQuotaWeeklyLimit
"
:
dailyResetMode
=
"
editDailyResetMode
"
:
dailyResetHour
=
"
editDailyResetHour
"
:
weeklyResetMode
=
"
editWeeklyResetMode
"
:
weeklyResetDay
=
"
editWeeklyResetDay
"
:
weeklyResetHour
=
"
editWeeklyResetHour
"
:
resetTimezone
=
"
editResetTimezone
"
@
update
:
totalLimit
=
"
editQuotaLimit = $event
"
@
update
:
dailyLimit
=
"
editQuotaDailyLimit = $event
"
@
update
:
weeklyLimit
=
"
editQuotaWeeklyLimit = $event
"
@
update
:
dailyResetMode
=
"
editDailyResetMode = $event
"
@
update
:
dailyResetHour
=
"
editDailyResetHour = $event
"
@
update
:
weeklyResetMode
=
"
editWeeklyResetMode = $event
"
@
update
:
weeklyResetDay
=
"
editWeeklyResetDay = $event
"
@
update
:
weeklyResetHour
=
"
editWeeklyResetHour = $event
"
@
update
:
resetTimezone
=
"
editResetTimezone = $event
"
/>
<
/div
>
...
...
@@ -3014,13 +2960,19 @@ interface TempUnschedRuleForm {
// State
const
step
=
ref
(
1
)
const
submitting
=
ref
(
false
)
const
accountCategory
=
ref
<
'
oauth-based
'
|
'
apikey
'
|
'
bedrock
'
|
'
bedrock-apikey
'
>
(
'
oauth-based
'
)
// UI selection for account category
const
accountCategory
=
ref
<
'
oauth-based
'
|
'
apikey
'
|
'
bedrock
'
>
(
'
oauth-based
'
)
// UI selection for account category
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
// For oauth-based: 'oauth' or 'setup-token'
const
apiKeyBaseUrl
=
ref
(
'
https://api.anthropic.com
'
)
const
apiKeyValue
=
ref
(
''
)
const
editQuotaLimit
=
ref
<
number
|
null
>
(
null
)
const
editQuotaDailyLimit
=
ref
<
number
|
null
>
(
null
)
const
editQuotaWeeklyLimit
=
ref
<
number
|
null
>
(
null
)
const
editDailyResetMode
=
ref
<
'
rolling
'
|
'
fixed
'
|
null
>
(
null
)
const
editDailyResetHour
=
ref
<
number
|
null
>
(
null
)
const
editWeeklyResetMode
=
ref
<
'
rolling
'
|
'
fixed
'
|
null
>
(
null
)
const
editWeeklyResetDay
=
ref
<
number
|
null
>
(
null
)
const
editWeeklyResetHour
=
ref
<
number
|
null
>
(
null
)
const
editResetTimezone
=
ref
<
string
|
null
>
(
null
)
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
...
...
@@ -3050,16 +3002,13 @@ const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('an
const
bedrockPresets
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
bedrock
'
))
// Bedrock credentials
const
bedrockAuthMode
=
ref
<
'
sigv4
'
|
'
apikey
'
>
(
'
sigv4
'
)
const
bedrockAccessKeyId
=
ref
(
''
)
const
bedrockSecretAccessKey
=
ref
(
''
)
const
bedrockSessionToken
=
ref
(
''
)
const
bedrockRegion
=
ref
(
'
us-east-1
'
)
const
bedrockForceGlobal
=
ref
(
false
)
// Bedrock API Key credentials
const
bedrockApiKeyValue
=
ref
(
''
)
const
bedrockApiKeyRegion
=
ref
(
'
us-east-1
'
)
const
bedrockApiKeyForceGlobal
=
ref
(
false
)
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-model-mapping
'
)
...
...
@@ -3343,7 +3292,8 @@ watch(
bedrockSessionToken
.
value
=
''
bedrockRegion
.
value
=
'
us-east-1
'
bedrockForceGlobal
.
value
=
false
bedrockApiKeyForceGlobal
.
value
=
false
bedrockAuthMode
.
value
=
'
sigv4
'
bedrockApiKeyValue
.
value
=
''
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
if
(
newPlatform
!==
'
anthropic
'
&&
newPlatform
!==
'
antigravity
'
)
{
interceptWarmupRequests
.
value
=
false
...
...
@@ -3719,6 +3669,12 @@ const resetForm = () => {
editQuotaLimit
.
value
=
null
editQuotaDailyLimit
.
value
=
null
editQuotaWeeklyLimit
.
value
=
null
editDailyResetMode
.
value
=
null
editDailyResetHour
.
value
=
null
editWeeklyResetMode
.
value
=
null
editWeeklyResetDay
.
value
=
null
editWeeklyResetHour
.
value
=
null
editResetTimezone
.
value
=
null
modelMappings
.
value
=
[]
modelRestrictionMode
.
value
=
'
whitelist
'
allowedModels
.
value
=
[...
claudeModels
]
// Default fill related models
...
...
@@ -3919,6 +3875,13 @@ const handleSubmit = async () => {
appStore
.
showError
(
t
(
'
admin.accounts.pleaseEnterAccountName
'
))
return
}
const
credentials
:
Record
<
string
,
unknown
>
=
{
auth_mode
:
bedrockAuthMode
.
value
,
aws_region
:
bedrockRegion
.
value
.
trim
()
||
'
us-east-1
'
,
}
if
(
bedrockAuthMode
.
value
===
'
sigv4
'
)
{
if
(
!
bedrockAccessKeyId
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockAccessKeyIdRequired
'
))
return
...
...
@@ -3927,53 +3890,20 @@ const handleSubmit = async () => {
appStore
.
showError
(
t
(
'
admin.accounts.bedrockSecretAccessKeyRequired
'
))
return
}
if
(
!
bedrockRegion
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockRegionRequired
'
))
return
}
const
credentials
:
Record
<
string
,
unknown
>
=
{
aws_access_key_id
:
bedrockAccessKeyId
.
value
.
trim
(),
aws_secret_access_key
:
bedrockSecretAccessKey
.
value
.
trim
(),
aws_region
:
bedrockRegion
.
value
.
trim
(),
}
credentials
.
aws_access_key_id
=
bedrockAccessKeyId
.
value
.
trim
()
credentials
.
aws_secret_access_key
=
bedrockSecretAccessKey
.
value
.
trim
()
if
(
bedrockSessionToken
.
value
.
trim
())
{
credentials
.
aws_session_token
=
bedrockSessionToken
.
value
.
trim
()
}
if
(
bedrockForceGlobal
.
value
)
{
credentials
.
aws_force_global
=
'
true
'
}
// Model mapping
const
modelMapping
=
buildModelMappingObject
(
modelRestrictionMode
.
value
,
allowedModels
.
value
,
modelMappings
.
value
)
if
(
modelMapping
)
{
credentials
.
model_mapping
=
modelMapping
}
applyInterceptWarmup
(
credentials
,
interceptWarmupRequests
.
value
,
'
create
'
)
await
createAccountAndFinish
(
'
anthropic
'
,
'
bedrock
'
as
AccountType
,
credentials
)
return
}
// For Bedrock API Key type, create directly
if
(
form
.
platform
===
'
anthropic
'
&&
accountCategory
.
value
===
'
bedrock-apikey
'
)
{
if
(
!
form
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.pleaseEnterAccountName
'
))
return
}
}
else
{
if
(
!
bedrockApiKeyValue
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockApiKeyRequired
'
))
return
}
const
credentials
:
Record
<
string
,
unknown
>
=
{
api_key
:
bedrockApiKeyValue
.
value
.
trim
(),
aws_region
:
bedrockApiKeyRegion
.
value
.
trim
()
||
'
us-east-1
'
,
credentials
.
api_key
=
bedrockApiKeyValue
.
value
.
trim
()
}
if
(
bedrockApiKeyForceGlobal
.
value
)
{
if
(
bedrockForceGlobal
.
value
)
{
credentials
.
aws_force_global
=
'
true
'
}
...
...
@@ -3985,9 +3915,15 @@ const handleSubmit = async () => {
credentials
.
model_mapping
=
modelMapping
}
// Pool mode
if
(
poolModeEnabled
.
value
)
{
credentials
.
pool_mode
=
true
credentials
.
pool_mode_retry_count
=
normalizePoolModeRetryCount
(
poolModeRetryCount
.
value
)
}
applyInterceptWarmup
(
credentials
,
interceptWarmupRequests
.
value
,
'
create
'
)
await
createAccountAndFinish
(
'
anthropic
'
,
'
bedrock
-apikey
'
as
AccountType
,
credentials
)
await
createAccountAndFinish
(
'
anthropic
'
,
'
bedrock
'
as
AccountType
,
credentials
)
return
}
...
...
@@ -4233,9 +4169,9 @@ const createAccountAndFinish = async (
if
(
!
applyTempUnschedConfig
(
credentials
))
{
return
}
// Inject quota limits for apikey accounts
// Inject quota limits for apikey
/bedrock
accounts
let
finalExtra
=
extra
if
(
type
===
'
apikey
'
)
{
if
(
type
===
'
apikey
'
||
type
===
'
bedrock
'
)
{
const
quotaExtra
:
Record
<
string
,
unknown
>
=
{
...(
extra
||
{
}
)
}
if
(
editQuotaLimit
.
value
!=
null
&&
editQuotaLimit
.
value
>
0
)
{
quotaExtra
.
quota_limit
=
editQuotaLimit
.
value
...
...
@@ -4246,6 +4182,19 @@ const createAccountAndFinish = async (
if
(
editQuotaWeeklyLimit
.
value
!=
null
&&
editQuotaWeeklyLimit
.
value
>
0
)
{
quotaExtra
.
quota_weekly_limit
=
editQuotaWeeklyLimit
.
value
}
// Quota reset mode config
if
(
editDailyResetMode
.
value
===
'
fixed
'
)
{
quotaExtra
.
quota_daily_reset_mode
=
'
fixed
'
quotaExtra
.
quota_daily_reset_hour
=
editDailyResetHour
.
value
??
0
}
if
(
editWeeklyResetMode
.
value
===
'
fixed
'
)
{
quotaExtra
.
quota_weekly_reset_mode
=
'
fixed
'
quotaExtra
.
quota_weekly_reset_day
=
editWeeklyResetDay
.
value
??
1
quotaExtra
.
quota_weekly_reset_hour
=
editWeeklyResetHour
.
value
??
0
}
if
(
editDailyResetMode
.
value
===
'
fixed
'
||
editWeeklyResetMode
.
value
===
'
fixed
'
)
{
quotaExtra
.
quota_reset_timezone
=
editResetTimezone
.
value
||
'
UTC
'
}
if
(
Object
.
keys
(
quotaExtra
).
length
>
0
)
{
finalExtra
=
quotaExtra
}
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
8f0ea7a0
...
...
@@ -563,8 +563,10 @@
<
/div
>
<
/div
>
<!--
Bedrock
fields
(
only
for
bedrock
type
)
-->
<!--
Bedrock
fields
(
for
bedrock
type
,
both
SigV4
and
API
Key
modes
)
-->
<
div
v
-
if
=
"
account.type === 'bedrock'
"
class
=
"
space-y-4
"
>
<!--
SigV4
fields
-->
<
template
v
-
if
=
"
!isBedrockAPIKeyMode
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockAccessKeyId
'
)
}}
<
/label
>
<
input
...
...
@@ -594,6 +596,21 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockSessionTokenHint
'
)
}}
<
/p
>
<
/div
>
<
/template
>
<!--
API
Key
field
-->
<
div
v
-
if
=
"
isBedrockAPIKeyMode
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockApiKeyInput
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockApiKeyValue
"
type
=
"
password
"
class
=
"
input font-mono
"
:
placeholder
=
"
t('admin.accounts.bedrockApiKeyLeaveEmpty')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockApiKeyLeaveEmpty
'
)
}}
<
/p
>
<
/div
>
<!--
Shared
:
Region
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockRegion
'
)
}}
<
/label
>
<
input
...
...
@@ -604,6 +621,8 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockRegionHint
'
)
}}
<
/p
>
<
/div
>
<!--
Shared
:
Force
Global
-->
<
div
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
...
...
@@ -684,108 +703,56 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Bedrock
API
Key
fields
(
only
for
bedrock
-
apikey
type
)
-->
<
div
v
-
if
=
"
account.type === 'bedrock-apikey'
"
class
=
"
space-y-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockApiKeyInput
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockApiKeyValue
"
type
=
"
password
"
class
=
"
input font-mono
"
:
placeholder
=
"
t('admin.accounts.bedrockApiKeyLeaveEmpty')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockApiKeyLeaveEmpty
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockRegion
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockApiKeyRegion
"
type
=
"
text
"
class
=
"
input
"
placeholder
=
"
us-east-1
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockRegionHint
'
)
}}
<
/p
>
<
/div
>
<!--
Pool
Mode
Section
for
Bedrock
-->
<
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
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
v
-
model
=
"
editBedrockApiKeyForceGlobal
"
type
=
"
checkbox
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500
"
/>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.accounts.bedrockForceGlobal
'
)
}}
<
/span
>
<
/label
>
<
p
class
=
"
input-hint mt-1
"
>
{{
t
(
'
admin.accounts.bedrockForceGlobalHint
'
)
}}
<
/p
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.poolMode
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.poolModeHint
'
)
}}
<
/p
>
<
/div
>
<!--
Model
Restriction
for
Bedrock
API
Key
-->
<
div
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
=
"
mb-4 flex gap-2
"
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
@
click
=
"
poolModeEnabled = !poolModeEnabled
"
:
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'
'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',
poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
{{
t
(
'
admin.accounts.modelWhitelist
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
<
span
:
class
=
"
[
'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'
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
>
{{
t
(
'
admin.accounts.modelMapping
'
)
}}
/>
<
/button
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
platform
=
"
anthropic
"
/>
<
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
>
<
div
v
-
if
=
"
poolModeEnabled
"
class
=
"
rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20
"
>
<
p
class
=
"
text-xs text-blue-700 dark:text-blue-400
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
sm
"
class
=
"
mr-1 inline
"
:
stroke
-
width
=
"
2
"
/>
{{
t
(
'
admin.accounts.poolModeInfo
'
)
}}
<
/p
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.fromModel')
"
/>
<
span
class
=
"
text-gray-400
"
>
→
<
/span
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.toModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.splice(index, 1)
"
class
=
"
text-red-500 hover:text-red-700
"
>
<
Icon
name
=
"
trash
"
size
=
"
sm
"
/>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: '', to: ''
}
)
"
class
=
"
btn btn-secondary text-sm
"
>
+
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Bedrock
Preset
Mappings
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in bedrockPresets
"
:
key
=
"
preset.from
"
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: preset.from, to: preset.to
}
)
"
:
class
=
"
['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]
"
>
+
{{
preset
.
label
}}
<
/button
>
<
/div
>
<
div
v
-
if
=
"
poolModeEnabled
"
class
=
"
mt-3
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.poolModeRetryCount
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
poolModeRetryCount
"
type
=
"
number
"
min
=
"
0
"
:
max
=
"
MAX_POOL_MODE_RETRY_COUNT
"
step
=
"
1
"
class
=
"
input
"
/>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.poolModeRetryCountHint
'
,
{
default
:
DEFAULT_POOL_MODE_RETRY_COUNT
,
max
:
MAX_POOL_MODE_RETRY_COUNT
}
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -1182,8 +1149,8 @@
<
/div
>
<
/div
>
<!--
API
Key
账号配额限制
-->
<
div
v
-
if
=
"
account?.type === 'apikey'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<!--
API
Key
/
Bedrock
账号配额限制
-->
<
div
v
-
if
=
"
account?.type === 'apikey'
|| account?.type === 'bedrock'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaLimit
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
...
...
@@ -1194,9 +1161,21 @@
:
totalLimit
=
"
editQuotaLimit
"
:
dailyLimit
=
"
editQuotaDailyLimit
"
:
weeklyLimit
=
"
editQuotaWeeklyLimit
"
:
dailyResetMode
=
"
editDailyResetMode
"
:
dailyResetHour
=
"
editDailyResetHour
"
:
weeklyResetMode
=
"
editWeeklyResetMode
"
:
weeklyResetDay
=
"
editWeeklyResetDay
"
:
weeklyResetHour
=
"
editWeeklyResetHour
"
:
resetTimezone
=
"
editResetTimezone
"
@
update
:
totalLimit
=
"
editQuotaLimit = $event
"
@
update
:
dailyLimit
=
"
editQuotaDailyLimit = $event
"
@
update
:
weeklyLimit
=
"
editQuotaWeeklyLimit = $event
"
@
update
:
dailyResetMode
=
"
editDailyResetMode = $event
"
@
update
:
dailyResetHour
=
"
editDailyResetHour = $event
"
@
update
:
weeklyResetMode
=
"
editWeeklyResetMode = $event
"
@
update
:
weeklyResetDay
=
"
editWeeklyResetDay = $event
"
@
update
:
weeklyResetHour
=
"
editWeeklyResetHour = $event
"
@
update
:
resetTimezone
=
"
editResetTimezone = $event
"
/>
<
/div
>
...
...
@@ -1781,11 +1760,11 @@ const editBedrockSecretAccessKey = ref('')
const
editBedrockSessionToken
=
ref
(
''
)
const
editBedrockRegion
=
ref
(
''
)
const
editBedrockForceGlobal
=
ref
(
false
)
// Bedrock API Key credentials
const
editBedrockApiKeyValue
=
ref
(
''
)
const
editBedrockApiKeyRegion
=
ref
(
''
)
const
editBedrockApiKeyForceGlobal
=
ref
(
false
)
const
isBedrockAPIKeyMode
=
computed
(()
=>
props
.
account
?.
type
===
'
bedrock
'
&&
(
props
.
account
?.
credentials
as
Record
<
string
,
unknown
>
)?.
auth_mode
===
'
apikey
'
)
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
...
...
@@ -1847,6 +1826,12 @@ const anthropicPassthroughEnabled = ref(false)
const
editQuotaLimit
=
ref
<
number
|
null
>
(
null
)
const
editQuotaDailyLimit
=
ref
<
number
|
null
>
(
null
)
const
editQuotaWeeklyLimit
=
ref
<
number
|
null
>
(
null
)
const
editDailyResetMode
=
ref
<
'
rolling
'
|
'
fixed
'
|
null
>
(
null
)
const
editDailyResetHour
=
ref
<
number
|
null
>
(
null
)
const
editWeeklyResetMode
=
ref
<
'
rolling
'
|
'
fixed
'
|
null
>
(
null
)
const
editWeeklyResetDay
=
ref
<
number
|
null
>
(
null
)
const
editWeeklyResetHour
=
ref
<
number
|
null
>
(
null
)
const
editResetTimezone
=
ref
<
string
|
null
>
(
null
)
const
openAIWSModeOptions
=
computed
(()
=>
[
{
value
:
OPENAI_WS_MODE_OFF
,
label
:
t
(
'
admin.accounts.openai.wsModeOff
'
)
}
,
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
...
...
@@ -2026,18 +2011,31 @@ watch(
anthropicPassthroughEnabled
.
value
=
extra
?.
anthropic_passthrough
===
true
}
// Load quota limit for apikey accounts
if
(
newAccount
.
type
===
'
apikey
'
)
{
// Load quota limit for apikey
/bedrock
accounts
(bedrock quota is also loaded in its own branch above)
if
(
newAccount
.
type
===
'
apikey
'
||
newAccount
.
type
===
'
bedrock
'
)
{
const
quotaVal
=
extra
?.
quota_limit
as
number
|
undefined
editQuotaLimit
.
value
=
(
quotaVal
&&
quotaVal
>
0
)
?
quotaVal
:
null
const
dailyVal
=
extra
?.
quota_daily_limit
as
number
|
undefined
editQuotaDailyLimit
.
value
=
(
dailyVal
&&
dailyVal
>
0
)
?
dailyVal
:
null
const
weeklyVal
=
extra
?.
quota_weekly_limit
as
number
|
undefined
editQuotaWeeklyLimit
.
value
=
(
weeklyVal
&&
weeklyVal
>
0
)
?
weeklyVal
:
null
// Load quota reset mode config
editDailyResetMode
.
value
=
(
extra
?.
quota_daily_reset_mode
as
'
rolling
'
|
'
fixed
'
)
||
null
editDailyResetHour
.
value
=
(
extra
?.
quota_daily_reset_hour
as
number
)
??
null
editWeeklyResetMode
.
value
=
(
extra
?.
quota_weekly_reset_mode
as
'
rolling
'
|
'
fixed
'
)
||
null
editWeeklyResetDay
.
value
=
(
extra
?.
quota_weekly_reset_day
as
number
)
??
null
editWeeklyResetHour
.
value
=
(
extra
?.
quota_weekly_reset_hour
as
number
)
??
null
editResetTimezone
.
value
=
(
extra
?.
quota_reset_timezone
as
string
)
||
null
}
else
{
editQuotaLimit
.
value
=
null
editQuotaDailyLimit
.
value
=
null
editQuotaWeeklyLimit
.
value
=
null
editDailyResetMode
.
value
=
null
editDailyResetHour
.
value
=
null
editWeeklyResetMode
.
value
=
null
editWeeklyResetDay
.
value
=
null
editWeeklyResetHour
.
value
=
null
editResetTimezone
.
value
=
null
}
// Load antigravity model mapping (Antigravity 只支持映射模式)
...
...
@@ -2130,11 +2128,28 @@ watch(
}
}
else
if
(
newAccount
.
type
===
'
bedrock
'
&&
newAccount
.
credentials
)
{
const
bedrockCreds
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBedrockAccessKeyId
.
valu
e
=
(
bedrockCreds
.
a
ws_access_key_id
as
string
)
||
''
const
authMod
e
=
(
bedrockCreds
.
a
uth_mode
as
string
)
||
'
sigv4
'
editBedrockRegion
.
value
=
(
bedrockCreds
.
aws_region
as
string
)
||
''
editBedrockForceGlobal
.
value
=
(
bedrockCreds
.
aws_force_global
as
string
)
===
'
true
'
if
(
authMode
===
'
apikey
'
)
{
editBedrockApiKeyValue
.
value
=
''
}
else
{
editBedrockAccessKeyId
.
value
=
(
bedrockCreds
.
aws_access_key_id
as
string
)
||
''
editBedrockSecretAccessKey
.
value
=
''
editBedrockSessionToken
.
value
=
''
}
// Load pool mode for bedrock
poolModeEnabled
.
value
=
bedrockCreds
.
pool_mode
===
true
const
retryCount
=
bedrockCreds
.
pool_mode_retry_count
poolModeRetryCount
.
value
=
(
typeof
retryCount
===
'
number
'
&&
retryCount
>=
0
)
?
retryCount
:
DEFAULT_POOL_MODE_RETRY_COUNT
// Load quota limits for bedrock
const
bedrockExtra
=
(
newAccount
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
editQuotaLimit
.
value
=
typeof
bedrockExtra
.
quota_limit
===
'
number
'
?
bedrockExtra
.
quota_limit
:
null
editQuotaDailyLimit
.
value
=
typeof
bedrockExtra
.
quota_daily_limit
===
'
number
'
?
bedrockExtra
.
quota_daily_limit
:
null
editQuotaWeeklyLimit
.
value
=
typeof
bedrockExtra
.
quota_weekly_limit
===
'
number
'
?
bedrockExtra
.
quota_weekly_limit
:
null
// Load model mappings for bedrock
const
existingMappings
=
bedrockCreds
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
...
...
@@ -2155,31 +2170,6 @@ watch(
modelMappings
.
value
=
[]
allowedModels
.
value
=
[]
}
}
else
if
(
newAccount
.
type
===
'
bedrock-apikey
'
&&
newAccount
.
credentials
)
{
const
bedrockApiKeyCreds
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBedrockApiKeyRegion
.
value
=
(
bedrockApiKeyCreds
.
aws_region
as
string
)
||
'
us-east-1
'
editBedrockApiKeyForceGlobal
.
value
=
(
bedrockApiKeyCreds
.
aws_force_global
as
string
)
===
'
true
'
editBedrockApiKeyValue
.
value
=
''
// Load model mappings for bedrock-apikey
const
existingMappings
=
bedrockApiKeyCreds
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
if
(
existingMappings
&&
typeof
existingMappings
===
'
object
'
)
{
const
entries
=
Object
.
entries
(
existingMappings
)
const
isWhitelistMode
=
entries
.
length
>
0
&&
entries
.
every
(([
from
,
to
])
=>
from
===
to
)
if
(
isWhitelistMode
)
{
modelRestrictionMode
.
value
=
'
whitelist
'
allowedModels
.
value
=
entries
.
map
(([
from
])
=>
from
)
modelMappings
.
value
=
[]
}
else
{
modelRestrictionMode
.
value
=
'
mapping
'
modelMappings
.
value
=
entries
.
map
(([
from
,
to
])
=>
({
from
,
to
}
))
allowedModels
.
value
=
[]
}
}
else
{
modelRestrictionMode
.
value
=
'
whitelist
'
modelMappings
.
value
=
[]
allowedModels
.
value
=
[]
}
}
else
if
(
newAccount
.
type
===
'
upstream
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBaseUrl
.
value
=
(
credentials
.
base_url
as
string
)
||
''
...
...
@@ -2727,7 +2717,6 @@ const handleSubmit = async () => {
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newCredentials
:
Record
<
string
,
unknown
>
=
{
...
currentCredentials
}
newCredentials
.
aws_access_key_id
=
editBedrockAccessKeyId
.
value
.
trim
()
newCredentials
.
aws_region
=
editBedrockRegion
.
value
.
trim
()
if
(
editBedrockForceGlobal
.
value
)
{
newCredentials
.
aws_force_global
=
'
true
'
...
...
@@ -2735,42 +2724,29 @@ const handleSubmit = async () => {
delete
newCredentials
.
aws_force_global
}
// Only update secrets if user provided new values
if
(
isBedrockAPIKeyMode
.
value
)
{
// API Key mode: only update api_key if user provided new value
if
(
editBedrockApiKeyValue
.
value
.
trim
())
{
newCredentials
.
api_key
=
editBedrockApiKeyValue
.
value
.
trim
()
}
}
else
{
// SigV4 mode
newCredentials
.
aws_access_key_id
=
editBedrockAccessKeyId
.
value
.
trim
()
if
(
editBedrockSecretAccessKey
.
value
.
trim
())
{
newCredentials
.
aws_secret_access_key
=
editBedrockSecretAccessKey
.
value
.
trim
()
}
if
(
editBedrockSessionToken
.
value
.
trim
())
{
newCredentials
.
aws_session_token
=
editBedrockSessionToken
.
value
.
trim
()
}
// Model mapping
const
modelMapping
=
buildModelMappingObject
(
modelRestrictionMode
.
value
,
allowedModels
.
value
,
modelMappings
.
value
)
if
(
modelMapping
)
{
newCredentials
.
model_mapping
=
modelMapping
}
else
{
delete
newCredentials
.
model_mapping
}
applyInterceptWarmup
(
newCredentials
,
interceptWarmupRequests
.
value
,
'
edit
'
)
if
(
!
applyTempUnschedConfig
(
newCredentials
))
{
return
}
updatePayload
.
credentials
=
newCredentials
}
else
if
(
props
.
account
.
type
===
'
bedrock-apikey
'
)
{
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newCredentials
:
Record
<
string
,
unknown
>
=
{
...
currentCredentials
}
newCredentials
.
aws_region
=
editBedrockApiKeyRegion
.
value
.
trim
()
||
'
us-east-1
'
if
(
editBedrockApiKeyForceGlobal
.
value
)
{
newCredentials
.
aws_force_global
=
'
true
'
// Pool mode
if
(
poolModeEnabled
.
value
)
{
newCredentials
.
pool_mode
=
true
newCredentials
.
pool_mode_retry_count
=
normalizePoolModeRetryCount
(
poolModeRetryCount
.
value
)
}
else
{
delete
newCredentials
.
aws_force_global
}
// Only update API key if user provided new value
if
(
editBedrockApiKeyValue
.
value
.
trim
())
{
newCredentials
.
api_key
=
editBedrockApiKeyValue
.
value
.
trim
()
delete
newCredentials
.
pool_mode
delete
newCredentials
.
pool_mode_retry_count
}
// Model mapping
...
...
@@ -2980,8 +2956,8 @@ const handleSubmit = async () => {
updatePayload
.
extra
=
newExtra
}
// For apikey accounts, handle quota_limit in extra
if
(
props
.
account
.
type
===
'
apikey
'
)
{
// For apikey
/bedrock
accounts, handle quota_limit in extra
if
(
props
.
account
.
type
===
'
apikey
'
||
props
.
account
.
type
===
'
bedrock
'
)
{
const
currentExtra
=
(
updatePayload
.
extra
as
Record
<
string
,
unknown
>
)
||
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
newExtra
:
Record
<
string
,
unknown
>
=
{
...
currentExtra
}
...
...
@@ -3000,6 +2976,28 @@ const handleSubmit = async () => {
}
else
{
delete
newExtra
.
quota_weekly_limit
}
// Quota reset mode config
if
(
editDailyResetMode
.
value
===
'
fixed
'
)
{
newExtra
.
quota_daily_reset_mode
=
'
fixed
'
newExtra
.
quota_daily_reset_hour
=
editDailyResetHour
.
value
??
0
}
else
{
delete
newExtra
.
quota_daily_reset_mode
delete
newExtra
.
quota_daily_reset_hour
}
if
(
editWeeklyResetMode
.
value
===
'
fixed
'
)
{
newExtra
.
quota_weekly_reset_mode
=
'
fixed
'
newExtra
.
quota_weekly_reset_day
=
editWeeklyResetDay
.
value
??
1
newExtra
.
quota_weekly_reset_hour
=
editWeeklyResetHour
.
value
??
0
}
else
{
delete
newExtra
.
quota_weekly_reset_mode
delete
newExtra
.
quota_weekly_reset_day
delete
newExtra
.
quota_weekly_reset_hour
}
if
(
editDailyResetMode
.
value
===
'
fixed
'
||
editWeeklyResetMode
.
value
===
'
fixed
'
)
{
newExtra
.
quota_reset_timezone
=
editResetTimezone
.
value
||
'
UTC
'
}
else
{
delete
newExtra
.
quota_reset_timezone
}
updatePayload
.
extra
=
newExtra
}
...
...
frontend/src/components/account/QuotaLimitCard.vue
View file @
8f0ea7a0
...
...
@@ -8,12 +8,24 @@ const props = defineProps<{
totalLimit
:
number
|
null
dailyLimit
:
number
|
null
weeklyLimit
:
number
|
null
dailyResetMode
:
'
rolling
'
|
'
fixed
'
|
null
dailyResetHour
:
number
|
null
weeklyResetMode
:
'
rolling
'
|
'
fixed
'
|
null
weeklyResetDay
:
number
|
null
weeklyResetHour
:
number
|
null
resetTimezone
:
string
|
null
}
>
()
const
emit
=
defineEmits
<
{
'
update:totalLimit
'
:
[
value
:
number
|
null
]
'
update:dailyLimit
'
:
[
value
:
number
|
null
]
'
update:weeklyLimit
'
:
[
value
:
number
|
null
]
'
update:dailyResetMode
'
:
[
value
:
'
rolling
'
|
'
fixed
'
|
null
]
'
update:dailyResetHour
'
:
[
value
:
number
|
null
]
'
update:weeklyResetMode
'
:
[
value
:
'
rolling
'
|
'
fixed
'
|
null
]
'
update:weeklyResetDay
'
:
[
value
:
number
|
null
]
'
update:weeklyResetHour
'
:
[
value
:
number
|
null
]
'
update:resetTimezone
'
:
[
value
:
string
|
null
]
}
>
()
const
enabled
=
computed
(()
=>
...
...
@@ -35,9 +47,56 @@ watch(localEnabled, (val) => {
emit
(
'
update:totalLimit
'
,
null
)
emit
(
'
update:dailyLimit
'
,
null
)
emit
(
'
update:weeklyLimit
'
,
null
)
emit
(
'
update:dailyResetMode
'
,
null
)
emit
(
'
update:dailyResetHour
'
,
null
)
emit
(
'
update:weeklyResetMode
'
,
null
)
emit
(
'
update:weeklyResetDay
'
,
null
)
emit
(
'
update:weeklyResetHour
'
,
null
)
emit
(
'
update:resetTimezone
'
,
null
)
}
})
// Whether any fixed mode is active (to show timezone selector)
const
hasFixedMode
=
computed
(()
=>
props
.
dailyResetMode
===
'
fixed
'
||
props
.
weeklyResetMode
===
'
fixed
'
)
// Common timezone options
const
timezoneOptions
=
[
'
UTC
'
,
'
Asia/Shanghai
'
,
'
Asia/Tokyo
'
,
'
Asia/Seoul
'
,
'
Asia/Singapore
'
,
'
Asia/Kolkata
'
,
'
Asia/Dubai
'
,
'
Europe/London
'
,
'
Europe/Paris
'
,
'
Europe/Berlin
'
,
'
Europe/Moscow
'
,
'
America/New_York
'
,
'
America/Chicago
'
,
'
America/Denver
'
,
'
America/Los_Angeles
'
,
'
America/Sao_Paulo
'
,
'
Australia/Sydney
'
,
'
Pacific/Auckland
'
,
]
// Hours for dropdown (0-23)
const
hourOptions
=
Array
.
from
({
length
:
24
},
(
_
,
i
)
=>
i
)
// Day of week options
const
dayOptions
=
[
{
value
:
1
,
key
:
'
monday
'
},
{
value
:
2
,
key
:
'
tuesday
'
},
{
value
:
3
,
key
:
'
wednesday
'
},
{
value
:
4
,
key
:
'
thursday
'
},
{
value
:
5
,
key
:
'
friday
'
},
{
value
:
6
,
key
:
'
saturday
'
},
{
value
:
0
,
key
:
'
sunday
'
},
]
const
onTotalInput
=
(
e
:
Event
)
=>
{
const
raw
=
(
e
.
target
as
HTMLInputElement
).
valueAsNumber
emit
(
'
update:totalLimit
'
,
Number
.
isNaN
(
raw
)
?
null
:
raw
)
...
...
@@ -50,6 +109,25 @@ const onWeeklyInput = (e: Event) => {
const
raw
=
(
e
.
target
as
HTMLInputElement
).
valueAsNumber
emit
(
'
update:weeklyLimit
'
,
Number
.
isNaN
(
raw
)
?
null
:
raw
)
}
const
onDailyModeChange
=
(
e
:
Event
)
=>
{
const
val
=
(
e
.
target
as
HTMLSelectElement
).
value
as
'
rolling
'
|
'
fixed
'
emit
(
'
update:dailyResetMode
'
,
val
)
if
(
val
===
'
fixed
'
)
{
if
(
props
.
dailyResetHour
==
null
)
emit
(
'
update:dailyResetHour
'
,
0
)
if
(
!
props
.
resetTimezone
)
emit
(
'
update:resetTimezone
'
,
'
UTC
'
)
}
}
const
onWeeklyModeChange
=
(
e
:
Event
)
=>
{
const
val
=
(
e
.
target
as
HTMLSelectElement
).
value
as
'
rolling
'
|
'
fixed
'
emit
(
'
update:weeklyResetMode
'
,
val
)
if
(
val
===
'
fixed
'
)
{
if
(
props
.
weeklyResetDay
==
null
)
emit
(
'
update:weeklyResetDay
'
,
1
)
if
(
props
.
weeklyResetHour
==
null
)
emit
(
'
update:weeklyResetHour
'
,
0
)
if
(
!
props
.
resetTimezone
)
emit
(
'
update:resetTimezone
'
,
'
UTC
'
)
}
}
</
script
>
<
template
>
...
...
@@ -94,7 +172,37 @@ const onWeeklyInput = (e: Event) => {
:placeholder=
"t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.quotaDailyLimitHint
'
)
}}
</p>
<!-- 日配额重置模式 -->
<div
class=
"mt-2 flex items-center gap-2"
>
<label
class=
"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{{
t
(
'
admin.accounts.quotaResetMode
'
)
}}
</label>
<select
:value=
"dailyResetMode || 'rolling'"
@
change=
"onDailyModeChange"
class=
"input py-1 text-xs"
>
<option
value=
"rolling"
>
{{
t
(
'
admin.accounts.quotaResetModeRolling
'
)
}}
</option>
<option
value=
"fixed"
>
{{
t
(
'
admin.accounts.quotaResetModeFixed
'
)
}}
</option>
</select>
</div>
<!-- 固定模式:小时选择 -->
<div
v-if=
"dailyResetMode === 'fixed'"
class=
"mt-2 flex items-center gap-2"
>
<label
class=
"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{{
t
(
'
admin.accounts.quotaResetHour
'
)
}}
</label>
<select
:value=
"dailyResetHour ?? 0"
@
change=
"emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))"
class=
"input py-1 text-xs w-24"
>
<option
v-for=
"h in hourOptions"
:key=
"h"
:value=
"h"
>
{{
String
(
h
).
padStart
(
2
,
'
0
'
)
}}
:00
</option>
</select>
</div>
<p
class=
"input-hint"
>
<template
v-if=
"dailyResetMode === 'fixed'"
>
{{
t
(
'
admin.accounts.quotaDailyLimitHintFixed
'
,
{
hour
:
String
(
dailyResetHour
??
0
).
padStart
(
2
,
'
0
'
),
timezone
:
resetTimezone
||
'
UTC
'
}
)
}}
<
/template
>
<
template
v
-
else
>
{{
t
(
'
admin.accounts.quotaDailyLimitHint
'
)
}}
<
/template
>
<
/p
>
<
/div
>
<!--
周配额
-->
...
...
@@ -112,7 +220,57 @@ const onWeeklyInput = (e: Event) => {
:
placeholder
=
"
t('admin.accounts.quotaLimitPlaceholder')
"
/>
<
/div
>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.quotaWeeklyLimitHint
'
)
}}
</p>
<!--
周配额重置模式
-->
<
div
class
=
"
mt-2 flex items-center gap-2
"
>
<
label
class
=
"
text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap
"
>
{{
t
(
'
admin.accounts.quotaResetMode
'
)
}}
<
/label
>
<
select
:
value
=
"
weeklyResetMode || 'rolling'
"
@
change
=
"
onWeeklyModeChange
"
class
=
"
input py-1 text-xs
"
>
<
option
value
=
"
rolling
"
>
{{
t
(
'
admin.accounts.quotaResetModeRolling
'
)
}}
<
/option
>
<
option
value
=
"
fixed
"
>
{{
t
(
'
admin.accounts.quotaResetModeFixed
'
)
}}
<
/option
>
<
/select
>
<
/div
>
<!--
固定模式
:
星期几
+
小时
-->
<
div
v
-
if
=
"
weeklyResetMode === 'fixed'
"
class
=
"
mt-2 flex items-center gap-2 flex-wrap
"
>
<
label
class
=
"
text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap
"
>
{{
t
(
'
admin.accounts.quotaWeeklyResetDay
'
)
}}
<
/label
>
<
select
:
value
=
"
weeklyResetDay ?? 1
"
@
change
=
"
emit('update:weeklyResetDay', Number(($event.target as HTMLSelectElement).value))
"
class
=
"
input py-1 text-xs w-28
"
>
<
option
v
-
for
=
"
d in dayOptions
"
:
key
=
"
d.value
"
:
value
=
"
d.value
"
>
{{
t
(
'
admin.accounts.dayOfWeek.
'
+
d
.
key
)
}}
<
/option
>
<
/select
>
<
label
class
=
"
text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap
"
>
{{
t
(
'
admin.accounts.quotaResetHour
'
)
}}
<
/label
>
<
select
:
value
=
"
weeklyResetHour ?? 0
"
@
change
=
"
emit('update:weeklyResetHour', Number(($event.target as HTMLSelectElement).value))
"
class
=
"
input py-1 text-xs w-24
"
>
<
option
v
-
for
=
"
h in hourOptions
"
:
key
=
"
h
"
:
value
=
"
h
"
>
{{
String
(
h
).
padStart
(
2
,
'
0
'
)
}}
:
00
<
/option
>
<
/select
>
<
/div
>
<
p
class
=
"
input-hint
"
>
<
template
v
-
if
=
"
weeklyResetMode === 'fixed'
"
>
{{
t
(
'
admin.accounts.quotaWeeklyLimitHintFixed
'
,
{
day
:
t
(
'
admin.accounts.dayOfWeek.
'
+
(
dayOptions
.
find
(
d
=>
d
.
value
===
(
weeklyResetDay
??
1
))?.
key
||
'
monday
'
)),
hour
:
String
(
weeklyResetHour
??
0
).
padStart
(
2
,
'
0
'
),
timezone
:
resetTimezone
||
'
UTC
'
}
)
}}
<
/template
>
<
template
v
-
else
>
{{
t
(
'
admin.accounts.quotaWeeklyLimitHint
'
)
}}
<
/template
>
<
/p
>
<
/div
>
<!--
时区选择
(
当任一维度使用固定模式时显示
)
-->
<
div
v
-
if
=
"
hasFixedMode
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaResetTimezone
'
)
}}
<
/label
>
<
select
:
value
=
"
resetTimezone || 'UTC'
"
@
change
=
"
emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)
"
class
=
"
input text-sm
"
>
<
option
v
-
for
=
"
tz in timezoneOptions
"
:
key
=
"
tz
"
:
value
=
"
tz
"
>
{{
tz
}}
<
/option
>
<
/select
>
<
/div
>
<!--
总配额
-->
...
...
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
8f0ea7a0
...
...
@@ -76,7 +76,7 @@ const hasRecoverableState = computed(() => {
return
props
.
account
?.
status
===
'
error
'
||
Boolean
(
isRateLimited
.
value
)
||
Boolean
(
isOverloaded
.
value
)
||
Boolean
(
isTempUnschedulable
.
value
)
})
const
hasQuotaLimit
=
computed
(()
=>
{
return
props
.
account
?.
type
===
'
apikey
'
&&
(
return
(
props
.
account
?.
type
===
'
apikey
'
||
props
.
account
?.
type
===
'
bedrock
'
)
&&
(
(
props
.
account
?.
quota_limit
??
0
)
>
0
||
(
props
.
account
?.
quota_daily_limit
??
0
)
>
0
||
(
props
.
account
?.
quota_weekly_limit
??
0
)
>
0
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
8f0ea7a0
...
...
@@ -83,7 +83,7 @@ const typeLabel = computed(() => {
case
'
apikey
'
:
return
'
Key
'
case
'
bedrock
'
:
return
'
Bedrock
'
return
'
AWS
'
default
:
return
props
.
type
}
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
8f0ea7a0
...
...
@@ -82,7 +82,7 @@
</
template
>
<!-- Regular User View -->
<
template
v-else
>
<
template
v-else
-if=
"!appStore.backendModeEnabled"
>
<div
class=
"sidebar-section"
>
<router-link
v-for=
"item in userNavItems"
...
...
frontend/src/components/layout/TablePageLayout.vue
View file @
8f0ea7a0
...
...
@@ -84,9 +84,7 @@ onUnmounted(() => {
}
.table-scroll-container
:deep
(
th
)
{
/* 表头高度和文字加粗优化 */
@apply
px-5
py-4
text-left
text-sm
font-bold
text-gray-900
dark
:
text-white
border-b
border-gray-200
dark
:
border-dark-700
;
@apply
uppercase
tracking-wider;
/* 让表头更有设计感 */
@apply
px-5
py-4
text-left
text-sm
font-medium
text-gray-600
dark
:
text-dark-300
border-b
border-gray-200
dark
:
border-dark-700
;
}
.table-scroll-container
:deep
(
td
)
{
...
...
frontend/src/composables/useModelWhitelist.ts
View file @
8f0ea7a0
...
...
@@ -412,7 +412,7 @@ export function getPresetMappingsByPlatform(platform: string) {
if
(
platform
===
'
gemini
'
)
return
geminiPresetMappings
if
(
platform
===
'
sora
'
)
return
soraPresetMappings
if
(
platform
===
'
antigravity
'
)
return
antigravityPresetMappings
if
(
platform
===
'
bedrock
'
||
platform
===
'
bedrock-apikey
'
)
return
bedrockPresetMappings
if
(
platform
===
'
bedrock
'
)
return
bedrockPresetMappings
return
anthropicPresetMappings
}
...
...
frontend/src/i18n/locales/en.ts
View file @
8f0ea7a0
...
...
@@ -1866,6 +1866,23 @@ export default {
quotaWeeklyLimitHint
:
'
Automatically resets every 7 days from first usage.
'
,
quotaTotalLimit
:
'
Total Limit
'
,
quotaTotalLimitHint
:
'
Cumulative spending limit. Does not auto-reset — use "Reset Quota" to clear.
'
,
quotaResetMode
:
'
Reset Mode
'
,
quotaResetModeRolling
:
'
Rolling Window
'
,
quotaResetModeFixed
:
'
Fixed Time
'
,
quotaResetHour
:
'
Reset Hour
'
,
quotaWeeklyResetDay
:
'
Reset Day
'
,
quotaResetTimezone
:
'
Reset Timezone
'
,
quotaDailyLimitHintFixed
:
'
Resets daily at {hour}:00 ({timezone}).
'
,
quotaWeeklyLimitHintFixed
:
'
Resets every {day} at {hour}:00 ({timezone}).
'
,
dayOfWeek
:
{
monday
:
'
Monday
'
,
tuesday
:
'
Tuesday
'
,
wednesday
:
'
Wednesday
'
,
thursday
:
'
Thursday
'
,
friday
:
'
Friday
'
,
saturday
:
'
Saturday
'
,
sunday
:
'
Sunday
'
,
},
quotaLimitAmount
:
'
Total Limit
'
,
quotaLimitAmountHint
:
'
Cumulative spending limit. Does not auto-reset.
'
,
testConnection
:
'
Test Connection
'
,
...
...
@@ -1934,7 +1951,7 @@ export default {
claudeCode
:
'
Claude Code
'
,
claudeConsole
:
'
Claude Console
'
,
bedrockLabel
:
'
AWS Bedrock
'
,
bedrockDesc
:
'
SigV4
Signing
'
,
bedrockDesc
:
'
SigV4
/ API Key
'
,
oauthSetupToken
:
'
OAuth / Setup Token
'
,
addMethod
:
'
Add Method
'
,
setupTokenLongLived
:
'
Setup Token (Long-lived)
'
,
...
...
@@ -2136,6 +2153,9 @@ export default {
bedrockRegionRequired
:
'
Please select AWS Region
'
,
bedrockSessionTokenHint
:
'
Optional, for temporary credentials
'
,
bedrockSecretKeyLeaveEmpty
:
'
Leave empty to keep current key
'
,
bedrockAuthMode
:
'
Authentication Mode
'
,
bedrockAuthModeSigv4
:
'
SigV4 Signing
'
,
bedrockAuthModeApikey
:
'
Bedrock API Key
'
,
bedrockApiKeyLabel
:
'
Bedrock API Key
'
,
bedrockApiKeyDesc
:
'
Bearer Token
'
,
bedrockApiKeyInput
:
'
API Key
'
,
...
...
@@ -2555,7 +2575,16 @@ export default {
unlimited
:
'
Unlimited
'
},
ineligibleWarning
:
'
This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.
'
'
This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.
'
,
forbidden
:
'
Forbidden
'
,
forbiddenValidation
:
'
Verification Required
'
,
forbiddenViolation
:
'
Violation Ban
'
,
openVerification
:
'
Open Verification Link
'
,
copyLink
:
'
Copy Link
'
,
linkCopied
:
'
Link Copied
'
,
needsReauth
:
'
Re-auth Required
'
,
rateLimited
:
'
Rate Limited
'
,
usageError
:
'
Fetch Error
'
},
// Scheduled Tests
...
...
@@ -3709,6 +3738,11 @@ export default {
refreshInterval15s
:
'
15 seconds
'
,
refreshInterval30s
:
'
30 seconds
'
,
refreshInterval60s
:
'
60 seconds
'
,
dashboardCards
:
'
Dashboard Cards
'
,
displayAlertEvents
:
'
Display alert events
'
,
displayAlertEventsHint
:
'
Show or hide the recent alert events card on the ops dashboard. Enabled by default.
'
,
displayOpenAITokenStats
:
'
Display OpenAI token request stats
'
,
displayOpenAITokenStatsHint
:
'
Show or hide the OpenAI token request stats card on the ops dashboard. Hidden by default.
'
,
autoRefreshCountdown
:
'
Auto refresh: {seconds}s
'
,
validation
:
{
title
:
'
Please fix the following issues
'
,
...
...
@@ -3888,6 +3922,9 @@ export default {
site
:
{
title
:
'
Site Settings
'
,
description
:
'
Customize site branding
'
,
backendMode
:
'
Backend Mode
'
,
backendModeDescription
:
'
Disables user registration, public site, and self-service features. Only admin can log in and manage the platform.
'
,
siteName
:
'
Site Name
'
,
siteNamePlaceholder
:
'
Sub2API
'
,
siteNameHint
:
'
Displayed in emails and page titles
'
,
...
...
@@ -4127,6 +4164,7 @@ export default {
scopeAll
:
'
All accounts
'
,
scopeOAuth
:
'
OAuth only
'
,
scopeAPIKey
:
'
API Key only
'
,
scopeBedrock
:
'
Bedrock only
'
,
errorMessage
:
'
Error message
'
,
errorMessagePlaceholder
:
'
Custom error message when blocked
'
,
errorMessageHint
:
'
Leave empty for default message
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
8f0ea7a0
...
...
@@ -1872,6 +1872,23 @@ export default {
quotaWeeklyLimitHint
:
'
从首次使用起每 7 天自动重置。
'
,
quotaTotalLimit
:
'
总限额
'
,
quotaTotalLimitHint
:
'
累计消费上限,不会自动重置 — 使用「重置配额」手动清零。
'
,
quotaResetMode
:
'
重置方式
'
,
quotaResetModeRolling
:
'
滚动窗口
'
,
quotaResetModeFixed
:
'
固定时间
'
,
quotaResetHour
:
'
重置时间
'
,
quotaWeeklyResetDay
:
'
重置日
'
,
quotaResetTimezone
:
'
重置时区
'
,
quotaDailyLimitHintFixed
:
'
每天 {hour}:00({timezone})重置。
'
,
quotaWeeklyLimitHintFixed
:
'
每{day} {hour}:00({timezone})重置。
'
,
dayOfWeek
:
{
monday
:
'
周一
'
,
tuesday
:
'
周二
'
,
wednesday
:
'
周三
'
,
thursday
:
'
周四
'
,
friday
:
'
周五
'
,
saturday
:
'
周六
'
,
sunday
:
'
周日
'
,
},
quotaLimitAmount
:
'
总限额
'
,
quotaLimitAmountHint
:
'
累计消费上限,不会自动重置。
'
,
testConnection
:
'
测试连接
'
,
...
...
@@ -1992,6 +2009,15 @@ export default {
},
ineligibleWarning
:
'
该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。
'
,
forbidden
:
'
已封禁
'
,
forbiddenValidation
:
'
需要验证
'
,
forbiddenViolation
:
'
违规封禁
'
,
openVerification
:
'
打开验证链接
'
,
copyLink
:
'
复制链接
'
,
linkCopied
:
'
链接已复制
'
,
needsReauth
:
'
需要重新授权
'
,
rateLimited
:
'
限流中
'
,
usageError
:
'
获取失败
'
,
form
:
{
nameLabel
:
'
账号名称
'
,
namePlaceholder
:
'
请输入账号名称
'
,
...
...
@@ -2082,7 +2108,7 @@ export default {
claudeCode
:
'
Claude Code
'
,
claudeConsole
:
'
Claude Console
'
,
bedrockLabel
:
'
AWS Bedrock
'
,
bedrockDesc
:
'
SigV4
签名
'
,
bedrockDesc
:
'
SigV4
/ API Key
'
,
oauthSetupToken
:
'
OAuth / Setup Token
'
,
addMethod
:
'
添加方式
'
,
setupTokenLongLived
:
'
Setup Token(长期有效)
'
,
...
...
@@ -2277,6 +2303,9 @@ export default {
bedrockRegionRequired
:
'
请选择 AWS Region
'
,
bedrockSessionTokenHint
:
'
可选,用于临时凭证
'
,
bedrockSecretKeyLeaveEmpty
:
'
留空以保持当前密钥
'
,
bedrockAuthMode
:
'
认证方式
'
,
bedrockAuthModeSigv4
:
'
SigV4 签名
'
,
bedrockAuthModeApikey
:
'
Bedrock API Key
'
,
bedrockApiKeyLabel
:
'
Bedrock API Key
'
,
bedrockApiKeyDesc
:
'
Bearer Token 认证
'
,
bedrockApiKeyInput
:
'
API Key
'
,
...
...
@@ -3883,6 +3912,11 @@ export default {
refreshInterval15s
:
'
15 秒
'
,
refreshInterval30s
:
'
30 秒
'
,
refreshInterval60s
:
'
60 秒
'
,
dashboardCards
:
'
仪表盘卡片
'
,
displayAlertEvents
:
'
展示告警事件
'
,
displayAlertEventsHint
:
'
控制运维监控仪表盘中告警事件卡片是否显示,默认开启。
'
,
displayOpenAITokenStats
:
'
展示 OpenAI Token 请求统计
'
,
displayOpenAITokenStatsHint
:
'
控制运维监控仪表盘中 OpenAI Token 请求统计卡片是否显示,默认关闭。
'
,
autoRefreshCountdown
:
'
自动刷新:{seconds}s
'
,
validation
:
{
title
:
'
请先修正以下问题
'
,
...
...
@@ -4060,6 +4094,9 @@ export default {
site
:
{
title
:
'
站点设置
'
,
description
:
'
自定义站点品牌
'
,
backendMode
:
'
Backend 模式
'
,
backendModeDescription
:
'
禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。
'
,
siteName
:
'
站点名称
'
,
siteNameHint
:
'
显示在邮件和页面标题中
'
,
siteNamePlaceholder
:
'
Sub2API
'
,
...
...
@@ -4300,6 +4337,7 @@ export default {
scopeAll
:
'
全部账号
'
,
scopeOAuth
:
'
仅 OAuth 账号
'
,
scopeAPIKey
:
'
仅 API Key 账号
'
,
scopeBedrock
:
'
仅 Bedrock 账号
'
,
errorMessage
:
'
错误消息
'
,
errorMessagePlaceholder
:
'
拦截时返回的自定义错误消息
'
,
errorMessageHint
:
'
留空则使用默认错误消息
'
,
...
...
frontend/src/router/__tests__/guards.spec.ts
View file @
8f0ea7a0
...
...
@@ -51,6 +51,7 @@ interface MockAuthState {
isAuthenticated
:
boolean
isAdmin
:
boolean
isSimpleMode
:
boolean
backendModeEnabled
:
boolean
}
/**
...
...
@@ -70,8 +71,17 @@ function simulateGuard(
authState
.
isAuthenticated
&&
(
toPath
===
'
/login
'
||
toPath
===
'
/register
'
)
)
{
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAdmin
)
{
return
null
}
return
authState
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
}
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAuthenticated
)
{
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
if
(
!
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
)))
{
return
'
/login
'
}
}
return
null
// 允许通过
}
...
...
@@ -99,6 +109,17 @@ function simulateGuard(
}
}
// Backend mode: admin gets full access, non-admin blocked
if
(
authState
.
backendModeEnabled
)
{
if
(
authState
.
isAuthenticated
&&
authState
.
isAdmin
)
{
return
null
}
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
if
(
!
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
)))
{
return
'
/login
'
}
}
return
null
// 允许通过
}
...
...
@@ -114,6 +135,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
}
it
(
'
访问需要认证的页面重定向到 /login
'
,
()
=>
{
...
...
@@ -144,6 +166,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
}
it
(
'
访问 /login 重定向到 /dashboard
'
,
()
=>
{
...
...
@@ -179,6 +202,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
}
it
(
'
访问 /login 重定向到 /admin/dashboard
'
,
()
=>
{
...
...
@@ -205,6 +229,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/subscriptions
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/dashboard
'
)
...
...
@@ -215,6 +240,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/redeem
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/dashboard
'
)
...
...
@@ -225,6 +251,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/groups
'
,
{
requiresAdmin
:
true
},
authState
)
expect
(
redirect
).
toBe
(
'
/admin/dashboard
'
)
...
...
@@ -235,6 +262,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/subscriptions
'
,
...
...
@@ -249,6 +277,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/dashboard
'
,
{},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -259,9 +288,111 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/keys
'
,
{},
authState
)
expect
(
redirect
).
toBeNull
()
})
})
describe
(
'
Backend Mode
'
,
()
=>
{
it
(
'
unauthenticated: /home redirects to /login
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/home
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
})
it
(
'
unauthenticated: /login is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /key-usage is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/key-usage
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /setup is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/setup
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
admin: /admin/dashboard is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/admin/dashboard
'
,
{
requiresAdmin
:
true
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
admin: /login redirects to /admin/dashboard
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/admin/dashboard
'
)
})
it
(
'
non-admin authenticated: /dashboard redirects to /login
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/dashboard
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
})
it
(
'
non-admin authenticated: /login is allowed (no redirect loop)
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
non-admin authenticated: /key-usage is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/key-usage
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
})
})
frontend/src/router/index.ts
View file @
8f0ea7a0
...
...
@@ -423,6 +423,7 @@ let authInitialized = false
const
navigationLoading
=
useNavigationLoadingState
()
// 延迟初始化预加载,传入 router 实例
let
routePrefetch
:
ReturnType
<
typeof
useRoutePrefetch
>
|
null
=
null
const
BACKEND_MODE_ALLOWED_PATHS
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
router
.
beforeEach
((
to
,
_from
,
next
)
=>
{
// 开始导航加载状态
...
...
@@ -463,10 +464,24 @@ router.beforeEach((to, _from, next) => {
if
(
!
requiresAuth
)
{
// If already authenticated and trying to access login/register, redirect to appropriate dashboard
if
(
authStore
.
isAuthenticated
&&
(
to
.
path
===
'
/login
'
||
to
.
path
===
'
/register
'
))
{
// In backend mode, non-admin users should NOT be redirected away from login
// (they are blocked from all protected routes, so redirecting would cause a loop)
if
(
appStore
.
backendModeEnabled
&&
!
authStore
.
isAdmin
)
{
next
()
return
}
// Admin users go to admin dashboard, regular users go to user dashboard
next
(
authStore
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
)
return
}
// Backend mode: block public pages for unauthenticated users (except login, key-usage, setup)
if
(
appStore
.
backendModeEnabled
&&
!
authStore
.
isAuthenticated
)
{
const
isAllowed
=
BACKEND_MODE_ALLOWED_PATHS
.
some
((
p
)
=>
to
.
path
===
p
||
to
.
path
.
startsWith
(
p
))
if
(
!
isAllowed
)
{
next
(
'
/login
'
)
return
}
}
next
()
return
}
...
...
@@ -505,6 +520,19 @@ router.beforeEach((to, _from, next) => {
}
}
// Backend mode: admin gets full access, non-admin blocked
if
(
appStore
.
backendModeEnabled
)
{
if
(
authStore
.
isAuthenticated
&&
authStore
.
isAdmin
)
{
next
()
return
}
const
isAllowed
=
BACKEND_MODE_ALLOWED_PATHS
.
some
((
p
)
=>
to
.
path
===
p
||
to
.
path
.
startsWith
(
p
))
if
(
!
isAllowed
)
{
next
(
'
/login
'
)
return
}
}
// All checks passed, allow navigation
next
()
})
...
...
frontend/src/stores/app.ts
View file @
8f0ea7a0
...
...
@@ -47,6 +47,7 @@ export const useAppStore = defineStore('app', () => {
// ==================== Computed ====================
const
hasActiveToasts
=
computed
(()
=>
toasts
.
value
.
length
>
0
)
const
backendModeEnabled
=
computed
(()
=>
cachedPublicSettings
.
value
?.
backend_mode_enabled
??
false
)
const
loadingCount
=
ref
<
number
>
(
0
)
...
...
@@ -331,6 +332,7 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
linuxdo_oauth_enabled
:
false
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
}
...
...
@@ -404,6 +406,7 @@ export const useAppStore = defineStore('app', () => {
// Computed
hasActiveToasts
,
backendModeEnabled
,
// Actions
toggleSidebar
,
...
...
frontend/src/types/index.ts
View file @
8f0ea7a0
...
...
@@ -106,6 +106,7 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
linuxdo_oauth_enabled
:
boolean
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
@@ -531,7 +532,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export
type
AccountPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
|
'
sora
'
export
type
AccountType
=
'
oauth
'
|
'
setup-token
'
|
'
apikey
'
|
'
upstream
'
|
'
bedrock
'
|
'
bedrock-apikey
'
export
type
AccountType
=
'
oauth
'
|
'
setup-token
'
|
'
apikey
'
|
'
upstream
'
|
'
bedrock
'
export
type
OAuthAddMethod
=
'
oauth
'
|
'
setup-token
'
export
type
ProxyProtocol
=
'
http
'
|
'
https
'
|
'
socks5
'
|
'
socks5h
'
...
...
@@ -727,6 +728,16 @@ export interface Account {
quota_weekly_limit
?:
number
|
null
quota_weekly_used
?:
number
|
null
// 配额固定时间重置配置
quota_daily_reset_mode
?:
'
rolling
'
|
'
fixed
'
|
null
quota_daily_reset_hour
?:
number
|
null
quota_weekly_reset_mode
?:
'
rolling
'
|
'
fixed
'
|
null
quota_weekly_reset_day
?:
number
|
null
quota_weekly_reset_hour
?:
number
|
null
quota_reset_timezone
?:
string
|
null
quota_daily_reset_at
?:
string
|
null
quota_weekly_reset_at
?:
string
|
null
// 运行时状态(仅当启用对应限制时返回)
current_window_cost
?:
number
|
null
// 当前窗口费用
active_sessions
?:
number
|
null
// 当前活跃会话数
...
...
@@ -769,6 +780,21 @@ export interface AccountUsageInfo {
gemini_pro_minute
?:
UsageProgress
|
null
gemini_flash_minute
?:
UsageProgress
|
null
antigravity_quota
?:
Record
<
string
,
AntigravityModelQuota
>
|
null
// Antigravity 403 forbidden 状态
is_forbidden
?:
boolean
forbidden_reason
?:
string
forbidden_type
?:
string
// "validation" | "violation" | "forbidden"
validation_url
?:
string
// 验证/申诉链接
// 状态标记(后端自动推导)
needs_verify
?:
boolean
// 需要人工验证(forbidden_type=validation)
is_banned
?:
boolean
// 账号被封(forbidden_type=violation)
needs_reauth
?:
boolean
// token 失效需重新授权(401)
// 机器可读错误码:forbidden / unauthenticated / rate_limited / network_error
error_code
?:
string
error
?:
string
// usage 获取失败时的错误信息
}
// OpenAI Codex usage snapshot (from response headers)
...
...
frontend/src/views/admin/AccountsView.vue
View file @
8f0ea7a0
...
...
@@ -171,7 +171,15 @@
<
span
v
-
else
class
=
"
text-sm text-gray-400 dark:text-dark-500
"
>-<
/span
>
<
/template
>
<
template
#
cell
-
platform_type
=
"
{ row
}
"
>
<
div
class
=
"
flex flex-wrap items-center gap-1
"
>
<
PlatformTypeBadge
:
platform
=
"
row.platform
"
:
type
=
"
row.type
"
:
plan
-
type
=
"
row.credentials?.plan_type
"
:
privacy
-
mode
=
"
row.extra?.privacy_mode
"
/>
<
span
v
-
if
=
"
getAntigravityTierLabel(row)
"
:
class
=
"
['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]
"
>
{{
getAntigravityTierLabel
(
row
)
}}
<
/span
>
<
/div
>
<
/template
>
<
template
#
cell
-
capacity
=
"
{ row
}
"
>
<
AccountCapacityCell
:
account
=
"
row
"
/>
...
...
@@ -794,6 +802,40 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
{
immediate
:
false
}
)
// Antigravity 订阅等级辅助函数
function
getAntigravityTierFromRow
(
row
:
any
):
string
|
null
{
if
(
row
.
platform
!==
'
antigravity
'
)
return
null
const
extra
=
row
.
extra
as
Record
<
string
,
unknown
>
|
undefined
if
(
!
extra
)
return
null
const
lca
=
extra
.
load_code_assist
as
Record
<
string
,
unknown
>
|
undefined
if
(
!
lca
)
return
null
const
paid
=
lca
.
paidTier
as
Record
<
string
,
unknown
>
|
undefined
if
(
paid
&&
typeof
paid
.
id
===
'
string
'
)
return
paid
.
id
const
current
=
lca
.
currentTier
as
Record
<
string
,
unknown
>
|
undefined
if
(
current
&&
typeof
current
.
id
===
'
string
'
)
return
current
.
id
return
null
}
function
getAntigravityTierLabel
(
row
:
any
):
string
|
null
{
const
tier
=
getAntigravityTierFromRow
(
row
)
switch
(
tier
)
{
case
'
free-tier
'
:
return
t
(
'
admin.accounts.tier.free
'
)
case
'
g1-pro-tier
'
:
return
t
(
'
admin.accounts.tier.pro
'
)
case
'
g1-ultra-tier
'
:
return
t
(
'
admin.accounts.tier.ultra
'
)
default
:
return
null
}
}
function
getAntigravityTierClass
(
row
:
any
):
string
{
const
tier
=
getAntigravityTierFromRow
(
row
)
switch
(
tier
)
{
case
'
free-tier
'
:
return
'
bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300
'
case
'
g1-pro-tier
'
:
return
'
bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300
'
case
'
g1-ultra-tier
'
:
return
'
bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300
'
default
:
return
''
}
}
// All available columns
const
allColumns
=
computed
(()
=>
{
const
c
=
[
...
...
frontend/src/views/admin/SettingsView.vue
View file @
8f0ea7a0
...
...
@@ -1070,6 +1070,21 @@
<
/p
>
<
/div
>
<
div
class
=
"
space-y-6 p-6
"
>
<!--
Backend
Mode
-->
<
div
class
=
"
flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20
"
>
<
div
>
<
h3
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.site.backendMode
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.backendModeDescription
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.backend_mode_enabled
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 md:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
...
...
@@ -1745,7 +1760,7 @@ const betaPolicyForm = reactive({
rules
:
[]
as
Array
<
{
beta_token
:
string
action
:
'
pass
'
|
'
filter
'
|
'
block
'
scope
:
'
all
'
|
'
oauth
'
|
'
apikey
'
scope
:
'
all
'
|
'
oauth
'
|
'
apikey
'
|
'
bedrock
'
error_message
?:
string
}
>
}
)
...
...
@@ -1785,6 +1800,7 @@ const form = reactive<SettingsForm>({
contact_info
:
''
,
doc_url
:
''
,
home_content
:
''
,
backend_mode_enabled
:
false
,
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
...
...
@@ -1962,6 +1978,7 @@ async function loadSettings() {
try
{
const
settings
=
await
adminAPI
.
settings
.
getSettings
()
Object
.
assign
(
form
,
settings
)
form
.
backend_mode_enabled
=
settings
.
backend_mode_enabled
form
.
default_subscriptions
=
Array
.
isArray
(
settings
.
default_subscriptions
)
?
settings
.
default_subscriptions
.
filter
((
item
)
=>
item
.
group_id
>
0
&&
item
.
validity_days
>
0
)
...
...
@@ -2060,6 +2077,7 @@ async function saveSettings() {
contact_info
:
form
.
contact_info
,
doc_url
:
form
.
doc_url
,
home_content
:
form
.
home_content
,
backend_mode_enabled
:
form
.
backend_mode_enabled
,
hide_ccs_import_button
:
form
.
hide_ccs_import_button
,
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
...
...
@@ -2297,7 +2315,8 @@ const betaPolicyActionOptions = computed(() => [
const
betaPolicyScopeOptions
=
computed
(()
=>
[
{
value
:
'
all
'
,
label
:
t
(
'
admin.settings.betaPolicy.scopeAll
'
)
}
,
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.settings.betaPolicy.scopeOAuth
'
)
}
,
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.settings.betaPolicy.scopeAPIKey
'
)
}
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.settings.betaPolicy.scopeAPIKey
'
)
}
,
{
value
:
'
bedrock
'
,
label
:
t
(
'
admin.settings.betaPolicy.scopeBedrock
'
)
}
])
// Beta Policy 方法
...
...
frontend/src/views/admin/ops/OpsDashboard.vue
View file @
8f0ea7a0
...
...
@@ -85,7 +85,7 @@
</div>
<!-- Row: OpenAI Token Stats -->
<div
v-if=
"opsEnabled && !(loading && !hasLoadedOnce)"
class=
"grid grid-cols-1 gap-6"
>
<div
v-if=
"opsEnabled &&
showOpenAITokenStats &&
!(loading && !hasLoadedOnce)"
class=
"grid grid-cols-1 gap-6"
>
<OpsOpenAITokenStatsCard
:platform-filter=
"platform"
:group-id-filter=
"groupId"
...
...
@@ -94,7 +94,7 @@
</div>
<!-- Alert Events -->
<OpsAlertEventsCard
v-if=
"opsEnabled && !(loading && !hasLoadedOnce)"
/>
<OpsAlertEventsCard
v-if=
"opsEnabled &&
showAlertEvents &&
!(loading && !hasLoadedOnce)"
/>
<!-- System Logs -->
<OpsSystemLogTable
...
...
@@ -381,6 +381,8 @@ const showSettingsDialog = ref(false)
const
showAlertRulesCard
=
ref
(
false
)
// Auto refresh settings
const
showAlertEvents
=
ref
(
true
)
const
showOpenAITokenStats
=
ref
(
false
)
const
autoRefreshEnabled
=
ref
(
false
)
const
autoRefreshIntervalMs
=
ref
(
30000
)
// default 30 seconds
const
autoRefreshCountdown
=
ref
(
0
)
...
...
@@ -408,15 +410,22 @@ const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn(
{
immediate
:
false
}
)
// Load
auto refresh
settings from backend
async
function
load
AutoRefresh
Settings
()
{
// Load
ops dashboard presentation
settings from backend
.
async
function
load
DashboardAdvanced
Settings
()
{
try
{
const
settings
=
await
opsAPI
.
getAdvancedSettings
()
showAlertEvents
.
value
=
settings
.
display_alert_events
showOpenAITokenStats
.
value
=
settings
.
display_openai_token_stats
autoRefreshEnabled
.
value
=
settings
.
auto_refresh_enabled
autoRefreshIntervalMs
.
value
=
settings
.
auto_refresh_interval_seconds
*
1000
autoRefreshCountdown
.
value
=
settings
.
auto_refresh_interval_seconds
}
catch
(
err
)
{
console
.
error
(
'
[OpsDashboard] Failed to load auto refresh settings
'
,
err
)
console
.
error
(
'
[OpsDashboard] Failed to load dashboard advanced settings
'
,
err
)
showAlertEvents
.
value
=
true
showOpenAITokenStats
.
value
=
false
autoRefreshEnabled
.
value
=
false
autoRefreshIntervalMs
.
value
=
30000
autoRefreshCountdown
.
value
=
0
}
}
...
...
@@ -464,7 +473,8 @@ function onCustomTimeRangeChange(startTime: string, endTime: string) {
customEndTime
.
value
=
endTime
}
function
onSettingsSaved
()
{
async
function
onSettingsSaved
()
{
await
loadDashboardAdvancedSettings
()
loadThresholds
()
fetchData
()
}
...
...
@@ -774,7 +784,7 @@ onMounted(async () => {
loadThresholds
()
// Load auto refresh settings
await
load
AutoRefresh
Settings
()
await
load
DashboardAdvanced
Settings
()
if
(
opsEnabled
.
value
)
{
await
fetchData
()
...
...
@@ -816,7 +826,7 @@ watch(autoRefreshEnabled, (enabled) => {
// Reload auto refresh settings after settings dialog is closed
watch
(
showSettingsDialog
,
async
(
show
)
=>
{
if
(
!
show
)
{
await
load
AutoRefresh
Settings
()
await
load
DashboardAdvanced
Settings
()
}
})
</
script
>
frontend/src/views/admin/ops/components/OpsOpenAITokenStatsCard.vue
View file @
8f0ea7a0
...
...
@@ -208,9 +208,11 @@ function onNextPage() {
:
description
=
"
t('admin.ops.openaiTokenStats.empty')
"
/>
<
div
v
-
else
class
=
"
overflow-x-auto
"
>
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
class
=
"
overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700
"
>
<
div
class
=
"
max-h-[420px] overflow-auto
"
>
<
table
class
=
"
min-w-full text-left text-xs md:text-sm
"
>
<
thead
>
<
thead
class
=
"
sticky top-0 z-10 bg-white dark:bg-dark-800
"
>
<
tr
class
=
"
border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400
"
>
<
th
class
=
"
px-2 py-2 font-semibold
"
>
{{
t
(
'
admin.ops.openaiTokenStats.table.model
'
)
}}
<
/th
>
<
th
class
=
"
px-2 py-2 font-semibold
"
>
{{
t
(
'
admin.ops.openaiTokenStats.table.requestCount
'
)
}}
<
/th
>
...
...
@@ -225,7 +227,7 @@ function onNextPage() {
<
tr
v
-
for
=
"
row in items
"
:
key
=
"
row.model
"
class
=
"
border-b border-gray-100 text-gray-700 dark:border-dark-800 dark:text-gray-200
"
class
=
"
border-b border-gray-100 text-gray-700
last:border-b-0
dark:border-dark-800 dark:text-gray-200
"
>
<
td
class
=
"
px-2 py-2 font-medium
"
>
{{
row
.
model
}}
<
/td
>
<
td
class
=
"
px-2 py-2
"
>
{{
formatInt
(
row
.
request_count
)
}}
<
/td
>
...
...
@@ -237,6 +239,8 @@ function onNextPage() {
<
/tr
>
<
/tbody
>
<
/table
>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
viewMode === 'topn'
"
class
=
"
mt-3 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.ops.openaiTokenStats.totalModels
'
,
{
total
}
)
}}
<
/div
>
...
...
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
View file @
8f0ea7a0
...
...
@@ -543,6 +543,31 @@ async function saveAllSettings() {
/>
</div>
</div>
<!-- Dashboard Cards -->
<div
class=
"space-y-3"
>
<h5
class=
"text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.dashboardCards
'
)
}}
</h5>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.displayAlertEvents
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.displayAlertEventsHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.display_alert_events"
/>
</div>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.displayOpenAITokenStats
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.displayOpenAITokenStatsHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.display_openai_token_stats"
/>
</div>
</div>
</div>
</details>
</div>
...
...
frontend/src/views/admin/ops/components/__tests__/OpsOpenAITokenStatsCard.spec.ts
View file @
8f0ea7a0
...
...
@@ -196,6 +196,23 @@ describe('OpsOpenAITokenStatsCard', () => {
expect
(
wrapper
.
find
(
'
.empty-state
'
).
exists
()).
toBe
(
true
)
})
it
(
'
数据表使用固定高度滚动容器,避免纵向无限增长
'
,
async
()
=>
{
mockGetOpenAITokenStats
.
mockResolvedValue
(
sampleResponse
)
const
wrapper
=
mount
(
OpsOpenAITokenStatsCard
,
{
props
:
{
refreshToken
:
0
},
global
:
{
stubs
:
{
Select
:
SelectStub
,
EmptyState
:
EmptyStateStub
,
},
},
})
await
flushPromises
()
expect
(
wrapper
.
find
(
'
.max-h-
\\
[420px
\\
]
'
).
exists
()).
toBe
(
true
)
})
it
(
'
接口异常时显示错误提示
'
,
async
()
=>
{
mockGetOpenAITokenStats
.
mockRejectedValue
(
new
Error
(
'
加载失败
'
))
...
...
Prev
1
2
3
4
5
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