Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
dd96ada3
"frontend/src/i18n/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "3c3419475d3496ccdea371dac9f65fbb608ec7ac"
Unverified
Commit
dd96ada3
authored
Feb 04, 2026
by
程序猿MT
Committed by
GitHub
Feb 04, 2026
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
31fe0178
8f397548
Changes
90
Hide whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
dd96ada3
...
@@ -291,7 +291,8 @@ export default {
...
@@ -291,7 +291,8 @@ export default {
sendingResetLink
:
'
发送中...
'
,
sendingResetLink
:
'
发送中...
'
,
sendResetLinkFailed
:
'
发送重置链接失败,请重试。
'
,
sendResetLinkFailed
:
'
发送重置链接失败,请重试。
'
,
resetEmailSent
:
'
重置链接已发送
'
,
resetEmailSent
:
'
重置链接已发送
'
,
resetEmailSentHint
:
'
如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。
'
,
resetEmailSentHint
:
'
如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。
'
,
backToLogin
:
'
返回登录
'
,
backToLogin
:
'
返回登录
'
,
rememberedPassword
:
'
想起密码了?
'
,
rememberedPassword
:
'
想起密码了?
'
,
// 重置密码
// 重置密码
...
@@ -404,6 +405,7 @@ export default {
...
@@ -404,6 +405,7 @@ export default {
usage
:
'
用量
'
,
usage
:
'
用量
'
,
today
:
'
今日
'
,
today
:
'
今日
'
,
total
:
'
累计
'
,
total
:
'
累计
'
,
quota
:
'
额度
'
,
useKey
:
'
使用密钥
'
,
useKey
:
'
使用密钥
'
,
useKeyModal
:
{
useKeyModal
:
{
title
:
'
使用 API 密钥
'
,
title
:
'
使用 API 密钥
'
,
...
@@ -412,36 +414,41 @@ export default {
...
@@ -412,36 +414,41 @@ export default {
copied
:
'
已复制
'
,
copied
:
'
已复制
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
noGroupTitle
:
'
请先分配分组
'
,
noGroupTitle
:
'
请先分配分组
'
,
noGroupDescription
:
'
此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。
'
,
noGroupDescription
:
'
此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。
'
,
openai
:
{
openai
:
{
description
:
'
将以下配置文件添加到 Codex CLI 配置目录中。
'
,
description
:
'
将以下配置文件添加到 Codex CLI 配置目录中。
'
,
configTomlHint
:
'
请确保以下内容位于 config.toml 文件的开头部分
'
,
configTomlHint
:
'
请确保以下内容位于 config.toml 文件的开头部分
'
,
note
:
'
请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。
'
,
note
:
'
请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。
'
,
noteWindows
:
'
按 Win+R,输入 %userprofile%
\\
.codex 打开配置目录。如目录不存在,请先手动创建。
'
,
noteWindows
:
'
按 Win+R,输入 %userprofile%
\\
.codex 打开配置目录。如目录不存在,请先手动创建。
'
},
},
cliTabs
:
{
cliTabs
:
{
claudeCode
:
'
Claude Code
'
,
claudeCode
:
'
Claude Code
'
,
geminiCli
:
'
Gemini CLI
'
,
geminiCli
:
'
Gemini CLI
'
,
codexCli
:
'
Codex CLI
'
,
codexCli
:
'
Codex CLI
'
,
opencode
:
'
OpenCode
'
,
opencode
:
'
OpenCode
'
},
},
antigravity
:
{
antigravity
:
{
description
:
'
为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。
'
,
description
:
'
为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。
'
,
claudeCode
:
'
Claude Code
'
,
claudeCode
:
'
Claude Code
'
,
geminiCli
:
'
Gemini CLI
'
,
geminiCli
:
'
Gemini CLI
'
,
claudeNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
claudeNote
:
geminiNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
geminiNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
},
},
gemini
:
{
gemini
:
{
description
:
'
将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。
'
,
description
:
'
将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。
'
,
modelComment
:
'
如果你有 Gemini 3 权限可以填:gemini-3-pro-preview
'
,
modelComment
:
'
如果你有 Gemini 3 权限可以填:gemini-3-pro-preview
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
},
},
opencode
:
{
opencode
:
{
title
:
'
OpenCode 配置示例
'
,
title
:
'
OpenCode 配置示例
'
,
subtitle
:
'
opencode.json
'
,
subtitle
:
'
opencode.json
'
,
hint
:
'
配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。
'
,
hint
:
'
配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。
'
}
,
}
},
},
customKeyLabel
:
'
自定义密钥
'
,
customKeyLabel
:
'
自定义密钥
'
,
customKeyPlaceholder
:
'
输入自定义密钥(至少16个字符)
'
,
customKeyPlaceholder
:
'
输入自定义密钥(至少16个字符)
'
,
...
@@ -457,15 +464,43 @@ export default {
...
@@ -457,15 +464,43 @@ export default {
ipBlacklistPlaceholder
:
'
1.2.3.4
\n
5.6.0.0/16
'
,
ipBlacklistPlaceholder
:
'
1.2.3.4
\n
5.6.0.0/16
'
,
ipBlacklistHint
:
'
每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥
'
,
ipBlacklistHint
:
'
每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥
'
,
ipRestrictionEnabled
:
'
已配置 IP 限制
'
,
ipRestrictionEnabled
:
'
已配置 IP 限制
'
,
ccSwitchNotInstalled
:
'
CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。
'
,
ccSwitchNotInstalled
:
'
CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。
'
,
ccsClientSelect
:
{
ccsClientSelect
:
{
title
:
'
选择客户端
'
,
title
:
'
选择客户端
'
,
description
:
'
请选择您要导入到 CC-Switch 的客户端类型:
'
,
description
:
'
请选择您要导入到 CC-Switch 的客户端类型:
'
,
claudeCode
:
'
Claude Code
'
,
claudeCode
:
'
Claude Code
'
,
claudeCodeDesc
:
'
导入为 Claude Code 配置
'
,
claudeCodeDesc
:
'
导入为 Claude Code 配置
'
,
geminiCli
:
'
Gemini CLI
'
,
geminiCli
:
'
Gemini CLI
'
,
geminiCliDesc
:
'
导入为 Gemini CLI 配置
'
,
geminiCliDesc
:
'
导入为 Gemini CLI 配置
'
},
},
// 配额和有效期
quotaLimit
:
'
额度限制
'
,
quotaAmount
:
'
额度金额 (USD)
'
,
quotaAmountPlaceholder
:
'
输入 USD 额度限制
'
,
quotaAmountHint
:
'
设置此密钥可消费的最大金额。0 = 无限制。
'
,
quotaUsed
:
'
已用额度
'
,
reset
:
'
重置
'
,
resetQuotaUsed
:
'
将已用额度重置为 0
'
,
resetQuotaTitle
:
'
确认重置额度
'
,
resetQuotaConfirmMessage
:
'
确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。
'
,
quotaResetSuccess
:
'
额度重置成功
'
,
failedToResetQuota
:
'
重置额度失败
'
,
expiration
:
'
密钥有效期
'
,
expiresInDays
:
'
{days} 天
'
,
extendDays
:
'
+{days} 天
'
,
customDate
:
'
自定义
'
,
expirationDate
:
'
过期时间
'
,
expirationDateHint
:
'
选择此 API 密钥的过期时间。
'
,
currentExpiration
:
'
当前过期时间
'
,
expiresAt
:
'
过期时间
'
,
noExpiration
:
'
永久有效
'
,
status
:
{
active
:
'
活跃
'
,
inactive
:
'
已停用
'
,
quota_exhausted
:
'
额度耗尽
'
,
expired
:
'
已过期
'
}
},
},
// Usage
// Usage
...
@@ -757,8 +792,8 @@ export default {
...
@@ -757,8 +792,8 @@ export default {
editUser
:
'
编辑用户
'
,
editUser
:
'
编辑用户
'
,
deleteUser
:
'
删除用户
'
,
deleteUser
:
'
删除用户
'
,
deleteConfirmMessage
:
"
确定要删除用户 '{email}' 吗?此操作无法撤销。
"
,
deleteConfirmMessage
:
"
确定要删除用户 '{email}' 吗?此操作无法撤销。
"
,
searchPlaceholder
:
'
搜索用户...
'
,
searchPlaceholder
:
'
搜索用户
邮箱或用户名、备注、支持模糊查询
...
'
,
searchUsers
:
'
搜索用户
...
'
,
searchUsers
:
'
搜索用户
邮箱或用户名、备注、支持模糊查询
'
,
roleFilter
:
'
角色筛选
'
,
roleFilter
:
'
角色筛选
'
,
allRoles
:
'
全部角色
'
,
allRoles
:
'
全部角色
'
,
allStatus
:
'
全部状态
'
,
allStatus
:
'
全部状态
'
,
...
@@ -1028,9 +1063,11 @@ export default {
...
@@ -1028,9 +1063,11 @@ export default {
exclusiveHint
:
'
专属分组,可以手动指定给特定用户
'
,
exclusiveHint
:
'
专属分组,可以手动指定给特定用户
'
,
exclusiveTooltip
:
{
exclusiveTooltip
:
{
title
:
'
什么是专属分组?
'
,
title
:
'
什么是专属分组?
'
,
description
:
'
开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。
'
,
description
:
'
开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。
'
,
example
:
'
使用场景:
'
,
example
:
'
使用场景:
'
,
exampleContent
:
'
公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。
'
exampleContent
:
'
公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。
'
},
},
rateMultiplierHint
:
'
1.0 = 标准费率,0.5 = 半价,2.0 = 双倍
'
,
rateMultiplierHint
:
'
1.0 = 标准费率,0.5 = 半价,2.0 = 双倍
'
,
platforms
:
{
platforms
:
{
...
@@ -1094,13 +1131,19 @@ export default {
...
@@ -1094,13 +1131,19 @@ export default {
},
},
claudeCode
:
{
claudeCode
:
{
title
:
'
Claude Code 客户端限制
'
,
title
:
'
Claude Code 客户端限制
'
,
tooltip
:
'
启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。
'
,
tooltip
:
'
启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。
'
,
enabled
:
'
仅限 Claude Code
'
,
enabled
:
'
仅限 Claude Code
'
,
disabled
:
'
允许所有客户端
'
,
disabled
:
'
允许所有客户端
'
,
fallbackGroup
:
'
降级分组
'
,
fallbackGroup
:
'
降级分组
'
,
fallbackHint
:
'
非 Claude Code 请求将使用此分组,留空则直接拒绝
'
,
fallbackHint
:
'
非 Claude Code 请求将使用此分组,留空则直接拒绝
'
,
noFallback
:
'
不降级(直接拒绝)
'
noFallback
:
'
不降级(直接拒绝)
'
},
},
invalidRequestFallback
:
{
title
:
'
无效请求兜底分组
'
,
hint
:
'
仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底
'
,
noFallback
:
'
不兜底
'
},
copyAccounts
:
{
copyAccounts
:
{
title
:
'
从分组复制账号
'
,
title
:
'
从分组复制账号
'
,
tooltip
:
'
选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。
'
,
tooltip
:
'
选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。
'
,
...
@@ -1111,7 +1154,8 @@ export default {
...
@@ -1111,7 +1154,8 @@ export default {
},
},
modelRouting
:
{
modelRouting
:
{
title
:
'
模型路由配置
'
,
title
:
'
模型路由配置
'
,
tooltip
:
'
配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。
'
,
tooltip
:
'
配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。
'
,
enabled
:
'
已启用
'
,
enabled
:
'
已启用
'
,
disabled
:
'
已禁用
'
,
disabled
:
'
已禁用
'
,
disabledHint
:
'
启用后,配置的路由规则才会生效
'
,
disabledHint
:
'
启用后,配置的路由规则才会生效
'
,
...
@@ -1128,6 +1172,20 @@ export default {
...
@@ -1128,6 +1172,20 @@ export default {
noRulesHint
:
'
添加路由规则以将特定模型请求优先路由到指定账号
'
,
noRulesHint
:
'
添加路由规则以将特定模型请求优先路由到指定账号
'
,
searchAccountPlaceholder
:
'
搜索账号...
'
,
searchAccountPlaceholder
:
'
搜索账号...
'
,
accountsHint
:
'
选择此模型模式优先使用的账号
'
accountsHint
:
'
选择此模型模式优先使用的账号
'
},
mcpXml
:
{
title
:
'
MCP XML 协议注入
'
,
tooltip
:
'
启用后,当请求包含 MCP 工具时,会在 system prompt 中注入 XML 格式调用协议提示词。关闭此选项可避免对某些客户端造成干扰。
'
,
enabled
:
'
已启用
'
,
disabled
:
'
已禁用
'
},
supportedScopes
:
{
title
:
'
支持的模型系列
'
,
tooltip
:
'
选择此分组支持的模型系列。未勾选的系列将不会被路由到此分组。
'
,
claude
:
'
Claude
'
,
geminiText
:
'
Gemini Text
'
,
geminiImage
:
'
Gemini Image
'
,
hint
:
'
至少选择一个模型系列
'
}
}
},
},
...
@@ -1313,6 +1371,8 @@ export default {
...
@@ -1313,6 +1371,8 @@ export default {
googleOauth
:
'
Google OAuth
'
,
googleOauth
:
'
Google OAuth
'
,
codeAssist
:
'
Code Assist
'
,
codeAssist
:
'
Code Assist
'
,
antigravityOauth
:
'
Antigravity OAuth
'
,
antigravityOauth
:
'
Antigravity OAuth
'
,
upstream
:
'
对接上游
'
,
upstreamDesc
:
'
通过 Base URL + API Key 连接上游
'
,
api_key
:
'
API Key
'
,
api_key
:
'
API Key
'
,
cookie
:
'
Cookie
'
cookie
:
'
Cookie
'
},
},
...
@@ -1584,6 +1644,15 @@ export default {
...
@@ -1584,6 +1644,15 @@ export default {
pleaseEnterApiKey
:
'
请输入 API Key
'
,
pleaseEnterApiKey
:
'
请输入 API Key
'
,
apiKeyIsRequired
:
'
API Key 是必需的
'
,
apiKeyIsRequired
:
'
API Key 是必需的
'
,
leaveEmptyToKeep
:
'
留空以保持当前密钥
'
,
leaveEmptyToKeep
:
'
留空以保持当前密钥
'
,
// Upstream type
upstream
:
{
baseUrl
:
'
上游 Base URL
'
,
baseUrlHint
:
'
上游 Antigravity 服务的地址,例如:https://s.konstants.xyz
'
,
apiKey
:
'
上游 API Key
'
,
apiKeyHint
:
'
上游服务的 API Key
'
,
pleaseEnterBaseUrl
:
'
请输入上游 Base URL
'
,
pleaseEnterApiKey
:
'
请输入上游 API Key
'
},
// OAuth flow
// OAuth flow
oauth
:
{
oauth
:
{
title
:
'
Claude 账号授权
'
,
title
:
'
Claude 账号授权
'
,
...
@@ -1614,8 +1683,7 @@ export default {
...
@@ -1614,8 +1683,7 @@ export default {
regenerate
:
'
重新生成
'
,
regenerate
:
'
重新生成
'
,
step2OpenUrl
:
'
在浏览器中打开 URL 并完成授权
'
,
step2OpenUrl
:
'
在浏览器中打开 URL 并完成授权
'
,
openUrlDesc
:
'
在新标签页中打开授权 URL,登录您的 Claude 账号并授权。
'
,
openUrlDesc
:
'
在新标签页中打开授权 URL,登录您的 Claude 账号并授权。
'
,
proxyWarning
:
proxyWarning
:
'
注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。
'
,
'
注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。
'
,
step3EnterCode
:
'
输入授权码
'
,
step3EnterCode
:
'
输入授权码
'
,
authCodeDesc
:
'
授权完成后,页面会显示一个授权码。复制并粘贴到下方:
'
,
authCodeDesc
:
'
授权完成后,页面会显示一个授权码。复制并粘贴到下方:
'
,
authCode
:
'
授权码
'
,
authCode
:
'
授权码
'
,
...
@@ -1647,45 +1715,50 @@ export default {
...
@@ -1647,45 +1715,50 @@ export default {
authCodeHint
:
'
您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别
'
authCodeHint
:
'
您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别
'
},
},
// Gemini specific
// Gemini specific
gemini
:
{
gemini
:
{
title
:
'
Gemini 账户授权
'
,
title
:
'
Gemini 账户授权
'
,
followSteps
:
'
请按照以下步骤完成 Gemini 账户的授权:
'
,
followSteps
:
'
请按照以下步骤完成 Gemini 账户的授权:
'
,
step1GenerateUrl
:
'
生成授权链接
'
,
step1GenerateUrl
:
'
生成授权链接
'
,
generateAuthUrl
:
'
生成授权链接
'
,
generateAuthUrl
:
'
生成授权链接
'
,
projectIdLabel
:
'
Project ID(可选)
'
,
projectIdLabel
:
'
Project ID(可选)
'
,
projectIdPlaceholder
:
'
例如:my-gcp-project 或 cloud-ai-companion-xxxxx
'
,
projectIdPlaceholder
:
'
例如:my-gcp-project 或 cloud-ai-companion-xxxxx
'
,
projectIdHint
:
'
留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。
'
,
projectIdHint
:
howToGetProjectId
:
'
如何获取
'
,
'
留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。
'
,
step2OpenUrl
:
'
在浏览器中打开链接并完成授权
'
,
howToGetProjectId
:
'
如何获取
'
,
openUrlDesc
:
'
请在新标签页中打开授权链接,登录您的 Google 账户并授权。
'
,
step2OpenUrl
:
'
在浏览器中打开链接并完成授权
'
,
step3EnterCode
:
'
输入回调链接或 Code
'
,
openUrlDesc
:
'
请在新标签页中打开授权链接,登录您的 Google 账户并授权。
'
,
authCodeDesc
:
'
授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。
'
,
step3EnterCode
:
'
输入回调链接或 Code
'
,
authCode
:
'
回调链接或 Code
'
,
authCodeDesc
:
authCodePlaceholder
:
'
方式1(推荐):粘贴回调链接
\n
方式2:仅粘贴 code 参数的值
'
,
'
授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。
'
,
authCodeHint
:
'
系统会自动从链接中解析 code/state。
'
,
authCode
:
'
回调链接或 Code
'
,
authCodePlaceholder
:
'
方式1(推荐):粘贴回调链接
\n
方式2:仅粘贴 code 参数的值
'
,
authCodeHint
:
'
系统会自动从链接中解析 code/state。
'
,
redirectUri
:
'
Redirect URI
'
,
redirectUri
:
'
Redirect URI
'
,
redirectUriHint
:
'
需要在 Google OAuth Client 中配置,且必须与此处完全一致。
'
,
redirectUriHint
:
'
需要在 Google OAuth Client 中配置,且必须与此处完全一致。
'
,
confirmRedirectUri
:
'
我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)
'
,
confirmRedirectUri
:
'
我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)
'
,
invalidRedirectUri
:
'
Redirect URI 必须是合法的 http(s) URL
'
,
invalidRedirectUri
:
'
Redirect URI 必须是合法的 http(s) URL
'
,
redirectUriNotConfirmed
:
'
请确认 Redirect URI 已在 Google OAuth Client 中正确配置
'
,
redirectUriNotConfirmed
:
'
请确认 Redirect URI 已在 Google OAuth Client 中正确配置
'
,
missingRedirectUri
:
'
缺少 Redirect URI
'
,
missingRedirectUri
:
'
缺少 Redirect URI
'
,
failedToGenerateUrl
:
'
生成 Gemini 授权链接失败
'
,
failedToGenerateUrl
:
'
生成 Gemini 授权链接失败
'
,
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
failedToExchangeCode
:
'
Gemini 授权码兑换失败
'
,
failedToExchangeCode
:
'
Gemini 授权码兑换失败
'
,
missingProjectId
:
'
GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。
'
,
missingProjectId
:
modelPassthrough
:
'
Gemini 直接转发模型
'
,
'
GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。
'
,
modelPassthroughDesc
:
'
所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。
'
,
modelPassthrough
:
'
Gemini 直接转发模型
'
,
stateWarningTitle
:
'
提示
'
,
modelPassthroughDesc
:
'
所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。
'
,
stateWarningDesc
:
'
建议粘贴完整回调链接(包含 code 和 state)。
'
,
stateWarningTitle
:
'
提示
'
,
oauthTypeLabel
:
'
OAuth 类型
'
,
stateWarningDesc
:
'
建议粘贴完整回调链接(包含 code 和 state)。
'
,
oauthTypeLabel
:
'
OAuth 类型
'
,
needsProjectId
:
'
内置授权(Code Assist)
'
,
needsProjectId
:
'
内置授权(Code Assist)
'
,
needsProjectIdDesc
:
'
需要 GCP 项目与 Project ID
'
,
needsProjectIdDesc
:
'
需要 GCP 项目与 Project ID
'
,
noProjectIdNeeded
:
'
自定义授权(AI Studio)
'
,
noProjectIdNeeded
:
'
自定义授权(AI Studio)
'
,
noProjectIdNeededDesc
:
'
需管理员配置 OAuth Client
'
,
noProjectIdNeededDesc
:
'
需管理员配置 OAuth Client
'
,
aiStudioNotConfiguredShort
:
'
未配置
'
,
aiStudioNotConfiguredShort
:
'
未配置
'
,
aiStudioNotConfiguredTip
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)
'
,
aiStudioNotConfiguredTip
:
aiStudioNotConfigured
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback
'
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)
'
,
},
aiStudioNotConfigured
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback
'
},
// Antigravity specific
// Antigravity specific
antigravity
:
{
antigravity
:
{
title
:
'
Antigravity 账户授权
'
,
title
:
'
Antigravity 账户授权
'
,
...
@@ -1707,7 +1780,7 @@ export default {
...
@@ -1707,7 +1780,7 @@ export default {
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
failedToExchangeCode
:
'
Antigravity 授权码兑换失败
'
failedToExchangeCode
:
'
Antigravity 授权码兑换失败
'
}
}
},
},
// Gemini specific (platform-wide)
// Gemini specific (platform-wide)
gemini
:
{
gemini
:
{
helpButton
:
'
使用帮助
'
,
helpButton
:
'
使用帮助
'
,
...
@@ -1722,7 +1795,8 @@ export default {
...
@@ -1722,7 +1795,8 @@ export default {
tier
:
{
tier
:
{
label
:
'
账号等级
'
,
label
:
'
账号等级
'
,
hint
:
'
提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。
'
,
hint
:
'
提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。
'
,
aiStudioHint
:
'
AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。
'
,
aiStudioHint
:
'
AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。
'
,
googleOne
:
{
googleOne
:
{
free
:
'
Google One Free
'
,
free
:
'
Google One Free
'
,
pro
:
'
Google One Pro
'
,
pro
:
'
Google One Pro
'
,
...
@@ -1866,9 +1940,9 @@ export default {
...
@@ -1866,9 +1940,9 @@ export default {
outputCopied
:
'
输出已复制
'
,
outputCopied
:
'
输出已复制
'
,
startingTestForAccount
:
'
开始测试账号:{name}
'
,
startingTestForAccount
:
'
开始测试账号:{name}
'
,
testAccountTypeLabel
:
'
账号类型:{type}
'
,
testAccountTypeLabel
:
'
账号类型:{type}
'
,
selectTestModel
:
'
选择测试模型
'
,
selectTestModel
:
'
选择测试模型
'
,
testModel
:
'
测试模型
'
,
testModel
:
'
测试模型
'
,
testPrompt
:
'
提示词:"hi"
'
,
testPrompt
:
'
提示词:"hi"
'
,
// Stats Modal
// Stats Modal
viewStats
:
'
查看统计
'
,
viewStats
:
'
查看统计
'
,
usageStatistics
:
'
使用统计
'
,
usageStatistics
:
'
使用统计
'
,
...
@@ -2349,6 +2423,7 @@ export default {
...
@@ -2349,6 +2423,7 @@ export default {
waiting
:
'
等待
'
,
waiting
:
'
等待
'
,
conns
:
'
连接
'
,
conns
:
'
连接
'
,
queue
:
'
队列
'
,
queue
:
'
队列
'
,
accountSwitches
:
'
账号切换
'
,
ok
:
'
正常
'
,
ok
:
'
正常
'
,
lastRun
:
'
最近运行
'
,
lastRun
:
'
最近运行
'
,
lastSuccess
:
'
最近成功
'
,
lastSuccess
:
'
最近成功
'
,
...
@@ -2398,6 +2473,7 @@ export default {
...
@@ -2398,6 +2473,7 @@ export default {
failedToLoadData
:
'
加载运维数据失败
'
,
failedToLoadData
:
'
加载运维数据失败
'
,
failedToLoadOverview
:
'
加载概览数据失败
'
,
failedToLoadOverview
:
'
加载概览数据失败
'
,
failedToLoadThroughputTrend
:
'
加载吞吐趋势失败
'
,
failedToLoadThroughputTrend
:
'
加载吞吐趋势失败
'
,
failedToLoadSwitchTrend
:
'
加载平均账号切换趋势失败
'
,
failedToLoadLatencyHistogram
:
'
加载请求时长分布失败
'
,
failedToLoadLatencyHistogram
:
'
加载请求时长分布失败
'
,
failedToLoadErrorTrend
:
'
加载错误趋势失败
'
,
failedToLoadErrorTrend
:
'
加载错误趋势失败
'
,
failedToLoadErrorDistribution
:
'
加载错误分布失败
'
,
failedToLoadErrorDistribution
:
'
加载错误分布失败
'
,
...
@@ -2406,9 +2482,11 @@ export default {
...
@@ -2406,9 +2482,11 @@ export default {
tpsK
:
'
TPS(千)
'
,
tpsK
:
'
TPS(千)
'
,
top
:
'
最高:
'
,
top
:
'
最高:
'
,
throughputTrend
:
'
吞吐趋势
'
,
throughputTrend
:
'
吞吐趋势
'
,
switchRateTrend
:
'
平均账号切换趋势
'
,
latencyHistogram
:
'
请求时长分布
'
,
latencyHistogram
:
'
请求时长分布
'
,
errorTrend
:
'
错误趋势
'
,
errorTrend
:
'
错误趋势
'
,
errorDistribution
:
'
错误分布
'
,
errorDistribution
:
'
错误分布
'
,
switchRate
:
'
平均账号切换
'
,
// Health Score & Diagnosis
// Health Score & Diagnosis
health
:
'
健康
'
,
health
:
'
健康
'
,
healthCondition
:
'
健康状况
'
,
healthCondition
:
'
健康状况
'
,
...
@@ -2547,7 +2625,7 @@ export default {
...
@@ -2547,7 +2625,7 @@ export default {
internal
:
'
内部
'
internal
:
'
内部
'
},
},
total
:
'
总计:
'
,
total
:
'
总计:
'
,
searchPlaceholder
:
'
搜索 request_id / client_request_id / message
'
,
searchPlaceholder
:
'
搜索 request_id / client_request_id / message
'
},
},
// Error Detail Modal
// Error Detail Modal
errorDetail
:
{
errorDetail
:
{
...
@@ -2978,7 +3056,8 @@ export default {
...
@@ -2978,7 +3056,8 @@ export default {
ignoreCountTokensErrors
:
'
忽略 count_tokens 错误
'
,
ignoreCountTokensErrors
:
'
忽略 count_tokens 错误
'
,
ignoreCountTokensErrorsHint
:
'
启用后,count_tokens 请求的错误将不会写入错误日志。
'
,
ignoreCountTokensErrorsHint
:
'
启用后,count_tokens 请求的错误将不会写入错误日志。
'
,
ignoreContextCanceled
:
'
忽略客户端断连错误
'
,
ignoreContextCanceled
:
'
忽略客户端断连错误
'
,
ignoreContextCanceledHint
:
'
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。
'
,
ignoreContextCanceledHint
:
'
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。
'
,
ignoreNoAvailableAccounts
:
'
忽略无可用账号错误
'
,
ignoreNoAvailableAccounts
:
'
忽略无可用账号错误
'
,
ignoreNoAvailableAccountsHint
:
'
启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。
'
,
ignoreNoAvailableAccountsHint
:
'
启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。
'
,
ignoreInvalidApiKeyErrors
:
'
忽略无效 API Key 错误
'
,
ignoreInvalidApiKeyErrors
:
'
忽略无效 API Key 错误
'
,
...
@@ -3036,6 +3115,7 @@ export default {
...
@@ -3036,6 +3115,7 @@ export default {
tooltips
:
{
tooltips
:
{
totalRequests
:
'
当前时间窗口内的总请求数和Token消耗量。
'
,
totalRequests
:
'
当前时间窗口内的总请求数和Token消耗量。
'
,
throughputTrend
:
'
当前窗口内的请求/QPS 与 token/TPS 趋势。
'
,
throughputTrend
:
'
当前窗口内的请求/QPS 与 token/TPS 趋势。
'
,
switchRateTrend
:
'
近5小时内账号切换次数 / 请求总数的趋势(平均切换次数)。
'
,
latencyHistogram
:
'
成功请求的请求时长分布(毫秒)。
'
,
latencyHistogram
:
'
成功请求的请求时长分布(毫秒)。
'
,
errorTrend
:
'
错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。
'
,
errorTrend
:
'
错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。
'
,
errorDistribution
:
'
按状态码统计的错误分布。
'
,
errorDistribution
:
'
按状态码统计的错误分布。
'
,
...
@@ -3097,7 +3177,8 @@ export default {
...
@@ -3097,7 +3177,8 @@ export default {
siteKeyHint
:
'
从 Cloudflare Dashboard 获取
'
,
siteKeyHint
:
'
从 Cloudflare Dashboard 获取
'
,
cloudflareDashboard
:
'
Cloudflare Dashboard
'
,
cloudflareDashboard
:
'
Cloudflare Dashboard
'
,
secretKeyHint
:
'
服务端验证密钥(请保密)
'
,
secretKeyHint
:
'
服务端验证密钥(请保密)
'
,
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
linuxdo
:
{
linuxdo
:
{
title
:
'
LinuxDo Connect 登录
'
,
title
:
'
LinuxDo Connect 登录
'
,
description
:
'
配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录
'
,
description
:
'
配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录
'
,
...
@@ -3151,9 +3232,12 @@ export default {
...
@@ -3151,9 +3232,12 @@ export default {
logoTypeError
:
'
请选择图片文件
'
,
logoTypeError
:
'
请选择图片文件
'
,
logoReadError
:
'
读取图片文件失败
'
,
logoReadError
:
'
读取图片文件失败
'
,
homeContent
:
'
首页内容
'
,
homeContent
:
'
首页内容
'
,
homeContentPlaceholder
:
'
在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。
'
,
homeContentPlaceholder
:
homeContentHint
:
'
自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。
'
,
'
在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。
'
,
homeContentIframeWarning
:
'
⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。
'
,
homeContentHint
:
'
自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。
'
,
homeContentIframeWarning
:
'
⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。
'
,
hideCcsImportButton
:
'
隐藏 CCS 导入按钮
'
,
hideCcsImportButton
:
'
隐藏 CCS 导入按钮
'
,
hideCcsImportButtonHint
:
'
启用后将在 API Keys 页面隐藏"导入 CCS"按钮
'
hideCcsImportButtonHint
:
'
启用后将在 API Keys 页面隐藏"导入 CCS"按钮
'
},
},
...
@@ -3390,131 +3474,158 @@ export default {
...
@@ -3390,131 +3474,158 @@ export default {
admin
:
{
admin
:
{
welcome
:
{
welcome
:
{
title
:
'
👋 欢迎使用 Sub2API
'
,
title
:
'
👋 欢迎使用 Sub2API
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>
'
,
nextBtn
:
'
开始配置 🚀
'
,
nextBtn
:
'
开始配置 🚀
'
,
prevBtn
:
'
跳过
'
prevBtn
:
'
跳过
'
},
},
groupManage
:
{
groupManage
:
{
title
:
'
📦 第一步:分组管理
'
,
title
:
'
📦 第一步:分组管理
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>
'
},
},
createGroup
:
{
createGroup
:
{
title
:
'
➕ 创建新分组
'
,
title
:
'
➕ 创建新分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>
'
},
},
groupName
:
{
groupName
:
{
title
:
'
✏️ 1. 分组名称
'
,
title
:
'
✏️ 1. 分组名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
groupPlatform
:
{
groupPlatform
:
{
title
:
'
🤖 2. 选择平台
'
,
title
:
'
🤖 2. 选择平台
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
groupMultiplier
:
{
groupMultiplier
:
{
title
:
'
💰 3. 费率倍数
'
,
title
:
'
💰 3. 费率倍数
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
groupExclusive
:
{
groupExclusive
:
{
title
:
'
🔒 4. 专属分组(可选)
'
,
title
:
'
🔒 4. 专属分组(可选)
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
groupSubmit
:
{
groupSubmit
:
{
title
:
'
✅ 保存分组
'
,
title
:
'
✅ 保存分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
},
},
accountManage
:
{
accountManage
:
{
title
:
'
🔗 第二步:添加账号
'
,
title
:
'
🔗 第二步:添加账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>
'
},
},
createAccount
:
{
createAccount
:
{
title
:
'
➕ 添加新账号
'
,
title
:
'
➕ 添加新账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>
'
},
},
accountName
:
{
accountName
:
{
title
:
'
✏️ 1. 账号名称
'
,
title
:
'
✏️ 1. 账号名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
accountPlatform
:
{
accountPlatform
:
{
title
:
'
🤖 2. 选择平台
'
,
title
:
'
🤖 2. 选择平台
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
accountType
:
{
accountType
:
{
title
:
'
🔐 3. 授权方式
'
,
title
:
'
🔐 3. 授权方式
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
accountPriority
:
{
accountPriority
:
{
title
:
'
⚖️ 4. 优先级(可选)
'
,
title
:
'
⚖️ 4. 优先级(可选)
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
accountGroups
:
{
accountGroups
:
{
title
:
'
🎯 5. 分配分组
'
,
title
:
'
🎯 5. 分配分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
accountSubmit
:
{
accountSubmit
:
{
title
:
'
✅ 保存账号
'
,
title
:
'
✅ 保存账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>
'
},
},
keyManage
:
{
keyManage
:
{
title
:
'
🔑 第三步:生成密钥
'
,
title
:
'
🔑 第三步:生成密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>
'
},
},
createKey
:
{
createKey
:
{
title
:
'
➕ 创建密钥
'
,
title
:
'
➕ 创建密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>
'
},
},
keyName
:
{
keyName
:
{
title
:
'
✏️ 1. 密钥名称
'
,
title
:
'
✏️ 1. 密钥名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
keyGroup
:
{
keyGroup
:
{
title
:
'
🎯 2. 选择分组
'
,
title
:
'
🎯 2. 选择分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
keySubmit
:
{
keySubmit
:
{
title
:
'
🎉 生成并复制
'
,
title
:
'
🎉 生成并复制
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
}
}
},
},
// User tour steps
// User tour steps
user
:
{
user
:
{
welcome
:
{
welcome
:
{
title
:
'
👋 欢迎使用 Sub2API
'
,
title
:
'
👋 欢迎使用 Sub2API
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>
'
,
nextBtn
:
'
开始 🚀
'
,
nextBtn
:
'
开始 🚀
'
,
prevBtn
:
'
跳过
'
prevBtn
:
'
跳过
'
},
},
keyManage
:
{
keyManage
:
{
title
:
'
🔑 API 密钥管理
'
,
title
:
'
🔑 API 密钥管理
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>
'
},
},
createKey
:
{
createKey
:
{
title
:
'
➕ 创建新密钥
'
,
title
:
'
➕ 创建新密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>
'
},
},
keyName
:
{
keyName
:
{
title
:
'
✏️ 密钥名称
'
,
title
:
'
✏️ 密钥名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
keyGroup
:
{
keyGroup
:
{
title
:
'
🎯 选择分组
'
,
title
:
'
🎯 选择分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>
'
,
nextBtn
:
'
下一步
'
nextBtn
:
'
下一步
'
},
},
keySubmit
:
{
keySubmit
:
{
title
:
'
🎉 完成创建
'
,
title
:
'
🎉 完成创建
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
}
}
}
}
}
}
...
...
frontend/src/types/index.ts
View file @
dd96ada3
...
@@ -355,6 +355,7 @@ export interface Group {
...
@@ -355,6 +355,7 @@ export interface Group {
// Claude Code 客户端限制
// Claude Code 客户端限制
claude_code_only
:
boolean
claude_code_only
:
boolean
fallback_group_id
:
number
|
null
fallback_group_id
:
number
|
null
fallback_group_id_on_invalid_request
:
number
|
null
created_at
:
string
created_at
:
string
updated_at
:
string
updated_at
:
string
}
}
...
@@ -364,6 +365,12 @@ export interface AdminGroup extends Group {
...
@@ -364,6 +365,12 @@ export interface AdminGroup extends Group {
model_routing
:
Record
<
string
,
number
[]
>
|
null
model_routing
:
Record
<
string
,
number
[]
>
|
null
model_routing_enabled
:
boolean
model_routing_enabled
:
boolean
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject
:
boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes
?:
string
[]
// 分组下账号数量(仅管理员可见)
// 分组下账号数量(仅管理员可见)
account_count
?:
number
account_count
?:
number
}
}
...
@@ -374,9 +381,12 @@ export interface ApiKey {
...
@@ -374,9 +381,12 @@ export interface ApiKey {
key
:
string
key
:
string
name
:
string
name
:
string
group_id
:
number
|
null
group_id
:
number
|
null
status
:
'
active
'
|
'
inactive
'
status
:
'
active
'
|
'
inactive
'
|
'
quota_exhausted
'
|
'
expired
'
ip_whitelist
:
string
[]
ip_whitelist
:
string
[]
ip_blacklist
:
string
[]
ip_blacklist
:
string
[]
quota
:
number
// Quota limit in USD (0 = unlimited)
quota_used
:
number
// Used quota amount in USD
expires_at
:
string
|
null
// Expiration time (null = never expires)
created_at
:
string
created_at
:
string
updated_at
:
string
updated_at
:
string
group
?:
Group
group
?:
Group
...
@@ -388,6 +398,8 @@ export interface CreateApiKeyRequest {
...
@@ -388,6 +398,8 @@ export interface CreateApiKeyRequest {
custom_key
?:
string
// Optional custom API Key
custom_key
?:
string
// Optional custom API Key
ip_whitelist
?:
string
[]
ip_whitelist
?:
string
[]
ip_blacklist
?:
string
[]
ip_blacklist
?:
string
[]
quota
?:
number
// Quota limit in USD (0 = unlimited)
expires_in_days
?:
number
// Days until expiry (null = never expires)
}
}
export
interface
UpdateApiKeyRequest
{
export
interface
UpdateApiKeyRequest
{
...
@@ -396,6 +408,9 @@ export interface UpdateApiKeyRequest {
...
@@ -396,6 +408,9 @@ export interface UpdateApiKeyRequest {
status
?:
'
active
'
|
'
inactive
'
status
?:
'
active
'
|
'
inactive
'
ip_whitelist
?:
string
[]
ip_whitelist
?:
string
[]
ip_blacklist
?:
string
[]
ip_blacklist
?:
string
[]
quota
?:
number
// Quota limit in USD (null = no change, 0 = unlimited)
expires_at
?:
string
|
null
// Expiration time (null = no change)
reset_quota
?:
boolean
// Reset quota_used to 0
}
}
export
interface
CreateGroupRequest
{
export
interface
CreateGroupRequest
{
...
@@ -413,6 +428,9 @@ export interface CreateGroupRequest {
...
@@ -413,6 +428,9 @@ export interface CreateGroupRequest {
image_price_4k
?:
number
|
null
image_price_4k
?:
number
|
null
claude_code_only
?:
boolean
claude_code_only
?:
boolean
fallback_group_id
?:
number
|
null
fallback_group_id
?:
number
|
null
fallback_group_id_on_invalid_request
?:
number
|
null
mcp_xml_inject
?:
boolean
supported_model_scopes
?:
string
[]
// 从指定分组复制账号
// 从指定分组复制账号
copy_accounts_from_group_ids
?:
number
[]
copy_accounts_from_group_ids
?:
number
[]
}
}
...
@@ -433,13 +451,16 @@ export interface UpdateGroupRequest {
...
@@ -433,13 +451,16 @@ export interface UpdateGroupRequest {
image_price_4k
?:
number
|
null
image_price_4k
?:
number
|
null
claude_code_only
?:
boolean
claude_code_only
?:
boolean
fallback_group_id
?:
number
|
null
fallback_group_id
?:
number
|
null
fallback_group_id_on_invalid_request
?:
number
|
null
mcp_xml_inject
?:
boolean
supported_model_scopes
?:
string
[]
copy_accounts_from_group_ids
?:
number
[]
copy_accounts_from_group_ids
?:
number
[]
}
}
// ==================== Account & Proxy Types ====================
// ==================== Account & Proxy Types ====================
export
type
AccountPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
export
type
AccountPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
export
type
AccountType
=
'
oauth
'
|
'
setup-token
'
|
'
apikey
'
export
type
AccountType
=
'
oauth
'
|
'
setup-token
'
|
'
apikey
'
|
'
upstream
'
export
type
OAuthAddMethod
=
'
oauth
'
|
'
setup-token
'
export
type
OAuthAddMethod
=
'
oauth
'
|
'
setup-token
'
export
type
ProxyProtocol
=
'
http
'
|
'
https
'
|
'
socks5
'
|
'
socks5h
'
export
type
ProxyProtocol
=
'
http
'
|
'
https
'
|
'
socks5
'
|
'
socks5h
'
...
...
frontend/src/views/admin/GroupsView.vue
View file @
dd96ada3
...
@@ -468,6 +468,107 @@
...
@@ -468,6 +468,107 @@
<
/div
>
<
/div
>
<
/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
'
)
}}
<
/label
>
<!--
Help
Tooltip
-->
<
div
class
=
"
group relative inline-flex
"
>
<
Icon
name
=
"
questionCircle
"
size
=
"
sm
"
:
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
"
>
<
p
class
=
"
text-xs leading-relaxed text-gray-300
"
>
{{
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
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
type
=
"
checkbox
"
:
checked
=
"
createForm.supported_model_scopes.includes('claude')
"
@
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
>
<
/label
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
type
=
"
checkbox
"
:
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
>
<
/label
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
type
=
"
checkbox
"
:
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
>
<
/label
>
<
/div
>
<
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
'
)
}}
<
/label
>
<
div
class
=
"
group relative inline-flex
"
>
<
Icon
name
=
"
questionCircle
"
size
=
"
sm
"
:
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
"
>
<
p
class
=
"
text-xs leading-relaxed text-gray-300
"
>
{{
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
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
flex items-center gap-3
"
>
<
button
type
=
"
button
"
@
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'
]
"
>
<
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'
]
"
/>
<
/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
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<!--
Claude
Code
客户端限制
(
仅
anthropic
平台
)
-->
<!--
Claude
Code
客户端限制
(
仅
anthropic
平台
)
-->
<
div
v
-
if
=
"
createForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
v
-
if
=
"
createForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
...
@@ -524,6 +625,20 @@
...
@@ -524,6 +625,20 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
无效请求兜底
(
仅
anthropic
/
antigravity
平台
,
且非订阅分组
)
-->
<
div
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
>
<
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
>
<
/div
>
<!--
模型路由配置
(
仅
anthropic
平台
)
-->
<!--
模型路由配置
(
仅
anthropic
平台
)
-->
<
div
v
-
if
=
"
createForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
v
-
if
=
"
createForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
...
@@ -975,6 +1090,107 @@
...
@@ -975,6 +1090,107 @@
<
/div
>
<
/div
>
<
/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
'
)
}}
<
/label
>
<!--
Help
Tooltip
-->
<
div
class
=
"
group relative inline-flex
"
>
<
Icon
name
=
"
questionCircle
"
size
=
"
sm
"
:
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
"
>
<
p
class
=
"
text-xs leading-relaxed text-gray-300
"
>
{{
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
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
type
=
"
checkbox
"
:
checked
=
"
editForm.supported_model_scopes.includes('claude')
"
@
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
>
<
/label
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
type
=
"
checkbox
"
:
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
>
<
/label
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
type
=
"
checkbox
"
:
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
>
<
/label
>
<
/div
>
<
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
'
)
}}
<
/label
>
<
div
class
=
"
group relative inline-flex
"
>
<
Icon
name
=
"
questionCircle
"
size
=
"
sm
"
:
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
"
>
<
p
class
=
"
text-xs leading-relaxed text-gray-300
"
>
{{
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
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
flex items-center gap-3
"
>
<
button
type
=
"
button
"
@
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'
]
"
>
<
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'
]
"
/>
<
/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
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<!--
Claude
Code
客户端限制
(
仅
anthropic
平台
)
-->
<!--
Claude
Code
客户端限制
(
仅
anthropic
平台
)
-->
<
div
v
-
if
=
"
editForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
v
-
if
=
"
editForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
...
@@ -1031,6 +1247,20 @@
...
@@ -1031,6 +1247,20 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
无效请求兜底
(
仅
anthropic
/
antigravity
平台
,
且非订阅分组
)
-->
<
div
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
>
<
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
>
<
/div
>
<!--
模型路由配置
(
仅
anthropic
平台
)
-->
<!--
模型路由配置
(
仅
anthropic
平台
)
-->
<
div
v
-
if
=
"
editForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
v
-
if
=
"
editForm.platform === 'anthropic'
"
class
=
"
border-t pt-4
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
...
@@ -1329,6 +1559,44 @@ const fallbackGroupOptionsForEdit = computed(() => {
...
@@ -1329,6 +1559,44 @@ const fallbackGroupOptionsForEdit = computed(() => {
return
options
return
options
}
)
}
)
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const
invalidRequestFallbackOptions
=
computed
(()
=>
{
const
options
:
{
value
:
number
|
null
;
label
:
string
}
[]
=
[
{
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
)
eligibleGroups
.
forEach
((
g
)
=>
{
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
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
.
id
!==
currentId
)
eligibleGroups
.
forEach
((
g
)
=>
{
options
.
push
({
value
:
g
.
id
,
label
:
g
.
name
}
)
}
)
return
options
}
)
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const
copyAccountsGroupOptions
=
computed
(()
=>
{
const
copyAccountsGroupOptions
=
computed
(()
=>
{
const
eligibleGroups
=
groups
.
value
.
filter
(
const
eligibleGroups
=
groups
.
value
.
filter
(
...
@@ -1393,8 +1661,13 @@ const createForm = reactive({
...
@@ -1393,8 +1661,13 @@ const createForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only
:
false
,
claude_code_only
:
false
,
fallback_group_id
:
null
as
number
|
null
,
fallback_group_id
:
null
as
number
|
null
,
fallback_group_id_on_invalid_request
:
null
as
number
|
null
,
// 模型路由开关
// 模型路由开关
model_routing_enabled
:
false
,
model_routing_enabled
:
false
,
// 支持的模型系列(仅 antigravity 平台)
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
[]
}
)
}
)
...
@@ -1466,6 +1739,26 @@ const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boo
...
@@ -1466,6 +1739,26 @@ const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boo
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
)
if
(
idx
===
-
1
)
{
createForm
.
supported_model_scopes
.
push
(
scope
)
}
else
{
createForm
.
supported_model_scopes
.
splice
(
idx
,
1
)
}
}
// 切换编辑表单的模型系列选择
const
toggleEditScope
=
(
scope
:
string
)
=>
{
const
idx
=
editForm
.
supported_model_scopes
.
indexOf
(
scope
)
if
(
idx
===
-
1
)
{
editForm
.
supported_model_scopes
.
push
(
scope
)
}
else
{
editForm
.
supported_model_scopes
.
splice
(
idx
,
1
)
}
}
// 处理账号搜索输入框聚焦
// 处理账号搜索输入框聚焦
const
onAccountSearchFocus
=
(
ruleIndex
:
number
,
isEdit
:
boolean
=
false
)
=>
{
const
onAccountSearchFocus
=
(
ruleIndex
:
number
,
isEdit
:
boolean
=
false
)
=>
{
const
key
=
`${isEdit ? 'edit' : 'create'
}
-${ruleIndex
}
`
const
key
=
`${isEdit ? 'edit' : 'create'
}
-${ruleIndex
}
`
...
@@ -1566,8 +1859,13 @@ const editForm = reactive({
...
@@ -1566,8 +1859,13 @@ const editForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only
:
false
,
claude_code_only
:
false
,
fallback_group_id
:
null
as
number
|
null
,
fallback_group_id
:
null
as
number
|
null
,
fallback_group_id_on_invalid_request
:
null
as
number
|
null
,
// 模型路由开关
// 模型路由开关
model_routing_enabled
:
false
,
model_routing_enabled
:
false
,
// 支持的模型系列(仅 antigravity 平台)
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
[]
}
)
}
)
...
@@ -1651,6 +1949,9 @@ const closeCreateModal = () => {
...
@@ -1651,6 +1949,9 @@ const closeCreateModal = () => {
createForm
.
image_price_4k
=
null
createForm
.
image_price_4k
=
null
createForm
.
claude_code_only
=
false
createForm
.
claude_code_only
=
false
createForm
.
fallback_group_id
=
null
createForm
.
fallback_group_id
=
null
createForm
.
fallback_group_id_on_invalid_request
=
null
createForm
.
supported_model_scopes
=
[
'
claude
'
,
'
gemini_text
'
,
'
gemini_image
'
]
createForm
.
mcp_xml_inject
=
true
createForm
.
copy_accounts_from_group_ids
=
[]
createForm
.
copy_accounts_from_group_ids
=
[]
createModelRoutingRules
.
value
=
[]
createModelRoutingRules
.
value
=
[]
}
}
...
@@ -1701,7 +2002,10 @@ const handleEdit = async (group: AdminGroup) => {
...
@@ -1701,7 +2002,10 @@ const handleEdit = async (group: AdminGroup) => {
editForm
.
image_price_4k
=
group
.
image_price_4k
editForm
.
image_price_4k
=
group
.
image_price_4k
editForm
.
claude_code_only
=
group
.
claude_code_only
||
false
editForm
.
claude_code_only
=
group
.
claude_code_only
||
false
editForm
.
fallback_group_id
=
group
.
fallback_group_id
editForm
.
fallback_group_id
=
group
.
fallback_group_id
editForm
.
fallback_group_id_on_invalid_request
=
group
.
fallback_group_id_on_invalid_request
editForm
.
model_routing_enabled
=
group
.
model_routing_enabled
||
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
=
[]
// 复制账号字段每次编辑时重置为空
editForm
.
copy_accounts_from_group_ids
=
[]
// 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称)
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules
.
value
=
await
convertApiFormatToRoutingRules
(
group
.
model_routing
)
editModelRoutingRules
.
value
=
await
convertApiFormatToRoutingRules
(
group
.
model_routing
)
...
@@ -1728,6 +2032,10 @@ const handleUpdateGroup = async () => {
...
@@ -1728,6 +2032,10 @@ const handleUpdateGroup = async () => {
const
payload
=
{
const
payload
=
{
...
editForm
,
...
editForm
,
fallback_group_id
:
editForm
.
fallback_group_id
===
null
?
0
:
editForm
.
fallback_group_id
,
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
)
}
}
await
adminAPI
.
groups
.
update
(
editingGroup
.
value
.
id
,
payload
)
await
adminAPI
.
groups
.
update
(
editingGroup
.
value
.
id
,
payload
)
...
@@ -1768,6 +2076,16 @@ watch(
...
@@ -1768,6 +2076,16 @@ watch(
(
newVal
)
=>
{
(
newVal
)
=>
{
if
(
newVal
===
'
subscription
'
)
{
if
(
newVal
===
'
subscription
'
)
{
createForm
.
is_exclusive
=
true
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
}
}
}
}
)
)
...
...
frontend/src/views/admin/SettingsView.vue
View file @
dd96ada3
...
@@ -353,7 +353,6 @@
...
@@ -353,7 +353,6 @@
</div>
</div>
<Toggle
v-model=
"form.invitation_code_enabled"
/>
<Toggle
v-model=
"form.invitation_code_enabled"
/>
</div>
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<!-- Password Reset - Only show when email verification is enabled -->
<div
<div
v-if=
"form.email_verify_enabled"
v-if=
"form.email_verify_enabled"
...
...
frontend/src/views/admin/UsageView.vue
View file @
dd96ada3
...
@@ -35,13 +35,13 @@
...
@@ -35,13 +35,13 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
saveAs
}
from
'
file-saver
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
;
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
;
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
{
formatReasoningEffort
}
from
'
@/utils/format
'
import
{
formatReasoningEffort
}
from
'
@/utils/format
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
Pagination
from
'
@/components/common/Pagination.vue
'
;
import
Select
from
'
@/components/common/Select.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
Pagination
from
'
@/components/common/Pagination.vue
'
;
import
Select
from
'
@/components/common/Select.vue
'
import
UsageStatsCards
from
'
@/components/admin/usage/UsageStatsCards.vue
'
;
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
UsageStatsCards
from
'
@/components/admin/usage/UsageStatsCards.vue
'
;
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
UsageTable
from
'
@/components/admin/usage/UsageTable.vue
'
;
import
UsageExportProgress
from
'
@/components/admin/usage/UsageExportProgress.vue
'
import
UsageTable
from
'
@/components/admin/usage/UsageTable.vue
'
;
import
UsageExportProgress
from
'
@/components/admin/usage/UsageExportProgress.vue
'
import
UsageCleanupDialog
from
'
@/components/admin/usage/UsageCleanupDialog.vue
'
import
UsageCleanupDialog
from
'
@/components/admin/usage/UsageCleanupDialog.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
;
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
;
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
type
{
AdminUsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
import
type
{
AdminUsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
...
...
frontend/src/views/admin/ops/OpsDashboard.vue
View file @
dd96ada3
...
@@ -40,10 +40,18 @@
...
@@ -40,10 +40,18 @@
/>
/>
<!-- Row: Concurrency + Throughput -->
<!-- Row: Concurrency + Throughput -->
<div
v-if=
"opsEnabled && !(loading && !hasLoadedOnce)"
class=
"grid grid-cols-1 gap-6 lg:grid-cols-
3
"
>
<div
v-if=
"opsEnabled && !(loading && !hasLoadedOnce)"
class=
"grid grid-cols-1 gap-6 lg:grid-cols-
4
"
>
<div
class=
"lg:col-span-1 min-h-[360px]"
>
<div
class=
"lg:col-span-1 min-h-[360px]"
>
<OpsConcurrencyCard
:platform-filter=
"platform"
:group-id-filter=
"groupId"
:refresh-token=
"dashboardRefreshToken"
/>
<OpsConcurrencyCard
:platform-filter=
"platform"
:group-id-filter=
"groupId"
:refresh-token=
"dashboardRefreshToken"
/>
</div>
</div>
<div
class=
"lg:col-span-1 min-h-[360px]"
>
<OpsSwitchRateTrendChart
:points=
"switchTrend?.points ?? []"
:loading=
"loadingSwitchTrend"
:time-range=
"switchTrendTimeRange"
:fullscreen=
"isFullscreen"
/>
</div>
<div
class=
"lg:col-span-2 min-h-[360px]"
>
<div
class=
"lg:col-span-2 min-h-[360px]"
>
<OpsThroughputTrendChart
<OpsThroughputTrendChart
:points=
"throughputTrend?.points ?? []"
:points=
"throughputTrend?.points ?? []"
...
@@ -138,6 +146,7 @@ import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
...
@@ -138,6 +146,7 @@ import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
import
OpsErrorTrendChart
from
'
./components/OpsErrorTrendChart.vue
'
import
OpsErrorTrendChart
from
'
./components/OpsErrorTrendChart.vue
'
import
OpsLatencyChart
from
'
./components/OpsLatencyChart.vue
'
import
OpsLatencyChart
from
'
./components/OpsLatencyChart.vue
'
import
OpsThroughputTrendChart
from
'
./components/OpsThroughputTrendChart.vue
'
import
OpsThroughputTrendChart
from
'
./components/OpsThroughputTrendChart.vue
'
import
OpsSwitchRateTrendChart
from
'
./components/OpsSwitchRateTrendChart.vue
'
import
OpsAlertEventsCard
from
'
./components/OpsAlertEventsCard.vue
'
import
OpsAlertEventsCard
from
'
./components/OpsAlertEventsCard.vue
'
import
OpsRequestDetailsModal
,
{
type
OpsRequestDetailsPreset
}
from
'
./components/OpsRequestDetailsModal.vue
'
import
OpsRequestDetailsModal
,
{
type
OpsRequestDetailsPreset
}
from
'
./components/OpsRequestDetailsModal.vue
'
import
OpsSettingsDialog
from
'
./components/OpsSettingsDialog.vue
'
import
OpsSettingsDialog
from
'
./components/OpsSettingsDialog.vue
'
...
@@ -168,6 +177,9 @@ const groupId = ref<number | null>(null)
...
@@ -168,6 +177,9 @@ const groupId = ref<number | null>(null)
const
queryMode
=
ref
<
QueryMode
>
(
'
auto
'
)
const
queryMode
=
ref
<
QueryMode
>
(
'
auto
'
)
const
customStartTime
=
ref
<
string
|
null
>
(
null
)
const
customStartTime
=
ref
<
string
|
null
>
(
null
)
const
customEndTime
=
ref
<
string
|
null
>
(
null
)
const
customEndTime
=
ref
<
string
|
null
>
(
null
)
const
switchTrendWindowHours
=
5
const
switchTrendTimeRange
=
`
${
switchTrendWindowHours
}
h`
const
switchTrendWindowMs
=
switchTrendWindowHours
*
60
*
60
*
1000
const
QUERY_KEYS
=
{
const
QUERY_KEYS
=
{
timeRange
:
'
tr
'
,
timeRange
:
'
tr
'
,
...
@@ -322,6 +334,9 @@ const metricThresholds = ref<OpsMetricThresholds | null>(null)
...
@@ -322,6 +334,9 @@ const metricThresholds = ref<OpsMetricThresholds | null>(null)
const
throughputTrend
=
ref
<
OpsThroughputTrendResponse
|
null
>
(
null
)
const
throughputTrend
=
ref
<
OpsThroughputTrendResponse
|
null
>
(
null
)
const
loadingTrend
=
ref
(
false
)
const
loadingTrend
=
ref
(
false
)
const
switchTrend
=
ref
<
OpsThroughputTrendResponse
|
null
>
(
null
)
const
loadingSwitchTrend
=
ref
(
false
)
const
latencyHistogram
=
ref
<
OpsLatencyHistogramResponse
|
null
>
(
null
)
const
latencyHistogram
=
ref
<
OpsLatencyHistogramResponse
|
null
>
(
null
)
const
loadingLatency
=
ref
(
false
)
const
loadingLatency
=
ref
(
false
)
...
@@ -491,6 +506,19 @@ function buildApiParams() {
...
@@ -491,6 +506,19 @@ function buildApiParams() {
return
params
return
params
}
}
function
buildSwitchTrendParams
()
{
const
params
:
any
=
{
platform
:
platform
.
value
||
undefined
,
group_id
:
groupId
.
value
??
undefined
,
mode
:
queryMode
.
value
}
const
endTime
=
new
Date
()
const
startTime
=
new
Date
(
endTime
.
getTime
()
-
switchTrendWindowMs
)
params
.
start_time
=
startTime
.
toISOString
()
params
.
end_time
=
endTime
.
toISOString
()
return
params
}
async
function
refreshOverviewWithCancel
(
fetchSeq
:
number
,
signal
:
AbortSignal
)
{
async
function
refreshOverviewWithCancel
(
fetchSeq
:
number
,
signal
:
AbortSignal
)
{
if
(
!
opsEnabled
.
value
)
return
if
(
!
opsEnabled
.
value
)
return
try
{
try
{
...
@@ -504,6 +532,24 @@ async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal)
...
@@ -504,6 +532,24 @@ async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal)
}
}
}
}
async
function
refreshSwitchTrendWithCancel
(
fetchSeq
:
number
,
signal
:
AbortSignal
)
{
if
(
!
opsEnabled
.
value
)
return
loadingSwitchTrend
.
value
=
true
try
{
const
data
=
await
opsAPI
.
getThroughputTrend
(
buildSwitchTrendParams
(),
{
signal
})
if
(
fetchSeq
!==
dashboardFetchSeq
)
return
switchTrend
.
value
=
data
}
catch
(
err
:
any
)
{
if
(
fetchSeq
!==
dashboardFetchSeq
||
isCanceledRequest
(
err
))
return
switchTrend
.
value
=
null
appStore
.
showError
(
err
?.
message
||
t
(
'
admin.ops.failedToLoadSwitchTrend
'
))
}
finally
{
if
(
fetchSeq
===
dashboardFetchSeq
)
{
loadingSwitchTrend
.
value
=
false
}
}
}
async
function
refreshThroughputTrendWithCancel
(
fetchSeq
:
number
,
signal
:
AbortSignal
)
{
async
function
refreshThroughputTrendWithCancel
(
fetchSeq
:
number
,
signal
:
AbortSignal
)
{
if
(
!
opsEnabled
.
value
)
return
if
(
!
opsEnabled
.
value
)
return
loadingTrend
.
value
=
true
loadingTrend
.
value
=
true
...
@@ -600,6 +646,7 @@ async function fetchData() {
...
@@ -600,6 +646,7 @@ async function fetchData() {
await
Promise
.
all
([
await
Promise
.
all
([
refreshOverviewWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshOverviewWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshThroughputTrendWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshThroughputTrendWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshSwitchTrendWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshLatencyHistogramWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshLatencyHistogramWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshErrorTrendWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshErrorTrendWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
),
refreshErrorDistributionWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
)
refreshErrorDistributionWithCancel
(
fetchSeq
,
dashboardFetchController
.
signal
)
...
...
frontend/src/views/admin/ops/components/OpsDashboardSkeleton.vue
View file @
dd96ada3
...
@@ -50,7 +50,11 @@ const props = withDefaults(defineProps<Props>(), {
...
@@ -50,7 +50,11 @@ const props = withDefaults(defineProps<Props>(), {
</div>
</div>
<!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
<!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-3"
>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-4"
>
<div
:class=
"['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']"
>
<div
class=
"h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"
></div>
</div>
<div
:class=
"['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']"
>
<div
:class=
"['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']"
>
<div
class=
"h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"
></div>
<div
class=
"mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"
></div>
...
@@ -96,4 +100,3 @@ const props = withDefaults(defineProps<Props>(), {
...
@@ -96,4 +100,3 @@ const props = withDefaults(defineProps<Props>(), {
</div>
</div>
</div>
</div>
</
template
>
</
template
>
frontend/src/views/admin/ops/components/OpsSwitchRateTrendChart.vue
0 → 100644
View file @
dd96ada3
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
Filler
,
Legend
,
LineElement
,
LinearScale
,
PointElement
,
Title
,
Tooltip
}
from
'
chart.js
'
import
{
Line
}
from
'
vue-chartjs
'
import
type
{
OpsThroughputTrendPoint
}
from
'
@/api/admin/ops
'
import
type
{
ChartState
}
from
'
../types
'
import
{
formatHistoryLabel
,
sumNumbers
}
from
'
../utils/opsFormatters
'
import
HelpTooltip
from
'
@/components/common/HelpTooltip.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
ChartJS
.
register
(
Title
,
Tooltip
,
Legend
,
LineElement
,
LinearScale
,
PointElement
,
CategoryScale
,
Filler
)
interface
Props
{
points
:
OpsThroughputTrendPoint
[]
loading
:
boolean
timeRange
:
string
fullscreen
?:
boolean
}
const
props
=
defineProps
<
Props
>
()
const
{
t
}
=
useI18n
()
const
isDarkMode
=
computed
(()
=>
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
const
colors
=
computed
(()
=>
({
teal
:
'
#14b8a6
'
,
tealAlpha
:
'
#14b8a620
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#f3f4f6
'
,
text
:
isDarkMode
.
value
?
'
#9ca3af
'
:
'
#6b7280
'
}))
const
totalRequests
=
computed
(()
=>
sumNumbers
(
props
.
points
.
map
((
p
)
=>
p
.
request_count
)))
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
points
.
length
||
totalRequests
.
value
<=
0
)
return
null
return
{
labels
:
props
.
points
.
map
((
p
)
=>
formatHistoryLabel
(
p
.
bucket_start
,
props
.
timeRange
)),
datasets
:
[
{
label
:
t
(
'
admin.ops.switchRate
'
),
data
:
props
.
points
.
map
((
p
)
=>
{
const
requests
=
p
.
request_count
??
0
const
switches
=
p
.
switch_count
??
0
if
(
requests
<=
0
)
return
0
return
switches
/
requests
}),
borderColor
:
colors
.
value
.
teal
,
backgroundColor
:
colors
.
value
.
tealAlpha
,
fill
:
true
,
tension
:
0.35
,
pointRadius
:
0
,
pointHitRadius
:
10
}
]
}
})
const
state
=
computed
<
ChartState
>
(()
=>
{
if
(
chartData
.
value
)
return
'
ready
'
if
(
props
.
loading
)
return
'
loading
'
return
'
empty
'
})
const
options
=
computed
(()
=>
{
const
c
=
colors
.
value
return
{
responsive
:
true
,
maintainAspectRatio
:
false
,
interaction
:
{
intersect
:
false
,
mode
:
'
index
'
as
const
},
plugins
:
{
legend
:
{
position
:
'
top
'
as
const
,
align
:
'
end
'
as
const
,
labels
:
{
color
:
c
.
text
,
usePointStyle
:
true
,
boxWidth
:
6
,
font
:
{
size
:
10
}
}
},
tooltip
:
{
backgroundColor
:
isDarkMode
.
value
?
'
#1f2937
'
:
'
#ffffff
'
,
titleColor
:
isDarkMode
.
value
?
'
#f3f4f6
'
:
'
#111827
'
,
bodyColor
:
isDarkMode
.
value
?
'
#d1d5db
'
:
'
#4b5563
'
,
borderColor
:
c
.
grid
,
borderWidth
:
1
,
padding
:
10
,
displayColors
:
true
,
callbacks
:
{
label
:
(
context
:
any
)
=>
{
const
value
=
typeof
context
?.
parsed
?.
y
===
'
number
'
?
context
.
parsed
.
y
:
0
return
`
${
t
(
'
admin.ops.switchRate
'
)}
:
${
value
.
toFixed
(
3
)}
`
}
}
}
},
scales
:
{
x
:
{
type
:
'
category
'
as
const
,
grid
:
{
display
:
false
},
ticks
:
{
color
:
c
.
text
,
font
:
{
size
:
10
},
maxTicksLimit
:
8
,
autoSkip
:
true
,
autoSkipPadding
:
10
}
},
y
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
left
'
as
const
,
grid
:
{
color
:
c
.
grid
,
borderDash
:
[
4
,
4
]
},
ticks
:
{
color
:
c
.
text
,
font
:
{
size
:
10
},
callback
:
(
value
:
any
)
=>
Number
(
value
).
toFixed
(
3
)
}
}
}
}
})
</
script
>
<
template
>
<div
class=
"flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
>
<div
class=
"mb-4 flex shrink-0 items-center justify-between"
>
<h3
class=
"flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white"
>
<svg
class=
"h-4 w-4 text-teal-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M7 7h10M7 12h6m-6 5h3"
/>
</svg>
{{
t
(
'
admin.ops.switchRateTrend
'
)
}}
<HelpTooltip
v-if=
"!props.fullscreen"
:content=
"t('admin.ops.tooltips.switchRateTrend')"
/>
</h3>
</div>
<div
class=
"min-h-0 flex-1"
>
<Line
v-if=
"state === 'ready' && chartData"
:data=
"chartData"
:options=
"options"
/>
<div
v-else
class=
"flex h-full items-center justify-center"
>
<div
v-if=
"state === 'loading'"
class=
"animate-pulse text-sm text-gray-400"
>
{{
t
(
'
common.loading
'
)
}}
</div>
<EmptyState
v-else
:title=
"t('common.noData')"
:description=
"t('admin.ops.charts.emptyRequest')"
/>
</div>
</div>
</div>
</
template
>
frontend/src/views/setup/SetupWizardView.vue
View file @
dd96ada3
...
@@ -91,6 +91,18 @@
...
@@ -91,6 +91,18 @@
</div>
</div>
</div>
</div>
<div
class=
"flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700"
>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ t("setup.redis.enableTls") }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{ t("setup.redis.enableTlsHint") }}
</p>
</div>
<Toggle
v-model=
"formData.redis.enable_tls"
/>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<div>
<label
class=
"input-label"
>
{{ t('setup.database.username') }}
</label>
<label
class=
"input-label"
>
{{ t('setup.database.username') }}
</label>
...
...
frontend/src/views/user/KeysView.vue
View file @
dd96ada3
...
@@ -108,12 +108,53 @@
...
@@ -108,12 +108,53 @@
$
{{
(
usageStats
[
row
.
id
]?.
total_actual_cost
??
0
).
toFixed
(
4
)
}}
$
{{
(
usageStats
[
row
.
id
]?.
total_actual_cost
??
0
).
toFixed
(
4
)
}}
</span>
</span>
</div>
</div>
<!-- Quota progress (if quota is set) -->
<div
v-if=
"row.quota > 0"
class=
"mt-1.5"
>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
keys.quota
'
)
}}
:
</span>
<span
:class=
"[
'font-medium',
row.quota_used >= row.quota ? 'text-red-500' :
row.quota_used >= row.quota * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]"
>
$
{{
row
.
quota_used
?.
toFixed
(
2
)
||
'
0.00
'
}}
/ $
{{
row
.
quota
?.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600"
>
<div
:class=
"[
'h-full rounded-full transition-all',
row.quota_used >= row.quota ? 'bg-red-500' :
row.quota_used >= row.quota * 0.8 ? 'bg-yellow-500' :
'bg-primary-500'
]"
:style=
"
{ width: Math.min((row.quota_used / row.quota) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
</div>
</
template
>
</
template
>
<
template
#cell-expires_at=
"{ value }"
>
<span
v-if=
"value"
:class=
"[
'text-sm',
new Date(value)
<
new
Date
()
?
'
text-red-500
dark:text-red-400
'
:
'
text-gray-500
dark:text-dark-400
'
]"
>
{{
formatDateTime
(
value
)
}}
</span>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
keys.noExpiration
'
)
}}
</span>
</
template
>
<
template
#cell-status=
"{ value }"
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
<span
:class=
"[
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
'badge',
value === 'active' ? 'badge-success' :
value === 'quota_exhausted' ? 'badge-warning' :
value === 'expired' ? 'badge-danger' :
'badge-gray'
]"
>
{{
t
(
'
keys.status.
'
+
value
)
}}
</span>
</span>
</
template
>
</
template
>
...
@@ -334,6 +375,145 @@
...
@@ -334,6 +375,145 @@
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quota Limit Section -->
<div
class=
"space-y-3"
>
<label
class=
"input-label"
>
{{ t('keys.quotaLimit') }}
</label>
<!-- Switch commented out - always show input, 0 = unlimited
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.quotaLimit') }}</label>
<button
type="button"
@click="formData.enable_quota = !formData.enable_quota"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_quota ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_quota ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
-->
<div
class=
"space-y-4"
>
<div>
<div
class=
"relative"
>
<span
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
>
$
</span>
<input
v-model.number=
"formData.quota"
type=
"number"
step=
"0.01"
min=
"0"
class=
"input pl-7"
:placeholder=
"t('keys.quotaAmountPlaceholder')"
/>
</div>
<p
class=
"input-hint"
>
{{ t('keys.quotaAmountHint') }}
</p>
</div>
<!-- Quota used display (only in edit mode) -->
<div
v-if=
"showEditModal && selectedKey && selectedKey.quota > 0"
>
<label
class=
"input-label"
>
{{ t('keys.quotaUsed') }}
</label>
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
${{ selectedKey.quota_used?.toFixed(4) || '0.0000' }}
</span>
<span
class=
"mx-2 text-gray-400"
>
/
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
${{ selectedKey.quota?.toFixed(2) || '0.00' }}
</span>
</div>
<button
type=
"button"
@
click=
"confirmResetQuota"
class=
"btn btn-secondary text-sm"
:title=
"t('keys.resetQuotaUsed')"
>
{{ t('keys.reset') }}
</button>
</div>
</div>
</div>
</div>
<!-- Expiration Section -->
<div
class=
"space-y-3"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"input-label mb-0"
>
{{ t('keys.expiration') }}
</label>
<button
type=
"button"
@
click=
"formData.enable_expiration = !formData.enable_expiration"
:class=
"[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_expiration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class=
"[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_expiration ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div
v-if=
"formData.enable_expiration"
class=
"space-y-4 pt-2"
>
<!-- Quick select buttons (for both create and edit mode) -->
<div
class=
"flex flex-wrap gap-2"
>
<button
v-for=
"days in ['7', '30', '90']"
:key=
"days"
type=
"button"
@
click=
"setExpirationDays(parseInt(days))"
:class=
"[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === days
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ showEditModal ? t('keys.extendDays', { days }) : t('keys.expiresInDays', { days }) }}
</button>
<button
type=
"button"
@
click=
"formData.expiration_preset = 'custom'"
:class=
"[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === 'custom'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ t('keys.customDate') }}
</button>
</div>
<!-- Date picker (always show for precise adjustment) -->
<div>
<label
class=
"input-label"
>
{{ t('keys.expirationDate') }}
</label>
<input
v-model=
"formData.expiration_date"
type=
"datetime-local"
class=
"input"
/>
<p
class=
"input-hint"
>
{{ t('keys.expirationDateHint') }}
</p>
</div>
<!-- Current expiration display (only in edit mode) -->
<div
v-if=
"showEditModal && selectedKey?.expires_at"
class=
"text-sm"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('keys.currentExpiration') }}:
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{ formatDateTime(selectedKey.expires_at) }}
</span>
</div>
</div>
</div>
</form>
</form>
<
template
#footer
>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<div
class=
"flex justify-end gap-3"
>
...
@@ -391,6 +571,18 @@
...
@@ -391,6 +571,18 @@
@
cancel=
"showDeleteDialog = false"
@
cancel=
"showDeleteDialog = false"
/>
/>
<!-- Reset Quota Confirmation Dialog -->
<ConfirmDialog
:show=
"showResetQuotaDialog"
:title=
"t('keys.resetQuotaTitle')"
:message=
"t('keys.resetQuotaConfirmMessage', { name: selectedKey?.name, used: selectedKey?.quota_used?.toFixed(4) })"
:confirm-text=
"t('keys.reset')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"resetQuotaUsed"
@
cancel=
"showResetQuotaDialog = false"
/>
<!-- Use Key Modal -->
<!-- Use Key Modal -->
<UseKeyModal
<UseKeyModal
:show=
"showUseKeyModal"
:show=
"showUseKeyModal"
...
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
...
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
{
formatDateTime
}
from
'
@/utils/format
'
// Helper to format date for datetime-local input
const
formatDateTimeLocal
=
(
isoDate
:
string
):
string
=>
{
const
date
=
new
Date
(
isoDate
)
const
pad
=
(
n
:
number
)
=>
n
.
toString
().
padStart
(
2
,
'
0
'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
T
${
pad
(
date
.
getHours
())}
:
${
pad
(
date
.
getMinutes
())}
`
}
interface
GroupOption
{
interface
GroupOption
{
value
:
number
value
:
number
label
:
string
label
:
string
...
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
...
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
{
key
:
'
key
'
,
label
:
t
(
'
keys.apiKey
'
),
sortable
:
false
},
{
key
:
'
key
'
,
label
:
t
(
'
keys.apiKey
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
keys.group
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
keys.group
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
keys.usage
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
keys.usage
'
),
sortable
:
false
},
{
key
:
'
expires_at
'
,
label
:
t
(
'
keys.expiresAt
'
),
sortable
:
true
},
{
key
:
'
status
'
,
label
:
t
(
'
common.status
'
),
sortable
:
true
},
{
key
:
'
status
'
,
label
:
t
(
'
common.status
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
keys.created
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
keys.created
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
common.actions
'
),
sortable
:
false
}
{
key
:
'
actions
'
,
label
:
t
(
'
common.actions
'
),
sortable
:
false
}
...
@@ -553,6 +753,7 @@ const pagination = ref({
...
@@ -553,6 +753,7 @@ const pagination = ref({
const
showCreateModal
=
ref
(
false
)
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showResetQuotaDialog
=
ref
(
false
)
const
showUseKeyModal
=
ref
(
false
)
const
showUseKeyModal
=
ref
(
false
)
const
showCcsClientSelect
=
ref
(
false
)
const
showCcsClientSelect
=
ref
(
false
)
const
pendingCcsRow
=
ref
<
ApiKey
|
null
>
(
null
)
const
pendingCcsRow
=
ref
<
ApiKey
|
null
>
(
null
)
...
@@ -587,7 +788,13 @@ const formData = ref({
...
@@ -587,7 +788,13 @@ const formData = ref({
custom_key
:
''
,
custom_key
:
''
,
enable_ip_restriction
:
false
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
ip_whitelist
:
''
,
ip_blacklist
:
''
ip_blacklist
:
''
,
// Quota settings (empty = unlimited)
enable_quota
:
false
,
quota
:
null
as
number
|
null
,
enable_expiration
:
false
,
expiration_preset
:
'
30
'
as
'
7
'
|
'
30
'
|
'
90
'
|
'
custom
'
,
expiration_date
:
''
})
})
// 自定义Key验证
// 自定义Key验证
...
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
...
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
const
editKey
=
(
key
:
ApiKey
)
=>
{
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
selectedKey
.
value
=
key
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
const
hasExpiration
=
!!
key
.
expires_at
formData
.
value
=
{
formData
.
value
=
{
name
:
key
.
name
,
name
:
key
.
name
,
group_id
:
key
.
group_id
,
group_id
:
key
.
group_id
,
status
:
key
.
status
,
status
:
key
.
status
===
'
quota_exhausted
'
||
key
.
status
===
'
expired
'
?
'
inactive
'
:
key
.
status
,
use_custom_key
:
false
,
use_custom_key
:
false
,
custom_key
:
''
,
custom_key
:
''
,
enable_ip_restriction
:
hasIPRestriction
,
enable_ip_restriction
:
hasIPRestriction
,
ip_whitelist
:
(
key
.
ip_whitelist
||
[]).
join
(
'
\n
'
),
ip_whitelist
:
(
key
.
ip_whitelist
||
[]).
join
(
'
\n
'
),
ip_blacklist
:
(
key
.
ip_blacklist
||
[]).
join
(
'
\n
'
)
ip_blacklist
:
(
key
.
ip_blacklist
||
[]).
join
(
'
\n
'
),
enable_quota
:
key
.
quota
>
0
,
quota
:
key
.
quota
>
0
?
key
.
quota
:
null
,
enable_expiration
:
hasExpiration
,
expiration_preset
:
'
custom
'
,
expiration_date
:
key
.
expires_at
?
formatDateTimeLocal
(
key
.
expires_at
)
:
''
}
}
showEditModal
.
value
=
true
showEditModal
.
value
=
true
}
}
...
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
...
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
const
ipWhitelist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_whitelist
)
:
[]
const
ipWhitelist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_whitelist
)
:
[]
const
ipBlacklist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_blacklist
)
:
[]
const
ipBlacklist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_blacklist
)
:
[]
// Calculate quota value (null/empty/0 = unlimited, stored as 0)
const
quota
=
formData
.
value
.
quota
&&
formData
.
value
.
quota
>
0
?
formData
.
value
.
quota
:
0
// Calculate expiration
let
expiresInDays
:
number
|
undefined
let
expiresAt
:
string
|
null
|
undefined
if
(
formData
.
value
.
enable_expiration
&&
formData
.
value
.
expiration_date
)
{
if
(
!
showEditModal
.
value
)
{
// Create mode: calculate days from date
const
expDate
=
new
Date
(
formData
.
value
.
expiration_date
)
const
now
=
new
Date
()
const
diffDays
=
Math
.
ceil
((
expDate
.
getTime
()
-
now
.
getTime
())
/
(
1000
*
60
*
60
*
24
))
expiresInDays
=
diffDays
>
0
?
diffDays
:
1
}
else
{
// Edit mode: use custom date directly
expiresAt
=
new
Date
(
formData
.
value
.
expiration_date
).
toISOString
()
}
}
else
if
(
showEditModal
.
value
)
{
// Edit mode: if expiration disabled or date cleared, send empty string to clear
expiresAt
=
''
}
submitting
.
value
=
true
submitting
.
value
=
true
try
{
try
{
if
(
showEditModal
.
value
&&
selectedKey
.
value
)
{
if
(
showEditModal
.
value
&&
selectedKey
.
value
)
{
...
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
...
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
group_id
:
formData
.
value
.
group_id
,
group_id
:
formData
.
value
.
group_id
,
status
:
formData
.
value
.
status
,
status
:
formData
.
value
.
status
,
ip_whitelist
:
ipWhitelist
,
ip_whitelist
:
ipWhitelist
,
ip_blacklist
:
ipBlacklist
ip_blacklist
:
ipBlacklist
,
quota
:
quota
,
expires_at
:
expiresAt
})
})
appStore
.
showSuccess
(
t
(
'
keys.keyUpdatedSuccess
'
))
appStore
.
showSuccess
(
t
(
'
keys.keyUpdatedSuccess
'
))
}
else
{
}
else
{
const
customKey
=
formData
.
value
.
use_custom_key
?
formData
.
value
.
custom_key
:
undefined
const
customKey
=
formData
.
value
.
use_custom_key
?
formData
.
value
.
custom_key
:
undefined
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
,
ipWhitelist
,
ipBlacklist
)
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
,
ipWhitelist
,
ipBlacklist
,
quota
,
expiresInDays
)
appStore
.
showSuccess
(
t
(
'
keys.keyCreatedSuccess
'
))
appStore
.
showSuccess
(
t
(
'
keys.keyCreatedSuccess
'
))
// Only advance tour if active, on submit step, and creation succeeded
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
...
@@ -883,7 +1128,42 @@ const closeModals = () => {
...
@@ -883,7 +1128,42 @@ const closeModals = () => {
custom_key
:
''
,
custom_key
:
''
,
enable_ip_restriction
:
false
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
ip_whitelist
:
''
,
ip_blacklist
:
''
ip_blacklist
:
''
,
enable_quota
:
false
,
quota
:
null
,
enable_expiration
:
false
,
expiration_preset
:
'
30
'
,
expiration_date
:
''
}
}
// Show reset quota confirmation dialog
const
confirmResetQuota
=
()
=>
{
showResetQuotaDialog
.
value
=
true
}
// Set expiration date based on quick select days
const
setExpirationDays
=
(
days
:
number
)
=>
{
formData
.
value
.
expiration_preset
=
days
.
toString
()
as
'
7
'
|
'
30
'
|
'
90
'
const
expDate
=
new
Date
()
expDate
.
setDate
(
expDate
.
getDate
()
+
days
)
formData
.
value
.
expiration_date
=
formatDateTimeLocal
(
expDate
.
toISOString
())
}
// Reset quota used for an API key
const
resetQuotaUsed
=
async
()
=>
{
if
(
!
selectedKey
.
value
)
return
showResetQuotaDialog
.
value
=
false
try
{
await
keysAPI
.
update
(
selectedKey
.
value
.
id
,
{
reset_quota
:
true
})
appStore
.
showSuccess
(
t
(
'
keys.quotaResetSuccess
'
))
// Update local state
if
(
selectedKey
.
value
)
{
selectedKey
.
value
.
quota_used
=
0
}
}
catch
(
error
:
any
)
{
const
errorMsg
=
error
.
response
?.
data
?.
detail
||
t
(
'
keys.failedToResetQuota
'
)
appStore
.
showError
(
errorMsg
)
}
}
}
}
...
...
Prev
1
2
3
4
5
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment