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
bbc79796
Unverified
Commit
bbc79796
authored
Apr 09, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 09, 2026
Browse files
Merge pull request #1529 from IanShaw027/feat/group-messages-dispatch-redo
feat: 为openai分组增加messages调度模型映射并支持instructions模板注入
parents
760cc7d6
7d008bd5
Changes
34
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/group.go
View file @
bbc79796
...
...
@@ -3,8 +3,12 @@ package service
import
(
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
type
OpenAIMessagesDispatchModelConfig
=
domain
.
OpenAIMessagesDispatchModelConfig
type
Group
struct
{
ID
int64
Name
string
...
...
@@ -49,10 +53,11 @@ type Group struct {
SortOrder
int
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch
bool
RequireOAuthOnly
bool
// 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
RequirePrivacySet
bool
// 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
DefaultMappedModel
string
AllowMessagesDispatch
bool
RequireOAuthOnly
bool
// 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
RequirePrivacySet
bool
// 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
DefaultMappedModel
string
MessagesDispatchModelConfig
OpenAIMessagesDispatchModelConfig
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
...
...
backend/internal/service/openai_codex_instructions_template.go
0 → 100644
View file @
bbc79796
package
service
import
(
"bytes"
"fmt"
"strings"
"text/template"
)
type
forcedCodexInstructionsTemplateData
struct
{
ExistingInstructions
string
OriginalModel
string
NormalizedModel
string
BillingModel
string
UpstreamModel
string
}
func
applyForcedCodexInstructionsTemplate
(
reqBody
map
[
string
]
any
,
templateText
string
,
data
forcedCodexInstructionsTemplateData
,
)
(
bool
,
error
)
{
rendered
,
err
:=
renderForcedCodexInstructionsTemplate
(
templateText
,
data
)
if
err
!=
nil
{
return
false
,
err
}
if
rendered
==
""
{
return
false
,
nil
}
existing
,
_
:=
reqBody
[
"instructions"
]
.
(
string
)
if
strings
.
TrimSpace
(
existing
)
==
rendered
{
return
false
,
nil
}
reqBody
[
"instructions"
]
=
rendered
return
true
,
nil
}
func
renderForcedCodexInstructionsTemplate
(
templateText
string
,
data
forcedCodexInstructionsTemplateData
,
)
(
string
,
error
)
{
tmpl
,
err
:=
template
.
New
(
"forced_codex_instructions"
)
.
Option
(
"missingkey=zero"
)
.
Parse
(
templateText
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"parse forced codex instructions template: %w"
,
err
)
}
var
buf
bytes
.
Buffer
if
err
:=
tmpl
.
Execute
(
&
buf
,
data
);
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"render forced codex instructions template: %w"
,
err
)
}
return
strings
.
TrimSpace
(
buf
.
String
()),
nil
}
backend/internal/service/openai_compat_model_test.go
View file @
bbc79796
...
...
@@ -6,9 +6,12 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
...
...
@@ -127,3 +130,101 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T
t
.
Logf
(
"upstream body: %s"
,
string
(
upstream
.
lastBody
))
t
.
Logf
(
"response body: %s"
,
rec
.
Body
.
String
())
}
func
TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
templateDir
:=
t
.
TempDir
()
templatePath
:=
filepath
.
Join
(
templateDir
,
"codex-instructions.md.tmpl"
)
require
.
NoError
(
t
,
os
.
WriteFile
(
templatePath
,
[]
byte
(
"server-prefix
\n\n
{{ .ExistingInstructions }}"
),
0
o644
))
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
body
:=
[]
byte
(
`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
bytes
.
NewReader
(
body
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
upstreamBody
:=
strings
.
Join
([]
string
{
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
,
""
,
"data: [DONE]"
,
""
,
},
"
\n
"
)
upstream
:=
&
httpUpstreamRecorder
{
resp
:
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
},
"x-request-id"
:
[]
string
{
"rid_forced"
}},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
upstreamBody
)),
}}
svc
:=
&
OpenAIGatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
ForcedCodexInstructionsTemplateFile
:
templatePath
,
ForcedCodexInstructionsTemplate
:
"server-prefix
\n\n
{{ .ExistingInstructions }}"
,
}},
httpUpstream
:
upstream
,
}
account
:=
&
Account
{
ID
:
1
,
Name
:
"openai-oauth"
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"oauth-token"
,
"chatgpt_account_id"
:
"chatgpt-acc"
,
},
}
result
,
err
:=
svc
.
ForwardAsAnthropic
(
context
.
Background
(),
c
,
account
,
body
,
""
,
"gpt-5.1"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"server-prefix
\n\n
client-system"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"instructions"
)
.
String
())
}
func
TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
body
:=
[]
byte
(
`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
bytes
.
NewReader
(
body
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
upstreamBody
:=
strings
.
Join
([]
string
{
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
,
""
,
"data: [DONE]"
,
""
,
},
"
\n
"
)
upstream
:=
&
httpUpstreamRecorder
{
resp
:
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
},
"x-request-id"
:
[]
string
{
"rid_forced_cached"
}},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
upstreamBody
)),
}}
svc
:=
&
OpenAIGatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
ForcedCodexInstructionsTemplateFile
:
"/path/that/should/not/be/read.tmpl"
,
ForcedCodexInstructionsTemplate
:
"cached-prefix
\n\n
{{ .ExistingInstructions }}"
,
}},
httpUpstream
:
upstream
,
}
account
:=
&
Account
{
ID
:
1
,
Name
:
"openai-oauth"
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"oauth-token"
,
"chatgpt_account_id"
:
"chatgpt-acc"
,
},
}
result
,
err
:=
svc
.
ForwardAsAnthropic
(
context
.
Background
(),
c
,
account
,
body
,
""
,
"gpt-5.1"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"cached-prefix
\n\n
client-system"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"instructions"
)
.
String
())
}
backend/internal/service/openai_gateway_messages.go
View file @
bbc79796
...
...
@@ -86,6 +86,24 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return
nil
,
fmt
.
Errorf
(
"unmarshal for codex transform: %w"
,
err
)
}
codexResult
:=
applyCodexOAuthTransform
(
reqBody
,
false
,
false
)
forcedTemplateText
:=
""
if
s
.
cfg
!=
nil
{
forcedTemplateText
=
s
.
cfg
.
Gateway
.
ForcedCodexInstructionsTemplate
}
templateUpstreamModel
:=
upstreamModel
if
codexResult
.
NormalizedModel
!=
""
{
templateUpstreamModel
=
codexResult
.
NormalizedModel
}
existingInstructions
,
_
:=
reqBody
[
"instructions"
]
.
(
string
)
if
_
,
err
:=
applyForcedCodexInstructionsTemplate
(
reqBody
,
forcedTemplateText
,
forcedCodexInstructionsTemplateData
{
ExistingInstructions
:
strings
.
TrimSpace
(
existingInstructions
),
OriginalModel
:
originalModel
,
NormalizedModel
:
normalizedModel
,
BillingModel
:
billingModel
,
UpstreamModel
:
templateUpstreamModel
,
});
err
!=
nil
{
return
nil
,
err
}
if
codexResult
.
NormalizedModel
!=
""
{
upstreamModel
=
codexResult
.
NormalizedModel
}
...
...
backend/internal/service/openai_messages_dispatch.go
0 → 100644
View file @
bbc79796
package
service
import
"strings"
const
(
defaultOpenAIMessagesDispatchOpusMappedModel
=
"gpt-5.4"
defaultOpenAIMessagesDispatchSonnetMappedModel
=
"gpt-5.3-codex"
defaultOpenAIMessagesDispatchHaikuMappedModel
=
"gpt-5.4-mini"
)
func
normalizeOpenAIMessagesDispatchMappedModel
(
model
string
)
string
{
model
=
NormalizeOpenAICompatRequestedModel
(
strings
.
TrimSpace
(
model
))
return
strings
.
TrimSpace
(
model
)
}
func
normalizeOpenAIMessagesDispatchModelConfig
(
cfg
OpenAIMessagesDispatchModelConfig
)
OpenAIMessagesDispatchModelConfig
{
out
:=
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
normalizeOpenAIMessagesDispatchMappedModel
(
cfg
.
OpusMappedModel
),
SonnetMappedModel
:
normalizeOpenAIMessagesDispatchMappedModel
(
cfg
.
SonnetMappedModel
),
HaikuMappedModel
:
normalizeOpenAIMessagesDispatchMappedModel
(
cfg
.
HaikuMappedModel
),
}
if
len
(
cfg
.
ExactModelMappings
)
>
0
{
out
.
ExactModelMappings
=
make
(
map
[
string
]
string
,
len
(
cfg
.
ExactModelMappings
))
for
requestedModel
,
mappedModel
:=
range
cfg
.
ExactModelMappings
{
requestedModel
=
strings
.
TrimSpace
(
requestedModel
)
mappedModel
=
normalizeOpenAIMessagesDispatchMappedModel
(
mappedModel
)
if
requestedModel
==
""
||
mappedModel
==
""
{
continue
}
out
.
ExactModelMappings
[
requestedModel
]
=
mappedModel
}
if
len
(
out
.
ExactModelMappings
)
==
0
{
out
.
ExactModelMappings
=
nil
}
}
return
out
}
func
claudeMessagesDispatchFamily
(
model
string
)
string
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
model
))
if
!
strings
.
HasPrefix
(
normalized
,
"claude"
)
{
return
""
}
switch
{
case
strings
.
Contains
(
normalized
,
"opus"
)
:
return
"opus"
case
strings
.
Contains
(
normalized
,
"sonnet"
)
:
return
"sonnet"
case
strings
.
Contains
(
normalized
,
"haiku"
)
:
return
"haiku"
default
:
return
""
}
}
func
(
g
*
Group
)
ResolveMessagesDispatchModel
(
requestedModel
string
)
string
{
if
g
==
nil
{
return
""
}
requestedModel
=
strings
.
TrimSpace
(
requestedModel
)
if
requestedModel
==
""
{
return
""
}
cfg
:=
normalizeOpenAIMessagesDispatchModelConfig
(
g
.
MessagesDispatchModelConfig
)
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
ExactModelMappings
[
requestedModel
]);
mappedModel
!=
""
{
return
mappedModel
}
switch
claudeMessagesDispatchFamily
(
requestedModel
)
{
case
"opus"
:
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
OpusMappedModel
);
mappedModel
!=
""
{
return
mappedModel
}
return
defaultOpenAIMessagesDispatchOpusMappedModel
case
"sonnet"
:
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
SonnetMappedModel
);
mappedModel
!=
""
{
return
mappedModel
}
return
defaultOpenAIMessagesDispatchSonnetMappedModel
case
"haiku"
:
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
HaikuMappedModel
);
mappedModel
!=
""
{
return
mappedModel
}
return
defaultOpenAIMessagesDispatchHaikuMappedModel
default
:
return
""
}
}
func
sanitizeGroupMessagesDispatchFields
(
g
*
Group
)
{
if
g
==
nil
||
g
.
Platform
==
PlatformOpenAI
{
return
}
g
.
AllowMessagesDispatch
=
false
g
.
DefaultMappedModel
=
""
g
.
MessagesDispatchModelConfig
=
OpenAIMessagesDispatchModelConfig
{}
}
backend/internal/service/openai_messages_dispatch_test.go
0 → 100644
View file @
bbc79796
package
service
import
"testing"
import
"github.com/stretchr/testify/require"
func
TestNormalizeOpenAIMessagesDispatchModelConfig
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
normalizeOpenAIMessagesDispatchModelConfig
(
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
" gpt-5.4-high "
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
" gpt-5.4-mini-medium "
,
ExactModelMappings
:
map
[
string
]
string
{
" claude-sonnet-4-5-20250929 "
:
" gpt-5.2-high "
,
""
:
"gpt-5.4"
,
"claude-opus-4-6"
:
" "
,
},
})
require
.
Equal
(
t
,
"gpt-5.4"
,
cfg
.
OpusMappedModel
)
require
.
Equal
(
t
,
"gpt-5.3-codex"
,
cfg
.
SonnetMappedModel
)
require
.
Equal
(
t
,
"gpt-5.4-mini"
,
cfg
.
HaikuMappedModel
)
require
.
Equal
(
t
,
map
[
string
]
string
{
"claude-sonnet-4-5-20250929"
:
"gpt-5.2"
,
},
cfg
.
ExactModelMappings
)
}
backend/migrations/091_add_group_messages_dispatch_model_config.sql
0 → 100644
View file @
bbc79796
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 @
bbc79796
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 @
bbc79796
...
...
@@ -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
...
...
deploy/docker-compose.yml
View file @
bbc79796
...
...
@@ -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/types/index.ts
View file @
bbc79796
...
...
@@ -369,6 +369,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
...
...
@@ -391,6 +398,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
...
...
@@ -417,6 +426,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 @
bbc79796
...
...
@@ -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>
...
...
@@ -75,7 +83,9 @@
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"groups"
:loading=
"loading"
>
<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 }"
>
...
...
@@ -88,11 +98,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
>
...
...
@@ -104,13 +114,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 -->
...
...
@@ -119,18 +129,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"
...
...
@@ -138,42 +159,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
>
...
...
@@ -195,19 +249,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
>
...
...
@@ -218,21 +289,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
>
...
...
@@ -267,9 +340,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"
...
...
@@ -280,7 +357,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"
...
...
@@ -289,20 +368,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
...
...
@@ -311,27 +392,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"
/>
...
...
@@ -341,28 +439,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"
...
...
@@ -372,12 +481,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"
>
...
...
@@ -388,20 +500,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>
...
...
@@ -412,18 +536,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>
...
...
@@ -431,9 +561,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) -->
...
...
@@ -442,7 +579,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"
...
...
@@ -453,7 +592,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"
...
...
@@ -464,7 +605,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"
...
...
@@ -478,12 +621,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>
...
...
@@ -522,13 +673,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"
>
...
...
@@ -538,12 +687,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>
...
...
@@ -556,35 +711,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
...
...
@@ -593,12 +760,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>
...
...
@@ -609,18 +782,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>
...
...
@@ -629,7 +808,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"
>
...
...
@@ -639,12 +818,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>
...
...
@@ -652,97 +837,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>
...
...
@@ -751,23 +1160,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>
...
...
@@ -776,23 +1197,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"
>
...
...
@@ -802,12 +1230,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>
...
...
@@ -816,28 +1250,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"
>
...
...
@@ -849,18 +1297,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"
...
...
@@ -879,33 +1336,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
...
...
@@ -927,16 +1406,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"
...
...
@@ -965,7 +1447,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
>
...
...
@@ -985,7 +1467,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"
...
...
@@ -995,24 +1477,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
...
...
@@ -1021,27 +1511,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"
/>
...
...
@@ -1051,28 +1558,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"
...
...
@@ -1086,7 +1606,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"
>
...
...
@@ -1097,20 +1617,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>
...
...
@@ -1121,36 +1653,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) -->
...
...
@@ -1159,7 +1701,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"
...
...
@@ -1170,7 +1714,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"
...
...
@@ -1181,7 +1727,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"
...
...
@@ -1195,12 +1743,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>
...
...
@@ -1239,13 +1795,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"
>
...
...
@@ -1255,12 +1809,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>
...
...
@@ -1273,35 +1833,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
...
...
@@ -1310,12 +1882,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>
...
...
@@ -1326,18 +1904,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>
...
...
@@ -1346,7 +1930,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"
>
...
...
@@ -1356,12 +1940,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>
...
...
@@ -1372,94 +1962,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>
...
...
@@ -1468,23 +2278,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>
...
...
@@ -1493,23 +2315,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"
>
...
...
@@ -1519,12 +2348,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>
...
...
@@ -1533,28 +2368,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"
>
...
...
@@ -1566,18 +2414,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"
...
...
@@ -1596,33 +2453,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
...
...
@@ -1644,16 +2523,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"
...
...
@@ -1682,7 +2564,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
>
...
...
@@ -1709,7 +2591,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"
...
...
@@ -1725,7 +2607,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=
"[
...
...
@@ -1736,24 +2620,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"
...
...
@@ -1780,7 +2666,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
>
...
...
@@ -1797,214 +2683,271 @@
</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
})
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
[]
>
([])
pages
:
0
,
});
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
,
...
...
@@ -2018,68 +2961,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
,
...
...
@@ -2089,163 +3053,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
,
...
...
@@ -2259,92 +3241,121 @@ 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
},
{
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
,
},
{
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
,
...
...
@@ -2352,313 +3363,417 @@ 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
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/__tests__/groupsMessagesDispatch.spec.ts
0 → 100644
View file @
bbc79796
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 @
bbc79796
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
=
[];
}
Prev
1
2
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