Commit 2b70d1d3 authored by IanShaw027's avatar IanShaw027
Browse files

merge upstream main into fix/bug-cleanup-main

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