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
2b70d1d3
Commit
2b70d1d3
authored
Apr 09, 2026
by
IanShaw027
Browse files
merge upstream main into fix/bug-cleanup-main
parents
b37afd68
00c08c57
Changes
60
Hide whitespace changes
Inline
Side-by-side
backend/migrations/091_add_group_messages_dispatch_model_config.sql
0 → 100644
View file @
2b70d1d3
ALTER
TABLE
groups
ADD
COLUMN
IF
NOT
EXISTS
messages_dispatch_model_config
JSONB
NOT
NULL
DEFAULT
'{}'
::
jsonb
;
deploy/codex-instructions.md.tmpl
0 → 100644
View file @
2b70d1d3
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
{{ if .ExistingInstructions }}
{{ .ExistingInstructions }}
{{ end }}
deploy/config.example.yaml
View file @
2b70d1d3
...
...
@@ -202,6 +202,32 @@ gateway:
#
# 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。
force_codex_cli
:
false
# Optional: template file used to build the final top-level Codex `instructions`.
# 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。
#
# This is applied on the `/v1/messages -> Responses/Codex` conversion path,
# after Claude `system` has already been normalized into Codex `instructions`.
# 该模板作用于 `/v1/messages -> Responses/Codex` 转换链路,且发生在 Claude `system`
# 已经被归一化为 Codex `instructions` 之后。
#
# The template can reference:
# 模板可引用:
# - {{ .ExistingInstructions }} : converted client instructions/system
# - {{ .OriginalModel }} : original requested model
# - {{ .NormalizedModel }} : normalized routing model
# - {{ .BillingModel }} : billing model
# - {{ .UpstreamModel }} : final upstream model
#
# If you want to preserve client system prompts, keep {{ .ExistingInstructions }}
# somewhere in the template. If omitted, the template output fully replaces it.
# 如需保留客户端 system 提示词,请在模板中显式包含 {{ .ExistingInstructions }}。
# 若省略,则模板输出会完全覆盖它。
#
# Docker users can mount a host file to /app/data/codex-instructions.md.tmpl
# and point this field there.
# Docker 用户可将宿主机文件挂载到 /app/data/codex-instructions.md.tmpl,
# 然后把本字段指向该路径。
forced_codex_instructions_template_file
:
"
"
# OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout)
# 默认 false:过滤超时头,降低上游提前断流风险。
openai_passthrough_allow_timeout_headers
:
false
...
...
@@ -347,12 +373,6 @@ gateway:
# Enable batch load calculation for scheduling
# 启用调度批量负载计算
load_batch_enabled
:
true
# Snapshot bucket MGET chunk size
# 调度快照分桶读取时的 MGET 分块大小
snapshot_mget_chunk_size
:
128
# Snapshot bucket write chunk size
# 调度快照重建写入时的分块大小
snapshot_write_chunk_size
:
256
# Slot cleanup interval (duration)
# 并发槽位清理周期(时间段)
slot_cleanup_interval
:
30s
...
...
@@ -826,6 +846,46 @@ linuxdo_connect:
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Generic OIDC OAuth Login (SSO)
# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录)
# =============================================================================
oidc_connect
:
enabled
:
false
provider_name
:
"
OIDC"
client_id
:
"
"
client_secret
:
"
"
# 例如: "https://keycloak.example.com/realms/myrealm"
issuer_url
:
"
"
# 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks
discovery_url
:
"
"
authorize_url
:
"
"
token_url
:
"
"
# 可选(仅补充 email/username,不用于 sub 可信绑定)
userinfo_url
:
"
"
# validate_id_token=true 时必填
jwks_url
:
"
"
scopes
:
"
openid
email
profile"
# 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback"
redirect_url
:
"
"
# 安全提示:
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
frontend_redirect_url
:
"
/auth/oidc/callback"
token_auth_method
:
"
client_secret_post"
# client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce
:
false
# 开启后强制校验 id_token 的签名和 claims(推荐)
validate_id_token
:
true
allowed_signing_algs
:
"
RS256,ES256,PS256"
# 允许的时钟偏移(秒)
clock_skew_seconds
:
120
# 若 Provider 返回 email_verified=false,是否拒绝登录
require_email_verified
:
false
userinfo_email_path
:
"
"
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Default Settings
# 默认设置
...
...
deploy/docker-compose.yml
View file @
2b70d1d3
...
...
@@ -31,6 +31,10 @@ services:
# Optional: Mount custom config.yaml (uncomment and create the file first)
# Copy config.example.yaml to config.yaml, modify it, then uncomment:
# - ./config.yaml:/app/data/config.yaml
# Optional: Mount a custom Codex instructions template file, then point
# gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl
# in config.yaml.
# - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro
environment
:
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
...
...
@@ -146,7 +150,17 @@ services:
networks
:
-
sub2api-network
healthcheck
:
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
-T"
,
"
5"
,
"
-O"
,
"
/dev/null"
,
"
http://localhost:8080/health"
]
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
-T"
,
"
5"
,
"
-O"
,
"
/dev/null"
,
"
http://localhost:8080/health"
,
]
interval
:
30s
timeout
:
10s
retries
:
3
...
...
@@ -177,11 +191,17 @@ services:
networks
:
-
sub2api-network
healthcheck
:
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-sub2api}
-d
${POSTGRES_DB:-sub2api}"
]
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-sub2api}
-d
${POSTGRES_DB:-sub2api}"
,
]
interval
:
10s
timeout
:
5s
retries
:
5
start_period
:
10s
ports
:
-
5432:5432
# 注意:不暴露端口到宿主机,应用通过内部网络连接
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
...
...
@@ -199,12 +219,12 @@ services:
volumes
:
-
redis_data:/data
command
:
>
sh -c '
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
sh -c '
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
environment
:
-
TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
...
...
@@ -217,7 +237,8 @@ services:
timeout
:
5s
retries
:
5
start_period
:
5s
ports
:
-
6379:6379
# =============================================================================
# Volumes
# =============================================================================
...
...
frontend/src/api/admin/settings.ts
View file @
2b70d1d3
...
...
@@ -64,6 +64,30 @@ export interface SystemSettings {
linuxdo_connect_client_secret_configured
:
boolean
linuxdo_connect_redirect_url
:
string
// Generic OIDC OAuth settings
oidc_connect_enabled
:
boolean
oidc_connect_provider_name
:
string
oidc_connect_client_id
:
string
oidc_connect_client_secret_configured
:
boolean
oidc_connect_issuer_url
:
string
oidc_connect_discovery_url
:
string
oidc_connect_authorize_url
:
string
oidc_connect_token_url
:
string
oidc_connect_userinfo_url
:
string
oidc_connect_jwks_url
:
string
oidc_connect_scopes
:
string
oidc_connect_redirect_url
:
string
oidc_connect_frontend_redirect_url
:
string
oidc_connect_token_auth_method
:
string
oidc_connect_use_pkce
:
boolean
oidc_connect_validate_id_token
:
boolean
oidc_connect_allowed_signing_algs
:
string
oidc_connect_clock_skew_seconds
:
number
oidc_connect_require_email_verified
:
boolean
oidc_connect_userinfo_email_path
:
string
oidc_connect_userinfo_id_path
:
string
oidc_connect_userinfo_username_path
:
string
// Model fallback configuration
enable_model_fallback
:
boolean
fallback_model_anthropic
:
string
...
...
@@ -135,6 +159,28 @@ export interface UpdateSettingsRequest {
linuxdo_connect_client_id
?:
string
linuxdo_connect_client_secret
?:
string
linuxdo_connect_redirect_url
?:
string
oidc_connect_enabled
?:
boolean
oidc_connect_provider_name
?:
string
oidc_connect_client_id
?:
string
oidc_connect_client_secret
?:
string
oidc_connect_issuer_url
?:
string
oidc_connect_discovery_url
?:
string
oidc_connect_authorize_url
?:
string
oidc_connect_token_url
?:
string
oidc_connect_userinfo_url
?:
string
oidc_connect_jwks_url
?:
string
oidc_connect_scopes
?:
string
oidc_connect_redirect_url
?:
string
oidc_connect_frontend_redirect_url
?:
string
oidc_connect_token_auth_method
?:
string
oidc_connect_use_pkce
?:
boolean
oidc_connect_validate_id_token
?:
boolean
oidc_connect_allowed_signing_algs
?:
string
oidc_connect_clock_skew_seconds
?:
number
oidc_connect_require_email_verified
?:
boolean
oidc_connect_userinfo_email_path
?:
string
oidc_connect_userinfo_id_path
?:
string
oidc_connect_userinfo_username_path
?:
string
enable_model_fallback
?:
boolean
fallback_model_anthropic
?:
string
fallback_model_openai
?:
string
...
...
frontend/src/api/auth.ts
View file @
2b70d1d3
...
...
@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
return
data
}
/**
* Complete OIDC OAuth registration by supplying an invitation code
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export
async
function
completeOIDCOAuthRegistration
(
pendingOAuthToken
:
string
,
invitationCode
:
string
):
Promise
<
{
access_token
:
string
;
refresh_token
:
string
;
expires_in
:
number
;
token_type
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
access_token
:
string
refresh_token
:
string
expires_in
:
number
token_type
:
string
}
>
(
'
/auth/oauth/oidc/complete-registration
'
,
{
pending_oauth_token
:
pendingOAuthToken
,
invitation_code
:
invitationCode
})
return
data
}
export
const
authAPI
=
{
login
,
login2FA
,
...
...
@@ -380,7 +402,8 @@ export const authAPI = {
resetPassword
,
refreshToken
,
revokeAllSessions
,
completeLinuxDoOAuthRegistration
completeLinuxDoOAuthRegistration
,
completeOIDCOAuthRegistration
}
export
default
authAPI
frontend/src/components/auth/LinuxDoOAuthSection.vue
View file @
2b70d1d3
...
...
@@ -29,10 +29,10 @@
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
</button>
<div
class=
"flex items-center gap-3"
>
<div
v-if=
"showDivider"
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.
linuxdo.o
rContinue
'
)
}}
{{
t
(
'
auth.
oauthO
rContinue
'
)
}}
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
...
...
@@ -43,9 +43,12 @@
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
<
{
withDefaults
(
defineProps
<
{
disabled
?:
boolean
}
>
()
showDivider
?:
boolean
}
>
(),
{
showDivider
:
true
})
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
...
...
@@ -58,4 +61,3 @@ function startLogin(): void {
window
.
location
.
href
=
startURL
}
</
script
>
frontend/src/components/auth/OidcOAuthSection.vue
0 → 100644
View file @
2b70d1d3
<
template
>
<div
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"disabled"
class=
"btn btn-secondary w-full"
@
click=
"startLogin"
>
<span
class=
"mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{
providerInitial
}}
</span>
{{
t
(
'
auth.oidc.signIn
'
,
{
providerName
:
normalizedProviderName
}
)
}}
<
/button
>
<
div
v
-
if
=
"
showDivider
"
class
=
"
flex items-center gap-3
"
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
span
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
<
/span
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
const
props
=
withDefaults
(
defineProps
<
{
disabled
?:
boolean
providerName
?:
string
showDivider
?:
boolean
}
>
(),
{
providerName
:
'
OIDC
'
,
showDivider
:
true
}
)
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
normalizedProviderName
=
computed
(()
=>
{
const
name
=
props
.
providerName
?.
trim
()
return
name
||
'
OIDC
'
}
)
const
providerInitial
=
computed
(()
=>
normalizedProviderName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
O
'
)
function
startLogin
():
void
{
const
redirectTo
=
(
route
.
query
.
redirect
as
string
)
||
'
/dashboard
'
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
startURL
=
`${normalized
}
/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)
}
`
window
.
location
.
href
=
startURL
}
<
/script
>
frontend/src/i18n/locales/en.ts
View file @
2b70d1d3
...
...
@@ -428,6 +428,7 @@ export default {
invitationCodeInvalid
:
'
Invalid or used invitation code
'
,
invitationCodeValidating
:
'
Validating invitation code...
'
,
invitationCodeInvalidCannotRegister
:
'
Invalid invitation code. Please check and try again
'
,
oauthOrContinue
:
'
or continue with email
'
,
linuxdo
:
{
signIn
:
'
Continue with Linux.do
'
,
orContinue
:
'
or continue with email
'
,
...
...
@@ -442,6 +443,20 @@ export default {
completing
:
'
Completing registration…
'
,
completeRegistrationFailed
:
'
Registration failed. Please check your invitation code and try again.
'
},
oidc
:
{
signIn
:
'
Continue with {providerName}
'
,
callbackTitle
:
'
Signing you in with {providerName}
'
,
callbackProcessing
:
'
Completing login with {providerName}, please wait...
'
,
callbackHint
:
'
If you are not redirected automatically, go back to the login page and try again.
'
,
callbackMissingToken
:
'
Missing login token, please try again.
'
,
backToLogin
:
'
Back to Login
'
,
invitationRequired
:
'
This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.
'
,
invalidPendingToken
:
'
The registration token has expired. Please sign in again.
'
,
completeRegistration
:
'
Complete Registration
'
,
completing
:
'
Completing registration…
'
,
completeRegistrationFailed
:
'
Registration failed. Please check your invitation code and try again.
'
},
oauth
:
{
code
:
'
Code
'
,
state
:
'
State
'
,
...
...
@@ -4228,6 +4243,57 @@ export default {
quickSetCopy
:
'
Generate & Copy (current site)
'
,
redirectUrlSetAndCopied
:
'
Redirect URL generated and copied to clipboard
'
},
oidc
:
{
title
:
'
OIDC Login
'
,
description
:
'
Configure a standard OIDC provider (for example Keycloak)
'
,
enable
:
'
Enable OIDC Login
'
,
enableHint
:
'
Show OIDC login on the login/register pages
'
,
providerName
:
'
Provider Name
'
,
providerNamePlaceholder
:
'
for example Keycloak
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
OIDC client id
'
,
clientSecret
:
'
Client Secret
'
,
clientSecretPlaceholder
:
'
********
'
,
clientSecretHint
:
'
Used by backend to exchange tokens (keep it secret)
'
,
clientSecretConfiguredPlaceholder
:
'
********
'
,
clientSecretConfiguredHint
:
'
Secret configured. Leave empty to keep the current value.
'
,
issuerUrl
:
'
Issuer URL
'
,
issuerUrlPlaceholder
:
'
https://id.example.com/realms/main
'
,
discoveryUrl
:
'
Discovery URL
'
,
discoveryUrlPlaceholder
:
'
Optional, leave empty to auto-derive from issuer
'
,
authorizeUrl
:
'
Authorize URL
'
,
authorizeUrlPlaceholder
:
'
Optional, can be discovered automatically
'
,
tokenUrl
:
'
Token URL
'
,
tokenUrlPlaceholder
:
'
Optional, can be discovered automatically
'
,
userinfoUrl
:
'
UserInfo URL
'
,
userinfoUrlPlaceholder
:
'
Optional, can be discovered automatically
'
,
jwksUrl
:
'
JWKS URL
'
,
jwksUrlPlaceholder
:
'
Optional, required when strict ID token validation is enabled
'
,
scopes
:
'
Scopes
'
,
scopesPlaceholder
:
'
openid email profile
'
,
scopesHint
:
'
Must include openid
'
,
redirectUrl
:
'
Backend Redirect URL
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/oidc/callback
'
,
redirectUrlHint
:
'
Must match the callback URL configured in the OIDC provider
'
,
quickSetCopy
:
'
Generate & Copy (current site)
'
,
redirectUrlSetAndCopied
:
'
Redirect URL generated and copied to clipboard
'
,
frontendRedirectUrl
:
'
Frontend Callback Path
'
,
frontendRedirectUrlPlaceholder
:
'
/auth/oidc/callback
'
,
frontendRedirectUrlHint
:
'
Frontend route used after backend callback
'
,
tokenAuthMethod
:
'
Token Auth Method
'
,
clockSkewSeconds
:
'
Clock Skew (seconds)
'
,
allowedSigningAlgs
:
'
Allowed Signing Algs
'
,
allowedSigningAlgsPlaceholder
:
'
RS256,ES256,PS256
'
,
usePkce
:
'
Use PKCE
'
,
validateIdToken
:
'
Validate ID Token
'
,
requireEmailVerified
:
'
Require Email Verified
'
,
userinfoEmailPath
:
'
UserInfo Email Path
'
,
userinfoEmailPathPlaceholder
:
'
for example data.email
'
,
userinfoIdPath
:
'
UserInfo ID Path
'
,
userinfoIdPathPlaceholder
:
'
for example data.id
'
,
userinfoUsernamePath
:
'
UserInfo Username Path
'
,
userinfoUsernamePathPlaceholder
:
'
for example data.username
'
},
defaults
:
{
title
:
'
Default User Settings
'
,
description
:
'
Default values for new users
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
2b70d1d3
...
...
@@ -427,6 +427,7 @@ export default {
invitationCodeInvalid
:
'
邀请码无效或已被使用
'
,
invitationCodeValidating
:
'
正在验证邀请码...
'
,
invitationCodeInvalidCannotRegister
:
'
邀请码无效,请检查后重试
'
,
oauthOrContinue
:
'
或使用邮箱密码继续
'
,
linuxdo
:
{
signIn
:
'
使用 Linux.do 登录
'
,
orContinue
:
'
或使用邮箱密码继续
'
,
...
...
@@ -441,6 +442,19 @@ export default {
completing
:
'
正在完成注册...
'
,
completeRegistrationFailed
:
'
注册失败,请检查邀请码后重试。
'
},
oidc
:
{
signIn
:
'
使用 {providerName} 登录
'
,
callbackTitle
:
'
正在完成 {providerName} 登录
'
,
callbackProcessing
:
'
正在验证 {providerName} 登录信息,请稍候...
'
,
callbackHint
:
'
如果页面未自动跳转,请返回登录页重试。
'
,
callbackMissingToken
:
'
登录信息缺失,请返回重试。
'
,
backToLogin
:
'
返回登录
'
,
invitationRequired
:
'
该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。
'
,
invalidPendingToken
:
'
注册凭证已失效,请重新登录。
'
,
completeRegistration
:
'
完成注册
'
,
completing
:
'
正在完成注册...
'
,
completeRegistrationFailed
:
'
注册失败,请检查邀请码后重试。
'
},
oauth
:
{
code
:
'
授权码
'
,
state
:
'
状态
'
,
...
...
@@ -4394,6 +4408,57 @@ export default {
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
},
oidc
:
{
title
:
'
OIDC 登录
'
,
description
:
'
配置标准 OIDC Provider(例如 Keycloak)
'
,
enable
:
'
启用 OIDC 登录
'
,
enableHint
:
'
在登录/注册页面显示 OIDC 登录入口
'
,
providerName
:
'
Provider 名称
'
,
providerNamePlaceholder
:
'
例如 Keycloak
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
OIDC client id
'
,
clientSecret
:
'
Client Secret
'
,
clientSecretPlaceholder
:
'
********
'
,
clientSecretHint
:
'
用于后端交换 token(请保密)
'
,
clientSecretConfiguredPlaceholder
:
'
********
'
,
clientSecretConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
,
issuerUrl
:
'
Issuer URL
'
,
issuerUrlPlaceholder
:
'
https://id.example.com/realms/main
'
,
discoveryUrl
:
'
Discovery URL
'
,
discoveryUrlPlaceholder
:
'
可选,留空将基于 issuer 自动推导
'
,
authorizeUrl
:
'
Authorize URL
'
,
authorizeUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
tokenUrl
:
'
Token URL
'
,
tokenUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
userinfoUrl
:
'
UserInfo URL
'
,
userinfoUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
jwksUrl
:
'
JWKS URL
'
,
jwksUrlPlaceholder
:
'
可选;启用严格 ID Token 校验时必填
'
,
scopes
:
'
Scopes
'
,
scopesPlaceholder
:
'
openid email profile
'
,
scopesHint
:
'
必须包含 openid
'
,
redirectUrl
:
'
后端回调地址(Redirect URL)
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/oidc/callback
'
,
redirectUrlHint
:
'
必须与 OIDC Provider 中配置的回调地址一致
'
,
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
,
frontendRedirectUrl
:
'
前端回调路径
'
,
frontendRedirectUrlPlaceholder
:
'
/auth/oidc/callback
'
,
frontendRedirectUrlHint
:
'
后端回调完成后重定向到此前端路径
'
,
tokenAuthMethod
:
'
Token 鉴权方式
'
,
clockSkewSeconds
:
'
时钟偏移(秒)
'
,
allowedSigningAlgs
:
'
允许的签名算法
'
,
allowedSigningAlgsPlaceholder
:
'
RS256,ES256,PS256
'
,
usePkce
:
'
启用 PKCE
'
,
validateIdToken
:
'
校验 ID Token
'
,
requireEmailVerified
:
'
要求邮箱已验证
'
,
userinfoEmailPath
:
'
UserInfo 邮箱字段路径
'
,
userinfoEmailPathPlaceholder
:
'
例如 data.email
'
,
userinfoIdPath
:
'
UserInfo ID 字段路径
'
,
userinfoIdPathPlaceholder
:
'
例如 data.id
'
,
userinfoUsernamePath
:
'
UserInfo 用户名字段路径
'
,
userinfoUsernamePathPlaceholder
:
'
例如 data.username
'
},
defaults
:
{
title
:
'
用户默认设置
'
,
description
:
'
新用户的默认值
'
,
...
...
frontend/src/router/index.ts
View file @
2b70d1d3
...
...
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
LinuxDo OAuth Callback
'
}
},
{
path
:
'
/auth/oidc/callback
'
,
name
:
'
OIDCOAuthCallback
'
,
component
:
()
=>
import
(
'
@/views/auth/OidcCallbackView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
OIDC OAuth Callback
'
}
},
{
path
:
'
/forgot-password
'
,
name
:
'
ForgotPassword
'
,
...
...
frontend/src/stores/app.ts
View file @
2b70d1d3
...
...
@@ -339,6 +339,9 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
oidc_oauth_enabled
:
false
,
oidc_oauth_provider_name
:
'
OIDC
'
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
...
...
frontend/src/types/index.ts
View file @
2b70d1d3
...
...
@@ -111,6 +111,9 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
oidc_oauth_enabled
:
boolean
oidc_oauth_provider_name
:
string
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
@@ -368,6 +371,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export
type
SubscriptionType
=
'
standard
'
|
'
subscription
'
export
interface
OpenAIMessagesDispatchModelConfig
{
opus_mapped_model
?:
string
sonnet_mapped_model
?:
string
haiku_mapped_model
?:
string
exact_model_mappings
?:
Record
<
string
,
string
>
}
export
interface
Group
{
id
:
number
name
:
string
...
...
@@ -390,6 +400,8 @@ export interface Group {
fallback_group_id_on_invalid_request
:
number
|
null
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
allow_messages_dispatch
?:
boolean
default_mapped_model
?:
string
messages_dispatch_model_config
?:
OpenAIMessagesDispatchModelConfig
require_oauth_only
:
boolean
require_privacy_set
:
boolean
created_at
:
string
...
...
@@ -416,6 +428,7 @@ export interface AdminGroup extends Group {
// OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model
?:
string
messages_dispatch_model_config
?:
OpenAIMessagesDispatchModelConfig
// 分组排序
sort_order
:
number
...
...
frontend/src/views/admin/GroupsView.vue
View file @
2b70d1d3
...
...
@@ -2,7 +2,9 @@
<AppLayout>
<TablePageLayout>
<template
#filters
>
<div
class=
"flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<div
class=
"flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<div
class=
"relative w-full sm:w-64"
>
...
...
@@ -19,38 +21,44 @@
@
input=
"handleSearch"
/>
</div>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
:placeholder=
"t('admin.groups.allPlatforms')"
class=
"w-44"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.groups.allStatus')"
class=
"w-40"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.is_exclusive"
:options=
"exclusiveOptions"
:placeholder=
"t('admin.groups.allGroups')"
class=
"w-44"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
:placeholder=
"t('admin.groups.allPlatforms')"
class=
"w-44"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.groups.allStatus')"
class=
"w-40"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.is_exclusive"
:options=
"exclusiveOptions"
:placeholder=
"t('admin.groups.allGroups')"
class=
"w-44"
@
change=
"loadGroups"
/>
</div>
<!-- Right: actions -->
<div
class=
"flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<div
class=
"flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button
@
click=
"loadGroups"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
@
click=
"openSortModal"
...
...
@@ -58,7 +66,7 @@
:title=
"t('admin.groups.sortOrder')"
>
<Icon
name=
"arrowsUpDown"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.groups.sortOrder
'
)
}}
{{
t
(
"
admin.groups.sortOrder
"
)
}}
</button>
<button
@
click=
"showCreateModal = true"
...
...
@@ -66,7 +74,7 @@
data-tour=
"groups-create-btn"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.groups.createGroup
'
)
}}
{{
t
(
"
admin.groups.createGroup
"
)
}}
</button>
</div>
</div>
...
...
@@ -83,7 +91,9 @@
@
sort=
"handleSort"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-platform=
"{ value }"
>
...
...
@@ -96,11 +106,11 @@
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: value === 'antigravity'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
,
]"
>
<PlatformIcon
:platform=
"value"
size=
"xs"
/>
{{
t
(
'
admin.groups.platforms.
'
+
value
)
}}
{{
t
(
"
admin.groups.platforms.
"
+
value
)
}}
</span>
</
template
>
...
...
@@ -112,13 +122,13 @@
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
row.subscription_type === 'subscription'
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
,
]"
>
{{
row
.
subscription_type
===
'
subscription
'
?
t
(
'
admin.groups.subscription.subscription
'
)
:
t
(
'
admin.groups.subscription.standard
'
)
row
.
subscription_type
===
"
subscription
"
?
t
(
"
admin.groups.subscription.subscription
"
)
:
t
(
"
admin.groups.subscription.standard
"
)
}}
</span>
<!-- Subscription Limits - compact single line -->
...
...
@@ -127,18 +137,29 @@
class=
"text-xs text-gray-500 dark:text-gray-400"
>
<template
v-if=
"row.daily_limit_usd || row.weekly_limit_usd || row.monthly_limit_usd"
v-if=
"
row.daily_limit_usd ||
row.weekly_limit_usd ||
row.monthly_limit_usd
"
>
<span
v-if=
"row.daily_limit_usd"
>
$
{{
row
.
daily_limit_usd
}}
/
{{
t
(
'
admin.groups.limitDay
'
)
}}
</span
>
$
{{
row
.
daily_limit_usd
}}
/
{{
t
(
"
admin.groups.limitDay
"
)
}}
</span
>
<span
v-if=
"row.daily_limit_usd && (row.weekly_limit_usd || row.monthly_limit_usd)"
v-if=
"
row.daily_limit_usd &&
(row.weekly_limit_usd || row.monthly_limit_usd)
"
class=
"mx-1 text-gray-300 dark:text-gray-600"
>
·
</span
>
<span
v-if=
"row.weekly_limit_usd"
>
$
{{
row
.
weekly_limit_usd
}}
/
{{
t
(
'
admin.groups.limitWeek
'
)
}}
</span
>
$
{{
row
.
weekly_limit_usd
}}
/
{{
t
(
"
admin.groups.limitWeek
"
)
}}
</span
>
<span
v-if=
"row.weekly_limit_usd && row.monthly_limit_usd"
...
...
@@ -146,42 +167,75 @@
>
·
</span
>
<span
v-if=
"row.monthly_limit_usd"
>
$
{{
row
.
monthly_limit_usd
}}
/
{{
t
(
'
admin.groups.limitMonth
'
)
}}
</span
>
$
{{
row
.
monthly_limit_usd
}}
/
{{
t
(
"
admin.groups.limitMonth
"
)
}}
</span
>
</
template
>
<span
v-else
class=
"text-gray-400 dark:text-gray-500"
>
{{
t(
'
admin.groups.subscription.noLimit
'
)
t(
"
admin.groups.subscription.noLimit
"
)
}}
</span>
</div>
</div>
</template>
<
template
#cell-rate_multiplier=
"{ value }"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
}}
x
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
}}
x
</span
>
</
template
>
<
template
#cell-is_exclusive=
"{ value }"
>
<span
:class=
"['badge', value ? 'badge-primary' : 'badge-gray']"
>
{{
value
?
t
(
'
admin.groups.exclusive
'
)
:
t
(
'
admin.groups.public
'
)
}}
{{
value
?
t
(
"
admin.groups.exclusive
"
)
:
t
(
"
admin.groups.public
"
)
}}
</span>
</
template
>
<
template
#cell-account_count=
"{ row }"
>
<div
class=
"space-y-0.5 text-xs"
>
<div>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.accountsAvailable
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-emerald-600 dark:text-emerald-400"
>
{{
(
row
.
active_account_count
||
0
)
-
(
row
.
rate_limited_account_count
||
0
)
}}
</span>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsUnit
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
"
admin.groups.accountsAvailable
"
)
}}
</span>
<span
class=
"ml-1 font-medium text-emerald-600 dark:text-emerald-400"
>
{{
(
row
.
active_account_count
||
0
)
-
(
row
.
rate_limited_account_count
||
0
)
}}
</span
>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
"
admin.groups.accountsUnit
"
)
}}
</span
>
</div>
<div
v-if=
"row.rate_limited_account_count"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.accountsRateLimited
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-amber-600 dark:text-amber-400"
>
{{
row
.
rate_limited_account_count
}}
</span>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsUnit
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
"
admin.groups.accountsRateLimited
"
)
}}
</span>
<span
class=
"ml-1 font-medium text-amber-600 dark:text-amber-400"
>
{{
row
.
rate_limited_account_count
}}
</span
>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
"
admin.groups.accountsUnit
"
)
}}
</span
>
</div>
<div>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.accountsTotal
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{
row
.
account_count
||
0
}}
</span>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsUnit
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
"
admin.groups.accountsTotal
"
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{
row
.
account_count
||
0
}}
</span
>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
"
admin.groups.accountsUnit
"
)
}}
</span
>
</div>
</div>
</
template
>
...
...
@@ -203,19 +257,36 @@
<div
v-if=
"usageLoading"
class=
"text-xs text-gray-400"
>
—
</div>
<div
v-else
class=
"space-y-0.5 text-xs"
>
<div
class=
"text-gray-500 dark:text-gray-400"
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
{{
t
(
'
admin.groups.usageToday
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
$
{{
formatCost
(
usageMap
.
get
(
row
.
id
)?.
today_cost
??
0
)
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
{{
t
(
"
admin.groups.usageToday
"
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
$
{{
formatCost
(
usageMap
.
get
(
row
.
id
)?.
today_cost
??
0
)
}}
</span
>
</div>
<div
class=
"text-gray-500 dark:text-gray-400"
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
{{
t
(
'
admin.groups.usageTotal
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
$
{{
formatCost
(
usageMap
.
get
(
row
.
id
)?.
total_cost
??
0
)
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
{{
t
(
"
admin.groups.usageTotal
"
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
$
{{
formatCost
(
usageMap
.
get
(
row
.
id
)?.
total_cost
??
0
)
}}
</span
>
</div>
</div>
</
template
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
<span
:class=
"[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger',
]"
>
{{
t
(
"
admin.accounts.status.
"
+
value
)
}}
</span>
</
template
>
...
...
@@ -226,21 +297,23 @@
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon
name=
"edit"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
<span
class=
"text-xs"
>
{{
t
(
"
common.edit
"
)
}}
</span>
</button>
<button
@
click=
"handleRateMultipliers(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
>
<Icon
name=
"dollar"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
admin.groups.rateMultipliers
'
)
}}
</span>
<span
class=
"text-xs"
>
{{
t
(
"
admin.groups.rateMultipliers
"
)
}}
</span>
</button>
<button
@
click=
"handleDelete(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon
name=
"trash"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
<span
class=
"text-xs"
>
{{
t
(
"
common.delete
"
)
}}
</span>
</button>
</div>
</
template
>
...
...
@@ -275,9 +348,13 @@
width=
"normal"
@
close=
"closeCreateModal"
>
<form
id=
"create-group-form"
@
submit.prevent=
"handleCreateGroup"
class=
"space-y-5"
>
<form
id=
"create-group-form"
@
submit.prevent=
"handleCreateGroup"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t(
'
admin.groups.form.name
'
) }}
</label>
<label
class=
"input-label"
>
{{ t(
"
admin.groups.form.name
"
) }}
</label>
<input
v-model=
"createForm.name"
type=
"text"
...
...
@@ -288,7 +365,9 @@
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.form.description') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.form.description")
}}
</label>
<textarea
v-model=
"createForm.description"
rows=
"3"
...
...
@@ -297,20 +376,22 @@
></textarea>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.form.platform') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.form.platform")
}}
</label>
<Select
v-model=
"createForm.platform"
:options=
"platformOptions"
data-tour=
"group-form-platform"
@
change=
"createForm.copy_accounts_from_group_ids = []"
/>
<p
class=
"input-hint"
>
{{ t(
'
admin.groups.platformHint
'
) }}
</p>
<p
class=
"input-hint"
>
{{ t(
"
admin.groups.platformHint
"
) }}
</p>
</div>
<!-- 从分组复制账号 -->
<div
v-if=
"copyAccountsGroupOptions.length > 0"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.copyAccounts.title
'
) }}
{{ t(
"
admin.groups.copyAccounts.title
"
) }}
</label>
<div
class=
"group relative inline-flex"
>
<Icon
...
...
@@ -319,27 +400,44 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.copyAccounts.tooltip
'
) }}
{{ t(
"
admin.groups.copyAccounts.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div
v-if=
"createForm.copy_accounts_from_group_ids.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<div
v-if=
"createForm.copy_accounts_from_group_ids.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<span
v-for=
"groupId in createForm.copy_accounts_from_group_ids"
:key=
"groupId"
class=
"inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptions.find(o => o.value === groupId)?.label || `#${groupId}` }}
{{
copyAccountsGroupOptions.find((o) => o.value === groupId)
?.label || `#${groupId}`
}}
<button
type=
"button"
@
click=
"createForm.copy_accounts_from_group_ids = createForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
@
click=
"
createForm.copy_accounts_from_group_ids =
createForm.copy_accounts_from_group_ids.filter(
(id) => id !== groupId,
)
"
class=
"ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon
name=
"x"
size=
"xs"
/>
...
...
@@ -349,28 +447,39 @@
<!-- 分组选择下拉 -->
<select
class=
"input"
@
change=
"(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !createForm.copy_accounts_from_group_ids.includes(val)) {
createForm.copy_accounts_from_group_ids.push(val)
@
change=
"
(e) => {
const val = Number((e.target as HTMLSelectElement).value);
if (
val &&
!createForm.copy_accounts_from_group_ids.includes(val)
) {
createForm.copy_accounts_from_group_ids.push(val);
}
(e.target as HTMLSelectElement).value = '';
}
(e.target as HTMLSelectElement).value = ''
}"
"
>
<option
value=
""
>
{{ t('admin.groups.copyAccounts.selectPlaceholder') }}
</option>
<option
value=
""
>
{{ t("admin.groups.copyAccounts.selectPlaceholder") }}
</option>
<option
v-for=
"opt in copyAccountsGroupOptions"
:key=
"opt.value"
:value=
"opt.value"
:disabled=
"createForm.copy_accounts_from_group_ids.includes(opt.value)"
:disabled=
"
createForm.copy_accounts_from_group_ids.includes(opt.value)
"
>
{{ opt.label }}
</option>
</select>
<p
class=
"input-hint"
>
{{ t(
'
admin.groups.copyAccounts.hint
'
) }}
</p>
<p
class=
"input-hint"
>
{{ t(
"
admin.groups.copyAccounts.hint
"
) }}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.form.rateMultiplier') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.form.rateMultiplier")
}}
</label>
<input
v-model.number=
"createForm.rate_multiplier"
type=
"number"
...
...
@@ -380,12 +489,15 @@
class=
"input"
data-tour=
"group-form-multiplier"
/>
<p
class=
"input-hint"
>
{{ t(
'
admin.groups.rateMultiplierHint
'
) }}
</p>
<p
class=
"input-hint"
>
{{ t(
"
admin.groups.rateMultiplierHint
"
) }}
</p>
</div>
<div
v-if=
"createForm.subscription_type !== 'subscription'"
data-tour=
"group-form-exclusive"
>
<div
v-if=
"createForm.subscription_type !== 'subscription'"
data-tour=
"group-form-exclusive"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.form.exclusive
'
) }}
{{ t(
"
admin.groups.form.exclusive
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -396,20 +508,32 @@
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<!-- Tooltip Popover -->
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"mb-2 text-xs font-medium"
>
{{ t('admin.groups.exclusiveTooltip.title') }}
</p>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"mb-2 text-xs font-medium"
>
{{ t("admin.groups.exclusiveTooltip.title") }}
</p>
<p
class=
"mb-2 text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.exclusiveTooltip.description
'
) }}
{{ t(
"
admin.groups.exclusiveTooltip.description
"
) }}
</p>
<div
class=
"rounded bg-gray-800 p-2 dark:bg-gray-700"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
<span
class=
"inline-flex items-center gap-1 text-primary-400"
><Icon
name=
"lightbulb"
size=
"xs"
/>
{{ t('admin.groups.exclusiveTooltip.example') }}
</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
<span
class=
"inline-flex items-center gap-1 text-primary-400"
><Icon
name=
"lightbulb"
size=
"xs"
/>
{{ t("admin.groups.exclusiveTooltip.example") }}
</span
>
{{ t("admin.groups.exclusiveTooltip.exampleContent") }}
</p>
</div>
<!-- Arrow -->
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -420,18 +544,24 @@
@
click=
"createForm.is_exclusive = !createForm.is_exclusive"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.is_exclusive
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
,
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
{{
createForm.is_exclusive
? t("admin.groups.exclusive")
: t("admin.groups.public")
}}
</span>
</div>
</div>
...
...
@@ -439,9 +569,16 @@
<!-- Subscription Configuration -->
<div
class=
"mt-4 border-t pt-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.type') }}
</label>
<Select
v-model=
"createForm.subscription_type"
:options=
"subscriptionTypeOptions"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.subscription.typeHint') }}
</p>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.type")
}}
</label>
<Select
v-model=
"createForm.subscription_type"
:options=
"subscriptionTypeOptions"
/>
<p
class=
"input-hint"
>
{{ t("admin.groups.subscription.typeHint") }}
</p>
</div>
<!-- Subscription limits (only show when subscription type is selected) -->
...
...
@@ -450,7 +587,9 @@
class=
"space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.dailyLimit') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.dailyLimit")
}}
</label>
<input
v-model.number=
"createForm.daily_limit_usd"
type=
"number"
...
...
@@ -461,7 +600,9 @@
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.weeklyLimit') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.weeklyLimit")
}}
</label>
<input
v-model.number=
"createForm.weekly_limit_usd"
type=
"number"
...
...
@@ -472,7 +613,9 @@
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.monthlyLimit') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.monthlyLimit")
}}
</label>
<input
v-model.number=
"createForm.monthly_limit_usd"
type=
"number"
...
...
@@ -486,12 +629,20 @@
</div>
<!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
<div
v-if=
"createForm.platform === 'antigravity' || createForm.platform === 'gemini'"
class=
"border-t pt-4"
>
<label
class=
"block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.imagePricing.title') }}
<div
v-if=
"
createForm.platform === 'antigravity' ||
createForm.platform === 'gemini'
"
class=
"border-t pt-4"
>
<label
class=
"block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.groups.imagePricing.title") }}
</label>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t(
'
admin.groups.imagePricing.description
'
) }}
{{ t(
"
admin.groups.imagePricing.description
"
) }}
</p>
<div
class=
"grid grid-cols-3 gap-3"
>
<div>
...
...
@@ -530,13 +681,11 @@
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div
v-if=
"createForm.platform === 'antigravity'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.supportedScopes.title
'
) }}
{{ t(
"
admin.groups.supportedScopes.title
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -546,12 +695,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.supportedScopes.tooltip
'
) }}
{{ t(
"
admin.groups.supportedScopes.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -564,35 +719,47 @@
@
change=
"toggleCreateScope('claude')"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.supportedScopes.claude') }}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t("admin.groups.supportedScopes.claude")
}}
</span>
</label>
<label
class=
"flex items-center gap-2 cursor-pointer"
>
<input
type=
"checkbox"
:checked=
"createForm.supported_model_scopes.includes('gemini_text')"
:checked=
"
createForm.supported_model_scopes.includes('gemini_text')
"
@
change=
"toggleCreateScope('gemini_text')"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.supportedScopes.geminiText') }}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t("admin.groups.supportedScopes.geminiText")
}}
</span>
</label>
<label
class=
"flex items-center gap-2 cursor-pointer"
>
<input
type=
"checkbox"
:checked=
"createForm.supported_model_scopes.includes('gemini_image')"
:checked=
"
createForm.supported_model_scopes.includes('gemini_image')
"
@
change=
"toggleCreateScope('gemini_image')"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.supportedScopes.geminiImage') }}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t("admin.groups.supportedScopes.geminiImage")
}}
</span>
</label>
</div>
<p
class=
"mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.groups.supportedScopes.hint') }}
</p>
<p
class=
"mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.supportedScopes.hint") }}
</p>
</div>
<!-- MCP XML 协议注入(仅 antigravity 平台) -->
<div
v-if=
"createForm.platform === 'antigravity'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.mcpXml.title
'
) }}
{{ t(
"
admin.groups.mcpXml.title
"
) }}
</label>
<div
class=
"group relative inline-flex"
>
<Icon
...
...
@@ -601,12 +768,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.mcpXml.tooltip
'
) }}
{{ t(
"
admin.groups.mcpXml.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -617,18 +790,24 @@
@
click=
"createForm.mcp_xml_inject = !createForm.mcp_xml_inject"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.mcp_xml_inject ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.mcp_xml_inject
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
,
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ createForm.mcp_xml_inject ? t('admin.groups.mcpXml.enabled') : t('admin.groups.mcpXml.disabled') }}
{{
createForm.mcp_xml_inject
? t("admin.groups.mcpXml.enabled")
: t("admin.groups.mcpXml.disabled")
}}
</span>
</div>
</div>
...
...
@@ -637,7 +816,7 @@
<div
v-if=
"createForm.platform === 'anthropic'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.claudeCode.title
'
) }}
{{ t(
"
admin.groups.claudeCode.title
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -647,12 +826,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.claudeCode.tooltip
'
) }}
{{ t(
"
admin.groups.claudeCode.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -660,97 +845,321 @@
<div
class=
"flex items-center gap-3"
>
<button
type=
"button"
@
click=
"createForm.claude_code_only = !createForm.claude_code_only"
@
click=
"
createForm.claude_code_only = !createForm.claude_code_only
"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.claude_code_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.claude_code_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
createForm.claude_code_only
? 'translate-x-6'
: 'translate-x-1',
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ createForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
{{
createForm.claude_code_only
? t("admin.groups.claudeCode.enabled")
: t("admin.groups.claudeCode.disabled")
}}
</span>
</div>
<!-- 降级分组选择(仅当启用 claude_code_only 时显示) -->
<div
v-if=
"createForm.claude_code_only"
class=
"mt-3"
>
<label
class=
"input-label"
>
{{ t('admin.groups.claudeCode.fallbackGroup') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.claudeCode.fallbackGroup")
}}
</label>
<Select
v-model=
"createForm.fallback_group_id"
:options=
"fallbackGroupOptions"
:placeholder=
"t('admin.groups.claudeCode.noFallback')"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.claudeCode.fallbackHint') }}
</p>
<p
class=
"input-hint"
>
{{ t("admin.groups.claudeCode.fallbackHint") }}
</p>
</div>
</div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div
v-if=
"createForm.platform === 'openai'"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
{{ t('admin.groups.openaiMessages.title') }}
</h4>
<div
v-if=
"createForm.platform === 'openai'"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
{{ t("admin.groups.openaiMessages.title") }}
</h4>
<!-- 允许 Messages 调度开关 -->
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{ t('admin.groups.openaiMessages.allowDispatch') }}
</label>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
t("admin.groups.openaiMessages.allowDispatch")
}}
</label>
<button
type=
"button"
@
click=
"createForm.allow_messages_dispatch = !createForm.allow_messages_dispatch"
@
click=
"
createForm.allow_messages_dispatch =
!createForm.allow_messages_dispatch
"
class=
"relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class=
"
createForm.allow_messages_dispatch ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.allow_messages_dispatch
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<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"
:class=
"
createForm.allow_messages_dispatch ? 'translate-x-6' : 'translate-x-1'
createForm.allow_messages_dispatch
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{ t('admin.groups.openaiMessages.allowDispatchHint') }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{ t("admin.groups.openaiMessages.allowDispatchHint") }}
</p>
<!-- 默认映射模型(仅当开关打开时显示) -->
<div
v-if=
"createForm.allow_messages_dispatch"
class=
"mt-3"
>
<label
class=
"input-label"
>
{{ t('admin.groups.openaiMessages.defaultModel') }}
</label>
<input
v-model=
"createForm.default_mapped_model"
type=
"text"
:placeholder=
"t('admin.groups.openaiMessages.defaultModelPlaceholder')"
class=
"input"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.openaiMessages.defaultModelHint') }}
</p>
<div
class=
"relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"border-b border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-700/50"
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"h-2 w-2 rounded-full bg-blue-500"
></div>
<label
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t("admin.groups.openaiMessages.familyMappingTitle")
}}
</label
>
</div>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.openaiMessages.familyMappingHint") }}
</p>
</div>
<div
class=
"p-4"
>
<div
class=
"grid gap-4 md:grid-cols-3"
>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.opusModel")
}}
</label>
<input
v-model=
"createForm.opus_mapped_model"
type=
"text"
:placeholder=
"
t('admin.groups.openaiMessages.opusModelPlaceholder')
"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.sonnetModel")
}}
</label>
<input
v-model=
"createForm.sonnet_mapped_model"
type=
"text"
:placeholder=
"
t('admin.groups.openaiMessages.sonnetModelPlaceholder')
"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.haikuModel")
}}
</label>
<input
v-model=
"createForm.haiku_mapped_model"
type=
"text"
:placeholder=
"
t('admin.groups.openaiMessages.haikuModelPlaceholder')
"
class=
"input"
/>
</div>
</div>
</div>
</div>
<div
class=
"mt-5 relative overflow-hidden rounded-xl border border-primary-200 bg-white shadow-sm dark:border-primary-900/50 dark:bg-dark-800"
>
<div
class=
"border-b border-primary-100 bg-primary-50/80 px-4 py-3 dark:border-primary-900/40 dark:bg-primary-900/20"
>
<div
class=
"flex items-start justify-between gap-3"
>
<div>
<div
class=
"flex items-center gap-2"
>
<div
class=
"h-2 w-2 rounded-full bg-primary-500"
></div>
<label
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
{{
t("admin.groups.openaiMessages.exactMappingTitle")
}}
</label
>
</div>
<p
class=
"mt-1 text-xs text-primary-600/90 dark:text-primary-400/90"
>
{{ t("admin.groups.openaiMessages.exactMappingHint") }}
</p>
</div>
</div>
</div>
<div
class=
"p-4 bg-gray-50/30 dark:bg-dark-800/30"
>
<div
v-if=
"createForm.exact_model_mappings.length === 0"
class=
"flex items-center justify-between gap-3 rounded-xl border-2 border-dashed border-primary-200 bg-white px-5 py-4 text-sm text-primary-700 transition-colors hover:border-primary-300 dark:border-primary-900/40 dark:bg-dark-800 dark:text-primary-300 dark:hover:border-primary-800"
>
<span>
{{
t("admin.groups.openaiMessages.noExactMappings")
}}
</span>
<button
type=
"button"
@
click=
"addCreateMessagesDispatchMapping"
class=
"flex items-center gap-1.5 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon
name=
"plus"
size=
"sm"
/>
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"row in createForm.exact_model_mappings"
:key=
"getCreateMessagesDispatchRowKey(row)"
class=
"group relative rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-primary-300 hover:shadow-md dark:border-dark-600 dark:bg-dark-700 dark:hover:border-primary-700"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"grid flex-1 gap-4 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-start"
>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.claudeModel")
}}
</label>
<input
v-model=
"row.claude_model"
type=
"text"
:placeholder=
"
t(
'admin.groups.openaiMessages.claudeModelPlaceholder',
)
"
class=
"input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
<div
class=
"hidden md:flex md:justify-center md:pt-7 text-primary-300 dark:text-primary-700"
>
<Icon
name=
"arrowRight"
size=
"sm"
class=
"transition-transform group-hover:translate-x-1"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.targetModel")
}}
</label>
<input
v-model=
"row.target_model"
type=
"text"
:placeholder=
"
t(
'admin.groups.openaiMessages.targetModelPlaceholder',
)
"
class=
"input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
</div>
<button
type=
"button"
@
click=
"removeCreateMessagesDispatchMapping(row)"
class=
"mt-6 flex h-9 w-9 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"
t('admin.groups.openaiMessages.removeExactMapping')
"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</div>
</div>
<button
type=
"button"
@
click=
"addCreateMessagesDispatchMapping"
class=
"flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-gray-300 bg-white py-3 text-sm font-medium text-gray-500 transition-all hover:border-primary-300 hover:bg-primary-50/50 hover:text-primary-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-primary-800 dark:hover:bg-primary-900/20 dark:hover:text-primary-400"
>
<Icon
name=
"plus"
size=
"sm"
/>
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 账号过滤控制 (OpenAI/Antigravity/Anthropic/Gemini) -->
<div
v-if=
"['openai', 'antigravity', 'anthropic', 'gemini'].includes(createForm.platform)"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
账号过滤控制
</h4>
<div
v-if=
"
['openai', 'antigravity', 'anthropic', 'gemini'].includes(
createForm.platform,
)
"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
账号过滤控制
</h4>
<!-- require_oauth_only toggle -->
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许 OAuth 账号
</label>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许 OAuth 账号
</label
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-0.5"
>
{{ createForm.require_oauth_only ? '已启用 — 排除 API Key 类型账号' : '未启用' }}
{{
createForm.require_oauth_only
? "已启用 — 排除 API Key 类型账号"
: "未启用"
}}
</p>
</div>
<button
type=
"button"
@
click=
"createForm.require_oauth_only = !createForm.require_oauth_only"
@
click=
"
createForm.require_oauth_only = !createForm.require_oauth_only
"
class=
"relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class=
"
createForm.require_oauth_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.require_oauth_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<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"
:class=
"
createForm.require_oauth_only ? 'translate-x-6' : 'translate-x-1'
createForm.require_oauth_only
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
...
...
@@ -759,23 +1168,35 @@
<!-- require_privacy_set toggle -->
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许隐私保护已设置的账号
</label>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许隐私保护已设置的账号
</label
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-0.5"
>
{{ createForm.require_privacy_set ? '已启用 — Privacy 未设置的账号将被排除' : '未启用' }}
{{
createForm.require_privacy_set
? "已启用 — Privacy 未设置的账号将被排除"
: "未启用"
}}
</p>
</div>
<button
type=
"button"
@
click=
"createForm.require_privacy_set = !createForm.require_privacy_set"
@
click=
"
createForm.require_privacy_set = !createForm.require_privacy_set
"
class=
"relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class=
"
createForm.require_privacy_set ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.require_privacy_set
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<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"
:class=
"
createForm.require_privacy_set ? 'translate-x-6' : 'translate-x-1'
createForm.require_privacy_set
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
...
...
@@ -784,23 +1205,30 @@
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div
v-if=
"['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'"
v-if=
"
['anthropic', 'antigravity'].includes(createForm.platform) &&
createForm.subscription_type !== 'subscription'
"
class=
"border-t pt-4"
>
<label
class=
"input-label"
>
{{ t('admin.groups.invalidRequestFallback.title') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.invalidRequestFallback.title")
}}
</label>
<Select
v-model=
"createForm.fallback_group_id_on_invalid_request"
:options=
"invalidRequestFallbackOptions"
:placeholder=
"t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.invalidRequestFallback.hint') }}
</p>
<p
class=
"input-hint"
>
{{ t("admin.groups.invalidRequestFallback.hint") }}
</p>
</div>
<!-- 模型路由配置(仅 anthropic 平台) -->
<div
v-if=
"createForm.platform === 'anthropic'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.modelRouting.title
'
) }}
{{ t(
"
admin.groups.modelRouting.title
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -810,12 +1238,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.modelRouting.tooltip
'
) }}
{{ t(
"
admin.groups.modelRouting.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -824,28 +1258,42 @@
<div
class=
"flex items-center gap-3 mb-3"
>
<button
type=
"button"
@
click=
"createForm.model_routing_enabled = !createForm.model_routing_enabled"
@
click=
"
createForm.model_routing_enabled =
!createForm.model_routing_enabled
"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.model_routing_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
createForm.model_routing_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1'
createForm.model_routing_enabled
? 'translate-x-6'
: 'translate-x-1',
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ createForm.model_routing_enabled ? t('admin.groups.modelRouting.enabled') : t('admin.groups.modelRouting.disabled') }}
{{
createForm.model_routing_enabled
? t("admin.groups.modelRouting.enabled")
: t("admin.groups.modelRouting.disabled")
}}
</span>
</div>
<p
v-if=
"!createForm.model_routing_enabled"
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t('admin.groups.modelRouting.disabledHint') }}
<p
v-if=
"!createForm.model_routing_enabled"
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t("admin.groups.modelRouting.disabledHint") }}
</p>
<p
v-else
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t(
'
admin.groups.modelRouting.noRulesHint
'
) }}
{{ t(
"
admin.groups.modelRouting.noRulesHint
"
) }}
</p>
<!-- 路由规则列表(仅在启用时显示) -->
<div
v-if=
"createForm.model_routing_enabled"
class=
"space-y-3"
>
...
...
@@ -857,18 +1305,27 @@
<div
class=
"flex items-start gap-3"
>
<div
class=
"flex-1 space-y-2"
>
<div>
<label
class=
"input-label text-xs"
>
{{ t('admin.groups.modelRouting.modelPattern') }}
</label>
<label
class=
"input-label text-xs"
>
{{
t("admin.groups.modelRouting.modelPattern")
}}
</label>
<input
v-model=
"rule.pattern"
type=
"text"
class=
"input text-sm"
:placeholder=
"t('admin.groups.modelRouting.modelPatternPlaceholder')"
:placeholder=
"
t('admin.groups.modelRouting.modelPatternPlaceholder')
"
/>
</div>
<div>
<label
class=
"input-label text-xs"
>
{{ t('admin.groups.modelRouting.accounts') }}
</label>
<label
class=
"input-label text-xs"
>
{{
t("admin.groups.modelRouting.accounts")
}}
</label>
<!-- 已选账号标签 -->
<div
v-if=
"rule.accounts.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<div
v-if=
"rule.accounts.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<span
v-for=
"account in rule.accounts"
:key=
"account.id"
...
...
@@ -887,33 +1344,55 @@
<!-- 账号搜索输入框 -->
<div
class=
"relative account-search-container"
>
<input
v-model=
"accountSearchKeyword[getCreateRuleSearchKey(rule)]"
v-model=
"
accountSearchKeyword[getCreateRuleSearchKey(rule)]
"
type=
"text"
class=
"input text-sm"
:placeholder=
"t('admin.groups.modelRouting.searchAccountPlaceholder')"
:placeholder=
"
t(
'admin.groups.modelRouting.searchAccountPlaceholder',
)
"
@
input=
"searchAccountsByRule(rule)"
@
focus=
"onAccountSearchFocus(rule)"
/>
<!-- 搜索结果下拉框 -->
<div
v-if=
"showAccountDropdown[getCreateRuleSearchKey(rule)] && accountSearchResults[getCreateRuleSearchKey(rule)]?.length > 0"
v-if=
"
showAccountDropdown[getCreateRuleSearchKey(rule)] &&
accountSearchResults[getCreateRuleSearchKey(rule)]
?.length > 0
"
class=
"absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for=
"account in accountSearchResults[getCreateRuleSearchKey(rule)]"
v-for=
"account in accountSearchResults[
getCreateRuleSearchKey(rule)
]"
:key=
"account.id"
type=
"button"
@
click=
"selectAccount(rule, account)"
class=
"w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
:disabled=
"rule.accounts.some(a => a.id === account.id)"
:class=
"{
'opacity-50': rule.accounts.some(
(a) => a.id === account.id,
),
}"
:disabled=
"
rule.accounts.some((a) => a.id === account.id)
"
>
<span>
{{ account.name }}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#{{ account.id }}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#{{ account.id }}
</span
>
</button>
</div>
</div>
<p
class=
"text-xs text-gray-400 mt-1"
>
{{ t('admin.groups.modelRouting.accountsHint') }}
</p>
<p
class=
"text-xs text-gray-400 mt-1"
>
{{ t("admin.groups.modelRouting.accountsHint") }}
</p>
</div>
</div>
<button
...
...
@@ -935,16 +1414,19 @@
class=
"mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon
name=
"plus"
size=
"sm"
/>
{{ t(
'
admin.groups.modelRouting.addRule
'
) }}
{{ t(
"
admin.groups.modelRouting.addRule
"
) }}
</button>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3 pt-4"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
"
common.cancel
"
)
}}
</button>
<button
type=
"submit"
...
...
@@ -973,7 +1455,7 @@
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.groups.creating
'
)
:
t
(
'
common.create
'
)
}}
{{
submitting
?
t
(
"
admin.groups.creating
"
)
:
t
(
"
common.create
"
)
}}
</button>
</div>
</
template
>
...
...
@@ -993,7 +1475,7 @@
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t(
'
admin.groups.form.name
'
) }}
</label>
<label
class=
"input-label"
>
{{ t(
"
admin.groups.form.name
"
) }}
</label>
<input
v-model=
"editForm.name"
type=
"text"
...
...
@@ -1003,24 +1485,32 @@
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.form.description') }}
</label>
<textarea
v-model=
"editForm.description"
rows=
"3"
class=
"input"
></textarea>
<label
class=
"input-label"
>
{{
t("admin.groups.form.description")
}}
</label>
<textarea
v-model=
"editForm.description"
rows=
"3"
class=
"input"
></textarea>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.form.platform') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.form.platform")
}}
</label>
<Select
v-model=
"editForm.platform"
:options=
"platformOptions"
:disabled=
"true"
data-tour=
"group-form-platform"
/>
<p
class=
"input-hint"
>
{{ t(
'
admin.groups.platformNotEditable
'
) }}
</p>
<p
class=
"input-hint"
>
{{ t(
"
admin.groups.platformNotEditable
"
) }}
</p>
</div>
<!-- 从分组复制账号(编辑时) -->
<div
v-if=
"copyAccountsGroupOptionsForEdit.length > 0"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.copyAccounts.title
'
) }}
{{ t(
"
admin.groups.copyAccounts.title
"
) }}
</label>
<div
class=
"group relative inline-flex"
>
<Icon
...
...
@@ -1029,27 +1519,44 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.copyAccounts.tooltipEdit
'
) }}
{{ t(
"
admin.groups.copyAccounts.tooltipEdit
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div
v-if=
"editForm.copy_accounts_from_group_ids.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<div
v-if=
"editForm.copy_accounts_from_group_ids.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<span
v-for=
"groupId in editForm.copy_accounts_from_group_ids"
:key=
"groupId"
class=
"inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptionsForEdit.find(o => o.value === groupId)?.label || `#${groupId}` }}
{{
copyAccountsGroupOptionsForEdit.find((o) => o.value === groupId)
?.label || `#${groupId}`
}}
<button
type=
"button"
@
click=
"editForm.copy_accounts_from_group_ids = editForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
@
click=
"
editForm.copy_accounts_from_group_ids =
editForm.copy_accounts_from_group_ids.filter(
(id) => id !== groupId,
)
"
class=
"ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon
name=
"x"
size=
"xs"
/>
...
...
@@ -1059,28 +1566,41 @@
<!-- 分组选择下拉 -->
<select
class=
"input"
@
change=
"(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !editForm.copy_accounts_from_group_ids.includes(val)) {
editForm.copy_accounts_from_group_ids.push(val)
@
change=
"
(e) => {
const val = Number((e.target as HTMLSelectElement).value);
if (
val &&
!editForm.copy_accounts_from_group_ids.includes(val)
) {
editForm.copy_accounts_from_group_ids.push(val);
}
(e.target as HTMLSelectElement).value = '';
}
(e.target as HTMLSelectElement).value = ''
}"
"
>
<option
value=
""
>
{{ t('admin.groups.copyAccounts.selectPlaceholder') }}
</option>
<option
value=
""
>
{{ t("admin.groups.copyAccounts.selectPlaceholder") }}
</option>
<option
v-for=
"opt in copyAccountsGroupOptionsForEdit"
:key=
"opt.value"
:value=
"opt.value"
:disabled=
"editForm.copy_accounts_from_group_ids.includes(opt.value)"
:disabled=
"
editForm.copy_accounts_from_group_ids.includes(opt.value)
"
>
{{ opt.label }}
</option>
</select>
<p
class=
"input-hint"
>
{{ t('admin.groups.copyAccounts.hintEdit') }}
</p>
<p
class=
"input-hint"
>
{{ t("admin.groups.copyAccounts.hintEdit") }}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.form.rateMultiplier') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.form.rateMultiplier")
}}
</label>
<input
v-model.number=
"editForm.rate_multiplier"
type=
"number"
...
...
@@ -1094,7 +1614,7 @@
<div
v-if=
"editForm.subscription_type !== 'subscription'"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.form.exclusive
'
) }}
{{ t(
"
admin.groups.form.exclusive
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -1105,20 +1625,32 @@
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<!-- Tooltip Popover -->
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"mb-2 text-xs font-medium"
>
{{ t('admin.groups.exclusiveTooltip.title') }}
</p>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"mb-2 text-xs font-medium"
>
{{ t("admin.groups.exclusiveTooltip.title") }}
</p>
<p
class=
"mb-2 text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.exclusiveTooltip.description
'
) }}
{{ t(
"
admin.groups.exclusiveTooltip.description
"
) }}
</p>
<div
class=
"rounded bg-gray-800 p-2 dark:bg-gray-700"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
<span
class=
"inline-flex items-center gap-1 text-primary-400"
><Icon
name=
"lightbulb"
size=
"xs"
/>
{{ t('admin.groups.exclusiveTooltip.example') }}
</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
<span
class=
"inline-flex items-center gap-1 text-primary-400"
><Icon
name=
"lightbulb"
size=
"xs"
/>
{{ t("admin.groups.exclusiveTooltip.example") }}
</span
>
{{ t("admin.groups.exclusiveTooltip.exampleContent") }}
</p>
</div>
<!-- Arrow -->
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -1129,36 +1661,46 @@
@
click=
"editForm.is_exclusive = !editForm.is_exclusive"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.is_exclusive
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
,
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ editForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
{{
editForm.is_exclusive
? t("admin.groups.exclusive")
: t("admin.groups.public")
}}
</span>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{ t(
'
admin.groups.form.status
'
) }}
</label>
<label
class=
"input-label"
>
{{ t(
"
admin.groups.form.status
"
) }}
</label>
<Select
v-model=
"editForm.status"
:options=
"editStatusOptions"
/>
</div>
<!-- Subscription Configuration -->
<div
class=
"mt-4 border-t pt-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.type') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.type")
}}
</label>
<Select
v-model=
"editForm.subscription_type"
:options=
"subscriptionTypeOptions"
:disabled=
"true"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.subscription.typeNotEditable') }}
</p>
<p
class=
"input-hint"
>
{{ t("admin.groups.subscription.typeNotEditable") }}
</p>
</div>
<!-- Subscription limits (only show when subscription type is selected) -->
...
...
@@ -1167,7 +1709,9 @@
class=
"space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.dailyLimit') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.dailyLimit")
}}
</label>
<input
v-model.number=
"editForm.daily_limit_usd"
type=
"number"
...
...
@@ -1178,7 +1722,9 @@
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.weeklyLimit') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.weeklyLimit")
}}
</label>
<input
v-model.number=
"editForm.weekly_limit_usd"
type=
"number"
...
...
@@ -1189,7 +1735,9 @@
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.subscription.monthlyLimit') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.subscription.monthlyLimit")
}}
</label>
<input
v-model.number=
"editForm.monthly_limit_usd"
type=
"number"
...
...
@@ -1203,12 +1751,20 @@
</div>
<!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
<div
v-if=
"editForm.platform === 'antigravity' || editForm.platform === 'gemini'"
class=
"border-t pt-4"
>
<label
class=
"block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.imagePricing.title') }}
<div
v-if=
"
editForm.platform === 'antigravity' ||
editForm.platform === 'gemini'
"
class=
"border-t pt-4"
>
<label
class=
"block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.groups.imagePricing.title") }}
</label>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t(
'
admin.groups.imagePricing.description
'
) }}
{{ t(
"
admin.groups.imagePricing.description
"
) }}
</p>
<div
class=
"grid grid-cols-3 gap-3"
>
<div>
...
...
@@ -1247,13 +1803,11 @@
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div
v-if=
"editForm.platform === 'antigravity'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.supportedScopes.title
'
) }}
{{ t(
"
admin.groups.supportedScopes.title
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -1263,12 +1817,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.supportedScopes.tooltip
'
) }}
{{ t(
"
admin.groups.supportedScopes.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -1281,35 +1841,47 @@
@
change=
"toggleEditScope('claude')"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.supportedScopes.claude') }}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t("admin.groups.supportedScopes.claude")
}}
</span>
</label>
<label
class=
"flex items-center gap-2 cursor-pointer"
>
<input
type=
"checkbox"
:checked=
"editForm.supported_model_scopes.includes('gemini_text')"
:checked=
"
editForm.supported_model_scopes.includes('gemini_text')
"
@
change=
"toggleEditScope('gemini_text')"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.supportedScopes.geminiText') }}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t("admin.groups.supportedScopes.geminiText")
}}
</span>
</label>
<label
class=
"flex items-center gap-2 cursor-pointer"
>
<input
type=
"checkbox"
:checked=
"editForm.supported_model_scopes.includes('gemini_image')"
:checked=
"
editForm.supported_model_scopes.includes('gemini_image')
"
@
change=
"toggleEditScope('gemini_image')"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.supportedScopes.geminiImage') }}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t("admin.groups.supportedScopes.geminiImage")
}}
</span>
</label>
</div>
<p
class=
"mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.groups.supportedScopes.hint') }}
</p>
<p
class=
"mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.supportedScopes.hint") }}
</p>
</div>
<!-- MCP XML 协议注入(仅 antigravity 平台) -->
<div
v-if=
"editForm.platform === 'antigravity'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.mcpXml.title
'
) }}
{{ t(
"
admin.groups.mcpXml.title
"
) }}
</label>
<div
class=
"group relative inline-flex"
>
<Icon
...
...
@@ -1318,12 +1890,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.mcpXml.tooltip
'
) }}
{{ t(
"
admin.groups.mcpXml.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -1334,18 +1912,24 @@
@
click=
"editForm.mcp_xml_inject = !editForm.mcp_xml_inject"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.mcp_xml_inject ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.mcp_xml_inject
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
,
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ editForm.mcp_xml_inject ? t('admin.groups.mcpXml.enabled') : t('admin.groups.mcpXml.disabled') }}
{{
editForm.mcp_xml_inject
? t("admin.groups.mcpXml.enabled")
: t("admin.groups.mcpXml.disabled")
}}
</span>
</div>
</div>
...
...
@@ -1354,7 +1938,7 @@
<div
v-if=
"editForm.platform === 'anthropic'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.claudeCode.title
'
) }}
{{ t(
"
admin.groups.claudeCode.title
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -1364,12 +1948,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.claudeCode.tooltip
'
) }}
{{ t(
"
admin.groups.claudeCode.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -1380,94 +1970,314 @@
@
click=
"editForm.claude_code_only = !editForm.claude_code_only"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.claude_code_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.claude_code_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
,
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ editForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
{{
editForm.claude_code_only
? t("admin.groups.claudeCode.enabled")
: t("admin.groups.claudeCode.disabled")
}}
</span>
</div>
<!-- 降级分组选择(仅当启用 claude_code_only 时显示) -->
<div
v-if=
"editForm.claude_code_only"
class=
"mt-3"
>
<label
class=
"input-label"
>
{{ t('admin.groups.claudeCode.fallbackGroup') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.claudeCode.fallbackGroup")
}}
</label>
<Select
v-model=
"editForm.fallback_group_id"
:options=
"fallbackGroupOptionsForEdit"
:placeholder=
"t('admin.groups.claudeCode.noFallback')"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.claudeCode.fallbackHint') }}
</p>
<p
class=
"input-hint"
>
{{ t("admin.groups.claudeCode.fallbackHint") }}
</p>
</div>
</div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div
v-if=
"editForm.platform === 'openai'"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
{{ t('admin.groups.openaiMessages.title') }}
</h4>
<div
v-if=
"editForm.platform === 'openai'"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
{{ t("admin.groups.openaiMessages.title") }}
</h4>
<!-- 允许 Messages 调度开关 -->
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{ t('admin.groups.openaiMessages.allowDispatch') }}
</label>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
t("admin.groups.openaiMessages.allowDispatch")
}}
</label>
<button
type=
"button"
@
click=
"editForm.allow_messages_dispatch = !editForm.allow_messages_dispatch"
@
click=
"
editForm.allow_messages_dispatch =
!editForm.allow_messages_dispatch
"
class=
"relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class=
"
editForm.allow_messages_dispatch ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.allow_messages_dispatch
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<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"
:class=
"
editForm.allow_messages_dispatch ? 'translate-x-6' : 'translate-x-1'
editForm.allow_messages_dispatch
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{ t('admin.groups.openaiMessages.allowDispatchHint') }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{ t("admin.groups.openaiMessages.allowDispatchHint") }}
</p>
<!-- 默认映射模型(仅当开关打开时显示) -->
<div
v-if=
"editForm.allow_messages_dispatch"
class=
"mt-3"
>
<label
class=
"input-label"
>
{{ t('admin.groups.openaiMessages.defaultModel') }}
</label>
<input
v-model=
"editForm.default_mapped_model"
type=
"text"
:placeholder=
"t('admin.groups.openaiMessages.defaultModelPlaceholder')"
class=
"input"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.openaiMessages.defaultModelHint') }}
</p>
<div
class=
"relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"border-b border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-700/50"
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"h-2 w-2 rounded-full bg-blue-500"
></div>
<label
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t("admin.groups.openaiMessages.familyMappingTitle")
}}
</label
>
</div>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.openaiMessages.familyMappingHint") }}
</p>
</div>
<div
class=
"p-4"
>
<div
class=
"grid gap-4 md:grid-cols-3"
>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.opusModel")
}}
</label>
<input
v-model=
"editForm.opus_mapped_model"
type=
"text"
:placeholder=
"
t('admin.groups.openaiMessages.opusModelPlaceholder')
"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.sonnetModel")
}}
</label>
<input
v-model=
"editForm.sonnet_mapped_model"
type=
"text"
:placeholder=
"
t('admin.groups.openaiMessages.sonnetModelPlaceholder')
"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.haikuModel")
}}
</label>
<input
v-model=
"editForm.haiku_mapped_model"
type=
"text"
:placeholder=
"
t('admin.groups.openaiMessages.haikuModelPlaceholder')
"
class=
"input"
/>
</div>
</div>
</div>
</div>
<div
class=
"mt-5 relative overflow-hidden rounded-xl border border-primary-200 bg-white shadow-sm dark:border-primary-900/50 dark:bg-dark-800"
>
<div
class=
"border-b border-primary-100 bg-primary-50/80 px-4 py-3 dark:border-primary-900/40 dark:bg-primary-900/20"
>
<div
class=
"flex items-start justify-between gap-3"
>
<div>
<div
class=
"flex items-center gap-2"
>
<div
class=
"h-2 w-2 rounded-full bg-primary-500"
></div>
<label
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
{{
t("admin.groups.openaiMessages.exactMappingTitle")
}}
</label
>
</div>
<p
class=
"mt-1 text-xs text-primary-600/90 dark:text-primary-400/90"
>
{{ t("admin.groups.openaiMessages.exactMappingHint") }}
</p>
</div>
</div>
</div>
<div
class=
"p-4 bg-gray-50/30 dark:bg-dark-800/30"
>
<div
v-if=
"editForm.exact_model_mappings.length === 0"
class=
"flex items-center justify-between gap-3 rounded-xl border-2 border-dashed border-primary-200 bg-white px-5 py-4 text-sm text-primary-700 transition-colors hover:border-primary-300 dark:border-primary-900/40 dark:bg-dark-800 dark:text-primary-300 dark:hover:border-primary-800"
>
<span>
{{
t("admin.groups.openaiMessages.noExactMappings")
}}
</span>
<button
type=
"button"
@
click=
"addEditMessagesDispatchMapping"
class=
"flex items-center gap-1.5 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon
name=
"plus"
size=
"sm"
/>
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"row in editForm.exact_model_mappings"
:key=
"getEditMessagesDispatchRowKey(row)"
class=
"group relative rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-primary-300 hover:shadow-md dark:border-dark-600 dark:bg-dark-700 dark:hover:border-primary-700"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"grid flex-1 gap-4 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-start"
>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.claudeModel")
}}
</label>
<input
v-model=
"row.claude_model"
type=
"text"
:placeholder=
"
t(
'admin.groups.openaiMessages.claudeModelPlaceholder',
)
"
class=
"input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
<div
class=
"hidden md:flex md:justify-center md:pt-7 text-primary-300 dark:text-primary-700"
>
<Icon
name=
"arrowRight"
size=
"sm"
class=
"transition-transform group-hover:translate-x-1"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t("admin.groups.openaiMessages.targetModel")
}}
</label>
<input
v-model=
"row.target_model"
type=
"text"
:placeholder=
"
t(
'admin.groups.openaiMessages.targetModelPlaceholder',
)
"
class=
"input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
</div>
<button
type=
"button"
@
click=
"removeEditMessagesDispatchMapping(row)"
class=
"mt-6 flex h-9 w-9 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"
t('admin.groups.openaiMessages.removeExactMapping')
"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</div>
</div>
<button
type=
"button"
@
click=
"addEditMessagesDispatchMapping"
class=
"flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-gray-300 bg-white py-3 text-sm font-medium text-gray-500 transition-all hover:border-primary-300 hover:bg-primary-50/50 hover:text-primary-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-primary-800 dark:hover:bg-primary-900/20 dark:hover:text-primary-400"
>
<Icon
name=
"plus"
size=
"sm"
/>
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 账号过滤控制 (OpenAI/Antigravity/Anthropic/Gemini) -->
<div
v-if=
"['openai', 'antigravity', 'anthropic', 'gemini'].includes(editForm.platform)"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
账号过滤控制
</h4>
<div
v-if=
"
['openai', 'antigravity', 'anthropic', 'gemini'].includes(
editForm.platform,
)
"
class=
"border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
>
账号过滤控制
</h4>
<!-- require_oauth_only toggle -->
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许 OAuth 账号
</label>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许 OAuth 账号
</label
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-0.5"
>
{{ editForm.require_oauth_only ? '已启用 — 排除 API Key 类型账号' : '未启用' }}
{{
editForm.require_oauth_only
? "已启用 — 排除 API Key 类型账号"
: "未启用"
}}
</p>
</div>
<button
type=
"button"
@
click=
"editForm.require_oauth_only = !editForm.require_oauth_only"
@
click=
"
editForm.require_oauth_only = !editForm.require_oauth_only
"
class=
"relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class=
"
editForm.require_oauth_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.require_oauth_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<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"
:class=
"
editForm.require_oauth_only ? 'translate-x-6' : 'translate-x-1'
editForm.require_oauth_only
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
...
...
@@ -1476,23 +2286,35 @@
<!-- require_privacy_set toggle -->
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许隐私保护已设置的账号
</label>
<label
class=
"text-sm text-gray-600 dark:text-gray-400"
>
仅允许隐私保护已设置的账号
</label
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-0.5"
>
{{ editForm.require_privacy_set ? '已启用 — Privacy 未设置的账号将被排除' : '未启用' }}
{{
editForm.require_privacy_set
? "已启用 — Privacy 未设置的账号将被排除"
: "未启用"
}}
</p>
</div>
<button
type=
"button"
@
click=
"editForm.require_privacy_set = !editForm.require_privacy_set"
@
click=
"
editForm.require_privacy_set = !editForm.require_privacy_set
"
class=
"relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class=
"
editForm.require_privacy_set ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.require_privacy_set
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<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"
:class=
"
editForm.require_privacy_set ? 'translate-x-6' : 'translate-x-1'
editForm.require_privacy_set
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
...
...
@@ -1501,23 +2323,30 @@
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div
v-if=
"['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'"
v-if=
"
['anthropic', 'antigravity'].includes(editForm.platform) &&
editForm.subscription_type !== 'subscription'
"
class=
"border-t pt-4"
>
<label
class=
"input-label"
>
{{ t('admin.groups.invalidRequestFallback.title') }}
</label>
<label
class=
"input-label"
>
{{
t("admin.groups.invalidRequestFallback.title")
}}
</label>
<Select
v-model=
"editForm.fallback_group_id_on_invalid_request"
:options=
"invalidRequestFallbackOptionsForEdit"
:placeholder=
"t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p
class=
"input-hint"
>
{{ t('admin.groups.invalidRequestFallback.hint') }}
</p>
<p
class=
"input-hint"
>
{{ t("admin.groups.invalidRequestFallback.hint") }}
</p>
</div>
<!-- 模型路由配置(仅 anthropic 平台) -->
<div
v-if=
"editForm.platform === 'anthropic'"
class=
"border-t pt-4"
>
<div
class=
"mb-1.5 flex items-center gap-1"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t(
'
admin.groups.modelRouting.title
'
) }}
{{ t(
"
admin.groups.modelRouting.title
"
) }}
</label>
<!-- Help Tooltip -->
<div
class=
"group relative inline-flex"
>
...
...
@@ -1527,12 +2356,18 @@
:stroke-width=
"2"
class=
"cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<div
class=
"pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class=
"rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p
class=
"text-xs leading-relaxed text-gray-300"
>
{{ t(
'
admin.groups.modelRouting.tooltip
'
) }}
{{ t(
"
admin.groups.modelRouting.tooltip
"
) }}
</p>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
<div
class=
"absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -1541,28 +2376,41 @@
<div
class=
"flex items-center gap-3 mb-3"
>
<button
type=
"button"
@
click=
"editForm.model_routing_enabled = !editForm.model_routing_enabled"
@
click=
"
editForm.model_routing_enabled = !editForm.model_routing_enabled
"
:class=
"[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.model_routing_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
editForm.model_routing_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1'
editForm.model_routing_enabled
? 'translate-x-6'
: 'translate-x-1',
]"
/>
</button>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ editForm.model_routing_enabled ? t('admin.groups.modelRouting.enabled') : t('admin.groups.modelRouting.disabled') }}
{{
editForm.model_routing_enabled
? t("admin.groups.modelRouting.enabled")
: t("admin.groups.modelRouting.disabled")
}}
</span>
</div>
<p
v-if=
"!editForm.model_routing_enabled"
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t('admin.groups.modelRouting.disabledHint') }}
<p
v-if=
"!editForm.model_routing_enabled"
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t("admin.groups.modelRouting.disabledHint") }}
</p>
<p
v-else
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t(
'
admin.groups.modelRouting.noRulesHint
'
) }}
{{ t(
"
admin.groups.modelRouting.noRulesHint
"
) }}
</p>
<!-- 路由规则列表(仅在启用时显示) -->
<div
v-if=
"editForm.model_routing_enabled"
class=
"space-y-3"
>
...
...
@@ -1574,18 +2422,27 @@
<div
class=
"flex items-start gap-3"
>
<div
class=
"flex-1 space-y-2"
>
<div>
<label
class=
"input-label text-xs"
>
{{ t('admin.groups.modelRouting.modelPattern') }}
</label>
<label
class=
"input-label text-xs"
>
{{
t("admin.groups.modelRouting.modelPattern")
}}
</label>
<input
v-model=
"rule.pattern"
type=
"text"
class=
"input text-sm"
:placeholder=
"t('admin.groups.modelRouting.modelPatternPlaceholder')"
:placeholder=
"
t('admin.groups.modelRouting.modelPatternPlaceholder')
"
/>
</div>
<div>
<label
class=
"input-label text-xs"
>
{{ t('admin.groups.modelRouting.accounts') }}
</label>
<label
class=
"input-label text-xs"
>
{{
t("admin.groups.modelRouting.accounts")
}}
</label>
<!-- 已选账号标签 -->
<div
v-if=
"rule.accounts.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<div
v-if=
"rule.accounts.length > 0"
class=
"flex flex-wrap gap-1.5 mb-2"
>
<span
v-for=
"account in rule.accounts"
:key=
"account.id"
...
...
@@ -1604,33 +2461,55 @@
<!-- 账号搜索输入框 -->
<div
class=
"relative account-search-container"
>
<input
v-model=
"accountSearchKeyword[getEditRuleSearchKey(rule)]"
v-model=
"
accountSearchKeyword[getEditRuleSearchKey(rule)]
"
type=
"text"
class=
"input text-sm"
:placeholder=
"t('admin.groups.modelRouting.searchAccountPlaceholder')"
:placeholder=
"
t(
'admin.groups.modelRouting.searchAccountPlaceholder',
)
"
@
input=
"searchAccountsByRule(rule, true)"
@
focus=
"onAccountSearchFocus(rule, true)"
/>
<!-- 搜索结果下拉框 -->
<div
v-if=
"showAccountDropdown[getEditRuleSearchKey(rule)] && accountSearchResults[getEditRuleSearchKey(rule)]?.length > 0"
v-if=
"
showAccountDropdown[getEditRuleSearchKey(rule)] &&
accountSearchResults[getEditRuleSearchKey(rule)]
?.length > 0
"
class=
"absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for=
"account in accountSearchResults[getEditRuleSearchKey(rule)]"
v-for=
"account in accountSearchResults[
getEditRuleSearchKey(rule)
]"
:key=
"account.id"
type=
"button"
@
click=
"selectAccount(rule, account, true)"
class=
"w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
:disabled=
"rule.accounts.some(a => a.id === account.id)"
:class=
"{
'opacity-50': rule.accounts.some(
(a) => a.id === account.id,
),
}"
:disabled=
"
rule.accounts.some((a) => a.id === account.id)
"
>
<span>
{{ account.name }}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#{{ account.id }}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#{{ account.id }}
</span
>
</button>
</div>
</div>
<p
class=
"text-xs text-gray-400 mt-1"
>
{{ t('admin.groups.modelRouting.accountsHint') }}
</p>
<p
class=
"text-xs text-gray-400 mt-1"
>
{{ t("admin.groups.modelRouting.accountsHint") }}
</p>
</div>
</div>
<button
...
...
@@ -1652,16 +2531,19 @@
class=
"mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon
name=
"plus"
size=
"sm"
/>
{{ t(
'
admin.groups.modelRouting.addRule
'
) }}
{{ t(
"
admin.groups.modelRouting.addRule
"
) }}
</button>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3 pt-4"
>
<button
@
click=
"closeEditModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
<button
@
click=
"closeEditModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
"
common.cancel
"
)
}}
</button>
<button
type=
"submit"
...
...
@@ -1690,7 +2572,7 @@
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.groups.updating
'
)
:
t
(
'
common.update
'
)
}}
{{
submitting
?
t
(
"
admin.groups.updating
"
)
:
t
(
"
common.update
"
)
}}
</button>
</div>
</
template
>
...
...
@@ -1717,7 +2599,7 @@
>
<div
class=
"space-y-4"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t(
'
admin.groups.sortOrderHint
'
) }}
{{ t(
"
admin.groups.sortOrderHint
"
) }}
</p>
<VueDraggable
v-model=
"sortableGroups"
...
...
@@ -1733,7 +2615,9 @@
<Icon
name=
"menu"
size=
"md"
/>
</div>
<div
class=
"flex-1"
>
<div
class=
"font-medium text-gray-900 dark:text-white"
>
{{ group.name }}
</div>
<div
class=
"font-medium text-gray-900 dark:text-white"
>
{{ group.name }}
</div>
<div
class=
"text-xs text-gray-500 dark:text-gray-400"
>
<span
:class=
"[
...
...
@@ -1744,24 +2628,26 @@
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: group.platform === 'antigravity'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
,
]"
>
{{ t(
'
admin.groups.platforms.
'
+ group.platform) }}
{{ t(
"
admin.groups.platforms.
"
+ group.platform) }}
</span>
</div>
</div>
<div
class=
"text-sm text-gray-400"
>
#{{ group.id }}
</div>
<div
class=
"text-sm text-gray-400"
>
#{{ group.id }}
</div>
</div>
</VueDraggable>
</div>
<
template
#footer
>
<div
class=
"flex justify-end gap-3 pt-4"
>
<button
@
click=
"closeSortModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
<button
@
click=
"closeSortModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
"
common.cancel
"
)
}}
</button>
<button
@
click=
"saveSortOrder"
...
...
@@ -1788,7 +2674,7 @@
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>
{{
sortSubmitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
{{
sortSubmitting
?
t
(
"
common.saving
"
)
:
t
(
"
common.save
"
)
}}
</button>
</div>
</
template
>
...
...
@@ -1805,218 +2691,275 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
AdminGroup
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
PlatformIcon
from
'
@/components/common/PlatformIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
GroupRateMultipliersModal
from
'
@/components/admin/group/GroupRateMultipliersModal.vue
'
import
GroupCapacityBadge
from
'
@/components/common/GroupCapacityBadge.vue
'
import
{
VueDraggable
}
from
'
vue-draggable-plus
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
useKeyedDebouncedSearch
}
from
'
@/composables/useKeyedDebouncedSearch
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
onboardingStore
=
useOnboardingStore
()
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
watch
}
from
"
vue
"
;
import
{
useI18n
}
from
"
vue-i18n
"
;
import
{
useAppStore
}
from
"
@/stores/app
"
;
import
{
useOnboardingStore
}
from
"
@/stores/onboarding
"
;
import
{
adminAPI
}
from
"
@/api/admin
"
;
import
type
{
AdminGroup
,
GroupPlatform
,
SubscriptionType
}
from
"
@/types
"
;
import
type
{
Column
}
from
"
@/components/common/types
"
;
import
AppLayout
from
"
@/components/layout/AppLayout.vue
"
;
import
TablePageLayout
from
"
@/components/layout/TablePageLayout.vue
"
;
import
DataTable
from
"
@/components/common/DataTable.vue
"
;
import
Pagination
from
"
@/components/common/Pagination.vue
"
;
import
BaseDialog
from
"
@/components/common/BaseDialog.vue
"
;
import
ConfirmDialog
from
"
@/components/common/ConfirmDialog.vue
"
;
import
EmptyState
from
"
@/components/common/EmptyState.vue
"
;
import
Select
from
"
@/components/common/Select.vue
"
;
import
PlatformIcon
from
"
@/components/common/PlatformIcon.vue
"
;
import
Icon
from
"
@/components/icons/Icon.vue
"
;
import
GroupRateMultipliersModal
from
"
@/components/admin/group/GroupRateMultipliersModal.vue
"
;
import
GroupCapacityBadge
from
"
@/components/common/GroupCapacityBadge.vue
"
;
import
{
VueDraggable
}
from
"
vue-draggable-plus
"
;
import
{
createStableObjectKeyResolver
}
from
"
@/utils/stableObjectKey
"
;
import
{
useKeyedDebouncedSearch
}
from
"
@/composables/useKeyedDebouncedSearch
"
;
import
{
getPersistedPageSize
}
from
"
@/composables/usePersistedPageSize
"
;
import
{
createDefaultMessagesDispatchFormState
,
messagesDispatchConfigToFormState
,
messagesDispatchFormStateToConfig
,
resetMessagesDispatchFormState
,
type
MessagesDispatchMappingRow
,
}
from
"
./groupsMessagesDispatch
"
;
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
onboardingStore
=
useOnboardingStore
();
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
name
'
,
label
:
t
(
'
admin.groups.columns.name
'
),
sortable
:
true
},
{
key
:
'
platform
'
,
label
:
t
(
'
admin.groups.columns.platform
'
),
sortable
:
true
},
{
key
:
'
billing_type
'
,
label
:
t
(
'
admin.groups.columns.billingType
'
),
sortable
:
true
},
{
key
:
'
rate_multiplier
'
,
label
:
t
(
'
admin.groups.columns.rateMultiplier
'
),
sortable
:
true
},
{
key
:
'
is_exclusive
'
,
label
:
t
(
'
admin.groups.columns.type
'
),
sortable
:
true
},
{
key
:
'
account_count
'
,
label
:
t
(
'
admin.groups.columns.accounts
'
),
sortable
:
true
},
{
key
:
'
capacity
'
,
label
:
t
(
'
admin.groups.columns.capacity
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.groups.columns.usage
'
),
sortable
:
false
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.groups.columns.status
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.groups.columns.actions
'
),
sortable
:
false
}
])
{
key
:
"
name
"
,
label
:
t
(
"
admin.groups.columns.name
"
),
sortable
:
true
},
{
key
:
"
platform
"
,
label
:
t
(
"
admin.groups.columns.platform
"
),
sortable
:
true
,
},
{
key
:
"
billing_type
"
,
label
:
t
(
"
admin.groups.columns.billingType
"
),
sortable
:
true
,
},
{
key
:
"
rate_multiplier
"
,
label
:
t
(
"
admin.groups.columns.rateMultiplier
"
),
sortable
:
true
,
},
{
key
:
"
is_exclusive
"
,
label
:
t
(
"
admin.groups.columns.type
"
),
sortable
:
true
,
},
{
key
:
"
account_count
"
,
label
:
t
(
"
admin.groups.columns.accounts
"
),
sortable
:
true
,
},
{
key
:
"
capacity
"
,
label
:
t
(
"
admin.groups.columns.capacity
"
),
sortable
:
false
,
},
{
key
:
"
usage
"
,
label
:
t
(
"
admin.groups.columns.usage
"
),
sortable
:
false
},
{
key
:
"
status
"
,
label
:
t
(
"
admin.groups.columns.status
"
),
sortable
:
true
},
{
key
:
"
actions
"
,
label
:
t
(
"
admin.groups.columns.actions
"
),
sortable
:
false
},
]);
// Filter options
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.groups.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
}
])
{
value
:
""
,
label
:
t
(
"
admin.groups.allStatus
"
)
},
{
value
:
"
active
"
,
label
:
t
(
"
admin.accounts.status.active
"
)
},
{
value
:
"
inactive
"
,
label
:
t
(
"
admin.accounts.status.inactive
"
)
}
,
])
;
const
exclusiveOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.groups.allGroups
'
)
},
{
value
:
'
true
'
,
label
:
t
(
'
admin.groups.exclusive
'
)
},
{
value
:
'
false
'
,
label
:
t
(
'
admin.groups.nonExclusive
'
)
}
])
{
value
:
""
,
label
:
t
(
"
admin.groups.allGroups
"
)
},
{
value
:
"
true
"
,
label
:
t
(
"
admin.groups.exclusive
"
)
},
{
value
:
"
false
"
,
label
:
t
(
"
admin.groups.nonExclusive
"
)
}
,
])
;
const
platformOptions
=
computed
(()
=>
[
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
])
{
value
:
"
anthropic
"
,
label
:
"
Anthropic
"
},
{
value
:
"
openai
"
,
label
:
"
OpenAI
"
},
{
value
:
"
gemini
"
,
label
:
"
Gemini
"
},
{
value
:
"
antigravity
"
,
label
:
"
Antigravity
"
}
,
])
;
const
platformFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.groups.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
])
{
value
:
""
,
label
:
t
(
"
admin.groups.allPlatforms
"
)
},
{
value
:
"
anthropic
"
,
label
:
"
Anthropic
"
},
{
value
:
"
openai
"
,
label
:
"
OpenAI
"
},
{
value
:
"
gemini
"
,
label
:
"
Gemini
"
},
{
value
:
"
antigravity
"
,
label
:
"
Antigravity
"
}
,
])
;
const
editStatusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
}
])
{
value
:
"
active
"
,
label
:
t
(
"
admin.accounts.status.active
"
)
},
{
value
:
"
inactive
"
,
label
:
t
(
"
admin.accounts.status.inactive
"
)
}
,
])
;
const
subscriptionTypeOptions
=
computed
(()
=>
[
{
value
:
'
standard
'
,
label
:
t
(
'
admin.groups.subscription.standard
'
)
},
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.groups.subscription.subscription
'
)
}
])
{
value
:
"
standard
"
,
label
:
t
(
"
admin.groups.subscription.standard
"
)
},
{
value
:
"
subscription
"
,
label
:
t
(
"
admin.groups.subscription.subscription
"
)
}
,
])
;
// 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组
const
fallbackGroupOptions
=
computed
(()
=>
{
const
options
:
{
value
:
number
|
null
;
label
:
string
}[]
=
[
{
value
:
null
,
label
:
t
(
'
admin.groups.claudeCode.noFallback
'
)
}
]
{
value
:
null
,
label
:
t
(
"
admin.groups.claudeCode.noFallback
"
)
}
,
]
;
const
eligibleGroups
=
groups
.
value
.
filter
(
(
g
)
=>
g
.
platform
===
'
anthropic
'
&&
!
g
.
claude_code_only
&&
g
.
status
===
'
active
'
)
(
g
)
=>
g
.
platform
===
"
anthropic
"
&&
!
g
.
claude_code_only
&&
g
.
status
===
"
active
"
,
);
eligibleGroups
.
forEach
((
g
)
=>
{
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
})
return
options
})
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
;
})
;
return
options
;
})
;
// 降级分组选项(编辑时)- 排除自身
const
fallbackGroupOptionsForEdit
=
computed
(()
=>
{
const
options
:
{
value
:
number
|
null
;
label
:
string
}[]
=
[
{
value
:
null
,
label
:
t
(
'
admin.groups.claudeCode.noFallback
'
)
}
]
const
currentId
=
editingGroup
.
value
?.
id
{
value
:
null
,
label
:
t
(
"
admin.groups.claudeCode.noFallback
"
)
}
,
]
;
const
currentId
=
editingGroup
.
value
?.
id
;
const
eligibleGroups
=
groups
.
value
.
filter
(
(
g
)
=>
g
.
platform
===
'
anthropic
'
&&
!
g
.
claude_code_only
&&
g
.
status
===
'
active
'
&&
g
.
id
!==
currentId
)
(
g
)
=>
g
.
platform
===
"
anthropic
"
&&
!
g
.
claude_code_only
&&
g
.
status
===
"
active
"
&&
g
.
id
!==
currentId
,
);
eligibleGroups
.
forEach
((
g
)
=>
{
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
})
return
options
})
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
;
})
;
return
options
;
})
;
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const
invalidRequestFallbackOptions
=
computed
(()
=>
{
const
options
:
{
value
:
number
|
null
;
label
:
string
}[]
=
[
{
value
:
null
,
label
:
t
(
'
admin.groups.invalidRequestFallback.noFallback
'
)
}
]
{
value
:
null
,
label
:
t
(
"
admin.groups.invalidRequestFallback.noFallback
"
)
}
,
]
;
const
eligibleGroups
=
groups
.
value
.
filter
(
(
g
)
=>
g
.
platform
===
'
anthropic
'
&&
g
.
status
===
'
active
'
&&
g
.
subscription_type
!==
'
subscription
'
&&
g
.
fallback_group_id_on_invalid_request
===
null
)
g
.
platform
===
"
anthropic
"
&&
g
.
status
===
"
active
"
&&
g
.
subscription_type
!==
"
subscription
"
&&
g
.
fallback_group_id_on_invalid_request
===
null
,
)
;
eligibleGroups
.
forEach
((
g
)
=>
{
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
})
return
options
})
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
;
})
;
return
options
;
})
;
// 无效请求兜底分组选项(编辑时)- 排除自身
const
invalidRequestFallbackOptionsForEdit
=
computed
(()
=>
{
const
options
:
{
value
:
number
|
null
;
label
:
string
}[]
=
[
{
value
:
null
,
label
:
t
(
'
admin.groups.invalidRequestFallback.noFallback
'
)
}
]
const
currentId
=
editingGroup
.
value
?.
id
{
value
:
null
,
label
:
t
(
"
admin.groups.invalidRequestFallback.noFallback
"
)
}
,
]
;
const
currentId
=
editingGroup
.
value
?.
id
;
const
eligibleGroups
=
groups
.
value
.
filter
(
(
g
)
=>
g
.
platform
===
'
anthropic
'
&&
g
.
status
===
'
active
'
&&
g
.
subscription_type
!==
'
subscription
'
&&
g
.
platform
===
"
anthropic
"
&&
g
.
status
===
"
active
"
&&
g
.
subscription_type
!==
"
subscription
"
&&
g
.
fallback_group_id_on_invalid_request
===
null
&&
g
.
id
!==
currentId
)
g
.
id
!==
currentId
,
)
;
eligibleGroups
.
forEach
((
g
)
=>
{
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
})
return
options
})
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
})
;
})
;
return
options
;
})
;
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const
copyAccountsGroupOptions
=
computed
(()
=>
{
const
eligibleGroups
=
groups
.
value
.
filter
(
(
g
)
=>
g
.
platform
===
createForm
.
platform
&&
(
g
.
account_count
||
0
)
>
0
)
(
g
)
=>
g
.
platform
===
createForm
.
platform
&&
(
g
.
account_count
||
0
)
>
0
,
)
;
return
eligibleGroups
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
`
${
g
.
name
}
(
${
g
.
account_count
||
0
}
个账号)`
}))
})
label
:
`
${
g
.
name
}
(
${
g
.
account_count
||
0
}
个账号)`
,
}))
;
})
;
// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身
const
copyAccountsGroupOptionsForEdit
=
computed
(()
=>
{
const
currentId
=
editingGroup
.
value
?.
id
const
currentId
=
editingGroup
.
value
?.
id
;
const
eligibleGroups
=
groups
.
value
.
filter
(
(
g
)
=>
g
.
platform
===
editForm
.
platform
&&
(
g
.
account_count
||
0
)
>
0
&&
g
.
id
!==
currentId
)
(
g
)
=>
g
.
platform
===
editForm
.
platform
&&
(
g
.
account_count
||
0
)
>
0
&&
g
.
id
!==
currentId
,
);
return
eligibleGroups
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
`
${
g
.
name
}
(
${
g
.
account_count
||
0
}
个账号)`
}))
})
const
groups
=
ref
<
AdminGroup
[]
>
([])
const
loading
=
ref
(
false
)
const
usageMap
=
ref
<
Map
<
number
,
{
today_cost
:
number
;
total_cost
:
number
}
>>
(
new
Map
())
const
usageLoading
=
ref
(
false
)
const
capacityMap
=
ref
<
Map
<
number
,
{
concurrencyUsed
:
number
;
concurrencyMax
:
number
;
sessionsUsed
:
number
;
sessionsMax
:
number
;
rpmUsed
:
number
;
rpmMax
:
number
}
>>
(
new
Map
())
const
searchQuery
=
ref
(
''
)
label
:
`
${
g
.
name
}
(
${
g
.
account_count
||
0
}
个账号)`
,
}));
});
const
groups
=
ref
<
AdminGroup
[]
>
([]);
const
loading
=
ref
(
false
);
const
usageMap
=
ref
<
Map
<
number
,
{
today_cost
:
number
;
total_cost
:
number
}
>>
(
new
Map
(),
);
const
usageLoading
=
ref
(
false
);
const
capacityMap
=
ref
<
Map
<
number
,
{
concurrencyUsed
:
number
;
concurrencyMax
:
number
;
sessionsUsed
:
number
;
sessionsMax
:
number
;
rpmUsed
:
number
;
rpmMax
:
number
;
}
>
>
(
new
Map
());
const
searchQuery
=
ref
(
""
);
const
filters
=
reactive
({
platform
:
''
,
status
:
''
,
is_exclusive
:
''
})
platform
:
""
,
status
:
""
,
is_exclusive
:
""
,
})
;
const
pagination
=
reactive
({
page
:
1
,
page_size
:
getPersistedPageSize
(),
total
:
0
,
pages
:
0
})
pages
:
0
,
})
;
const
sortState
=
reactive
({
sort_by
:
'
sort_order
'
,
sort_order
:
'
asc
'
as
'
asc
'
|
'
desc
'
})
let
abortController
:
AbortController
|
null
=
null
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showSortModal
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
sortSubmitting
=
ref
(
false
)
const
editingGroup
=
ref
<
AdminGroup
|
null
>
(
null
)
const
deletingGroup
=
ref
<
AdminGroup
|
null
>
(
null
)
const
showRateMultipliersModal
=
ref
(
false
)
const
rateMultipliersGroup
=
ref
<
AdminGroup
|
null
>
(
null
)
const
sortableGroups
=
ref
<
AdminGroup
[]
>
([])
sort_by
:
"
sort_order
"
,
sort_order
:
"
asc
"
as
"
asc
"
|
"
desc
"
,
});
let
abortController
:
AbortController
|
null
=
null
;
const
showCreateModal
=
ref
(
false
);
const
showEditModal
=
ref
(
false
);
const
showDeleteDialog
=
ref
(
false
);
const
showSortModal
=
ref
(
false
);
const
submitting
=
ref
(
false
);
const
sortSubmitting
=
ref
(
false
);
const
editingGroup
=
ref
<
AdminGroup
|
null
>
(
null
);
const
deletingGroup
=
ref
<
AdminGroup
|
null
>
(
null
);
const
showRateMultipliersModal
=
ref
(
false
);
const
rateMultipliersGroup
=
ref
<
AdminGroup
|
null
>
(
null
);
const
sortableGroups
=
ref
<
AdminGroup
[]
>
([]);
const
createMessagesDispatchDefaults
=
createDefaultMessagesDispatchFormState
();
const
editMessagesDispatchDefaults
=
createDefaultMessagesDispatchFormState
();
const
createForm
=
reactive
({
name
:
''
,
description
:
''
,
platform
:
'
anthropic
'
as
GroupPlatform
,
name
:
""
,
description
:
""
,
platform
:
"
anthropic
"
as
GroupPlatform
,
rate_multiplier
:
1.0
,
is_exclusive
:
false
,
subscription_type
:
'
standard
'
as
SubscriptionType
,
subscription_type
:
"
standard
"
as
SubscriptionType
,
daily_limit_usd
:
null
as
number
|
null
,
weekly_limit_usd
:
null
as
number
|
null
,
monthly_limit_usd
:
null
as
number
|
null
,
...
...
@@ -2030,68 +2973,89 @@ const createForm = reactive({
fallback_group_id_on_invalid_request
:
null
as
number
|
null
,
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch
:
false
,
default_mapped_model
:
'
gpt-5.4
'
,
opus_mapped_model
:
createMessagesDispatchDefaults
.
opus_mapped_model
,
sonnet_mapped_model
:
createMessagesDispatchDefaults
.
sonnet_mapped_model
,
haiku_mapped_model
:
createMessagesDispatchDefaults
.
haiku_mapped_model
,
exact_model_mappings
:
[]
as
MessagesDispatchMappingRow
[],
// 账号过滤控制(OpenAI/Antigravity 平台)
require_oauth_only
:
false
,
require_privacy_set
:
false
,
// 模型路由开关
model_routing_enabled
:
false
,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes
:
[
'
claude
'
,
'
gemini_text
'
,
'
gemini_image
'
]
as
string
[],
supported_model_scopes
:
[
"
claude
"
,
"
gemini_text
"
,
"
gemini_image
"
]
as
string
[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject
:
true
,
// 从分组复制账号
copy_accounts_from_group_ids
:
[]
as
number
[]
})
copy_accounts_from_group_ids
:
[]
as
number
[]
,
})
;
// 简单账号类型(用于模型路由选择)
interface
SimpleAccount
{
id
:
number
name
:
string
id
:
number
;
name
:
string
;
}
// 模型路由规则类型
interface
ModelRoutingRule
{
pattern
:
string
accounts
:
SimpleAccount
[]
// 选中的账号对象数组
pattern
:
string
;
accounts
:
SimpleAccount
[]
;
// 选中的账号对象数组
}
// 创建表单的模型路由规则
const
createModelRoutingRules
=
ref
<
ModelRoutingRule
[]
>
([])
const
createModelRoutingRules
=
ref
<
ModelRoutingRule
[]
>
([])
;
// 编辑表单的模型路由规则
const
editModelRoutingRules
=
ref
<
ModelRoutingRule
[]
>
([])
const
editModelRoutingRules
=
ref
<
ModelRoutingRule
[]
>
([])
;
// 规则对象稳定 key(避免使用 index 导致状态错位)
const
resolveCreateRuleKey
=
createStableObjectKeyResolver
<
ModelRoutingRule
>
(
'
create-rule
'
)
const
resolveEditRuleKey
=
createStableObjectKeyResolver
<
ModelRoutingRule
>
(
'
edit-rule
'
)
const
getCreateRuleRenderKey
=
(
rule
:
ModelRoutingRule
)
=>
resolveCreateRuleKey
(
rule
)
const
getEditRuleRenderKey
=
(
rule
:
ModelRoutingRule
)
=>
resolveEditRuleKey
(
rule
)
const
getCreateRuleSearchKey
=
(
rule
:
ModelRoutingRule
)
=>
`create-
${
resolveCreateRuleKey
(
rule
)}
`
const
getEditRuleSearchKey
=
(
rule
:
ModelRoutingRule
)
=>
`edit-
${
resolveEditRuleKey
(
rule
)}
`
const
resolveCreateRuleKey
=
createStableObjectKeyResolver
<
ModelRoutingRule
>
(
"
create-rule
"
);
const
resolveEditRuleKey
=
createStableObjectKeyResolver
<
ModelRoutingRule
>
(
"
edit-rule
"
);
const
resolveCreateMessagesDispatchRowKey
=
createStableObjectKeyResolver
<
MessagesDispatchMappingRow
>
(
"
create-messages-dispatch-row
"
,
);
const
resolveEditMessagesDispatchRowKey
=
createStableObjectKeyResolver
<
MessagesDispatchMappingRow
>
(
"
edit-messages-dispatch-row
"
,
);
const
getCreateRuleRenderKey
=
(
rule
:
ModelRoutingRule
)
=>
resolveCreateRuleKey
(
rule
);
const
getEditRuleRenderKey
=
(
rule
:
ModelRoutingRule
)
=>
resolveEditRuleKey
(
rule
);
const
getCreateMessagesDispatchRowKey
=
(
row
:
MessagesDispatchMappingRow
)
=>
resolveCreateMessagesDispatchRowKey
(
row
);
const
getEditMessagesDispatchRowKey
=
(
row
:
MessagesDispatchMappingRow
)
=>
resolveEditMessagesDispatchRowKey
(
row
);
const
getCreateRuleSearchKey
=
(
rule
:
ModelRoutingRule
)
=>
`create-
${
resolveCreateRuleKey
(
rule
)}
`
;
const
getEditRuleSearchKey
=
(
rule
:
ModelRoutingRule
)
=>
`edit-
${
resolveEditRuleKey
(
rule
)}
`
;
const
getRuleSearchKey
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
)
=>
{
return
isEdit
?
getEditRuleSearchKey
(
rule
)
:
getCreateRuleSearchKey
(
rule
)
}
return
isEdit
?
getEditRuleSearchKey
(
rule
)
:
getCreateRuleSearchKey
(
rule
)
;
}
;
// 账号搜索相关状态
const
accountSearchKeyword
=
ref
<
Record
<
string
,
string
>>
({})
const
accountSearchResults
=
ref
<
Record
<
string
,
SimpleAccount
[]
>>
({})
const
showAccountDropdown
=
ref
<
Record
<
string
,
boolean
>>
({})
const
accountSearchKeyword
=
ref
<
Record
<
string
,
string
>>
({})
;
const
accountSearchResults
=
ref
<
Record
<
string
,
SimpleAccount
[]
>>
({})
;
const
showAccountDropdown
=
ref
<
Record
<
string
,
boolean
>>
({})
;
const
clearAccountSearchStateByKey
=
(
key
:
string
)
=>
{
delete
accountSearchKeyword
.
value
[
key
]
delete
accountSearchResults
.
value
[
key
]
delete
showAccountDropdown
.
value
[
key
]
}
delete
accountSearchKeyword
.
value
[
key
]
;
delete
accountSearchResults
.
value
[
key
]
;
delete
showAccountDropdown
.
value
[
key
]
;
}
;
const
clearAllAccountSearchState
=
()
=>
{
accountSearchKeyword
.
value
=
{}
accountSearchResults
.
value
=
{}
showAccountDropdown
.
value
=
{}
}
accountSearchKeyword
.
value
=
{}
;
accountSearchResults
.
value
=
{}
;
showAccountDropdown
.
value
=
{}
;
}
;
const
accountSearchRunner
=
useKeyedDebouncedSearch
<
SimpleAccount
[]
>
({
delay
:
300
,
...
...
@@ -2101,163 +3065,181 @@ const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
20
,
{
search
:
keyword
,
platform
:
'
anthropic
'
platform
:
"
anthropic
"
,
},
{
signal
}
)
return
res
.
items
.
map
((
account
)
=>
({
id
:
account
.
id
,
name
:
account
.
name
}))
{
signal
}
,
)
;
return
res
.
items
.
map
((
account
)
=>
({
id
:
account
.
id
,
name
:
account
.
name
}))
;
},
onSuccess
:
(
key
,
result
)
=>
{
accountSearchResults
.
value
[
key
]
=
result
accountSearchResults
.
value
[
key
]
=
result
;
},
onError
:
(
key
)
=>
{
accountSearchResults
.
value
[
key
]
=
[]
}
})
accountSearchResults
.
value
[
key
]
=
[]
;
}
,
})
;
// 搜索账号(仅限 anthropic 平台)
const
searchAccounts
=
(
key
:
string
)
=>
{
accountSearchRunner
.
trigger
(
key
,
accountSearchKeyword
.
value
[
key
]
||
''
)
}
accountSearchRunner
.
trigger
(
key
,
accountSearchKeyword
.
value
[
key
]
||
""
);
}
;
const
searchAccountsByRule
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
)
=>
{
searchAccounts
(
getRuleSearchKey
(
rule
,
isEdit
))
}
const
searchAccountsByRule
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
,
)
=>
{
searchAccounts
(
getRuleSearchKey
(
rule
,
isEdit
));
};
// 选择账号
const
selectAccount
=
(
rule
:
ModelRoutingRule
,
account
:
SimpleAccount
,
isEdit
:
boolean
=
false
)
=>
{
if
(
!
rule
)
return
const
selectAccount
=
(
rule
:
ModelRoutingRule
,
account
:
SimpleAccount
,
isEdit
:
boolean
=
false
,
)
=>
{
if
(
!
rule
)
return
;
// 检查是否已选择
if
(
!
rule
.
accounts
.
some
(
a
=>
a
.
id
===
account
.
id
))
{
rule
.
accounts
.
push
(
account
)
if
(
!
rule
.
accounts
.
some
(
(
a
)
=>
a
.
id
===
account
.
id
))
{
rule
.
accounts
.
push
(
account
)
;
}
// 清空搜索
const
key
=
getRuleSearchKey
(
rule
,
isEdit
)
accountSearchKeyword
.
value
[
key
]
=
''
showAccountDropdown
.
value
[
key
]
=
false
}
const
key
=
getRuleSearchKey
(
rule
,
isEdit
)
;
accountSearchKeyword
.
value
[
key
]
=
""
;
showAccountDropdown
.
value
[
key
]
=
false
;
}
;
// 移除已选账号
const
removeSelectedAccount
=
(
rule
:
ModelRoutingRule
,
accountId
:
number
,
_isEdit
:
boolean
=
false
)
=>
{
if
(
!
rule
)
return
const
removeSelectedAccount
=
(
rule
:
ModelRoutingRule
,
accountId
:
number
,
_isEdit
:
boolean
=
false
,
)
=>
{
if
(
!
rule
)
return
;
rule
.
accounts
=
rule
.
accounts
.
filter
(
a
=>
a
.
id
!==
accountId
)
}
rule
.
accounts
=
rule
.
accounts
.
filter
(
(
a
)
=>
a
.
id
!==
accountId
)
;
}
;
// 切换创建表单的模型系列选择
const
toggleCreateScope
=
(
scope
:
string
)
=>
{
const
idx
=
createForm
.
supported_model_scopes
.
indexOf
(
scope
)
const
idx
=
createForm
.
supported_model_scopes
.
indexOf
(
scope
)
;
if
(
idx
===
-
1
)
{
createForm
.
supported_model_scopes
.
push
(
scope
)
createForm
.
supported_model_scopes
.
push
(
scope
)
;
}
else
{
createForm
.
supported_model_scopes
.
splice
(
idx
,
1
)
createForm
.
supported_model_scopes
.
splice
(
idx
,
1
)
;
}
}
}
;
// 切换编辑表单的模型系列选择
const
toggleEditScope
=
(
scope
:
string
)
=>
{
const
idx
=
editForm
.
supported_model_scopes
.
indexOf
(
scope
)
const
idx
=
editForm
.
supported_model_scopes
.
indexOf
(
scope
)
;
if
(
idx
===
-
1
)
{
editForm
.
supported_model_scopes
.
push
(
scope
)
editForm
.
supported_model_scopes
.
push
(
scope
)
;
}
else
{
editForm
.
supported_model_scopes
.
splice
(
idx
,
1
)
editForm
.
supported_model_scopes
.
splice
(
idx
,
1
)
;
}
}
}
;
// 处理账号搜索输入框聚焦
const
onAccountSearchFocus
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
)
=>
{
const
key
=
getRuleSearchKey
(
rule
,
isEdit
)
showAccountDropdown
.
value
[
key
]
=
true
const
onAccountSearchFocus
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
,
)
=>
{
const
key
=
getRuleSearchKey
(
rule
,
isEdit
);
showAccountDropdown
.
value
[
key
]
=
true
;
// 如果没有搜索结果,触发一次搜索
if
(
!
accountSearchResults
.
value
[
key
]?.
length
)
{
searchAccounts
(
key
)
searchAccounts
(
key
)
;
}
}
}
;
// 添加创建表单的路由规则
const
addCreateRoutingRule
=
()
=>
{
createModelRoutingRules
.
value
.
push
({
pattern
:
''
,
accounts
:
[]
})
}
createModelRoutingRules
.
value
.
push
({
pattern
:
""
,
accounts
:
[]
})
;
}
;
// 删除创建表单的路由规则
const
removeCreateRoutingRule
=
(
rule
:
ModelRoutingRule
)
=>
{
const
index
=
createModelRoutingRules
.
value
.
indexOf
(
rule
)
if
(
index
===
-
1
)
return
const
index
=
createModelRoutingRules
.
value
.
indexOf
(
rule
)
;
if
(
index
===
-
1
)
return
;
const
key
=
getCreateRuleSearchKey
(
rule
)
accountSearchRunner
.
clearKey
(
key
)
clearAccountSearchStateByKey
(
key
)
createModelRoutingRules
.
value
.
splice
(
index
,
1
)
}
const
key
=
getCreateRuleSearchKey
(
rule
)
;
accountSearchRunner
.
clearKey
(
key
)
;
clearAccountSearchStateByKey
(
key
)
;
createModelRoutingRules
.
value
.
splice
(
index
,
1
)
;
}
;
// 添加编辑表单的路由规则
const
addEditRoutingRule
=
()
=>
{
editModelRoutingRules
.
value
.
push
({
pattern
:
''
,
accounts
:
[]
})
}
editModelRoutingRules
.
value
.
push
({
pattern
:
""
,
accounts
:
[]
})
;
}
;
// 删除编辑表单的路由规则
const
removeEditRoutingRule
=
(
rule
:
ModelRoutingRule
)
=>
{
const
index
=
editModelRoutingRules
.
value
.
indexOf
(
rule
)
if
(
index
===
-
1
)
return
const
index
=
editModelRoutingRules
.
value
.
indexOf
(
rule
)
;
if
(
index
===
-
1
)
return
;
const
key
=
getEditRuleSearchKey
(
rule
)
accountSearchRunner
.
clearKey
(
key
)
clearAccountSearchStateByKey
(
key
)
editModelRoutingRules
.
value
.
splice
(
index
,
1
)
}
const
key
=
getEditRuleSearchKey
(
rule
)
;
accountSearchRunner
.
clearKey
(
key
)
;
clearAccountSearchStateByKey
(
key
)
;
editModelRoutingRules
.
value
.
splice
(
index
,
1
)
;
}
;
// 将 UI 格式的路由规则转换为 API 格式
const
convertRoutingRulesToApiFormat
=
(
rules
:
ModelRoutingRule
[]):
Record
<
string
,
number
[]
>
|
null
=>
{
const
result
:
Record
<
string
,
number
[]
>
=
{}
let
hasValidRules
=
false
const
convertRoutingRulesToApiFormat
=
(
rules
:
ModelRoutingRule
[],
):
Record
<
string
,
number
[]
>
|
null
=>
{
const
result
:
Record
<
string
,
number
[]
>
=
{};
let
hasValidRules
=
false
;
for
(
const
rule
of
rules
)
{
const
pattern
=
rule
.
pattern
.
trim
()
if
(
!
pattern
)
continue
const
pattern
=
rule
.
pattern
.
trim
()
;
if
(
!
pattern
)
continue
;
const
accountIds
=
rule
.
accounts
.
map
(
a
=>
a
.
id
).
filter
(
id
=>
id
>
0
)
const
accountIds
=
rule
.
accounts
.
map
(
(
a
)
=>
a
.
id
).
filter
(
(
id
)
=>
id
>
0
)
;
if
(
accountIds
.
length
>
0
)
{
result
[
pattern
]
=
accountIds
hasValidRules
=
true
result
[
pattern
]
=
accountIds
;
hasValidRules
=
true
;
}
}
return
hasValidRules
?
result
:
null
}
return
hasValidRules
?
result
:
null
;
}
;
// 将 API 格式的路由规则转换为 UI 格式(需要加载账号名称)
const
convertApiFormatToRoutingRules
=
async
(
apiFormat
:
Record
<
string
,
number
[]
>
|
null
):
Promise
<
ModelRoutingRule
[]
>
=>
{
if
(
!
apiFormat
)
return
[]
const
convertApiFormatToRoutingRules
=
async
(
apiFormat
:
Record
<
string
,
number
[]
>
|
null
,
):
Promise
<
ModelRoutingRule
[]
>
=>
{
if
(
!
apiFormat
)
return
[];
const
rules
:
ModelRoutingRule
[]
=
[]
const
rules
:
ModelRoutingRule
[]
=
[]
;
for
(
const
[
pattern
,
accountIds
]
of
Object
.
entries
(
apiFormat
))
{
// 加载账号信息
const
accounts
:
SimpleAccount
[]
=
[]
const
accounts
:
SimpleAccount
[]
=
[]
;
for
(
const
id
of
accountIds
)
{
try
{
const
account
=
await
adminAPI
.
accounts
.
getById
(
id
)
accounts
.
push
({
id
:
account
.
id
,
name
:
account
.
name
})
const
account
=
await
adminAPI
.
accounts
.
getById
(
id
)
;
accounts
.
push
({
id
:
account
.
id
,
name
:
account
.
name
})
;
}
catch
{
// 如果账号不存在,仍然显示 ID
accounts
.
push
({
id
,
name
:
`#
${
id
}
`
})
accounts
.
push
({
id
,
name
:
`#
${
id
}
`
})
;
}
}
rules
.
push
({
pattern
,
accounts
})
rules
.
push
({
pattern
,
accounts
})
;
}
return
rules
}
return
rules
;
}
;
const
editForm
=
reactive
({
name
:
''
,
description
:
''
,
platform
:
'
anthropic
'
as
GroupPlatform
,
name
:
""
,
description
:
""
,
platform
:
"
anthropic
"
as
GroupPlatform
,
rate_multiplier
:
1.0
,
is_exclusive
:
false
,
status
:
'
active
'
as
'
active
'
|
'
inactive
'
,
subscription_type
:
'
standard
'
as
SubscriptionType
,
status
:
"
active
"
as
"
active
"
|
"
inactive
"
,
subscription_type
:
"
standard
"
as
SubscriptionType
,
daily_limit_usd
:
null
as
number
|
null
,
weekly_limit_usd
:
null
as
number
|
null
,
monthly_limit_usd
:
null
as
number
|
null
,
...
...
@@ -2271,94 +3253,123 @@ const editForm = reactive({
fallback_group_id_on_invalid_request
:
null
as
number
|
null
,
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch
:
false
,
default_mapped_model
:
''
,
opus_mapped_model
:
editMessagesDispatchDefaults
.
opus_mapped_model
,
sonnet_mapped_model
:
editMessagesDispatchDefaults
.
sonnet_mapped_model
,
haiku_mapped_model
:
editMessagesDispatchDefaults
.
haiku_mapped_model
,
exact_model_mappings
:
[]
as
MessagesDispatchMappingRow
[],
// 账号过滤控制(OpenAI/Antigravity 平台)
require_oauth_only
:
false
,
require_privacy_set
:
false
,
// 模型路由开关
model_routing_enabled
:
false
,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes
:
[
'
claude
'
,
'
gemini_text
'
,
'
gemini_image
'
]
as
string
[],
supported_model_scopes
:
[
"
claude
"
,
"
gemini_text
"
,
"
gemini_image
"
]
as
string
[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject
:
true
,
// 从分组复制账号
copy_accounts_from_group_ids
:
[]
as
number
[]
})
copy_accounts_from_group_ids
:
[]
as
number
[]
,
})
;
// 根据分组类型返回不同的删除确认消息
const
deleteConfirmMessage
=
computed
(()
=>
{
if
(
!
deletingGroup
.
value
)
{
return
''
return
""
;
}
if
(
deletingGroup
.
value
.
subscription_type
===
'
subscription
'
)
{
return
t
(
'
admin.groups.deleteConfirmSubscription
'
,
{
name
:
deletingGroup
.
value
.
name
})
if
(
deletingGroup
.
value
.
subscription_type
===
"
subscription
"
)
{
return
t
(
"
admin.groups.deleteConfirmSubscription
"
,
{
name
:
deletingGroup
.
value
.
name
,
});
}
return
t
(
'
admin.groups.deleteConfirm
'
,
{
name
:
deletingGroup
.
value
.
name
})
})
return
t
(
"
admin.groups.deleteConfirm
"
,
{
name
:
deletingGroup
.
value
.
name
})
;
})
;
const
loadGroups
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
abortController
.
abort
()
;
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
const
{
signal
}
=
currentController
loading
.
value
=
true
const
currentController
=
new
AbortController
()
;
abortController
=
currentController
;
const
{
signal
}
=
currentController
;
loading
.
value
=
true
;
try
{
const
response
=
await
adminAPI
.
groups
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
status
:
filters
.
status
as
any
,
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
,
search
:
searchQuery
.
value
.
trim
()
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
})
if
(
signal
.
aborted
)
return
groups
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
loadUsageSummary
()
loadCapacitySummary
()
const
response
=
await
adminAPI
.
groups
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
status
:
filters
.
status
as
any
,
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
"
true
"
:
undefined
,
search
:
searchQuery
.
value
.
trim
()
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
,
},
{
signal
},
);
if
(
signal
.
aborted
)
return
;
groups
.
value
=
response
.
items
;
pagination
.
total
=
response
.
total
;
pagination
.
pages
=
response
.
pages
;
loadUsageSummary
();
loadCapacitySummary
();
}
catch
(
error
:
any
)
{
if
(
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
if
(
signal
.
aborted
||
error
?.
name
===
"
AbortError
"
||
error
?.
code
===
"
ERR_CANCELED
"
)
{
return
;
}
appStore
.
showError
(
t
(
'
admin.groups.failedToLoad
'
))
console
.
error
(
'
Error loading groups:
'
,
error
)
appStore
.
showError
(
t
(
"
admin.groups.failedToLoad
"
))
;
console
.
error
(
"
Error loading groups:
"
,
error
)
;
}
finally
{
if
(
abortController
===
currentController
&&
!
signal
.
aborted
)
{
loading
.
value
=
false
loading
.
value
=
false
;
}
}
}
}
;
const
formatCost
=
(
cost
:
number
):
string
=>
{
if
(
cost
>=
1000
)
return
cost
.
toFixed
(
0
)
if
(
cost
>=
100
)
return
cost
.
toFixed
(
1
)
return
cost
.
toFixed
(
2
)
}
if
(
cost
>=
1000
)
return
cost
.
toFixed
(
0
)
;
if
(
cost
>=
100
)
return
cost
.
toFixed
(
1
)
;
return
cost
.
toFixed
(
2
)
;
}
;
const
loadUsageSummary
=
async
()
=>
{
usageLoading
.
value
=
true
usageLoading
.
value
=
true
;
try
{
const
tz
=
Intl
.
DateTimeFormat
().
resolvedOptions
().
timeZone
const
data
=
await
adminAPI
.
groups
.
getUsageSummary
(
tz
)
const
map
=
new
Map
<
number
,
{
today_cost
:
number
;
total_cost
:
number
}
>
()
const
tz
=
Intl
.
DateTimeFormat
().
resolvedOptions
().
timeZone
;
const
data
=
await
adminAPI
.
groups
.
getUsageSummary
(
tz
)
;
const
map
=
new
Map
<
number
,
{
today_cost
:
number
;
total_cost
:
number
}
>
()
;
for
(
const
item
of
data
)
{
map
.
set
(
item
.
group_id
,
{
today_cost
:
item
.
today_cost
,
total_cost
:
item
.
total_cost
})
map
.
set
(
item
.
group_id
,
{
today_cost
:
item
.
today_cost
,
total_cost
:
item
.
total_cost
,
});
}
usageMap
.
value
=
map
usageMap
.
value
=
map
;
}
catch
(
error
)
{
console
.
error
(
'
Error loading group usage summary:
'
,
error
)
console
.
error
(
"
Error loading group usage summary:
"
,
error
)
;
}
finally
{
usageLoading
.
value
=
false
usageLoading
.
value
=
false
;
}
}
}
;
const
loadCapacitySummary
=
async
()
=>
{
try
{
const
data
=
await
adminAPI
.
groups
.
getCapacitySummary
()
const
map
=
new
Map
<
number
,
{
concurrencyUsed
:
number
;
concurrencyMax
:
number
;
sessionsUsed
:
number
;
sessionsMax
:
number
;
rpmUsed
:
number
;
rpmMax
:
number
}
>
()
const
data
=
await
adminAPI
.
groups
.
getCapacitySummary
();
const
map
=
new
Map
<
number
,
{
concurrencyUsed
:
number
;
concurrencyMax
:
number
;
sessionsUsed
:
number
;
sessionsMax
:
number
;
rpmUsed
:
number
;
rpmMax
:
number
;
}
>
();
for
(
const
item
of
data
)
{
map
.
set
(
item
.
group_id
,
{
concurrencyUsed
:
item
.
concurrency_used
,
...
...
@@ -2366,320 +3377,424 @@ const loadCapacitySummary = async () => {
sessionsUsed
:
item
.
sessions_used
,
sessionsMax
:
item
.
sessions_max
,
rpmUsed
:
item
.
rpm_used
,
rpmMax
:
item
.
rpm_max
})
rpmMax
:
item
.
rpm_max
,
})
;
}
capacityMap
.
value
=
map
capacityMap
.
value
=
map
;
}
catch
(
error
)
{
console
.
error
(
'
Error loading group capacity summary:
'
,
error
)
console
.
error
(
"
Error loading group capacity summary:
"
,
error
)
;
}
}
}
;
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
;
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
clearTimeout
(
searchTimeout
)
;
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadGroups
()
},
300
)
}
pagination
.
page
=
1
;
loadGroups
()
;
},
300
)
;
}
;
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadGroups
()
}
pagination
.
page
=
page
;
loadGroups
()
;
}
;
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadGroups
()
}
pagination
.
page_size
=
pageSize
;
pagination
.
page
=
1
;
loadGroups
()
;
}
;
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadGroups
()
}
sortState
.
sort_by
=
key
;
sortState
.
sort_order
=
order
;
pagination
.
page
=
1
;
loadGroups
()
;
}
;
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
showCreateModal
.
value
=
false
;
createModelRoutingRules
.
value
.
forEach
((
rule
)
=>
{
accountSearchRunner
.
clearKey
(
getCreateRuleSearchKey
(
rule
))
})
clearAllAccountSearchState
()
createForm
.
name
=
''
createForm
.
description
=
''
createForm
.
platform
=
'
anthropic
'
createForm
.
rate_multiplier
=
1.0
createForm
.
is_exclusive
=
false
createForm
.
subscription_type
=
'
standard
'
createForm
.
daily_limit_usd
=
null
createForm
.
weekly_limit_usd
=
null
createForm
.
monthly_limit_usd
=
null
createForm
.
image_price_1k
=
null
createForm
.
image_price_2k
=
null
createForm
.
image_price_4k
=
null
createForm
.
claude_code_only
=
false
createForm
.
fallback_group_id
=
null
createForm
.
fallback_group_id_on_invalid_request
=
null
createForm
.
allow_messages_dispatch
=
false
createForm
.
require_oauth_only
=
false
createForm
.
require_privacy_set
=
false
createForm
.
default_mapped_model
=
'
gpt-5.4
'
createForm
.
supported_model_scopes
=
[
'
claude
'
,
'
gemini_text
'
,
'
gemini_image
'
]
createForm
.
mcp_xml_inject
=
true
createForm
.
copy_accounts_from_group_ids
=
[]
createModelRoutingRules
.
value
=
[]
}
const
normalizeOptionalLimit
=
(
value
:
number
|
string
|
null
|
undefined
):
number
|
null
=>
{
accountSearchRunner
.
clearKey
(
getCreateRuleSearchKey
(
rule
));
});
clearAllAccountSearchState
();
createForm
.
name
=
""
;
createForm
.
description
=
""
;
createForm
.
platform
=
"
anthropic
"
;
createForm
.
rate_multiplier
=
1.0
;
createForm
.
is_exclusive
=
false
;
createForm
.
subscription_type
=
"
standard
"
;
createForm
.
daily_limit_usd
=
null
;
createForm
.
weekly_limit_usd
=
null
;
createForm
.
monthly_limit_usd
=
null
;
createForm
.
image_price_1k
=
null
;
createForm
.
image_price_2k
=
null
;
createForm
.
image_price_4k
=
null
;
createForm
.
claude_code_only
=
false
;
createForm
.
fallback_group_id
=
null
;
createForm
.
fallback_group_id_on_invalid_request
=
null
;
resetMessagesDispatchFormState
(
createForm
);
createForm
.
require_oauth_only
=
false
;
createForm
.
require_privacy_set
=
false
;
createForm
.
supported_model_scopes
=
[
"
claude
"
,
"
gemini_text
"
,
"
gemini_image
"
];
createForm
.
mcp_xml_inject
=
true
;
createForm
.
copy_accounts_from_group_ids
=
[];
createModelRoutingRules
.
value
=
[];
};
const
normalizeOptionalLimit
=
(
value
:
number
|
string
|
null
|
undefined
,
):
number
|
null
=>
{
if
(
value
===
null
||
value
===
undefined
)
{
return
null
return
null
;
}
if
(
typeof
value
===
'
string
'
)
{
const
trimmed
=
value
.
trim
()
if
(
typeof
value
===
"
string
"
)
{
const
trimmed
=
value
.
trim
()
;
if
(
!
trimmed
)
{
return
null
return
null
;
}
const
parsed
=
Number
(
trimmed
)
return
Number
.
isFinite
(
parsed
)
&&
parsed
>
0
?
parsed
:
null
const
parsed
=
Number
(
trimmed
)
;
return
Number
.
isFinite
(
parsed
)
&&
parsed
>
0
?
parsed
:
null
;
}
return
Number
.
isFinite
(
value
)
&&
value
>
0
?
value
:
null
}
return
Number
.
isFinite
(
value
)
&&
value
>
0
?
value
:
null
;
}
;
const
handleCreateGroup
=
async
()
=>
{
if
(
!
createForm
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.groups.nameRequired
'
))
return
appStore
.
showError
(
t
(
"
admin.groups.nameRequired
"
))
;
return
;
}
submitting
.
value
=
true
submitting
.
value
=
true
;
try
{
// 构建请求数据,包含模型路由配置
const
requestData
=
{
...
createForm
,
daily_limit_usd
:
normalizeOptionalLimit
(
createForm
.
daily_limit_usd
as
number
|
string
|
null
),
weekly_limit_usd
:
normalizeOptionalLimit
(
createForm
.
weekly_limit_usd
as
number
|
string
|
null
),
monthly_limit_usd
:
normalizeOptionalLimit
(
createForm
.
monthly_limit_usd
as
number
|
string
|
null
),
model_routing
:
convertRoutingRulesToApiFormat
(
createModelRoutingRules
.
value
)
}
daily_limit_usd
:
normalizeOptionalLimit
(
createForm
.
daily_limit_usd
as
number
|
string
|
null
,
),
weekly_limit_usd
:
normalizeOptionalLimit
(
createForm
.
weekly_limit_usd
as
number
|
string
|
null
,
),
monthly_limit_usd
:
normalizeOptionalLimit
(
createForm
.
monthly_limit_usd
as
number
|
string
|
null
,
),
model_routing
:
convertRoutingRulesToApiFormat
(
createModelRoutingRules
.
value
,
),
messages_dispatch_model_config
:
createForm
.
platform
===
"
openai
"
?
messagesDispatchFormStateToConfig
({
allow_messages_dispatch
:
createForm
.
allow_messages_dispatch
,
opus_mapped_model
:
createForm
.
opus_mapped_model
,
sonnet_mapped_model
:
createForm
.
sonnet_mapped_model
,
haiku_mapped_model
:
createForm
.
haiku_mapped_model
,
exact_model_mappings
:
createForm
.
exact_model_mappings
,
})
:
undefined
,
};
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const
emptyToNull
=
(
v
:
any
)
=>
v
===
''
?
null
:
v
requestData
.
daily_limit_usd
=
emptyToNull
(
requestData
.
daily_limit_usd
)
requestData
.
weekly_limit_usd
=
emptyToNull
(
requestData
.
weekly_limit_usd
)
requestData
.
monthly_limit_usd
=
emptyToNull
(
requestData
.
monthly_limit_usd
)
await
adminAPI
.
groups
.
create
(
requestData
)
appStore
.
showSuccess
(
t
(
'
admin.groups.groupCreated
'
))
closeCreateModal
()
loadGroups
()
const
emptyToNull
=
(
v
:
any
)
=>
(
v
===
""
?
null
:
v
);
requestData
.
daily_limit_usd
=
emptyToNull
(
requestData
.
daily_limit_usd
)
;
requestData
.
weekly_limit_usd
=
emptyToNull
(
requestData
.
weekly_limit_usd
)
;
requestData
.
monthly_limit_usd
=
emptyToNull
(
requestData
.
monthly_limit_usd
)
;
await
adminAPI
.
groups
.
create
(
requestData
)
;
appStore
.
showSuccess
(
t
(
"
admin.groups.groupCreated
"
))
;
closeCreateModal
()
;
loadGroups
()
;
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="group-form-submit"]
'
))
{
onboardingStore
.
nextStep
(
500
)
onboardingStore
.
nextStep
(
500
)
;
}
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.groups.failedToCreate
'
))
console
.
error
(
'
Error creating group:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
"
admin.groups.failedToCreate
"
),
);
console
.
error
(
"
Error creating group:
"
,
error
);
// Don't advance tour on error
}
finally
{
submitting
.
value
=
false
submitting
.
value
=
false
;
}
}
}
;
const
handleEdit
=
async
(
group
:
AdminGroup
)
=>
{
editingGroup
.
value
=
group
editForm
.
name
=
group
.
name
editForm
.
description
=
group
.
description
||
''
editForm
.
platform
=
group
.
platform
editForm
.
rate_multiplier
=
group
.
rate_multiplier
editForm
.
is_exclusive
=
group
.
is_exclusive
editForm
.
status
=
group
.
status
editForm
.
subscription_type
=
group
.
subscription_type
||
'
standard
'
editForm
.
daily_limit_usd
=
group
.
daily_limit_usd
editForm
.
weekly_limit_usd
=
group
.
weekly_limit_usd
editForm
.
monthly_limit_usd
=
group
.
monthly_limit_usd
editForm
.
image_price_1k
=
group
.
image_price_1k
editForm
.
image_price_2k
=
group
.
image_price_2k
editForm
.
image_price_4k
=
group
.
image_price_4k
editForm
.
claude_code_only
=
group
.
claude_code_only
||
false
editForm
.
fallback_group_id
=
group
.
fallback_group_id
editForm
.
fallback_group_id_on_invalid_request
=
group
.
fallback_group_id_on_invalid_request
editForm
.
allow_messages_dispatch
=
group
.
allow_messages_dispatch
||
false
editForm
.
require_oauth_only
=
group
.
require_oauth_only
??
false
editForm
.
require_privacy_set
=
group
.
require_privacy_set
??
false
editForm
.
default_mapped_model
=
group
.
default_mapped_model
||
''
editForm
.
model_routing_enabled
=
group
.
model_routing_enabled
||
false
editForm
.
supported_model_scopes
=
group
.
supported_model_scopes
||
[
'
claude
'
,
'
gemini_text
'
,
'
gemini_image
'
]
editForm
.
mcp_xml_inject
=
group
.
mcp_xml_inject
??
true
editForm
.
copy_accounts_from_group_ids
=
[]
// 复制账号字段每次编辑时重置为空
editingGroup
.
value
=
group
;
editForm
.
name
=
group
.
name
;
editForm
.
description
=
group
.
description
||
""
;
editForm
.
platform
=
group
.
platform
;
editForm
.
rate_multiplier
=
group
.
rate_multiplier
;
editForm
.
is_exclusive
=
group
.
is_exclusive
;
editForm
.
status
=
group
.
status
;
editForm
.
subscription_type
=
group
.
subscription_type
||
"
standard
"
;
editForm
.
daily_limit_usd
=
group
.
daily_limit_usd
;
editForm
.
weekly_limit_usd
=
group
.
weekly_limit_usd
;
editForm
.
monthly_limit_usd
=
group
.
monthly_limit_usd
;
editForm
.
image_price_1k
=
group
.
image_price_1k
;
editForm
.
image_price_2k
=
group
.
image_price_2k
;
editForm
.
image_price_4k
=
group
.
image_price_4k
;
editForm
.
claude_code_only
=
group
.
claude_code_only
||
false
;
editForm
.
fallback_group_id
=
group
.
fallback_group_id
;
editForm
.
fallback_group_id_on_invalid_request
=
group
.
fallback_group_id_on_invalid_request
;
const
messagesDispatchFormState
=
messagesDispatchConfigToFormState
(
group
.
messages_dispatch_model_config
,
);
editForm
.
allow_messages_dispatch
=
group
.
allow_messages_dispatch
||
messagesDispatchFormState
.
allow_messages_dispatch
;
editForm
.
opus_mapped_model
=
messagesDispatchFormState
.
opus_mapped_model
;
editForm
.
sonnet_mapped_model
=
messagesDispatchFormState
.
sonnet_mapped_model
;
editForm
.
haiku_mapped_model
=
messagesDispatchFormState
.
haiku_mapped_model
;
editForm
.
exact_model_mappings
=
messagesDispatchFormState
.
exact_model_mappings
;
editForm
.
require_oauth_only
=
group
.
require_oauth_only
??
false
;
editForm
.
require_privacy_set
=
group
.
require_privacy_set
??
false
;
editForm
.
model_routing_enabled
=
group
.
model_routing_enabled
||
false
;
editForm
.
supported_model_scopes
=
group
.
supported_model_scopes
||
[
"
claude
"
,
"
gemini_text
"
,
"
gemini_image
"
,
];
editForm
.
mcp_xml_inject
=
group
.
mcp_xml_inject
??
true
;
editForm
.
copy_accounts_from_group_ids
=
[];
// 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules
.
value
=
await
convertApiFormatToRoutingRules
(
group
.
model_routing
)
showEditModal
.
value
=
true
}
editModelRoutingRules
.
value
=
await
convertApiFormatToRoutingRules
(
group
.
model_routing
,
);
showEditModal
.
value
=
true
;
};
const
closeEditModal
=
()
=>
{
editModelRoutingRules
.
value
.
forEach
((
rule
)
=>
{
accountSearchRunner
.
clearKey
(
getEditRuleSearchKey
(
rule
))
})
clearAllAccountSearchState
()
showEditModal
.
value
=
false
editingGroup
.
value
=
null
editModelRoutingRules
.
value
=
[]
editForm
.
copy_accounts_from_group_ids
=
[]
}
accountSearchRunner
.
clearKey
(
getEditRuleSearchKey
(
rule
));
});
clearAllAccountSearchState
();
showEditModal
.
value
=
false
;
editingGroup
.
value
=
null
;
editModelRoutingRules
.
value
=
[];
editForm
.
copy_accounts_from_group_ids
=
[];
resetMessagesDispatchFormState
(
editForm
);
};
const
handleUpdateGroup
=
async
()
=>
{
if
(
!
editingGroup
.
value
)
return
if
(
!
editingGroup
.
value
)
return
;
if
(
!
editForm
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.groups.nameRequired
'
))
return
appStore
.
showError
(
t
(
"
admin.groups.nameRequired
"
))
;
return
;
}
submitting
.
value
=
true
submitting
.
value
=
true
;
try
{
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const
payload
=
{
...
editForm
,
daily_limit_usd
:
normalizeOptionalLimit
(
editForm
.
daily_limit_usd
as
number
|
string
|
null
),
weekly_limit_usd
:
normalizeOptionalLimit
(
editForm
.
weekly_limit_usd
as
number
|
string
|
null
),
monthly_limit_usd
:
normalizeOptionalLimit
(
editForm
.
monthly_limit_usd
as
number
|
string
|
null
),
fallback_group_id
:
editForm
.
fallback_group_id
===
null
?
0
:
editForm
.
fallback_group_id
,
daily_limit_usd
:
normalizeOptionalLimit
(
editForm
.
daily_limit_usd
as
number
|
string
|
null
,
),
weekly_limit_usd
:
normalizeOptionalLimit
(
editForm
.
weekly_limit_usd
as
number
|
string
|
null
,
),
monthly_limit_usd
:
normalizeOptionalLimit
(
editForm
.
monthly_limit_usd
as
number
|
string
|
null
,
),
fallback_group_id
:
editForm
.
fallback_group_id
===
null
?
0
:
editForm
.
fallback_group_id
,
fallback_group_id_on_invalid_request
:
editForm
.
fallback_group_id_on_invalid_request
===
null
?
0
:
editForm
.
fallback_group_id_on_invalid_request
,
model_routing
:
convertRoutingRulesToApiFormat
(
editModelRoutingRules
.
value
)
}
model_routing
:
convertRoutingRulesToApiFormat
(
editModelRoutingRules
.
value
,
),
messages_dispatch_model_config
:
editForm
.
platform
===
"
openai
"
?
messagesDispatchFormStateToConfig
({
allow_messages_dispatch
:
editForm
.
allow_messages_dispatch
,
opus_mapped_model
:
editForm
.
opus_mapped_model
,
sonnet_mapped_model
:
editForm
.
sonnet_mapped_model
,
haiku_mapped_model
:
editForm
.
haiku_mapped_model
,
exact_model_mappings
:
editForm
.
exact_model_mappings
,
})
:
undefined
,
};
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const
emptyToNull
=
(
v
:
any
)
=>
v
===
''
?
null
:
v
payload
.
daily_limit_usd
=
emptyToNull
(
payload
.
daily_limit_usd
)
payload
.
weekly_limit_usd
=
emptyToNull
(
payload
.
weekly_limit_usd
)
payload
.
monthly_limit_usd
=
emptyToNull
(
payload
.
monthly_limit_usd
)
await
adminAPI
.
groups
.
update
(
editingGroup
.
value
.
id
,
payload
)
appStore
.
showSuccess
(
t
(
'
admin.groups.groupUpdated
'
))
closeEditModal
()
loadGroups
()
const
emptyToNull
=
(
v
:
any
)
=>
(
v
===
""
?
null
:
v
);
payload
.
daily_limit_usd
=
emptyToNull
(
payload
.
daily_limit_usd
)
;
payload
.
weekly_limit_usd
=
emptyToNull
(
payload
.
weekly_limit_usd
)
;
payload
.
monthly_limit_usd
=
emptyToNull
(
payload
.
monthly_limit_usd
)
;
await
adminAPI
.
groups
.
update
(
editingGroup
.
value
.
id
,
payload
)
;
appStore
.
showSuccess
(
t
(
"
admin.groups.groupUpdated
"
))
;
closeEditModal
()
;
loadGroups
()
;
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.groups.failedToUpdate
'
))
console
.
error
(
'
Error updating group:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
"
admin.groups.failedToUpdate
"
),
);
console
.
error
(
"
Error updating group:
"
,
error
);
}
finally
{
submitting
.
value
=
false
submitting
.
value
=
false
;
}
}
};
const
addCreateMessagesDispatchMapping
=
()
=>
{
createForm
.
exact_model_mappings
.
push
({
claude_model
:
""
,
target_model
:
""
});
};
const
removeCreateMessagesDispatchMapping
=
(
row
:
MessagesDispatchMappingRow
,
)
=>
{
const
index
=
createForm
.
exact_model_mappings
.
indexOf
(
row
);
if
(
index
!==
-
1
)
{
createForm
.
exact_model_mappings
.
splice
(
index
,
1
);
}
};
const
addEditMessagesDispatchMapping
=
()
=>
{
editForm
.
exact_model_mappings
.
push
({
claude_model
:
""
,
target_model
:
""
});
};
const
removeEditMessagesDispatchMapping
=
(
row
:
MessagesDispatchMappingRow
)
=>
{
const
index
=
editForm
.
exact_model_mappings
.
indexOf
(
row
);
if
(
index
!==
-
1
)
{
editForm
.
exact_model_mappings
.
splice
(
index
,
1
);
}
};
const
handleRateMultipliers
=
(
group
:
AdminGroup
)
=>
{
rateMultipliersGroup
.
value
=
group
showRateMultipliersModal
.
value
=
true
}
rateMultipliersGroup
.
value
=
group
;
showRateMultipliersModal
.
value
=
true
;
}
;
const
handleDelete
=
(
group
:
AdminGroup
)
=>
{
deletingGroup
.
value
=
group
showDeleteDialog
.
value
=
true
}
deletingGroup
.
value
=
group
;
showDeleteDialog
.
value
=
true
;
}
;
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingGroup
.
value
)
return
if
(
!
deletingGroup
.
value
)
return
;
try
{
await
adminAPI
.
groups
.
delete
(
deletingGroup
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.groups.groupDeleted
'
))
showDeleteDialog
.
value
=
false
deletingGroup
.
value
=
null
loadGroups
()
await
adminAPI
.
groups
.
delete
(
deletingGroup
.
value
.
id
)
;
appStore
.
showSuccess
(
t
(
"
admin.groups.groupDeleted
"
))
;
showDeleteDialog
.
value
=
false
;
deletingGroup
.
value
=
null
;
loadGroups
()
;
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.groups.failedToDelete
'
))
console
.
error
(
'
Error deleting group:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
"
admin.groups.failedToDelete
"
),
);
console
.
error
(
"
Error deleting group:
"
,
error
);
}
}
}
;
// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true
watch
(
()
=>
createForm
.
subscription_type
,
(
newVal
)
=>
{
if
(
newVal
===
'
subscription
'
)
{
createForm
.
is_exclusive
=
true
createForm
.
fallback_group_id_on_invalid_request
=
null
if
(
newVal
===
"
subscription
"
)
{
createForm
.
is_exclusive
=
true
;
createForm
.
fallback_group_id_on_invalid_request
=
null
;
}
}
)
}
,
)
;
watch
(
()
=>
createForm
.
platform
,
(
newVal
)
=>
{
if
(
!
[
'
anthropic
'
,
'
antigravity
'
].
includes
(
newVal
))
{
createForm
.
fallback_group_id_on_invalid_request
=
null
if
(
!
[
"
anthropic
"
,
"
antigravity
"
].
includes
(
newVal
))
{
createForm
.
fallback_group_id_on_invalid_request
=
null
;
}
if
(
newVal
!==
'
openai
'
)
{
createForm
.
allow_messages_dispatch
=
false
createForm
.
default_mapped_model
=
''
if
(
newVal
!==
"
openai
"
)
{
resetMessagesDispatchFormState
(
createForm
);
}
if
(
!
[
'
openai
'
,
'
antigravity
'
,
'
anthropic
'
,
'
gemini
'
].
includes
(
newVal
))
{
createForm
.
require_oauth_only
=
false
createForm
.
require_privacy_set
=
false
if
(
!
[
"
openai
"
,
"
antigravity
"
,
"
anthropic
"
,
"
gemini
"
].
includes
(
newVal
))
{
createForm
.
require_oauth_only
=
false
;
createForm
.
require_privacy_set
=
false
;
}
}
)
},
);
watch
(
()
=>
editForm
.
platform
,
(
newVal
)
=>
{
if
(
!
[
"
anthropic
"
,
"
antigravity
"
].
includes
(
newVal
))
{
editForm
.
fallback_group_id_on_invalid_request
=
null
;
}
if
(
newVal
!==
"
openai
"
)
{
resetMessagesDispatchFormState
(
editForm
);
}
if
(
!
[
"
openai
"
,
"
antigravity
"
,
"
anthropic
"
,
"
gemini
"
].
includes
(
newVal
))
{
editForm
.
require_oauth_only
=
false
;
editForm
.
require_privacy_set
=
false
;
}
},
);
// 点击外部关闭账号搜索下拉框
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
const
target
=
event
.
target
as
HTMLElement
;
// 检查是否点击在下拉框或输入框内
if
(
!
target
.
closest
(
'
.account-search-container
'
))
{
Object
.
keys
(
showAccountDropdown
.
value
).
forEach
(
key
=>
{
showAccountDropdown
.
value
[
key
]
=
false
})
if
(
!
target
.
closest
(
"
.account-search-container
"
))
{
Object
.
keys
(
showAccountDropdown
.
value
).
forEach
(
(
key
)
=>
{
showAccountDropdown
.
value
[
key
]
=
false
;
})
;
}
}
}
;
// 打开排序弹窗
const
openSortModal
=
async
()
=>
{
try
{
// 获取所有分组(不分页)
const
allGroups
=
await
adminAPI
.
groups
.
getAll
()
const
allGroups
=
await
adminAPI
.
groups
.
getAll
()
;
// 按 sort_order 排序
sortableGroups
.
value
=
[...
allGroups
].
sort
((
a
,
b
)
=>
a
.
sort_order
-
b
.
sort_order
)
showSortModal
.
value
=
true
sortableGroups
.
value
=
[...
allGroups
].
sort
(
(
a
,
b
)
=>
a
.
sort_order
-
b
.
sort_order
,
);
showSortModal
.
value
=
true
;
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
admin.groups.failedToLoad
'
))
console
.
error
(
'
Error loading groups for sorting:
'
,
error
)
appStore
.
showError
(
t
(
"
admin.groups.failedToLoad
"
))
;
console
.
error
(
"
Error loading groups for sorting:
"
,
error
)
;
}
}
}
;
// 关闭排序弹窗
const
closeSortModal
=
()
=>
{
showSortModal
.
value
=
false
sortableGroups
.
value
=
[]
}
showSortModal
.
value
=
false
;
sortableGroups
.
value
=
[]
;
}
;
// 保存排序
const
saveSortOrder
=
async
()
=>
{
sortSubmitting
.
value
=
true
sortSubmitting
.
value
=
true
;
try
{
const
updates
=
sortableGroups
.
value
.
map
((
g
,
index
)
=>
({
id
:
g
.
id
,
sort_order
:
index
*
10
}))
await
adminAPI
.
groups
.
updateSortOrder
(
updates
)
appStore
.
showSuccess
(
t
(
'
admin.groups.sortOrderUpdated
'
))
closeSortModal
()
loadGroups
()
sort_order
:
index
*
10
,
}))
;
await
adminAPI
.
groups
.
updateSortOrder
(
updates
)
;
appStore
.
showSuccess
(
t
(
"
admin.groups.sortOrderUpdated
"
))
;
closeSortModal
()
;
loadGroups
()
;
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.groups.failedToUpdateSortOrder
'
))
console
.
error
(
'
Error updating sort order:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
"
admin.groups.failedToUpdateSortOrder
"
),
);
console
.
error
(
"
Error updating sort order:
"
,
error
);
}
finally
{
sortSubmitting
.
value
=
false
sortSubmitting
.
value
=
false
;
}
}
}
;
onMounted
(()
=>
{
loadGroups
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
loadGroups
()
;
document
.
addEventListener
(
"
click
"
,
handleClickOutside
)
;
})
;
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
accountSearchRunner
.
clearAll
()
clearAllAccountSearchState
()
})
document
.
removeEventListener
(
"
click
"
,
handleClickOutside
)
;
accountSearchRunner
.
clearAll
()
;
clearAllAccountSearchState
()
;
})
;
</
script
>
frontend/src/views/admin/SettingsView.vue
View file @
2b70d1d3
...
...
@@ -1124,7 +1124,327 @@
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Registration
,
Turnstile
,
LinuxDo
-->
<!--
Generic
OIDC
OAuth
登录
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.title
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-5 p-6
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.enable
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.enableHint
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_enabled
"
/>
<
/div
>
<
div
v
-
if
=
"
form.oidc_connect_enabled
"
class
=
"
space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.providerName
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_provider_name
"
type
=
"
text
"
class
=
"
input
"
:
placeholder
=
"
t('admin.settings.oidc.providerNamePlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clientId
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_client_id
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.clientIdPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clientSecret
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_client_secret
"
type
=
"
password
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
form.oidc_connect_client_secret_configured
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
: t('admin.settings.oidc.clientSecretPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
form
.
oidc_connect_client_secret_configured
?
t
(
'
admin.settings.oidc.clientSecretConfiguredHint
'
)
:
t
(
'
admin.settings.oidc.clientSecretHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.issuerUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_issuer_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.issuerUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.discoveryUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_discovery_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.discoveryUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.authorizeUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_authorize_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.authorizeUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.tokenUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_token_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.tokenUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.jwksUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_jwks_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.jwksUrlPlaceholder')
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.scopes
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_scopes
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.scopesPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.scopesHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.redirectUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_redirect_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.redirectUrlPlaceholder')
"
/>
<
div
class
=
"
mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm w-fit
"
@
click
=
"
setAndCopyOIDCRedirectUrl
"
>
{{
t
(
'
admin.settings.oidc.quickSetCopy
'
)
}}
<
/button
>
<
code
v
-
if
=
"
oidcRedirectUrlSuggestion
"
class
=
"
select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300
"
>
{{
oidcRedirectUrlSuggestion
}}
<
/code
>
<
/div
>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.redirectUrlHint
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
lg:col-span-2
"
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.frontendRedirectUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_frontend_redirect_url
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.frontendRedirectUrlPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.frontendRedirectUrlHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.tokenAuthMethod
'
)
}}
<
/label
>
<
select
v
-
model
=
"
form.oidc_connect_token_auth_method
"
class
=
"
input font-mono text-sm
"
>
<
option
value
=
"
client_secret_post
"
>
client_secret_post
<
/option
>
<
option
value
=
"
client_secret_basic
"
>
client_secret_basic
<
/option
>
<
option
value
=
"
none
"
>
none
<
/option
>
<
/select
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clockSkewSeconds
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.oidc_connect_clock_skew_seconds
"
type
=
"
number
"
min
=
"
0
"
max
=
"
600
"
class
=
"
input
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.allowedSigningAlgs
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_allowed_signing_algs
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.allowedSigningAlgsPlaceholder')
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.usePkce
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_use_pkce
"
/>
<
/div
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.validateIdToken
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_validate_id_token
"
/>
<
/div
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.requireEmailVerified
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_require_email_verified
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoEmailPath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_email_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoEmailPathPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoIdPath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_id_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoIdPathPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoUsernamePath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_username_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoUsernamePathPlaceholder')
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Registration
,
Turnstile
,
LinuxDo
,
OIDC
-->
<!--
Tab
:
Users
-->
<
div
v
-
show
=
"
activeTab === 'users'
"
class
=
"
space-y-6
"
>
...
...
@@ -2240,6 +2560,7 @@ type SettingsForm = SystemSettings & {
smtp_password
:
string
turnstile_secret_key
:
string
linuxdo_connect_client_secret
:
string
oidc_connect_client_secret
:
string
}
const
form
=
reactive
<
SettingsForm
>
({
...
...
@@ -2289,6 +2610,30 @@ const form = reactive<SettingsForm>({
linuxdo_connect_client_secret
:
''
,
linuxdo_connect_client_secret_configured
:
false
,
linuxdo_connect_redirect_url
:
''
,
// Generic OIDC OAuth 登录
oidc_connect_enabled
:
false
,
oidc_connect_provider_name
:
'
OIDC
'
,
oidc_connect_client_id
:
''
,
oidc_connect_client_secret
:
''
,
oidc_connect_client_secret_configured
:
false
,
oidc_connect_issuer_url
:
''
,
oidc_connect_discovery_url
:
''
,
oidc_connect_authorize_url
:
''
,
oidc_connect_token_url
:
''
,
oidc_connect_userinfo_url
:
''
,
oidc_connect_jwks_url
:
''
,
oidc_connect_scopes
:
'
openid email profile
'
,
oidc_connect_redirect_url
:
''
,
oidc_connect_frontend_redirect_url
:
'
/auth/oidc/callback
'
,
oidc_connect_token_auth_method
:
'
client_secret_post
'
,
oidc_connect_use_pkce
:
false
,
oidc_connect_validate_id_token
:
true
,
oidc_connect_allowed_signing_algs
:
'
RS256,ES256,PS256
'
,
oidc_connect_clock_skew_seconds
:
120
,
oidc_connect_require_email_verified
:
false
,
oidc_connect_userinfo_email_path
:
''
,
oidc_connect_userinfo_id_path
:
''
,
oidc_connect_userinfo_username_path
:
''
,
// Model fallback
enable_model_fallback
:
false
,
fallback_model_anthropic
:
'
claude-3-5-sonnet-20241022
'
,
...
...
@@ -2409,6 +2754,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
await
copyToClipboard
(
url
,
t
(
'
admin.settings.linuxdo.redirectUrlSetAndCopied
'
))
}
const
oidcRedirectUrlSuggestion
=
computed
(()
=>
{
if
(
typeof
window
===
'
undefined
'
)
return
''
const
origin
=
window
.
location
.
origin
||
`${window.location.protocol
}
//${window.location.host
}
`
return
`${origin
}
/api/v1/auth/oauth/oidc/callback`
}
)
async
function
setAndCopyOIDCRedirectUrl
()
{
const
url
=
oidcRedirectUrlSuggestion
.
value
if
(
!
url
)
return
form
.
oidc_connect_redirect_url
=
url
await
copyToClipboard
(
url
,
t
(
'
admin.settings.oidc.redirectUrlSetAndCopied
'
))
}
// Custom menu item management
function
addMenuItem
()
{
form
.
custom_menu_items
.
push
({
...
...
@@ -2506,6 +2866,7 @@ async function loadSettings() {
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
}
catch
(
error
:
any
)
{
loadFailed
.
value
=
true
appStore
.
showError
(
...
...
@@ -2673,6 +3034,28 @@ async function saveSettings() {
linuxdo_connect_client_id
:
form
.
linuxdo_connect_client_id
,
linuxdo_connect_client_secret
:
form
.
linuxdo_connect_client_secret
||
undefined
,
linuxdo_connect_redirect_url
:
form
.
linuxdo_connect_redirect_url
,
oidc_connect_enabled
:
form
.
oidc_connect_enabled
,
oidc_connect_provider_name
:
form
.
oidc_connect_provider_name
,
oidc_connect_client_id
:
form
.
oidc_connect_client_id
,
oidc_connect_client_secret
:
form
.
oidc_connect_client_secret
||
undefined
,
oidc_connect_issuer_url
:
form
.
oidc_connect_issuer_url
,
oidc_connect_discovery_url
:
form
.
oidc_connect_discovery_url
,
oidc_connect_authorize_url
:
form
.
oidc_connect_authorize_url
,
oidc_connect_token_url
:
form
.
oidc_connect_token_url
,
oidc_connect_userinfo_url
:
form
.
oidc_connect_userinfo_url
,
oidc_connect_jwks_url
:
form
.
oidc_connect_jwks_url
,
oidc_connect_scopes
:
form
.
oidc_connect_scopes
,
oidc_connect_redirect_url
:
form
.
oidc_connect_redirect_url
,
oidc_connect_frontend_redirect_url
:
form
.
oidc_connect_frontend_redirect_url
,
oidc_connect_token_auth_method
:
form
.
oidc_connect_token_auth_method
,
oidc_connect_use_pkce
:
form
.
oidc_connect_use_pkce
,
oidc_connect_validate_id_token
:
form
.
oidc_connect_validate_id_token
,
oidc_connect_allowed_signing_algs
:
form
.
oidc_connect_allowed_signing_algs
,
oidc_connect_clock_skew_seconds
:
form
.
oidc_connect_clock_skew_seconds
,
oidc_connect_require_email_verified
:
form
.
oidc_connect_require_email_verified
,
oidc_connect_userinfo_email_path
:
form
.
oidc_connect_userinfo_email_path
,
oidc_connect_userinfo_id_path
:
form
.
oidc_connect_userinfo_id_path
,
oidc_connect_userinfo_username_path
:
form
.
oidc_connect_userinfo_username_path
,
enable_model_fallback
:
form
.
enable_model_fallback
,
fallback_model_anthropic
:
form
.
fallback_model_anthropic
,
fallback_model_openai
:
form
.
fallback_model_openai
,
...
...
@@ -2700,6 +3083,7 @@ async function saveSettings() {
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
// Refresh cached settings so sidebar/header update immediately
await
appStore
.
fetchPublicSettings
(
true
)
await
adminSettingsStore
.
fetch
(
true
)
...
...
frontend/src/views/admin/__tests__/groupsMessagesDispatch.spec.ts
0 → 100644
View file @
2b70d1d3
import
{
describe
,
expect
,
it
}
from
"
vitest
"
;
import
{
createDefaultMessagesDispatchFormState
,
messagesDispatchConfigToFormState
,
messagesDispatchFormStateToConfig
,
resetMessagesDispatchFormState
,
}
from
"
../groupsMessagesDispatch
"
;
describe
(
"
groupsMessagesDispatch
"
,
()
=>
{
it
(
"
returns the expected default form state
"
,
()
=>
{
expect
(
createDefaultMessagesDispatchFormState
()).
toEqual
({
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[],
});
});
it
(
"
sanitizes exact model mapping rows when converting to config
"
,
()
=>
{
const
config
=
messagesDispatchFormStateToConfig
({
allow_messages_dispatch
:
true
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[
{
claude_model
:
"
claude-sonnet-4-5-20250929
"
,
target_model
:
"
gpt-5.2
"
,
},
{
claude_model
:
""
,
target_model
:
"
gpt-5.4
"
},
{
claude_model
:
"
claude-opus-4-6
"
,
target_model
:
"
"
},
],
});
expect
(
config
).
toEqual
({
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
{
"
claude-sonnet-4-5-20250929
"
:
"
gpt-5.2
"
,
},
});
});
it
(
"
hydrates form state from api config
"
,
()
=>
{
expect
(
messagesDispatchConfigToFormState
({
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.2
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
{
"
claude-opus-4-6
"
:
"
gpt-5.4
"
,
"
claude-haiku-4-5-20251001
"
:
"
gpt-5.4-mini
"
,
},
}),
).
toEqual
({
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.2
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[
{
claude_model
:
"
claude-haiku-4-5-20251001
"
,
target_model
:
"
gpt-5.4-mini
"
,
},
{
claude_model
:
"
claude-opus-4-6
"
,
target_model
:
"
gpt-5.4
"
},
],
});
});
it
(
"
resets mutable form state when platform switches away from openai
"
,
()
=>
{
const
state
=
{
allow_messages_dispatch
:
true
,
opus_mapped_model
:
"
gpt-5.2
"
,
sonnet_mapped_model
:
"
gpt-5.4
"
,
haiku_mapped_model
:
"
gpt-5.1
"
,
exact_model_mappings
:
[
{
claude_model
:
"
claude-opus-4-6
"
,
target_model
:
"
gpt-5.4
"
},
],
};
resetMessagesDispatchFormState
(
state
);
expect
(
state
).
toEqual
({
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[],
});
});
});
frontend/src/views/admin/groupsMessagesDispatch.ts
0 → 100644
View file @
2b70d1d3
import
type
{
OpenAIMessagesDispatchModelConfig
}
from
"
@/types
"
;
export
interface
MessagesDispatchMappingRow
{
claude_model
:
string
;
target_model
:
string
;
}
export
interface
MessagesDispatchFormState
{
allow_messages_dispatch
:
boolean
;
opus_mapped_model
:
string
;
sonnet_mapped_model
:
string
;
haiku_mapped_model
:
string
;
exact_model_mappings
:
MessagesDispatchMappingRow
[];
}
export
function
createDefaultMessagesDispatchFormState
():
MessagesDispatchFormState
{
return
{
allow_messages_dispatch
:
false
,
opus_mapped_model
:
"
gpt-5.4
"
,
sonnet_mapped_model
:
"
gpt-5.3-codex
"
,
haiku_mapped_model
:
"
gpt-5.4-mini
"
,
exact_model_mappings
:
[],
};
}
export
function
messagesDispatchConfigToFormState
(
config
?:
OpenAIMessagesDispatchModelConfig
|
null
,
):
MessagesDispatchFormState
{
const
defaults
=
createDefaultMessagesDispatchFormState
();
const
exactMappings
=
Object
.
entries
(
config
?.
exact_model_mappings
||
{})
.
sort
(([
left
],
[
right
])
=>
left
.
localeCompare
(
right
))
.
map
(([
claude_model
,
target_model
])
=>
({
claude_model
,
target_model
}));
return
{
allow_messages_dispatch
:
false
,
opus_mapped_model
:
config
?.
opus_mapped_model
?.
trim
()
||
defaults
.
opus_mapped_model
,
sonnet_mapped_model
:
config
?.
sonnet_mapped_model
?.
trim
()
||
defaults
.
sonnet_mapped_model
,
haiku_mapped_model
:
config
?.
haiku_mapped_model
?.
trim
()
||
defaults
.
haiku_mapped_model
,
exact_model_mappings
:
exactMappings
,
};
}
export
function
messagesDispatchFormStateToConfig
(
state
:
MessagesDispatchFormState
,
):
OpenAIMessagesDispatchModelConfig
{
const
exactModelMappings
=
Object
.
fromEntries
(
state
.
exact_model_mappings
.
map
((
row
)
=>
[
row
.
claude_model
.
trim
(),
row
.
target_model
.
trim
()]
as
const
)
.
filter
(([
claudeModel
,
targetModel
])
=>
claudeModel
&&
targetModel
),
);
return
{
opus_mapped_model
:
state
.
opus_mapped_model
.
trim
(),
sonnet_mapped_model
:
state
.
sonnet_mapped_model
.
trim
(),
haiku_mapped_model
:
state
.
haiku_mapped_model
.
trim
(),
exact_model_mappings
:
exactModelMappings
,
};
}
export
function
resetMessagesDispatchFormState
(
target
:
MessagesDispatchFormState
,
):
void
{
const
defaults
=
createDefaultMessagesDispatchFormState
();
target
.
allow_messages_dispatch
=
defaults
.
allow_messages_dispatch
;
target
.
opus_mapped_model
=
defaults
.
opus_mapped_model
;
target
.
sonnet_mapped_model
=
defaults
.
sonnet_mapped_model
;
target
.
haiku_mapped_model
=
defaults
.
haiku_mapped_model
;
target
.
exact_model_mappings
=
[];
}
frontend/src/views/auth/LoginView.vue
View file @
2b70d1d3
...
...
@@ -11,8 +11,26 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled && !backendModeEnabled"
:disabled=
"isLoading"
/>
<div
v-if=
"!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)"
class=
"space-y-4"
>
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
:show-divider=
"false"
/>
<OidcOAuthSection
v-if=
"oidcOAuthEnabled"
:disabled=
"isLoading"
:provider-name=
"oidcOAuthProviderName"
:show-divider=
"false"
/>
<div
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
<!-- Login Form -->
<form
@
submit.prevent=
"handleLogin"
class=
"space-y-5"
>
...
...
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
OidcOAuthSection
from
'
@/components/auth/OidcOAuthSection.vue
'
import
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
...
...
@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
backendModeEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
...
...
@@ -247,6 +268,9 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
...
...
frontend/src/views/auth/OidcCallbackView.vue
0 → 100644
View file @
2b70d1d3
<
template
>
<AuthLayout>
<div
class=
"space-y-6"
>
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.oidc.callbackTitle
'
,
{
providerName
}
)
}}
<
/h2
>
<
p
class
=
"
mt-2 text-sm text-gray-500 dark:text-dark-400
"
>
{{
isProcessing
?
t
(
'
auth.oidc.callbackProcessing
'
,
{
providerName
}
)
:
t
(
'
auth.oidc.callbackHint
'
)
}}
<
/p
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
needsInvitation
"
class
=
"
space-y-4
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
auth.oidc.invitationRequired
'
,
{
providerName
}
)
}}
<
/p
>
<
div
>
<
input
v
-
model
=
"
invitationCode
"
type
=
"
text
"
class
=
"
input w-full
"
:
placeholder
=
"
t('auth.invitationCodePlaceholder')
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleSubmitInvitation
"
/>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
invitationError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
invitationError
}}
<
/p
>
<
/transition
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || !invitationCode.trim()
"
@
click
=
"
handleSubmitInvitation
"
>
{{
isSubmitting
?
t
(
'
auth.oidc.completing
'
)
:
t
(
'
auth.oidc.completeRegistration
'
)
}}
<
/button
>
<
/div
>
<
/transition
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex-shrink-0
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
p
class
=
"
text-sm text-red-700 dark:text-red-400
"
>
{{
errorMessage
}}
<
/p
>
<
router
-
link
to
=
"
/login
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
auth.oidc.backToLogin
'
)
}}
<
/router-link
>
<
/div
>
<
/div
>
<
/div
>
<
/transition
>
<
/div
>
<
/AuthLayout
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
completeOIDCOAuthRegistration
,
getPublicSettings
}
from
'
@/api/auth
'
const
route
=
useRoute
()
const
router
=
useRouter
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
isProcessing
=
ref
(
true
)
const
errorMessage
=
ref
(
''
)
const
needsInvitation
=
ref
(
false
)
const
pendingOAuthToken
=
ref
(
''
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
invitationError
=
ref
(
''
)
const
redirectTo
=
ref
(
'
/dashboard
'
)
const
providerName
=
ref
(
'
OIDC
'
)
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
return
new
URLSearchParams
(
hash
)
}
function
sanitizeRedirectPath
(
path
:
string
|
null
|
undefined
):
string
{
if
(
!
path
)
return
'
/dashboard
'
if
(
!
path
.
startsWith
(
'
/
'
))
return
'
/dashboard
'
if
(
path
.
startsWith
(
'
//
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
://
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
\n
'
)
||
path
.
includes
(
'
\r
'
))
return
'
/dashboard
'
return
path
}
async
function
loadProviderName
()
{
try
{
const
settings
=
await
getPublicSettings
()
const
name
=
settings
.
oidc_oauth_provider_name
?.
trim
()
if
(
name
)
{
providerName
.
value
=
name
}
}
catch
{
// Ignore; fallback remains OIDC
}
}
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
isSubmitting
.
value
=
true
try
{
const
tokenData
=
await
completeOIDCOAuthRegistration
(
pendingOAuthToken
.
value
,
invitationCode
.
value
.
trim
()
)
if
(
tokenData
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
tokenData
.
refresh_token
)
}
if
(
tokenData
.
expires_in
)
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
tokenData
.
expires_in
*
1000
))
}
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.oidc.completeRegistrationFailed
'
)
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
void
loadProviderName
()
const
params
=
parseFragmentParams
()
const
token
=
params
.
get
(
'
access_token
'
)
||
''
const
refreshToken
=
params
.
get
(
'
refresh_token
'
)
||
''
const
expiresInStr
=
params
.
get
(
'
expires_in
'
)
||
''
const
redirect
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
)
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
if
(
error
)
{
if
(
error
===
'
invitation_required
'
)
{
pendingOAuthToken
.
value
=
params
.
get
(
'
pending_oauth_token
'
)
||
''
redirectTo
.
value
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
))
if
(
!
pendingOAuthToken
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.oidc.invalidPendingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
return
}
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
if
(
!
token
)
{
errorMessage
.
value
=
t
(
'
auth.oidc.callbackMissingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
try
{
if
(
refreshToken
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
refreshToken
)
}
if
(
expiresInStr
)
{
const
expiresIn
=
parseInt
(
expiresInStr
,
10
)
if
(
!
isNaN
(
expiresIn
))
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
expiresIn
*
1000
))
}
}
await
authStore
.
setToken
(
token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
}
}
)
<
/script
>
<
style
scoped
>
.
fade
-
enter
-
active
,
.
fade
-
leave
-
active
{
transition
:
all
0.3
s
ease
;
}
.
fade
-
enter
-
from
,
.
fade
-
leave
-
to
{
opacity
:
0
;
transform
:
translateY
(
-
8
px
);
}
<
/style
>
frontend/src/views/auth/RegisterView.vue
View file @
2b70d1d3
...
...
@@ -11,8 +11,26 @@
<
/p
>
<
/div
>
<!--
LinuxDo
Connect
OAuth
登录
-->
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
/>
<
div
v
-
if
=
"
linuxdoOAuthEnabled || oidcOAuthEnabled
"
class
=
"
space-y-4
"
>
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
show
-
divider
=
"
false
"
/>
<
OidcOAuthSection
v
-
if
=
"
oidcOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
provider
-
name
=
"
oidcOAuthProviderName
"
:
show
-
divider
=
"
false
"
/>
<
div
class
=
"
flex items-center gap-3
"
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
span
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
<
/span
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
/div
>
<
/div
>
<!--
Registration
Disabled
Message
-->
<
div
...
...
@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
OidcOAuthSection
from
'
@/components/auth/OidcOAuthSection.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
...
...
@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
registrationEmailSuffixWhitelist
=
ref
<
string
[]
>
([])
// Turnstile
...
...
@@ -376,6 +397,8 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
registrationEmailSuffixWhitelist
.
value
=
normalizeRegistrationEmailSuffixWhitelist
(
settings
.
registration_email_suffix_whitelist
||
[]
)
...
...
Prev
1
2
3
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