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
0b746501
Commit
0b746501
authored
Apr 16, 2026
by
陈曦
Browse files
1. merge upstream v0.1.113 2.提交migration相关文件
parents
45061102
be7551b9
Changes
225
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/en.ts
View file @
0b746501
...
...
@@ -71,28 +71,28 @@ export default {
items
:
{
pricing
:
{
feature
:
'
Pricing
'
,
official
:
'
Fixed monthly fee p
er provider
'
,
us
:
'
Pay
per request, unified billing
'
official
:
'
Fixed monthly fee
,
p
ay even if unused
'
,
us
:
'
Pay
only for what you use
'
},
models
:
{
feature
:
'
Model
Access
'
,
feature
:
'
Model
Selection
'
,
official
:
'
Single provider only
'
,
us
:
'
Any model, one endpoint
'
us
:
'
Switch between models freely
'
},
management
:
{
feature
:
'
Account Management
'
,
official
:
'
E
ach service
managed
separately
'
,
us
:
'
Unified dashboard
, one API key
'
official
:
'
Manage e
ach service separately
'
,
us
:
'
Unified
key, one
dashboard
'
},
stability
:
{
feature
:
'
Reli
ability
'
,
official
:
'
Single account
, no failover
'
,
feature
:
'
St
ability
'
,
official
:
'
Single account
rate limits
'
,
us
:
'
Multi-account pool, auto-failover
'
},
control
:
{
feature
:
'
Traffic Control
'
,
official
:
'
Not available
'
,
us
:
'
Quotas
, rate limits &
analytics
'
us
:
'
Quotas
& detailed
analytics
'
}
}
},
...
...
@@ -4381,6 +4381,15 @@ export default {
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
apiBaseUrlHint
:
'
Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.
'
,
tablePreferencesTitle
:
'
Global Table Preferences
'
,
tablePreferencesDescription
:
'
Configure default pagination behavior for shared table components
'
,
tableDefaultPageSize
:
'
Default Rows Per Page
'
,
tableDefaultPageSizeHint
:
'
Must be an integer between 5 and 1000
'
,
tablePageSizeOptions
:
'
Rows Per Page Options
'
,
tablePageSizeOptionsPlaceholder
:
'
10, 20, 50, 100
'
,
tablePageSizeOptionsHint
:
'
Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save
'
,
tableDefaultPageSizeRangeError
:
'
Default rows per page must be between {min} and {max}
'
,
tablePageSizeOptionsFormatError
:
'
Invalid options format. Enter comma-separated integers between {min} and {max}
'
,
customEndpoints
:
{
title
:
'
Custom Endpoints
'
,
description
:
'
Add additional API endpoint URLs for users to quickly copy on the API Keys page
'
,
...
...
@@ -4453,6 +4462,129 @@ export default {
moveUp
:
'
Move Up
'
,
moveDown
:
'
Move Down
'
,
},
payment
:
{
title
:
'
Payment Settings
'
,
description
:
'
Configure payment system options
'
,
configGuide
:
'
Configuration Guide
'
,
enabled
:
'
Enable Payment
'
,
enabledHint
:
'
Enable or disable the payment system
'
,
enabledPaymentTypes
:
'
Enabled Providers
'
,
enabledPaymentTypesHint
:
'
Disabling a provider will also disable its instances.
'
,
findProvider
:
'
Looking for a suitable EasyPay provider?
'
,
minAmount
:
'
Minimum Amount
'
,
maxAmount
:
'
Maximum Amount
'
,
dailyLimit
:
'
Daily Limit
'
,
balanceRechargeMultiplier
:
'
Balance Recharge Multiplier
'
,
balanceRechargeMultiplierHint
:
'
How many USD balance the user receives for each 1 CNY paid
'
,
balanceRechargePreview
:
'
Preview: 1 CNY = {usd} USD
'
,
rechargeFeeRate
:
'
Recharge Fee Rate
'
,
rechargeFeeRateHint
:
'
Percentage of service fee charged on top of recharge amount, 0 means no fee
'
,
rechargeFeePreview
:
'
Preview: Recharge 100, fee {fee}
'
,
orderTimeout
:
'
Order Timeout
'
,
orderTimeoutHint
:
'
In minutes, minimum 1
'
,
maxPendingOrders
:
'
Max Pending Orders
'
,
cancelRateLimit
:
'
Limit Cancel Rate
'
,
cancelRateLimitHint
:
'
When enabled, users who exceed the cancel limit within the time window cannot create new orders
'
,
cancelRateLimitEvery
:
'
Every
'
,
cancelRateLimitAllowMax
:
'
allow max
'
,
cancelRateLimitTimes
:
'
cancels
'
,
cancelRateLimitWindow
:
'
Window
'
,
cancelRateLimitUnit
:
'
Unit
'
,
cancelRateLimitMax
:
'
Max Cancels
'
,
cancelRateLimitUnitMinute
:
'
Minutes
'
,
cancelRateLimitUnitHour
:
'
Hours
'
,
cancelRateLimitUnitDay
:
'
Days
'
,
cancelRateLimitWindowMode
:
'
Window Mode
'
,
cancelRateLimitWindowModeRolling
:
'
Rolling
'
,
cancelRateLimitWindowModeFixed
:
'
Fixed
'
,
helpText
:
'
Help Text
'
,
helpImageUrl
:
'
Help Image URL
'
,
manageProviders
:
'
Manage Providers
'
,
balancePaymentDisabled
:
'
Disable Balance Recharge
'
,
noLimit
:
'
Empty = no limit
'
,
helpImage
:
'
Help Image
'
,
helpImagePlaceholder
:
'
Upload or enter image URL
'
,
helpTextPlaceholder
:
'
Enter help text...
'
,
providerEasypay
:
'
EasyPay
'
,
providerAlipay
:
'
Alipay (Direct)
'
,
providerWxpay
:
'
WeChat Pay (Direct)
'
,
providerStripe
:
'
Stripe
'
,
typeDisabled
:
'
type disabled
'
,
enableTypesFirst
:
'
Enable at least one payment type above first
'
,
easypayRedirect
:
'
Redirect
'
,
paymentMode
:
'
Payment Mode
'
,
modeRedirect
:
'
Redirect
'
,
modeQRCode
:
'
QR Code
'
,
modePopup
:
'
Popup
'
,
validationNameRequired
:
'
Provider name is required
'
,
validationTypesRequired
:
'
Please select at least one supported payment type
'
,
validationFieldRequired
:
'
{field} is required
'
,
field_apiBase
:
'
API Base URL
'
,
field_notifyUrl
:
'
Notify URL
'
,
field_returnUrl
:
'
Return URL
'
,
callbackBaseUrl
:
'
Callback Base URL
'
,
field_privateKey
:
'
Private Key
'
,
field_publicKey
:
'
Public Key
'
,
field_mchId
:
'
Merchant ID
'
,
field_apiV3Key
:
'
API v3 Key
'
,
field_publicKeyId
:
'
Public Key ID
'
,
field_certSerial
:
'
Certificate Serial
'
,
field_secretKey
:
'
Secret Key
'
,
field_publishableKey
:
'
Publishable Key
'
,
field_webhookSecret
:
'
Webhook Secret
'
,
field_cid
:
'
Channel ID
'
,
field_cidAlipay
:
'
Alipay Channel ID
'
,
field_cidWxpay
:
'
WeChat Channel ID
'
,
stripeWebhookHint
:
'
Configure the following URL as a Webhook endpoint in Stripe Dashboard:
'
,
limitsTitle
:
'
Limits
'
,
limitSingleMin
:
'
Min per order
'
,
limitSingleMax
:
'
Max per order
'
,
limitDaily
:
'
Daily limit
'
,
limitsHint
:
'
All empty = use global config; partially filled = empty means no limit
'
,
limitsUseGlobal
:
'
Use global
'
,
limitsNoLimit
:
'
No limit
'
,
productNamePrefix
:
'
Product Name Prefix
'
,
productNameSuffix
:
'
Product Name Suffix
'
,
preview
:
'
Preview
'
,
loadBalanceStrategy
:
'
Load Balance Strategy
'
,
strategyRoundRobin
:
'
Round Robin
'
,
strategyLeastAmount
:
'
Least Daily Amount
'
,
providerManagement
:
'
Provider Management
'
,
providerManagementDesc
:
'
Manage payment provider instances
'
,
createProvider
:
'
Add Provider
'
,
editProvider
:
'
Edit Provider
'
,
deleteProvider
:
'
Delete Provider
'
,
deleteProviderConfirm
:
'
Are you sure you want to delete this provider?
'
,
providerName
:
'
Provider Name
'
,
providerKey
:
'
Provider Type
'
,
selectProviderKey
:
'
Select Provider Type
'
,
providerConfig
:
'
Credentials
'
,
noProviders
:
'
No provider instances configured
'
,
supportedTypes
:
'
Supported Payment Types
'
,
supportedTypesHint
:
'
Comma-separated, e.g. alipay,wxpay
'
,
refundEnabled
:
'
Allow Refund
'
,
allowUserRefund
:
'
Allow User Refund
'
,
},
balanceNotify
:
{
title
:
'
Balance Low Notification
'
,
description
:
'
Send email notification when user balance falls below threshold
'
,
enabled
:
'
Enable Balance Low Notification
'
,
threshold
:
'
Default Threshold
'
,
thresholdHint
:
'
Used when user has not set a custom value
'
,
thresholdPlaceholder
:
'
Enter amount
'
,
rechargeUrl
:
'
Recharge Page URL
'
,
rechargeUrlPlaceholder
:
'
https://example.com/payment
'
,
rechargeUrlHint
:
'
A top-up button will appear in the email when set
'
,
},
quotaNotify
:
{
title
:
'
Account Quota Notification
'
,
description
:
'
Notify admins when account quota usage reaches alert threshold
'
,
enabled
:
'
Enable Account Quota Notification
'
,
emails
:
'
Notification Emails
'
,
emailsHint
:
'
Leave empty to disable notifications
'
,
addEmail
:
'
Add Email
'
,
emailPlaceholder
:
'
Enter email address
'
,
},
smtp
:
{
title
:
'
SMTP Settings
'
,
description
:
'
Configure email sending for verification codes
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
0b746501
...
...
@@ -254,6 +254,8 @@ export default {
loading
:
'
加载中...
'
,
justNow
:
'
刚刚
'
,
save
:
'
保存
'
,
saved
:
'
保存成功
'
,
deleted
:
'
删除成功
'
,
cancel
:
'
取消
'
,
delete
:
'
删除
'
,
edit
:
'
编辑
'
,
...
...
@@ -311,6 +313,7 @@ export default {
saving
:
'
保存中...
'
,
selectedCount
:
'
(已选 {count} 个)
'
,
refresh
:
'
刷新
'
,
view
:
'
查看
'
,
settings
:
'
设置
'
,
chooseFile
:
'
选择文件
'
,
notAvailable
:
'
不可用
'
,
...
...
@@ -740,6 +743,7 @@ export default {
totalCost
:
'
总消费
'
,
standardCost
:
'
标准
'
,
actualCost
:
'
实际
'
,
accountCost
:
'
成本
'
,
userBilled
:
'
用户扣费
'
,
accountBilled
:
'
账号计费
'
,
accountMultiplier
:
'
账号倍率
'
,
...
...
@@ -785,6 +789,8 @@ export default {
inputTokenPrice
:
'
输入单价
'
,
outputTokenPrice
:
'
输出单价
'
,
perMillionTokens
:
'
/ 1M Token
'
,
unitPrice
:
'
单次价格
'
,
imageUnitPrice
:
'
单张价格
'
,
cacheRead
:
'
读取
'
,
cacheWrite
:
'
写入
'
,
serviceTier
:
'
服务档位
'
,
...
...
@@ -913,6 +919,38 @@ export default {
sendCode
:
'
发送验证码
'
,
codeSent
:
'
验证码已发送到您的邮箱
'
,
sendCodeFailed
:
'
发送验证码失败
'
},
balanceNotify
:
{
title
:
'
余额不足提醒
'
,
description
:
'
当账户余额低于阈值时发送邮件提醒
'
,
enabled
:
'
启用余额不足提醒
'
,
threshold
:
'
自定义提醒阈值
'
,
thresholdHint
:
'
留空使用系统默认值
'
,
thresholdPlaceholder
:
'
输入金额
'
,
systemDefault
:
'
系统默认值
'
,
extraEmails
:
'
通知邮箱
'
,
extraEmailsHint
:
'
必须添加并验证邮箱后,余额不足时才能收到提醒邮件
'
,
primaryEmail
:
'
主邮箱
'
,
noExtraEmails
:
'
暂无额外通知邮箱
'
,
enterEmail
:
'
输入邮箱地址
'
,
addEmail
:
'
添加邮箱
'
,
emailPlaceholder
:
'
输入邮箱地址
'
,
sendCode
:
'
发送验证码
'
,
resend
:
'
重发
'
,
codeSent
:
'
验证码已发送
'
,
codeSentTo
:
'
验证码已发送到 {email}
'
,
enterCode
:
'
输入验证码
'
,
codePlaceholder
:
'
6位验证码
'
,
verify
:
'
验证
'
,
emailAdded
:
'
邮箱已添加
'
,
emailRemoved
:
'
邮箱已移除
'
,
verifySuccess
:
'
邮箱添加成功
'
,
removeEmail
:
'
移除
'
,
removeSuccess
:
'
邮箱已移除
'
,
emailDuplicate
:
'
该邮箱已存在
'
,
maxEmailsReached
:
'
已达到通知邮箱数量上限
'
,
unverified
:
'
未验证
'
,
verified
:
'
已验证
'
,
}
},
...
...
@@ -996,6 +1034,7 @@ export default {
totalCost
:
'
总消费
'
,
actual
:
'
实际
'
,
standard
:
'
标准
'
,
accountCost
:
'
成本
'
,
todayTokens
:
'
今日 Token
'
,
totalTokens
:
'
总 Token
'
,
input
:
'
输入
'
,
...
...
@@ -1922,12 +1961,28 @@ export default {
defaultPerRequestPrice
:
'
默认单次价格(未命中层级时使用)
'
,
defaultImagePrice
:
'
默认图片价格(未命中层级时使用)
'
,
platformConfig
:
'
平台配置
'
,
webSearchEmulation
:
'
Web Search 模拟
'
,
webSearchEmulationHint
:
'
⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作
'
,
webSearchEmulationGlobalDisabled
:
'
请先在系统设置 → 网关 → Web Search 模拟中启用全局开关
'
,
basicSettings
:
'
基础设置
'
,
addPlatform
:
'
添加平台
'
,
noPlatforms
:
'
点击"添加平台"开始配置渠道
'
,
mappingCount
:
'
条映射
'
,
pricingEntry
:
'
定价配置
'
,
noModels
:
'
未添加模型
'
noModels
:
'
未添加模型
'
,
applyPricingToAccountStats
:
'
应用模型定价到账号统计
'
,
applyPricingToAccountStatsDesc
:
'
启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用
'
,
accountStatsPricingRules
:
'
自定义账号统计定价规则
'
,
addRule
:
'
添加规则
'
,
noRulesConfigured
:
'
未配置自定义规则,将使用上方的模型定价。
'
,
ruleName
:
'
规则名称(可选)
'
,
ruleGroups
:
'
分组
'
,
ruleAccounts
:
'
账号
'
,
searchAccountPlaceholder
:
'
搜索账号...
'
,
ruleAccountsHint
:
'
留空表示匹配所有账号
'
,
ruleModelPricing
:
'
模型定价
'
,
noGroupsInChannel
:
'
上方平台标签页中未选择分组
'
,
unnamed
:
'
未命名
'
}
},
...
...
@@ -2219,6 +2274,12 @@ export default {
},
quotaLimitAmount
:
'
总限额
'
,
quotaLimitAmountHint
:
'
累计消费上限,不会自动重置。
'
,
quotaNotify
:
{
alert
:
'
提醒阈值
'
,
enabled
:
'
启用告警
'
,
threshold
:
'
告警金额
'
,
thresholdPlaceholder
:
'
输入百分比
'
,
},
testConnection
:
'
测试连接
'
,
reAuthorize
:
'
重新授权
'
,
refreshToken
:
'
刷新令牌
'
,
...
...
@@ -2479,7 +2540,13 @@ export default {
anthropic
:
{
apiKeyPassthrough
:
'
自动透传(仅替换认证)
'
,
apiKeyPassthroughDesc
:
'
仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。
'
'
仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。
'
,
webSearchEmulation
:
'
Web Search 模拟
'
,
webSearchEmulationDesc
:
'
为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。默认跟随渠道配置。
'
,
webSearchDefault
:
'
默认
'
,
webSearchEnabled
:
'
开启
'
,
webSearchDisabled
:
'
关闭
'
,
},
modelRestriction
:
'
模型限制(可选)
'
,
modelWhitelist
:
'
模型白名单
'
,
...
...
@@ -4527,6 +4594,40 @@ export default {
cchSigning
:
'
CCH 签名
'
,
cchSigningHint
:
'
对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。
'
,
},
webSearchEmulation
:
{
title
:
'
Web Search 模拟
'
,
description
:
'
为不原生支持搜索的 Anthropic API Key 账号注入 web search 能力
'
,
enabled
:
'
启用 Web Search 模拟
'
,
enabledHint
:
'
全局开关。关闭后所有渠道和账号的 web search 模拟均不生效。
'
,
providers
:
'
搜索服务商
'
,
addProvider
:
'
添加服务商
'
,
providerType
:
'
服务商类型
'
,
apiKey
:
'
API Key
'
,
apiKeyPlaceholder
:
'
输入 API Key
'
,
apiKeyConfigured
:
'
已配置
'
,
showApiKey
:
'
显示
'
,
hideApiKey
:
'
隐藏
'
,
copyApiKey
:
'
复制
'
,
copied
:
'
已复制
'
,
quotaLimit
:
'
配额上限
'
,
quotaLimitHint
:
'
留空表示无限制;填写时必须大于 0
'
,
quotaLimitMustBePositive
:
'
配额上限必须大于 0
'
,
subscribedAt
:
'
订阅时间
'
,
subscribedAtHint
:
'
配额从此日期起每月自动重置;留空则不自动重置
'
,
quotaUsage
:
'
用量
'
,
resetUsage
:
'
重置
'
,
resetUsageConfirm
:
'
确定要重置此服务商的用量计数吗?
'
,
resetUsageSuccess
:
'
用量已重置
'
,
proxy
:
'
代理
'
,
removeProvider
:
'
删除
'
,
noProviders
:
'
未配置搜索服务商
'
,
test
:
'
测试
'
,
testDefaultQuery
:
'
搜索今年世界大事件
'
,
testing
:
'
搜索中...
'
,
testResultTitle
:
'
搜索结果
'
,
testResultProvider
:
'
服务商
'
,
testNoResults
:
'
无搜索结果
'
,
},
site
:
{
title
:
'
站点设置
'
,
description
:
'
自定义站点品牌
'
,
...
...
@@ -4634,10 +4735,16 @@ export default {
enabledHint
:
'
启用或禁用支付系统
'
,
enabledPaymentTypes
:
'
启用的服务商
'
,
enabledPaymentTypesHint
:
'
禁用服务商将同时禁用对应的实例。
'
,
findProvider
:
'
正在寻找合适的
EasyPay
服务商?
'
,
findProvider
:
'
正在寻找合适的
易支付
服务商?
'
,
minAmount
:
'
最低金额
'
,
maxAmount
:
'
最高金额
'
,
dailyLimit
:
'
每日限额
'
,
balanceRechargeMultiplier
:
'
余额充值倍率
'
,
balanceRechargeMultiplierHint
:
'
用户每支付 1 CNY 可获得多少 USD 余额
'
,
balanceRechargePreview
:
'
预览:1 CNY = {usd} USD
'
,
rechargeFeeRate
:
'
充值手续费率
'
,
rechargeFeeRateHint
:
'
用户充值时额外收取的手续费百分比,0 表示不收取手续费
'
,
rechargeFeePreview
:
'
预览:充值 100 元,手续费 {fee} 元
'
,
orderTimeout
:
'
订单超时时间
'
,
orderTimeoutHint
:
'
单位:分钟,至少 1 分钟
'
,
maxPendingOrders
:
'
最大待支付订单数
'
,
...
...
@@ -4721,6 +4828,27 @@ export default {
supportedTypes
:
'
支持的支付方式
'
,
supportedTypesHint
:
'
逗号分隔,如 alipay,wxpay
'
,
refundEnabled
:
'
允许退款
'
,
allowUserRefund
:
'
允许用户退款
'
,
},
balanceNotify
:
{
title
:
'
余额不足提醒
'
,
description
:
'
当用户余额低于阈值时发送邮件提醒
'
,
enabled
:
'
启用余额不足提醒
'
,
threshold
:
'
默认提醒阈值
'
,
thresholdHint
:
'
用户未自定义时使用此值
'
,
thresholdPlaceholder
:
'
输入金额
'
,
rechargeUrl
:
'
充值页面 URL
'
,
rechargeUrlPlaceholder
:
'
https://example.com/payment
'
,
rechargeUrlHint
:
'
设置后邮件中将包含充值链接按钮
'
,
},
quotaNotify
:
{
title
:
'
账号限额通知
'
,
description
:
'
当账号配额用量达到告警阈值时通知管理员
'
,
enabled
:
'
启用账号限额通知
'
,
emails
:
'
通知邮箱
'
,
emailsHint
:
'
留空则不发送通知
'
,
addEmail
:
'
添加邮箱
'
,
emailPlaceholder
:
'
输入邮箱地址
'
,
},
smtp
:
{
title
:
'
SMTP 设置
'
,
...
...
@@ -5399,6 +5527,8 @@ export default {
payment
:
{
title
:
'
充值/订阅
'
,
amountLabel
:
'
充值金额
'
,
paymentAmount
:
'
支付金额
'
,
creditedBalance
:
'
到账余额
'
,
quickAmounts
:
'
快捷金额
'
,
customAmount
:
'
自定义金额
'
,
enterAmount
:
'
输入金额
'
,
...
...
@@ -5454,6 +5584,10 @@ export default {
orderNo
:
'
订单编号
'
,
amount
:
'
金额
'
,
payAmount
:
'
实付
'
,
creditedAmount
:
'
到账金额
'
,
fee
:
'
手续费
'
,
baseAmount
:
'
充值金额
'
,
includedInPayAmount
:
'
已含在实付金额中
'
,
status
:
'
状态
'
,
paymentMethod
:
'
支付方式
'
,
createdAt
:
'
创建时间
'
,
...
...
@@ -5483,6 +5617,7 @@ export default {
amountTooLow
:
'
最低金额为 {min}
'
,
amountTooHigh
:
'
最高金额为 {max}
'
,
amountNoMethod
:
'
该金额没有可用的支付方式
'
,
rechargeRatePreview
:
'
当前倍率:1 CNY = {usd} USD
'
,
refundReason
:
'
退款原因
'
,
refundReasonPlaceholder
:
'
请描述您的退款原因
'
,
stripeLoadFailed
:
'
支付组件加载失败,请刷新页面重试
'
,
...
...
@@ -5634,6 +5769,9 @@ export default {
tabPlanConfig
:
'
套餐配置
'
,
tabUserSubs
:
'
用户订阅
'
,
selectGroup
:
'
请选择分组
'
,
groupRequired
:
'
请选择订阅分组
'
,
priceRequired
:
'
价格必须大于 0
'
,
validityDaysRequired
:
'
有效期天数必须大于 0
'
,
groupMissing
:
'
缺失
'
,
groupInfo
:
'
分组信息
'
,
platform
:
'
平台
'
,
...
...
frontend/src/stores/app.ts
View file @
0b746501
...
...
@@ -339,7 +339,10 @@ export const useAppStore = defineStore('app', () => {
oidc_oauth_enabled
:
false
,
oidc_oauth_provider_name
:
'
OIDC
'
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
version
:
siteVersion
.
value
,
balance_low_notify_enabled
:
false
,
account_quota_notify_enabled
:
false
,
balance_low_notify_threshold
:
0
,
}
}
...
...
frontend/src/stores/payment.ts
View file @
0b746501
...
...
@@ -66,7 +66,7 @@ export const usePaymentStore = defineStore('payment', () => {
return
response
.
data
}
/** Poll order status by ID */
/** Poll order status by ID
(read-only, no upstream check)
*/
async
function
pollOrderStatus
(
orderId
:
number
):
Promise
<
PaymentOrder
|
null
>
{
try
{
const
response
=
await
paymentAPI
.
getOrder
(
orderId
)
...
...
frontend/src/types/index.ts
View file @
0b746501
...
...
@@ -22,6 +22,16 @@ export interface FetchOptions {
signal
?:
AbortSignal
}
// ==================== Notification Types ====================
/** Notification email entry with enable/disable and verification state.
* email="" is a placeholder for the primary email (user's registration email or admin email). */
export
interface
NotifyEmailEntry
{
email
:
string
disabled
:
boolean
verified
:
boolean
}
// ==================== User & Auth Types ====================
export
interface
User
{
...
...
@@ -33,6 +43,9 @@ export interface User {
concurrency
:
number
// Allowed concurrent requests
status
:
'
active
'
|
'
disabled
'
// Account status
allowed_groups
:
number
[]
|
null
// Allowed group IDs (null = all non-exclusive groups)
balance_notify_enabled
:
boolean
balance_notify_threshold
:
number
|
null
balance_notify_extra_emails
:
NotifyEmailEntry
[]
subscriptions
?:
UserSubscription
[]
// User's active subscriptions
created_at
:
string
updated_at
:
string
...
...
@@ -114,6 +127,9 @@ export interface PublicSettings {
oidc_oauth_provider_name
:
string
backend_mode_enabled
:
boolean
version
:
string
balance_low_notify_enabled
:
boolean
account_quota_notify_enabled
:
boolean
balance_low_notify_threshold
:
number
}
export
interface
AuthResponse
{
...
...
@@ -413,8 +429,6 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject
:
boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled
:
boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes
?:
string
[]
...
...
@@ -507,7 +521,6 @@ export interface CreateGroupRequest {
fallback_group_id
?:
number
|
null
fallback_group_id_on_invalid_request
?:
number
|
null
mcp_xml_inject
?:
boolean
simulate_claude_max_enabled
?:
boolean
supported_model_scopes
?:
string
[]
require_oauth_only
?:
boolean
require_privacy_set
?:
boolean
...
...
@@ -533,7 +546,6 @@ export interface UpdateGroupRequest {
fallback_group_id
?:
number
|
null
fallback_group_id_on_invalid_request
?:
number
|
null
mcp_xml_inject
?:
boolean
simulate_claude_max_enabled
?:
boolean
supported_model_scopes
?:
string
[]
require_oauth_only
?:
boolean
require_privacy_set
?:
boolean
...
...
@@ -675,6 +687,7 @@ export interface Account {
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra
?:
(
CodexUsageSnapshot
&
{
model_rate_limits
?:
Record
<
string
,
{
rate_limited_at
:
string
;
rate_limit_reset_at
:
string
}
>
antigravity_credits_overages
?:
Record
<
string
,
{
activated_at
:
string
;
active_until
:
string
}
>
}
&
Record
<
string
,
unknown
>
)
proxy_id
:
number
|
null
concurrency
:
number
...
...
@@ -736,12 +749,6 @@ export interface Account {
custom_base_url_enabled
?:
boolean
|
null
custom_base_url
?:
string
|
null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled
?:
boolean
|
null
affinity_client_count
?:
number
|
null
affinity_clients
?:
string
[]
|
null
// API Key 账号配额限制
quota_limit
?:
number
|
null
quota_used
?:
number
|
null
...
...
@@ -1050,6 +1057,12 @@ export interface AdminUsageLog extends UsageLog {
// 账号计费倍率(仅管理员可见)
account_rate_multiplier
?:
number
|
null
// 自定义定价规则计算的账号统计费用(nil 时使用 total_cost * multiplier)
account_stats_cost
?:
number
|
null
// 渠道 ID 和计费等级(仅管理员可见)
channel_id
?:
number
|
null
billing_tier
?:
string
|
null
// 用户请求 IP(仅管理员可见)
ip_address
?:
string
|
null
...
...
@@ -1145,6 +1158,7 @@ export interface DashboardStats {
total_tokens
:
number
total_cost
:
number
// 累计标准计费
total_actual_cost
:
number
// 累计实际扣除
total_account_cost
:
number
// 累计账号成本
// 今日 Token 使用统计
today_requests
:
number
...
...
@@ -1155,6 +1169,7 @@ export interface DashboardStats {
today_tokens
:
number
today_cost
:
number
// 今日标准计费
today_actual_cost
:
number
// 今日实际扣除
today_account_cost
:
number
// 今日账号成本
// 系统运行统计
average_duration_ms
:
number
// 平均响应时间
...
...
@@ -1202,6 +1217,7 @@ export interface ModelStat {
total_tokens
:
number
cost
:
number
// 标准计费
actual_cost
:
number
// 实际扣除
account_cost
:
number
// 账号成本
}
export
interface
EndpointStat
{
...
...
@@ -1219,6 +1235,7 @@ export interface GroupStat {
total_tokens
:
number
cost
:
number
// 标准计费
actual_cost
:
number
// 实际扣除
account_cost
:
number
// 账号成本
}
export
interface
UserBreakdownItem
{
...
...
@@ -1228,6 +1245,7 @@ export interface UserBreakdownItem {
total_tokens
:
number
cost
:
number
actual_cost
:
number
account_cost
:
number
}
export
interface
UserUsageTrendPoint
{
...
...
frontend/src/types/payment.ts
View file @
0b746501
...
...
@@ -32,6 +32,7 @@ export interface PaymentConfig {
max_pending_orders
:
number
order_timeout_minutes
:
number
balance_disabled
:
boolean
balance_recharge_multiplier
:
number
enabled_payment_types
:
PaymentType
[]
help_image_url
:
string
help_text
:
string
...
...
@@ -62,6 +63,8 @@ export interface CheckoutInfoResponse {
global_max
:
number
plans
:
SubscriptionPlan
[]
balance_disabled
:
boolean
balance_recharge_multiplier
:
number
recharge_fee_rate
:
number
help_text
:
string
help_image_url
:
string
stripe_publishable_key
:
string
...
...
@@ -89,6 +92,7 @@ export interface PaymentOrder {
refund_requested_by
?:
number
refund_request_reason
?:
string
plan_id
?:
number
provider_instance_id
?:
string
}
// ==================== Plans & Channels ====================
...
...
@@ -138,6 +142,7 @@ export interface ProviderInstance {
enabled
:
boolean
payment_mode
:
string
refund_enabled
:
boolean
allow_user_refund
:
boolean
limits
:
string
sort_order
:
number
}
...
...
@@ -153,10 +158,12 @@ export interface CreateOrderRequest {
export
interface
CreateOrderResult
{
order_id
:
number
amount
:
number
pay_url
?:
string
qr_code
?:
string
client_secret
?:
string
pay_amount
:
number
fee_rate
:
number
expires_at
:
string
payment_mode
?:
string
}
...
...
frontend/src/utils/__tests__/usageLoadQueue.spec.ts
0 → 100644
View file @
0b746501
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
enqueueUsageRequest
}
from
'
../usageLoadQueue
'
import
type
{
Account
}
from
'
@/types
'
/** Helper to create a minimal Account with proxy info */
function
makeAccount
(
platform
:
string
,
type
:
string
=
'
oauth
'
,
proxy
?:
{
host
:
string
;
port
:
number
;
username
?:
string
|
null
}
|
null
):
Account
{
return
{
id
:
Math
.
floor
(
Math
.
random
()
*
10000
),
platform
,
type
,
name
:
'
test
'
,
status
:
'
active
'
,
proxy_id
:
proxy
?
1
:
null
,
proxy
:
proxy
?
{
id
:
1
,
name
:
'
p
'
,
protocol
:
'
http
'
,
host
:
proxy
.
host
,
port
:
proxy
.
port
,
username
:
proxy
.
username
??
null
,
status
:
'
active
'
,
created_at
:
''
,
updated_at
:
''
}
:
undefined
,
credentials
:
{},
created_at
:
''
,
updated_at
:
''
}
as
unknown
as
Account
}
describe
(
'
usageLoadQueue
'
,
()
=>
{
// ─── Anthropic 账号:按代理出口排队 ───
it
(
'
Anthropic 同代理出口串行执行,间隔 >= 1s
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
,
username
:
'
u1
'
})
const
p1
=
enqueueUsageRequest
(
acc
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc
,
makeFn
())
const
p3
=
enqueueUsageRequest
(
acc
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
,
p3
])
expect
(
timestamps
).
toHaveLength
(
3
)
expect
(
timestamps
[
1
]
-
timestamps
[
0
]).
toBeGreaterThanOrEqual
(
950
)
expect
(
timestamps
[
1
]
-
timestamps
[
0
]).
toBeLessThan
(
2100
)
expect
(
timestamps
[
2
]
-
timestamps
[
1
]).
toBeGreaterThanOrEqual
(
950
)
expect
(
timestamps
[
2
]
-
timestamps
[
1
]).
toBeLessThan
(
2100
)
})
it
(
'
Anthropic 不同代理出口并行执行
'
,
async
()
=>
{
const
timestamps
:
Record
<
string
,
number
>
=
{}
const
makeTracked
=
(
key
:
string
)
=>
async
()
=>
{
timestamps
[
key
]
=
Date
.
now
()
return
key
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
,
username
:
'
u1
'
})
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
5.6.7.8
'
,
port
:
3128
,
username
:
'
u2
'
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeTracked
(
'
proxy1
'
))
const
p2
=
enqueueUsageRequest
(
acc2
,
makeTracked
(
'
proxy2
'
))
await
Promise
.
all
([
p1
,
p2
])
const
spread
=
Math
.
abs
(
timestamps
[
'
proxy1
'
]
-
timestamps
[
'
proxy2
'
])
expect
(
spread
).
toBeLessThan
(
50
)
})
it
(
'
Anthropic 相同代理连接信息的不同账号归为同一队列
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
10.0.0.1
'
,
port
:
3128
,
username
:
'
admin
'
})
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
setup-token
'
,
{
host
:
'
10.0.0.1
'
,
port
:
3128
,
username
:
'
admin
'
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
expect
(
timestamps
[
1
]
-
timestamps
[
0
]).
toBeGreaterThanOrEqual
(
950
)
})
it
(
'
Anthropic 直连(无代理)的账号归为同一队列
'
,
async
()
=>
{
const
order
:
number
[]
=
[]
const
makeFn
=
(
n
:
number
)
=>
async
()
=>
{
order
.
push
(
n
)
return
n
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
)
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
setup-token
'
)
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
(
1
))
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
(
2
))
await
Promise
.
all
([
p1
,
p2
])
expect
(
order
).
toEqual
([
1
,
2
])
})
it
(
'
Anthropic 请求失败时 reject,后续任务继续执行
'
,
async
()
=>
{
const
results
:
string
[]
=
[]
const
acc
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
99.99.99.99
'
,
port
:
1234
})
const
p1
=
enqueueUsageRequest
(
acc
,
async
()
=>
{
throw
new
Error
(
'
fail
'
)
})
const
p2
=
enqueueUsageRequest
(
acc
,
async
()
=>
{
results
.
push
(
'
second
'
)
return
'
ok
'
})
await
expect
(
p1
).
rejects
.
toThrow
(
'
fail
'
)
await
p2
expect
(
results
).
toEqual
([
'
second
'
])
})
// ─── 非 Anthropic 平台:直接执行,不排队 ───
it
(
'
非 Anthropic 平台直接执行,不排队
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
// 同一代理的 Gemini 账号 — 应当并行,不排队
const
acc1
=
makeAccount
(
'
gemini
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
acc2
=
makeAccount
(
'
gemini
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
// 并行执行,几乎同时完成
expect
(
Math
.
abs
(
timestamps
[
1
]
-
timestamps
[
0
])).
toBeLessThan
(
50
)
})
it
(
'
OpenAI 平台直接执行,不排队
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc1
=
makeAccount
(
'
openai
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
acc2
=
makeAccount
(
'
openai
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
expect
(
Math
.
abs
(
timestamps
[
1
]
-
timestamps
[
0
])).
toBeLessThan
(
50
)
})
// ─── Anthropic apikey 类型不排队 ───
it
(
'
Anthropic apikey 类型直接执行,不排队
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
apikey
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
apikey
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
expect
(
Math
.
abs
(
timestamps
[
1
]
-
timestamps
[
0
])).
toBeLessThan
(
50
)
})
// ─── 返回值透传 ───
it
(
'
返回值正确透传
'
,
async
()
=>
{
const
acc
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
)
const
result
=
await
enqueueUsageRequest
(
acc
,
async
()
=>
{
return
{
usage
:
42
}
})
expect
(
result
).
toEqual
({
usage
:
42
})
})
it
(
'
非 Anthropic 返回值正确透传
'
,
async
()
=>
{
const
acc
=
makeAccount
(
'
gemini
'
,
'
oauth
'
)
const
result
=
await
enqueueUsageRequest
(
acc
,
async
()
=>
{
return
{
quota
:
100
}
})
expect
(
result
).
toEqual
({
quota
:
100
})
})
})
frontend/src/utils/billingMode.ts
0 → 100644
View file @
0b746501
export
const
BILLING_MODE_TOKEN
=
'
token
'
export
const
BILLING_MODE_PER_REQUEST
=
'
per_request
'
export
const
BILLING_MODE_IMAGE
=
'
image
'
export
function
getBillingModeLabel
(
mode
:
string
|
null
|
undefined
,
t
:
(
key
:
string
)
=>
string
):
string
{
switch
(
mode
)
{
case
BILLING_MODE_PER_REQUEST
:
return
t
(
'
admin.usage.billingModePerRequest
'
)
case
BILLING_MODE_IMAGE
:
return
t
(
'
admin.usage.billingModeImage
'
)
default
:
return
t
(
'
admin.usage.billingModeToken
'
)
}
}
export
function
getBillingModeBadgeClass
(
mode
:
string
|
null
|
undefined
):
string
{
switch
(
mode
)
{
case
BILLING_MODE_PER_REQUEST
:
return
'
bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
'
case
BILLING_MODE_IMAGE
:
return
'
bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
'
default
:
return
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
'
}
}
frontend/src/utils/usageLoadQueue.ts
0 → 100644
View file @
0b746501
/**
* Usage request scheduler — throttles Anthropic API calls by proxy exit.
*
* Anthropic OAuth/setup-token accounts sharing the same proxy exit are placed
* into a serial queue with a random 1–2s delay between requests, preventing
* upstream 429 rate-limit errors.
*
* Proxy identity = host:port:username — two proxy records pointing to the
* same exit share a single queue. Accounts without a proxy go into a
* "direct" queue.
*
* All other platforms bypass the queue and execute immediately.
*/
import
type
{
Account
}
from
'
@/types
'
const
GROUP_DELAY_MIN_MS
=
1000
const
GROUP_DELAY_MAX_MS
=
2000
type
Task
<
T
>
=
{
fn
:
()
=>
Promise
<
T
>
resolve
:
(
value
:
T
)
=>
void
reject
:
(
reason
:
unknown
)
=>
void
}
const
queues
=
new
Map
<
string
,
Task
<
unknown
>
[]
>
()
const
running
=
new
Set
<
string
>
()
/** Whether this account needs throttled queuing. */
function
needsThrottle
(
account
:
Account
):
boolean
{
return
(
account
.
platform
===
'
anthropic
'
&&
(
account
.
type
===
'
oauth
'
||
account
.
type
===
'
setup-token
'
)
)
}
/** Build a queue key from proxy connection details. */
function
buildGroupKey
(
account
:
Account
):
string
{
const
proxy
=
account
.
proxy
const
proxyIdentity
=
proxy
?
`
${
proxy
.
host
}
:
${
proxy
.
port
}
:
${
proxy
.
username
||
''
}
`
:
'
direct
'
return
`anthropic:
${
proxyIdentity
}
`
}
async
function
drain
(
groupKey
:
string
)
{
if
(
running
.
has
(
groupKey
))
return
running
.
add
(
groupKey
)
const
queue
=
queues
.
get
(
groupKey
)
while
(
queue
&&
queue
.
length
>
0
)
{
const
task
=
queue
.
shift
()
!
try
{
const
result
=
await
task
.
fn
()
task
.
resolve
(
result
)
}
catch
(
err
)
{
task
.
reject
(
err
)
}
if
(
queue
.
length
>
0
)
{
const
jitter
=
GROUP_DELAY_MIN_MS
+
Math
.
random
()
*
(
GROUP_DELAY_MAX_MS
-
GROUP_DELAY_MIN_MS
)
await
new
Promise
((
r
)
=>
setTimeout
(
r
,
jitter
))
}
}
running
.
delete
(
groupKey
)
queues
.
delete
(
groupKey
)
}
/**
* Schedule a usage fetch. Anthropic accounts are queued by proxy exit;
* all other platforms execute immediately.
*/
export
function
enqueueUsageRequest
<
T
>
(
account
:
Account
,
fn
:
()
=>
Promise
<
T
>
):
Promise
<
T
>
{
// Non-Anthropic → fire immediately, no queuing
if
(
!
needsThrottle
(
account
))
{
return
fn
()
}
const
key
=
buildGroupKey
(
account
)
return
new
Promise
<
T
>
((
resolve
,
reject
)
=>
{
let
queue
=
queues
.
get
(
key
)
if
(
!
queue
)
{
queue
=
[]
queues
.
set
(
key
,
queue
)
}
queue
.
push
({
fn
,
resolve
,
reject
}
as
Task
<
unknown
>
)
drain
(
key
)
})
}
frontend/src/views/admin/AccountsView.vue
View file @
0b746501
...
...
@@ -144,6 +144,7 @@
<
AccountBulkActionsBar
:
selected
-
ids
=
"
selIds
"
@
delete
=
"
handleBulkDelete
"
@
reset
-
status
=
"
handleBulkResetStatus
"
@
refresh
-
token
=
"
handleBulkRefreshToken
"
@
edit
=
"
showBulkEdit = true
"
@
clear
=
"
clearSelection
"
@
select
-
page
=
"
selectPage
"
@
toggle
-
schedulable
=
"
handleBulkToggleSchedulable
"
/>
<
div
ref
=
"
accountTableRef
"
class
=
"
flex min-h-0 flex-1 flex-col overflow-hidden
"
>
<
DataTable
ref
=
"
dataTableRef
"
:
columns
=
"
cols
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
...
...
@@ -153,6 +154,8 @@
default
-
sort
-
key
=
"
name
"
default
-
sort
-
order
=
"
asc
"
:
sort
-
storage
-
key
=
"
ACCOUNT_SORT_STORAGE_KEY
"
:
estimate
-
row
-
height
=
"
72
"
:
overscan
=
"
5
"
>
<
template
#
header
-
select
>
<
input
...
...
@@ -164,7 +167,7 @@
/>
<
/template
>
<
template
#
cell
-
select
=
"
{ row
}
"
>
<
input
type
=
"
checkbox
"
:
checked
=
"
selIds.includes
(row.id)
"
@
change
=
"
toggleSel(row.id)
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
input
type
=
"
checkbox
"
:
checked
=
"
isSelected
(row.id)
"
@
change
=
"
toggleSel(row.id)
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/template
>
<
template
#
cell
-
name
=
"
{ row, value
}
"
>
<
div
class
=
"
flex flex-col
"
>
...
...
@@ -197,7 +200,9 @@
<
AccountCapacityCell
:
account
=
"
row
"
/>
<
/template
>
<
template
#
cell
-
status
=
"
{ row
}
"
>
<
div
class
=
"
flex items-center gap-1.5
"
>
<
AccountStatusIndicator
:
account
=
"
row
"
@
show
-
temp
-
unsched
=
"
handleShowTempUnsched
"
/>
<
/div
>
<
/template
>
<
template
#
cell
-
schedulable
=
"
{ row
}
"
>
<
button
@
click
=
"
handleToggleSchedulable(row)
"
:
disabled
=
"
togglingSchedulable === row.id
"
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 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800
"
:
class
=
"
[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']
"
:
title
=
"
row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')
"
>
...
...
@@ -313,7 +318,7 @@ import { useAppStore } from '@/stores/app'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useTableLoader
}
from
'
@/composables/useTableLoader
'
import
{
useSwipeSelect
}
from
'
@/composables/useSwipeSelect
'
import
{
useSwipeSelect
,
type
SwipeSelectVirtualContext
}
from
'
@/composables/useSwipeSelect
'
import
{
useTableSelection
}
from
'
@/composables/useTableSelection
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
...
...
@@ -351,6 +356,7 @@ const authStore = useAuthStore()
const
proxies
=
ref
<
AccountProxy
[]
>
([])
const
groups
=
ref
<
AdminGroup
[]
>
([])
const
accountTableRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dataTableRef
=
ref
<
InstanceType
<
typeof
DataTable
>
|
null
>
(
null
)
const
selPlatforms
=
computed
<
AccountPlatform
[]
>
(()
=>
{
const
platforms
=
new
Set
(
accounts
.
value
...
...
@@ -650,17 +656,25 @@ const {
clear
:
clearSelection
,
removeMany
:
removeSelectedAccounts
,
toggleVisible
,
selectVisible
:
selectPage
selectVisible
:
selectPage
,
batchUpdate
}
=
useTableSelection
<
Account
>
({
rows
:
accounts
,
getId
:
(
account
)
=>
account
.
id
}
)
const
swipeVirtualContext
:
SwipeSelectVirtualContext
=
{
getVirtualizer
:
()
=>
dataTableRef
.
value
?.
virtualizer
??
null
,
getSortedData
:
()
=>
dataTableRef
.
value
?.
sortedData
??
accounts
.
value
,
getRowId
:
(
row
:
any
)
=>
row
.
id
,
}
useSwipeSelect
(
accountTableRef
,
{
isSelected
,
select
,
deselect
}
)
deselect
,
batchUpdate
}
,
swipeVirtualContext
)
const
resetAutoRefreshCache
=
()
=>
{
autoRefreshETag
.
value
=
null
...
...
frontend/src/views/admin/ChannelsView.vue
View file @
0b746501
...
...
@@ -166,8 +166,8 @@
class=
"channel-tab group"
:class=
"activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
>
<PlatformIcon
:platform=
"section.platform"
size=
"xs"
:class=
"
getP
latformTextC
olor
(section.platform)"
/>
<span
:class=
"
getP
latformTextC
olor
(section.platform)"
>
{{ t('admin.groups.platforms.' + section.platform, section.platform) }}
</span>
<PlatformIcon
:platform=
"section.platform"
size=
"xs"
:class=
"
p
latformTextC
lass
(section.platform)"
/>
<span
:class=
"
p
latformTextC
lass
(section.platform)"
>
{{ t('admin.groups.platforms.' + section.platform, section.platform) }}
</span>
</button>
</div>
...
...
@@ -246,11 +246,29 @@
class=
"h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@
change=
"togglePlatform(p)"
/>
<PlatformIcon
:platform=
"p"
size=
"xs"
:class=
"
getP
latformTextC
olor
(p)"
/>
<span
:class=
"
getP
latformTextC
olor
(p)"
>
{{ t('admin.groups.platforms.' + p, p) }}
</span>
<PlatformIcon
:platform=
"p"
size=
"xs"
:class=
"
p
latformTextC
lass
(p)"
/>
<span
:class=
"
p
latformTextC
lass
(p)"
>
{{ t('admin.groups.platforms.' + p, p) }}
</span>
</label>
</div>
</div>
<!-- Apply Pricing to Account Stats (toggle only in basic settings) -->
<div
class=
"border-t border-gray-200 pt-4 dark:border-dark-700"
>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.channels.form.applyPricingToAccountStats') }}
</label>
<p
class=
"mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.channels.form.applyPricingToAccountStatsDesc') }}
</p>
</div>
<Toggle
:modelValue=
"form.apply_pricing_to_account_stats"
@
update:modelValue=
"form.apply_pricing_to_account_stats = $event"
/>
</div>
</div>
</div>
<!-- Platform Tab Content -->
...
...
@@ -292,9 +310,9 @@
class=
"h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@
change=
"toggleGroupInSection(sIdx, group.id)"
/>
<span
:class=
"['font-medium',
getP
latformTextC
olor
(group.platform)]"
>
{{ group.name }}
</span>
<span
:class=
"['font-medium',
p
latformTextC
lass
(group.platform)]"
>
{{ group.name }}
</span>
<span
:class=
"['rounded-full px-1 py-0 text-[10px]',
getRateBadge
Class(group.platform)]"
:class=
"['rounded-full px-1 py-0 text-[10px]',
platformBadgeLight
Class(group.platform)]"
>
{{ group.rate_multiplier }}x
</span>
<span
class=
"text-[10px] text-gray-400"
>
{{ group.account_count || 0 }}
</span>
<span
...
...
@@ -306,6 +324,21 @@
</div>
</div>
<!-- Web Search Emulation (Anthropic only, hidden when global disabled) -->
<div
v-if=
"section.platform === 'anthropic' && webSearchGlobalEnabled"
class=
"border-t border-gray-200 pt-3 dark:border-dark-600"
>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-xs font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.channels.form.webSearchEmulation') }}
</label>
<p
class=
"mt-0.5 text-[11px] text-red-500 dark:text-red-400"
>
{{ t('admin.channels.form.webSearchEmulationHint') }}
</p>
</div>
<Toggle
v-model=
"section.web_search_emulation"
/>
</div>
</div>
<!-- Model Mapping -->
<div>
<div
class=
"mb-1 flex items-center justify-between"
>
...
...
@@ -330,7 +363,7 @@
:value=
"srcModel"
type=
"text"
class=
"input flex-1 text-xs"
:class=
"
getP
latformTextC
olor
(section.platform)"
:class=
"
p
latformTextC
lass
(section.platform)"
:placeholder=
"t('admin.channels.form.mappingSource', 'Source model')"
@
change=
"renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
/>
...
...
@@ -339,7 +372,7 @@
:value=
"section.model_mapping[srcModel]"
type=
"text"
class=
"input flex-1 text-xs"
:class=
"
getP
latformTextC
olor
(section.platform)"
:class=
"
p
latformTextC
lass
(section.platform)"
:placeholder=
"t('admin.channels.form.mappingTarget', 'Target model')"
@
input=
"section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
/>
...
...
@@ -379,6 +412,138 @@
/>
</div>
</div>
<!-- Account Stats Pricing Rules (per-platform, always visible) -->
<div
class=
"mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3"
>
<div
class=
"flex items-center justify-between"
>
<h4
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.channels.form.accountStatsPricingRules') }}
</h4>
<button
type=
"button"
@
click=
"addAccountStatsRule(sIdx)"
class=
"rounded-lg border border-primary-300 px-3 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:border-primary-600 dark:text-primary-400 dark:hover:bg-primary-900/20"
>
+ {{ t('admin.channels.form.addRule') }}
</button>
</div>
<!-- Filter rules for this platform's groups -->
<p
v-if=
"section.account_stats_pricing_rules.length === 0"
class=
"text-xs italic text-gray-400 dark:text-gray-500"
>
{{ t('admin.channels.form.noRulesConfigured') }}
</p>
<div
v-for=
"(rule, ruleIndex) in section.account_stats_pricing_rules"
:key=
"ruleIndex"
class=
"space-y-3 rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
<div
class=
"flex items-center justify-between"
>
<input
v-model=
"rule.name"
:placeholder=
"t('admin.channels.form.ruleName')"
class=
"bg-transparent text-sm font-medium text-gray-700 placeholder-gray-400 outline-none dark:text-gray-300"
/>
<button
type=
"button"
@
click=
"removeAccountStatsRule(sIdx, ruleIndex)"
class=
"text-xs text-red-500 hover:text-red-700"
>
{{ t('common.delete') }}
</button>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.channels.form.ruleGroups') }}
</label>
<div
class=
"mt-1 flex flex-wrap gap-1"
>
<label
v-for=
"gid in section.group_ids"
:key=
"gid"
class=
"inline-flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors"
:class=
"rule.group_ids.includes(gid)
? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
>
<input
type=
"checkbox"
:checked=
"rule.group_ids.includes(gid)"
class=
"h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@
change=
"rule.group_ids.includes(gid) ? rule.group_ids.splice(rule.group_ids.indexOf(gid), 1) : rule.group_ids.push(gid)"
/>
<span
:class=
"['font-medium', platformTextClass(section.platform)]"
>
{{ getGroupNameById(gid) }}
</span>
</label>
</div>
<p
v-if=
"section.group_ids.length === 0"
class=
"mt-1 text-xs text-gray-400"
>
{{ t('admin.channels.form.noGroupsInChannel') }}
</p>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.channels.form.ruleAccounts') }}
</label>
<!-- Selected account chips -->
<div
class=
"mt-1 flex flex-wrap gap-1"
>
<span
v-for=
"accountId in rule.account_ids"
:key=
"accountId"
class=
"inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
>
<span
:class=
"['font-medium', platformTextClass(section.platform)]"
>
{{ getRuleAccountLabel(accountId) }}
</span>
<button
type=
"button"
@
click=
"removeRuleAccount(rule, accountId)"
class=
"text-gray-400 hover:text-red-500"
>
<Icon
name=
"x"
size=
"xs"
/>
</button>
</span>
</div>
<!-- Account search input -->
<div
class=
"relative mt-1 rule-account-search-container"
>
<input
v-model=
"ruleAccountSearchKeyword[`${section.platform}-${ruleIndex}`]"
type=
"text"
class=
"input text-sm"
:placeholder=
"t('admin.channels.form.searchAccountPlaceholder')"
@
input=
"onRuleAccountSearchInput(section.platform, ruleIndex)"
@
focus=
"onRuleAccountSearchFocus(section.platform, ruleIndex)"
/>
<!-- Search results dropdown -->
<div
v-if=
"showRuleAccountDropdown[`${section.platform}-${ruleIndex}`] && (ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]?.length ?? 0) > 0"
class=
"absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for=
"account in ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]"
:key=
"account.id"
type=
"button"
@
click=
"selectRuleAccount(rule, account, section.platform, ruleIndex)"
class=
"w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"{ 'opacity-50': rule.account_ids.includes(account.id) }"
:disabled=
"rule.account_ids.includes(account.id)"
>
<span
:class=
"platformTextClass(account.platform)"
>
{{ account.name }}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#{{ account.id }}
</span>
</button>
</div>
</div>
<p
class=
"mt-1 text-xs text-gray-400"
>
{{ t('admin.channels.form.ruleAccountsHint') }}
</p>
</div>
<div>
<div
class=
"mb-1 flex items-center justify-between"
>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.channels.form.ruleModelPricing') }}
</label>
<button
type=
"button"
@
click=
"addRulePricingEntry(sIdx, ruleIndex)"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+ {{ t('common.add') }}
</button>
</div>
<div
v-if=
"rule.pricing.length === 0"
class=
"rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500"
>
{{ t('admin.channels.form.noPricingRules') }}
</div>
<div
v-else
class=
"space-y-2"
>
<PricingEntryCard
v-for=
"(entry, pIdx) in rule.pricing"
:key=
"pIdx"
:entry=
"entry"
:platform=
"section.platform"
@
update=
"rule.pricing.splice(pIdx, 1, $event)"
@
remove=
"removeRulePricingEntry(sIdx, ruleIndex, pIdx)"
/>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
...
...
@@ -423,12 +588,14 @@
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Channel
,
ChannelModelPricing
,
CreateChannelRequest
,
UpdateChannelRequest
}
from
'
@/api/admin/channels
'
import
type
{
Channel
,
ChannelModelPricing
,
CreateChannelRequest
,
UpdateChannelRequest
,
AccountStatsPricingRule
}
from
'
@/api/admin/channels
'
import
type
{
PricingFormEntry
}
from
'
@/components/admin/channel/types
'
import
{
mTokToPerToken
,
perTokenToMTok
,
apiIntervalsToForm
,
formIntervalsToAPI
,
findModelConflict
,
validateIntervals
}
from
'
@/components/admin/channel/types
'
import
type
{
AdminGroup
,
GroupPlatform
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
platformTextClass
,
platformBadgeLightClass
}
from
'
@/utils/platformColors
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
...
...
@@ -442,10 +609,31 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
PricingEntryCard
from
'
@/components/admin/channel/PricingEntryCard.vue
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
import
{
useKeyedDebouncedSearch
}
from
'
@/composables/useKeyedDebouncedSearch
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
// Web Search global enabled state (loaded once on mount)
const
webSearchGlobalEnabled
=
ref
(
false
)
async
function
loadWebSearchGlobalState
()
{
try
{
const
cfg
=
await
adminAPI
.
settings
.
getWebSearchEmulationConfig
()
webSearchGlobalEnabled
.
value
=
cfg
?.
enabled
===
true
&&
(
cfg
?.
providers
?.
length
??
0
)
>
0
}
catch
(
err
:
unknown
)
{
console
.
warn
(
'
Failed to load web search global state:
'
,
err
)
webSearchGlobalEnabled
.
value
=
false
}
}
// ── Form-level pricing rule type (per-platform) ──
interface
FormPricingRule
{
name
:
string
group_ids
:
number
[]
account_ids
:
number
[]
pricing
:
PricingFormEntry
[]
}
// ── Platform Section type ──
interface
PlatformSection
{
platform
:
GroupPlatform
...
...
@@ -454,6 +642,8 @@ interface PlatformSection {
group_ids
:
number
[]
model_mapping
:
Record
<
string
,
string
>
model_pricing
:
PricingFormEntry
[]
web_search_emulation
:
boolean
account_stats_pricing_rules
:
FormPricingRule
[]
}
// ── Table columns ──
...
...
@@ -521,7 +711,8 @@ const form = reactive({
status
:
'
active
'
,
restrict_models
:
false
,
billing_model_source
:
'
channel_mapped
'
as
string
,
platforms
:
[]
as
PlatformSection
[]
platforms
:
[]
as
PlatformSection
[],
apply_pricing_to_account_stats
:
false
,
})
let
abortController
:
AbortController
|
null
=
null
...
...
@@ -529,26 +720,6 @@ let abortController: AbortController | null = null
// ── Platform config ──
const
platformOrder
:
GroupPlatform
[]
=
[
'
anthropic
'
,
'
openai
'
,
'
gemini
'
,
'
antigravity
'
]
function
getPlatformTextColor
(
platform
:
string
):
string
{
switch
(
platform
)
{
case
'
anthropic
'
:
return
'
text-orange-600 dark:text-orange-400
'
case
'
openai
'
:
return
'
text-emerald-600 dark:text-emerald-400
'
case
'
gemini
'
:
return
'
text-blue-600 dark:text-blue-400
'
case
'
antigravity
'
:
return
'
text-purple-600 dark:text-purple-400
'
default
:
return
'
text-gray-600 dark:text-gray-400
'
}
}
function
getRateBadgeClass
(
platform
:
string
):
string
{
switch
(
platform
)
{
case
'
anthropic
'
:
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
case
'
openai
'
:
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
case
'
gemini
'
:
return
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
case
'
antigravity
'
:
return
'
bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400
'
default
:
return
'
bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400
'
}
}
// ── Helpers ──
function
formatDate
(
value
:
string
):
string
{
if
(
!
value
)
return
'
-
'
...
...
@@ -565,7 +736,9 @@ function addPlatformSection(platform: GroupPlatform) {
collapsed
:
false
,
group_ids
:
[],
model_mapping
:
{},
model_pricing
:
[]
model_pricing
:
[],
web_search_emulation
:
false
,
account_stats_pricing_rules
:
[],
})
}
...
...
@@ -678,11 +851,158 @@ function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) {
mapping
[
newKey
]
=
value
}
// ── Account Stats Pricing helpers ──
function
addAccountStatsRule
(
sectionIdx
:
number
)
{
form
.
platforms
[
sectionIdx
].
account_stats_pricing_rules
.
push
({
name
:
''
,
group_ids
:
[],
account_ids
:
[],
pricing
:
[]
})
}
function
addRulePricingEntry
(
sectionIdx
:
number
,
ruleIndex
:
number
)
{
form
.
platforms
[
sectionIdx
].
account_stats_pricing_rules
[
ruleIndex
].
pricing
.
push
({
models
:
[],
billing_mode
:
'
token
'
,
input_price
:
null
,
output_price
:
null
,
cache_write_price
:
null
,
cache_read_price
:
null
,
image_output_price
:
null
,
per_request_price
:
null
,
intervals
:
[]
})
}
function
removeAccountStatsRule
(
sectionIdx
:
number
,
ruleIndex
:
number
)
{
form
.
platforms
[
sectionIdx
].
account_stats_pricing_rules
.
splice
(
ruleIndex
,
1
)
// Clear all search state since indices shift after removal
ruleAccountSearchRunner
.
clearAll
()
clearAllRuleAccountSearchState
()
}
function
removeRulePricingEntry
(
sectionIdx
:
number
,
ruleIndex
:
number
,
pricingIndex
:
number
)
{
form
.
platforms
[
sectionIdx
].
account_stats_pricing_rules
[
ruleIndex
].
pricing
.
splice
(
pricingIndex
,
1
)
}
function
getGroupNameById
(
groupId
:
number
):
string
{
const
group
=
allGroups
.
value
.
find
(
g
=>
g
.
id
===
groupId
)
return
group
?
group
.
name
:
`#
${
groupId
}
`
}
// ── Account search for pricing rules ──
interface
SimpleAccount
{
id
:
number
;
name
:
string
;
platform
:
string
}
const
ruleAccountSearchKeyword
=
ref
<
Record
<
string
,
string
>>
({})
const
ruleAccountSearchResults
=
ref
<
Record
<
string
,
SimpleAccount
[]
>>
({})
const
showRuleAccountDropdown
=
ref
<
Record
<
string
,
boolean
>>
({})
// Cache: account ID → name, populated when search results are selected
const
ruleAccountNameCache
=
ref
<
Record
<
number
,
string
>>
({})
const
ruleAccountSearchRunner
=
useKeyedDebouncedSearch
<
SimpleAccount
[]
>
({
delay
:
300
,
search
:
async
(
keyword
,
{
key
,
signal
})
=>
{
const
platform
=
key
.
split
(
'
-
'
)[
0
]
const
res
=
await
adminAPI
.
accounts
.
list
(
1
,
20
,
{
platform
,
search
:
keyword
},
{
signal
})
return
res
.
items
.
map
(
a
=>
({
id
:
a
.
id
,
name
:
a
.
name
,
platform
:
a
.
platform
}))
},
onSuccess
:
(
key
,
result
)
=>
{
ruleAccountSearchResults
.
value
[
key
]
=
result
},
onError
:
(
key
)
=>
{
ruleAccountSearchResults
.
value
[
key
]
=
[]
},
})
function
onRuleAccountSearchInput
(
platform
:
string
,
ruleIndex
:
number
)
{
const
key
=
`
${
platform
}
-
${
ruleIndex
}
`
showRuleAccountDropdown
.
value
[
key
]
=
true
ruleAccountSearchRunner
.
trigger
(
key
,
ruleAccountSearchKeyword
.
value
[
key
]
||
''
)
}
function
onRuleAccountSearchFocus
(
platform
:
string
,
ruleIndex
:
number
)
{
const
key
=
`
${
platform
}
-
${
ruleIndex
}
`
showRuleAccountDropdown
.
value
[
key
]
=
true
if
(
!
ruleAccountSearchResults
.
value
[
key
]?.
length
)
{
ruleAccountSearchRunner
.
trigger
(
key
,
ruleAccountSearchKeyword
.
value
[
key
]
||
''
)
}
}
function
selectRuleAccount
(
rule
:
{
account_ids
:
number
[]
},
account
:
SimpleAccount
,
platform
:
string
,
ruleIndex
:
number
,
)
{
if
(
!
rule
.
account_ids
.
includes
(
account
.
id
))
{
rule
.
account_ids
.
push
(
account
.
id
)
ruleAccountNameCache
.
value
[
account
.
id
]
=
account
.
name
}
const
key
=
`
${
platform
}
-
${
ruleIndex
}
`
ruleAccountSearchKeyword
.
value
[
key
]
=
''
showRuleAccountDropdown
.
value
[
key
]
=
false
}
function
removeRuleAccount
(
rule
:
{
account_ids
:
number
[]
},
accountId
:
number
)
{
const
idx
=
rule
.
account_ids
.
indexOf
(
accountId
)
if
(
idx
!==
-
1
)
rule
.
account_ids
.
splice
(
idx
,
1
)
}
function
getRuleAccountLabel
(
accountId
:
number
):
string
{
const
name
=
ruleAccountNameCache
.
value
[
accountId
]
return
name
?
`
${
name
}
#
${
accountId
}
`
:
`#
${
accountId
}
`
}
function
handleRuleAccountClickOutside
(
event
:
MouseEvent
)
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.rule-account-search-container
'
))
{
Object
.
keys
(
showRuleAccountDropdown
.
value
).
forEach
(
key
=>
{
showRuleAccountDropdown
.
value
[
key
]
=
false
})
}
}
function
clearAllRuleAccountSearchState
()
{
ruleAccountSearchKeyword
.
value
=
{}
ruleAccountSearchResults
.
value
=
{}
showRuleAccountDropdown
.
value
=
{}
}
function
accountStatsRulesToAPI
():
AccountStatsPricingRule
[]
{
const
rules
:
AccountStatsPricingRule
[]
=
[]
for
(
const
section
of
form
.
platforms
)
{
if
(
!
section
.
enabled
)
continue
for
(
const
rule
of
section
.
account_stats_pricing_rules
)
{
rules
.
push
({
name
:
rule
.
name
,
group_ids
:
rule
.
group_ids
,
account_ids
:
rule
.
account_ids
,
pricing
:
rule
.
pricing
.
filter
(
p
=>
p
.
models
.
length
>
0
)
.
map
(
p
=>
({
platform
:
section
.
platform
,
models
:
p
.
models
,
billing_mode
:
p
.
billing_mode
,
input_price
:
mTokToPerToken
(
p
.
input_price
),
output_price
:
mTokToPerToken
(
p
.
output_price
),
cache_write_price
:
mTokToPerToken
(
p
.
cache_write_price
),
cache_read_price
:
mTokToPerToken
(
p
.
cache_read_price
),
image_output_price
:
mTokToPerToken
(
p
.
image_output_price
),
per_request_price
:
p
.
per_request_price
!=
null
&&
p
.
per_request_price
!==
''
?
Number
(
p
.
per_request_price
)
:
null
,
intervals
:
formIntervalsToAPI
(
p
.
intervals
||
[])
}))
})
}
}
return
rules
}
// ── Form ↔ API conversion ──
function
formToAPI
():
{
group_ids
:
number
[],
model_pricing
:
ChannelModelPricing
[],
model_mapping
:
Record
<
string
,
Record
<
string
,
string
>>
}
{
function
formToAPI
():
{
group_ids
:
number
[],
model_pricing
:
ChannelModelPricing
[],
model_mapping
:
Record
<
string
,
Record
<
string
,
string
>>
,
features_config
:
Record
<
string
,
unknown
>
}
{
const
group_ids
:
number
[]
=
[]
const
model_pricing
:
ChannelModelPricing
[]
=
[]
const
model_mapping
:
Record
<
string
,
Record
<
string
,
string
>>
=
{}
// Preserve existing features_config fields not managed by the form
const
featuresConfig
:
Record
<
string
,
unknown
>
=
editingChannel
.
value
?.
features_config
?
{
...
editingChannel
.
value
.
features_config
}
:
{}
for
(
const
section
of
form
.
platforms
)
{
if
(
!
section
.
enabled
)
continue
...
...
@@ -711,7 +1031,23 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
}
}
return
{
group_ids
,
model_pricing
,
model_mapping
}
// Collect web_search_emulation (only anthropic platform supports it)
// Always write the key so that disabling in the UI correctly sets platform to false,
// rather than leaving a stale true value from the cloned features_config.
const
wsEmulation
:
Record
<
string
,
boolean
>
=
{}
for
(
const
section
of
form
.
platforms
)
{
if
(
!
section
.
enabled
)
continue
if
(
section
.
platform
===
'
anthropic
'
)
{
wsEmulation
[
section
.
platform
]
=
!!
section
.
web_search_emulation
}
}
if
(
Object
.
keys
(
wsEmulation
).
length
>
0
)
{
featuresConfig
.
web_search_emulation
=
wsEmulation
}
else
{
delete
featuresConfig
.
web_search_emulation
}
return
{
group_ids
,
model_pricing
,
model_mapping
,
features_config
:
featuresConfig
}
}
function
apiToForm
(
channel
:
Channel
):
PlatformSection
[]
{
...
...
@@ -755,13 +1091,20 @@ function apiToForm(channel: Channel): PlatformSection[] {
intervals
:
apiIntervalsToForm
(
p
.
intervals
||
[])
}
as
PricingFormEntry
))
// Read web_search_emulation from features_config
const
fc
=
channel
.
features_config
const
wsEmulation
=
fc
?.
web_search_emulation
as
Record
<
string
,
boolean
>
|
undefined
const
webSearchEnabled
=
wsEmulation
?.[
platform
]
===
true
sections
.
push
({
platform
,
enabled
:
true
,
collapsed
:
false
,
group_ids
:
groupIds
,
model_mapping
:
{
...
mapping
},
model_pricing
:
pricing
model_pricing
:
pricing
,
web_search_emulation
:
webSearchEnabled
,
account_stats_pricing_rules
:
[],
})
}
...
...
@@ -786,10 +1129,10 @@ async function loadChannels() {
if
(
ctrl
.
signal
.
aborted
||
abortController
!==
ctrl
)
return
channels
.
value
=
response
.
items
||
[]
pagination
.
total
=
response
.
total
}
catch
(
error
:
any
)
{
if
(
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
return
appStore
.
showError
(
t
(
'
admin.channels.loadError
'
,
'
Failed to load channels
'
))
console
.
error
(
'
Error lo
adin
g
channels
:
'
,
error
)
}
catch
(
error
:
unknown
)
{
const
e
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
e
?.
name
===
'
AbortError
'
||
e
?.
code
===
'
ERR_CANCELED
'
)
return
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
ad
m
in
.
channels
.loadError
'
,
'
Failed to load channels
'
))
)
}
finally
{
if
(
abortController
===
ctrl
)
{
loading
.
value
=
false
...
...
@@ -854,7 +1197,11 @@ function resetForm() {
form
.
restrict_models
=
false
form
.
billing_model_source
=
'
channel_mapped
'
form
.
platforms
=
[]
form
.
apply_pricing_to_account_stats
=
false
activeTab
.
value
=
'
basic
'
ruleAccountSearchRunner
.
clearAll
()
clearAllRuleAccountSearchState
()
ruleAccountNameCache
.
value
=
{}
}
async
function
openCreateDialog
()
{
...
...
@@ -871,12 +1218,92 @@ async function openEditDialog(channel: Channel) {
form
.
status
=
channel
.
status
form
.
restrict_models
=
channel
.
restrict_models
||
false
form
.
billing_model_source
=
channel
.
billing_model_source
||
'
channel_mapped
'
form
.
apply_pricing_to_account_stats
=
channel
.
apply_pricing_to_account_stats
||
false
// Must load groups first so apiToForm can map groupID → platform
await
Promise
.
all
([
loadGroups
(),
loadAllChannelsForConflict
()])
form
.
platforms
=
apiToForm
(
channel
)
// Distribute channel-level rules into per-platform sections
distributeRulesToPlatforms
(
channel
.
account_stats_pricing_rules
||
[])
// Populate ruleAccountNameCache for existing rule accounts
await
populateRuleAccountNameCache
()
showDialog
.
value
=
true
}
/** Distribute flat channel-level rules into the matching platform section based on group_ids */
function
distributeRulesToPlatforms
(
apiRules
:
AccountStatsPricingRule
[])
{
// Build groupID → platform lookup
const
groupPlatformMap
=
new
Map
<
number
,
GroupPlatform
>
()
for
(
const
g
of
allGroups
.
value
)
{
groupPlatformMap
.
set
(
g
.
id
,
g
.
platform
)
}
for
(
const
apiRule
of
apiRules
)
{
// Infer platform from group_ids
const
platforms
=
new
Set
<
GroupPlatform
>
()
for
(
const
gid
of
apiRule
.
group_ids
||
[])
{
const
p
=
groupPlatformMap
.
get
(
gid
)
if
(
p
)
platforms
.
add
(
p
)
}
// If pricing has a platform field, use that as fallback
if
(
platforms
.
size
===
0
&&
apiRule
.
pricing
?.
length
>
0
)
{
const
p
=
apiRule
.
pricing
[
0
].
platform
as
GroupPlatform
|
undefined
if
(
p
)
platforms
.
add
(
p
)
}
const
targetPlatform
=
platforms
.
size
>=
1
?
[...
platforms
][
0
]
:
null
if
(
!
targetPlatform
)
continue
const
section
=
form
.
platforms
.
find
(
s
=>
s
.
platform
===
targetPlatform
)
if
(
!
section
)
continue
const
formRule
:
FormPricingRule
=
{
name
:
apiRule
.
name
||
''
,
group_ids
:
[...(
apiRule
.
group_ids
||
[])],
account_ids
:
[...(
apiRule
.
account_ids
||
[])],
pricing
:
(
apiRule
.
pricing
||
[]).
map
(
p
=>
({
models
:
[...(
p
.
models
||
[])],
billing_mode
:
p
.
billing_mode
,
input_price
:
perTokenToMTok
(
p
.
input_price
),
output_price
:
perTokenToMTok
(
p
.
output_price
),
cache_write_price
:
perTokenToMTok
(
p
.
cache_write_price
),
cache_read_price
:
perTokenToMTok
(
p
.
cache_read_price
),
image_output_price
:
perTokenToMTok
(
p
.
image_output_price
),
per_request_price
:
p
.
per_request_price
,
intervals
:
apiIntervalsToForm
(
p
.
intervals
||
[])
}
as
PricingFormEntry
))
}
section
.
account_stats_pricing_rules
.
push
(
formRule
)
}
}
/** Populate ruleAccountNameCache by fetching account details for all account_ids in rules */
async
function
populateRuleAccountNameCache
()
{
const
allAccountIds
=
new
Set
<
number
>
()
for
(
const
section
of
form
.
platforms
)
{
for
(
const
rule
of
section
.
account_stats_pricing_rules
)
{
for
(
const
id
of
rule
.
account_ids
)
{
allAccountIds
.
add
(
id
)
}
}
}
if
(
allAccountIds
.
size
===
0
)
return
// Fetch account details in parallel (batch of individual getById calls)
const
ids
=
[...
allAccountIds
]
const
results
=
await
Promise
.
allSettled
(
ids
.
map
(
id
=>
adminAPI
.
accounts
.
getById
(
id
))
)
for
(
let
i
=
0
;
i
<
ids
.
length
;
i
++
)
{
const
result
=
results
[
i
]
if
(
result
.
status
===
'
fulfilled
'
)
{
ruleAccountNameCache
.
value
[
ids
[
i
]]
=
result
.
value
.
name
}
// If rejected, the cache won't have the name, so it'll show "#ID" which is acceptable
}
}
function
closeDialog
()
{
showDialog
.
value
=
false
editingChannel
.
value
=
null
...
...
@@ -961,7 +1388,7 @@ async function handleSubmit() {
const
intervalErr
=
validateIntervals
(
entry
.
intervals
)
if
(
intervalErr
)
{
const
platformLabel
=
t
(
'
admin.groups.platforms.
'
+
section
.
platform
,
section
.
platform
)
const
modelLabel
=
entry
.
models
.
join
(
'
,
'
)
||
'
未命名
'
const
modelLabel
=
entry
.
models
.
join
(
'
,
'
)
||
t
(
'
admin.channels.form.unnamed
'
)
appStore
.
showError
(
`
${
platformLabel
}
-
${
modelLabel
}
:
${
intervalErr
}
`
)
activeTab
.
value
=
section
.
platform
return
...
...
@@ -969,7 +1396,7 @@ async function handleSubmit() {
}
}
const
{
group_ids
,
model_pricing
,
model_mapping
}
=
formToAPI
()
const
{
group_ids
,
model_pricing
,
model_mapping
,
features_config
}
=
formToAPI
()
submitting
.
value
=
true
try
{
...
...
@@ -982,7 +1409,10 @@ async function handleSubmit() {
model_pricing
,
model_mapping
:
Object
.
keys
(
model_mapping
).
length
>
0
?
model_mapping
:
{},
billing_model_source
:
form
.
billing_model_source
,
restrict_models
:
form
.
restrict_models
restrict_models
:
form
.
restrict_models
,
features_config
,
apply_pricing_to_account_stats
:
form
.
apply_pricing_to_account_stats
,
account_stats_pricing_rules
:
accountStatsRulesToAPI
()
}
await
adminAPI
.
channels
.
update
(
editingChannel
.
value
.
id
,
req
)
appStore
.
showSuccess
(
t
(
'
admin.channels.updateSuccess
'
,
'
Channel updated
'
))
...
...
@@ -994,19 +1424,20 @@ async function handleSubmit() {
model_pricing
,
model_mapping
:
Object
.
keys
(
model_mapping
).
length
>
0
?
model_mapping
:
{},
billing_model_source
:
form
.
billing_model_source
,
restrict_models
:
form
.
restrict_models
restrict_models
:
form
.
restrict_models
,
features_config
,
apply_pricing_to_account_stats
:
form
.
apply_pricing_to_account_stats
,
account_stats_pricing_rules
:
accountStatsRulesToAPI
()
}
await
adminAPI
.
channels
.
create
(
req
)
appStore
.
showSuccess
(
t
(
'
admin.channels.createSuccess
'
,
'
Channel created
'
))
}
closeDialog
()
loadChannels
()
}
catch
(
error
:
any
)
{
const
msg
=
error
.
response
?.
data
?.
detail
||
(
editingChannel
.
value
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
editingChannel
.
value
?
t
(
'
admin.channels.updateError
'
,
'
Failed to update channel
'
)
:
t
(
'
admin.channels.createError
'
,
'
Failed to create channel
'
))
appStore
.
showError
(
msg
)
console
.
error
(
'
Error saving channel:
'
,
error
)
:
t
(
'
admin.channels.createError
'
,
'
Failed to create channel
'
)))
}
finally
{
submitting
.
value
=
false
}
...
...
@@ -1044,9 +1475,8 @@ async function confirmDelete() {
showDeleteDialog
.
value
=
false
deletingChannel
.
value
=
null
loadChannels
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.channels.deleteError
'
,
'
Failed to delete channel
'
))
console
.
error
(
'
Error deleting channel:
'
,
error
)
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
admin.channels.deleteError
'
,
'
Failed to delete channel
'
)))
}
}
...
...
@@ -1054,11 +1484,16 @@ async function confirmDelete() {
onMounted
(()
=>
{
loadChannels
()
loadGroups
()
loadWebSearchGlobalState
()
document
.
addEventListener
(
'
click
'
,
handleRuleAccountClickOutside
)
})
onUnmounted
(()
=>
{
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
document
.
removeEventListener
(
'
click
'
,
handleRuleAccountClickOutside
)
ruleAccountSearchRunner
.
clearAll
()
clearAllRuleAccountSearchState
()
})
</
script
>
...
...
frontend/src/views/admin/DashboardView.vue
View file @
0b746501
...
...
@@ -112,15 +112,21 @@
</p>
<p
class=
"text-xs"
>
<span
class=
"text-
amber
-600 dark:text-
amber
-400"
class=
"text-
green
-600 dark:text-
green
-400"
:title=
"t('admin.dashboard.actual')"
>
$
{{
formatCost
(
stats
.
today_actual_cost
)
}}
</span
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"text-orange-500 dark:text-orange-400"
:title=
"t('admin.dashboard.accountCost')"
>
$
{{
formatCost
(
stats
.
today_account_cost
)
}}
</span
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
:title=
"t('admin.dashboard.standard')"
>
/ $
{{
formatCost
(
stats
.
today_cost
)
}}
</span
>
$
{{
formatCost
(
stats
.
today_cost
)
}}
</span
>
</p>
</div>
...
...
@@ -142,15 +148,21 @@
</p>
<p
class=
"text-xs"
>
<span
class=
"text-
indigo
-600 dark:text-
indigo
-400"
class=
"text-
green
-600 dark:text-
green
-400"
:title=
"t('admin.dashboard.actual')"
>
$
{{
formatCost
(
stats
.
total_actual_cost
)
}}
</span
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"text-orange-500 dark:text-orange-400"
:title=
"t('admin.dashboard.accountCost')"
>
$
{{
formatCost
(
stats
.
total_account_cost
)
}}
</span
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
:title=
"t('admin.dashboard.standard')"
>
/ $
{{
formatCost
(
stats
.
total_cost
)
}}
</span
>
$
{{
formatCost
(
stats
.
total_cost
)
}}
</span
>
</p>
</div>
...
...
frontend/src/views/admin/GroupsView.vue
View file @
0b746501
...
...
@@ -3253,6 +3253,7 @@ const editForm = reactive({
fallback_group_id_on_invalid_request
:
null
as
number
|
null
,
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch
:
false
,
default_mapped_model
:
''
,
opus_mapped_model
:
editMessagesDispatchDefaults
.
opus_mapped_model
,
sonnet_mapped_model
:
editMessagesDispatchDefaults
.
sonnet_mapped_model
,
haiku_mapped_model
:
editMessagesDispatchDefaults
.
haiku_mapped_model
,
...
...
@@ -3732,6 +3733,19 @@ watch(
},
);
watch
(
()
=>
editForm
.
platform
,
(
newVal
)
=>
{
if
(
!
[
'
anthropic
'
,
'
antigravity
'
].
includes
(
newVal
))
{
editForm
.
fallback_group_id_on_invalid_request
=
null
}
if
(
newVal
!==
'
openai
'
)
{
editForm
.
allow_messages_dispatch
=
false
editForm
.
default_mapped_model
=
''
}
}
)
// 点击外部关闭账号搜索下拉框
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
;
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
0b746501
...
...
@@ -985,7 +985,8 @@ const {
deselect
,
clear
:
clearSelectedProxies
,
removeMany
:
removeSelectedProxies
,
toggleVisible
toggleVisible
,
batchUpdate
}
=
useTableSelection
<
Proxy
>
({
rows
:
proxies
,
getId
:
(
proxy
)
=>
proxy
.
id
...
...
@@ -993,7 +994,8 @@ const {
useSwipeSelect
(
proxyTableRef
,
{
isSelected
,
select
,
deselect
deselect
,
batchUpdate
}
)
const
accountsProxy
=
ref
<
Proxy
|
null
>
(
null
)
const
proxyAccounts
=
ref
<
ProxyAccountSummary
[]
>
([])
...
...
frontend/src/views/admin/SettingsView.vue
View file @
0b746501
...
...
@@ -1711,6 +1711,231 @@
<
/div
>
<
/div
>
<
/div
>
<!--
Web
Search
Emulation
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.webSearchEmulation.title
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.webSearchEmulation.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-5 p-6
"
>
<!--
Global
Toggle
-->
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.webSearchEmulation.enabled
'
)
}}
<
/label
>
<
p
class
=
"
mt-0.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.webSearchEmulation.enabledHint
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
webSearchConfig.enabled
"
/>
<
/div
>
<!--
Providers
-->
<
div
v
-
if
=
"
webSearchConfig.enabled
"
class
=
"
space-y-4
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.webSearchEmulation.providers
'
)
}}
<
/label
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm
"
@
click
=
"
addWebSearchProvider
"
>
{{
t
(
'
admin.settings.webSearchEmulation.addProvider
'
)
}}
<
/button
>
<
/div
>
<
div
v
-
if
=
"
webSearchConfig.providers.length === 0
"
class
=
"
rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-400 dark:border-dark-600
"
>
{{
t
(
'
admin.settings.webSearchEmulation.noProviders
'
)
}}
<
/div
>
<
div
v
-
for
=
"
(provider, pIdx) in webSearchConfig.providers
"
:
key
=
"
pIdx
"
class
=
"
rounded-lg border border-gray-200 dark:border-dark-600
"
>
<!--
Collapsible
header
-->
<
div
class
=
"
flex cursor-pointer items-center justify-between px-4 py-3
"
@
click
=
"
toggleProviderExpand(pIdx)
"
>
<
div
class
=
"
flex items-center gap-3
"
>
<
svg
class
=
"
h-4 w-4 text-gray-400 transition-transform
"
:
class
=
"
{ 'rotate-90': expandedProviders[pIdx]
}
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 5l7 7-7 7
"
/>
<
/svg
>
<
Select
v
-
model
=
"
provider.type
"
:
options
=
"
[
{ value: 'brave', label: 'Brave Search'
}
,
{ value: 'tavily', label: 'Tavily'
}
,
]
"
class
=
"
w-36
"
@
click
.
stop
/>
<!--
Quota
summary
(
always
visible
)
-->
<
span
class
=
"
text-xs text-gray-400
"
>
{{
provider
.
quota_used
??
0
}}
/
{{
provider
.
quota_limit
!=
null
&&
provider
.
quota_limit
>
0
?
provider
.
quota_limit
:
'
∞
'
}}
<
/span
>
<
span
v
-
if
=
"
!expandedProviders[pIdx] && provider.api_key_configured
"
class
=
"
text-xs text-green-500
"
>
{{
t
(
'
admin.settings.webSearchEmulation.apiKeyConfigured
'
)
}}
<
/span
>
<
/div
>
<
button
type
=
"
button
"
class
=
"
text-red-500 hover:text-red-700 text-xs
"
@
click
.
stop
=
"
removeWebSearchProvider(pIdx)
"
>
{{
t
(
'
admin.settings.webSearchEmulation.removeProvider
'
)
}}
<
/button
>
<
/div
>
<!--
Expanded
content
-->
<
div
v
-
if
=
"
expandedProviders[pIdx]
"
class
=
"
space-y-3 border-t border-gray-100 px-4 pb-4 pt-3 dark:border-dark-700
"
>
<!--
API
Key
with
inline
show
/
copy
-->
<
div
>
<
label
class
=
"
text-xs text-gray-500
"
>
{{
t
(
'
admin.settings.webSearchEmulation.apiKey
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
v
-
model
=
"
provider.api_key
"
:
type
=
"
apiKeyVisible[pIdx] ? 'text' : 'password'
"
class
=
"
input w-full text-sm
"
:
class
=
"
(provider.api_key || provider.api_key_configured) ? 'pr-16' : ''
"
:
placeholder
=
"
provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')
"
/>
<
div
v
-
if
=
"
provider.api_key || provider.api_key_configured
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-1.5
"
>
<
button
type
=
"
button
"
class
=
"
rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
"
:
title
=
"
apiKeyVisible[pIdx] ? t('admin.settings.webSearchEmulation.hideApiKey') : t('admin.settings.webSearchEmulation.showApiKey')
"
@
click
=
"
apiKeyVisible[pIdx] = !apiKeyVisible[pIdx]
"
>
<
svg
v
-
if
=
"
!apiKeyVisible[pIdx]
"
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M15 12a3 3 0 11-6 0 3 3 0 016 0z
"
/>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z
"
/>
<
/svg
>
<
svg
v
-
else
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21
"
/>
<
/svg
>
<
/button
>
<
button
type
=
"
button
"
class
=
"
rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
"
:
class
=
"
{ 'opacity-30 cursor-not-allowed': !provider.api_key
}
"
:
title
=
"
t('admin.settings.webSearchEmulation.copyApiKey')
"
:
disabled
=
"
!provider.api_key
"
@
click
=
"
copyApiKey(pIdx)
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z
"
/>
<
/svg
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<!--
Quota
+
Subscription
in
compact
row
-->
<
div
class
=
"
grid grid-cols-2 gap-3
"
>
<
div
>
<
label
class
=
"
text-xs text-gray-500
"
>
{{
t
(
'
admin.settings.webSearchEmulation.quotaLimit
'
)
}}
<
/label
>
<
input
v
-
model
=
"
provider.quota_limit
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input text-sm
"
:
placeholder
=
"
'∞'
"
/>
<
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.webSearchEmulation.quotaLimitHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
text-xs text-gray-500
"
>
{{
t
(
'
admin.settings.webSearchEmulation.subscribedAt
'
)
}}
<
/label
>
<
input
:
value
=
"
formatSubscribedAt(provider.subscribed_at)
"
type
=
"
date
"
class
=
"
input text-sm
"
@
input
=
"
provider.subscribed_at = parseSubscribedAt(($event.target as HTMLInputElement).value)
"
/>
<
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.webSearchEmulation.subscribedAtHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<!--
Usage
display
-->
<
div
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-xs text-gray-500
"
>
{{
t
(
'
admin.settings.webSearchEmulation.quotaUsage
'
)
}}
:
<
/span
>
<
div
v
-
if
=
"
provider.quota_limit != null && provider.quota_limit > 0
"
class
=
"
flex-1 rounded-full bg-gray-200 dark:bg-dark-600
"
style
=
"
height: 6px
"
>
<
div
class
=
"
h-full rounded-full transition-all
"
:
class
=
"
quotaPercentage(provider) > 90 ? 'bg-red-500' : quotaPercentage(provider) > 70 ? 'bg-yellow-500' : 'bg-green-500'
"
:
style
=
"
{ width: Math.min(quotaPercentage(provider), 100) + '%'
}
"
/>
<
/div
>
<
div
v
-
else
class
=
"
flex-1
"
/>
<
span
class
=
"
text-xs text-gray-500
"
>
{{
provider
.
quota_used
??
0
}}
/
{{
provider
.
quota_limit
!=
null
&&
provider
.
quota_limit
>
0
?
provider
.
quota_limit
:
'
∞
'
}}
<
/span
>
<
button
v
-
if
=
"
(provider.quota_used ?? 0) > 0
"
type
=
"
button
"
class
=
"
text-xs text-primary-600 hover:text-primary-700
"
@
click
=
"
resetWebSearchUsage(pIdx)
"
>
{{
t
(
'
admin.settings.webSearchEmulation.resetUsage
'
)
}}
<
/button
>
<
/div
>
<!--
Proxy
+
Test
on
same
row
-->
<
div
class
=
"
flex items-end gap-3
"
>
<
div
class
=
"
flex-1
"
>
<
label
class
=
"
text-xs text-gray-500
"
>
{{
t
(
'
admin.settings.webSearchEmulation.proxy
'
)
}}
<
/label
>
<
ProxySelector
v
-
model
=
"
provider.proxy_id
"
:
proxies
=
"
webSearchProxies
"
/>
<
/div
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm whitespace-nowrap
"
@
click
=
"
openTestDialog()
"
>
{{
t
(
'
admin.settings.webSearchEmulation.test
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Web
Search
Test
Dialog
-->
<
div
v
-
if
=
"
wsTestDialogOpen
"
class
=
"
fixed inset-0 z-50 flex items-center justify-center bg-black/50
"
@
click
.
self
=
"
wsTestDialogOpen = false
"
>
<
div
class
=
"
mx-4 w-full max-w-lg rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800
"
>
<
h3
class
=
"
mb-4 text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.webSearchEmulation.testResultTitle
'
)
}}
<
/h3
>
<
div
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
wsTestQuery
"
type
=
"
text
"
class
=
"
input flex-1 text-sm
"
:
placeholder
=
"
t('admin.settings.webSearchEmulation.testDefaultQuery')
"
@
keyup
.
enter
=
"
testWebSearchProvider()
"
/>
<
button
type
=
"
button
"
class
=
"
btn btn-primary btn-sm
"
:
disabled
=
"
wsTestLoading
"
@
click
=
"
testWebSearchProvider()
"
>
{{
wsTestLoading
?
t
(
'
admin.settings.webSearchEmulation.testing
'
)
:
t
(
'
admin.settings.webSearchEmulation.test
'
)
}}
<
/button
>
<
/div
>
<!--
Test
results
-->
<
div
v
-
if
=
"
wsTestResult
"
class
=
"
mt-4 max-h-80 overflow-y-auto rounded-lg bg-gray-50 p-4 dark:bg-dark-700
"
>
<
p
class
=
"
mb-2 text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.webSearchEmulation.testResultProvider
'
)
}}
:
{{
wsTestResult
.
provider
}}
<
/p
>
<
div
v
-
if
=
"
wsTestResult.results.length === 0
"
class
=
"
text-sm text-gray-400
"
>
{{
t
(
'
admin.settings.webSearchEmulation.testNoResults
'
)
}}
<
/div
>
<
div
v
-
for
=
"
(r, rIdx) in wsTestResult.results
"
:
key
=
"
rIdx
"
class
=
"
mt-2 border-t border-gray-200 pt-2 first:mt-0 first:border-0 first:pt-0 dark:border-dark-600
"
>
<
a
:
href
=
"
r.url
"
target
=
"
_blank
"
class
=
"
text-sm font-medium text-blue-600 hover:underline dark:text-blue-400
"
>
{{
r
.
title
}}
<
/a
>
<
p
class
=
"
mt-0.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
r
.
snippet
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
mt-4 flex justify-end
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm
"
@
click
=
"
wsTestDialogOpen = false
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Gateway
—
Claude
Code
,
Scheduling
-->
<!--
Tab
:
General
-->
...
...
@@ -2146,10 +2371,25 @@
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.preview
'
)
}}
<
/label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{
(
form.payment_product_name_prefix || 'Sub2API'
)
+ ' 100 ' +
(
form.payment_product_name_suffix || 'CNY'
)
}}
</
div
><
/div
>
<
/div
>
<!--
Row
2
:
Balance
toggle
+
amounts
-->
<
div
class
=
"
grid grid-cols-2 gap-3 sm:grid-cols-
4
"
>
<
div
class
=
"
grid grid-cols-2 gap-3 sm:grid-cols-
5
"
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.minAmount
'
)
}}
<
/label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat
((
$event.target as HTMLInputElement
)
.value
)
|| 0" type="number" step="0.01" min="0" class="input" :placeholder="t
(
'admin.settings.payment.noLimit'
)
" /
><
/div
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.maxAmount
'
)
}}
<
/label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat
((
$event.target as HTMLInputElement
)
.value
)
|| 0" type="number" step="0.01" min="0" class="input" :placeholder="t
(
'admin.settings.payment.noLimit'
)
" /
><
/div
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.dailyLimit
'
)
}}
<
/label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat
((
$event.target as HTMLInputElement
)
.value
)
|| 0" type="number" step="0.01" min="0" class="input" :placeholder="t
(
'admin.settings.payment.noLimit'
)
" /
><
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.balanceRechargeMultiplier
'
)
}}
<
/label
>
<
input
:
value
=
"
form.payment_balance_recharge_multiplier || ''
"
@
input
=
"
form.payment_balance_recharge_multiplier = parseFloat(($event.target as HTMLInputElement).value) || 1
"
type
=
"
number
"
step
=
"
0.01
"
min
=
"
0.01
"
class
=
"
input
"
/>
<
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.payment.balanceRechargeMultiplierHint
'
)
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs font-medium text-primary-600 dark:text-primary-400
"
>
{{
t
(
'
admin.settings.payment.balanceRechargePreview
'
,
{
usd
:
(
Number
(
form
.
payment_balance_recharge_multiplier
)
||
1
).
toFixed
(
2
)
}
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.rechargeFeeRate
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
:
value
=
"
form.payment_recharge_fee_rate ?? ''
"
@
input
=
"
form.payment_recharge_fee_rate = Math.min(100, Math.max(0, Math.round(parseFloat(($event.target as HTMLInputElement).value || '0') * 100) / 100))
"
type
=
"
number
"
step
=
"
0.01
"
min
=
"
0
"
max
=
"
100
"
class
=
"
input pr-8
"
/>
<
span
class
=
"
pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400
"
>%<
/span
>
<
/div
>
<
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.payment.rechargeFeeRateHint
'
)
}}
<
/p
>
<
p
v
-
if
=
"
(Number(form.payment_recharge_fee_rate) || 0) > 0
"
class
=
"
mt-1 text-xs font-medium text-primary-600 dark:text-primary-400
"
>
{{
t
(
'
admin.settings.payment.rechargeFeePreview
'
,
{
fee
:
(
Number
(
form
.
payment_recharge_fee_rate
)
||
0
).
toFixed
(
2
)
}
)
}}
<
/p
>
<
/div
>
<
div
><
label
class
=
"
input-label
"
>
{{
t
(
'
admin.settings.payment.orderTimeout
'
)
}}
<
span
class
=
"
text-red-500
"
>*<
/span></
label
><
input
v
-
model
.
number
=
"
form.payment_order_timeout_minutes
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
required
/><
p
class
=
"
mt-0.5 text-xs text-gray-400
"
>
{{
t
(
'
admin.settings.payment.orderTimeoutHint
'
)
}}
<
/p></
div
>
<
/div
>
<!--
Row
3
:
Pending
orders
+
load
balance
+
cancel
rate
limit
(
all
in
one
row
)
-->
...
...
@@ -2468,6 +2708,73 @@
<
/div
>
<
/div
>
<
/div
>
<!--
Balance
Low
Notification
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h3
class
=
"
text-base font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.balanceNotify.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.balanceNotify.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
px-6 py-6 space-y-4
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
label
class
=
"
mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.balanceNotify.enabled
'
)
}}
<
/label
>
<
Toggle
v
-
model
=
"
form.balance_low_notify_enabled
"
/>
<
/div
>
<
div
v
-
if
=
"
form.balance_low_notify_enabled
"
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.balanceNotify.threshold
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
form.balance_low_notify_threshold
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input pl-7
"
/>
<
/div
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.balanceNotify.thresholdHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.balanceNotify.rechargeUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.balance_low_notify_recharge_url
"
type
=
"
url
"
class
=
"
input
"
:
placeholder
=
"
currentOrigin
"
/>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.balanceNotify.rechargeUrlHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Account
Quota
Notification
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h3
class
=
"
text-base font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.quotaNotify.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.quotaNotify.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
px-6 py-6 space-y-4
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
label
class
=
"
mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.quotaNotify.enabled
'
)
}}
<
/label
>
<
Toggle
v
-
model
=
"
form.account_quota_notify_enabled
"
/>
<
/div
>
<
div
v
-
if
=
"
form.account_quota_notify_enabled
"
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.quotaNotify.emails
'
)
}}
<
/label
>
<
div
class
=
"
space-y-2
"
>
<
div
v
-
for
=
"
(entry, index) in (form.account_quota_notify_emails || [])
"
:
key
=
"
index
"
class
=
"
flex items-center gap-2
"
>
<
label
class
=
"
relative inline-flex items-center cursor-pointer shrink-0
"
>
<
input
type
=
"
checkbox
"
:
checked
=
"
!entry.disabled
"
@
change
=
"
entry.disabled = !entry.disabled
"
class
=
"
sr-only peer
"
/>
<
div
class
=
"
w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600
"
><
/div
>
<
/label
>
<
input
v
-
model
=
"
entry.email
"
type
=
"
email
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.settings.quotaNotify.emailPlaceholder')
"
/>
<
button
@
click
=
"
form.account_quota_notify_emails.splice(index, 1)
"
class
=
"
btn btn-secondary px-2
"
type
=
"
button
"
>
<
Icon
name
=
"
x
"
size
=
"
xs
"
class
=
"
h-4 w-4
"
/>
<
/button
>
<
/div
>
<
button
@
click
=
"
addQuotaNotifyEmail
"
class
=
"
btn btn-secondary btn-sm
"
type
=
"
button
"
>
+
{{
t
(
'
admin.settings.quotaNotify.addEmail
'
)
}}
<
/button
>
<
/div
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.quotaNotify.emailsHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Email
-->
<!--
Tab
:
Backup
-->
...
...
@@ -2523,9 +2830,12 @@ import { adminAPI } from '@/api'
import
type
{
SystemSettings
,
UpdateSettingsRequest
,
DefaultSubscriptionSetting
DefaultSubscriptionSetting
,
WebSearchEmulationConfig
,
WebSearchProviderConfig
,
WebSearchTestResult
,
}
from
'
@/api/admin/settings
'
import
type
{
AdminGroup
}
from
'
@/types
'
import
type
{
AdminGroup
,
Proxy
,
NotifyEmailEntry
}
from
'
@/types
'
import
type
{
ProviderInstance
}
from
'
@/types/payment
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
...
...
@@ -2536,6 +2846,7 @@ import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vu
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
ImageUpload
from
'
@/components/common/ImageUpload.vue
'
import
BackupSettings
from
'
@/views/admin/BackupView.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
...
...
@@ -2672,7 +2983,7 @@ const form = reactive<SettingsForm>({
home_content
:
''
,
backend_mode_enabled
:
false
,
hide_ccs_import_button
:
false
,
payment_enabled
:
false
,
payment_min_amount
:
1
,
payment_max_amount
:
10000
,
payment_daily_limit
:
50000
,
payment_max_pending_orders
:
3
,
payment_order_timeout_minutes
:
30
,
payment_balance_disabled
:
false
,
payment_enabled_types
:
[],
payment_help_image_url
:
''
,
payment_help_text
:
''
,
payment_product_name_prefix
:
''
,
payment_product_name_suffix
:
''
,
payment_load_balance_strategy
:
'
round-robin
'
,
payment_cancel_rate_limit_enabled
:
false
,
payment_cancel_rate_limit_max
:
10
,
payment_cancel_rate_limit_window
:
1
,
payment_cancel_rate_limit_unit
:
'
day
'
,
payment_cancel_rate_limit_window_mode
:
'
rolling
'
,
payment_enabled
:
false
,
payment_min_amount
:
1
,
payment_max_amount
:
10000
,
payment_daily_limit
:
50000
,
payment_max_pending_orders
:
3
,
payment_order_timeout_minutes
:
30
,
payment_balance_disabled
:
false
,
payment_balance_recharge_multiplier
:
1
,
payment_recharge_fee_rate
:
0
,
payment_enabled_types
:
[],
payment_help_image_url
:
''
,
payment_help_text
:
''
,
payment_product_name_prefix
:
''
,
payment_product_name_suffix
:
''
,
payment_load_balance_strategy
:
'
round-robin
'
,
payment_cancel_rate_limit_enabled
:
false
,
payment_cancel_rate_limit_max
:
10
,
payment_cancel_rate_limit_window
:
1
,
payment_cancel_rate_limit_unit
:
'
day
'
,
payment_cancel_rate_limit_window_mode
:
'
rolling
'
,
table_default_page_size
:
tablePageSizeDefault
,
table_page_size_options
:
[
10
,
20
,
50
,
100
],
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
...
...
@@ -2743,9 +3054,177 @@ const form = reactive<SettingsForm>({
// Gateway forwarding behavior
enable_fingerprint_unification
:
true
,
enable_metadata_passthrough
:
false
,
enable_cch_signing
:
false
enable_cch_signing
:
false
,
// Balance & quota notification
balance_low_notify_enabled
:
false
,
balance_low_notify_threshold
:
0
,
balance_low_notify_recharge_url
:
''
,
account_quota_notify_enabled
:
false
,
account_quota_notify_emails
:
[]
as
NotifyEmailEntry
[]
}
)
// Proxies for web search emulation ProxySelector
const
webSearchProxies
=
ref
<
Proxy
[]
>
([])
// Web Search Emulation config (loaded/saved separately)
const
DEFAULT_WEB_SEARCH_QUOTA_LIMIT
=
1000
const
webSearchConfig
=
reactive
<
WebSearchEmulationConfig
>
({
enabled
:
false
,
providers
:
[],
}
)
const
expandedProviders
=
reactive
<
Record
<
number
,
boolean
>>
({
}
)
const
apiKeyVisible
=
reactive
<
Record
<
number
,
boolean
>>
({
}
)
const
wsTestQuery
=
ref
(
''
)
const
wsTestLoading
=
ref
(
false
)
const
wsTestResult
=
ref
<
WebSearchTestResult
|
null
>
(
null
)
const
wsTestDialogOpen
=
ref
(
false
)
function
openTestDialog
()
{
wsTestResult
.
value
=
null
wsTestDialogOpen
.
value
=
true
}
function
toggleProviderExpand
(
idx
:
number
)
{
expandedProviders
[
idx
]
=
!
expandedProviders
[
idx
]
}
function
removeWebSearchProvider
(
idx
:
number
)
{
webSearchConfig
.
providers
.
splice
(
idx
,
1
)
// Re-index expandedProviders and apiKeyVisible after removal
const
newExpanded
:
Record
<
number
,
boolean
>
=
{
}
const
newVisible
:
Record
<
number
,
boolean
>
=
{
}
for
(
let
i
=
0
;
i
<
webSearchConfig
.
providers
.
length
;
i
++
)
{
const
oldIdx
=
i
>=
idx
?
i
+
1
:
i
newExpanded
[
i
]
=
expandedProviders
[
oldIdx
]
??
false
newVisible
[
i
]
=
apiKeyVisible
[
oldIdx
]
??
false
}
Object
.
keys
(
expandedProviders
).
forEach
((
k
)
=>
delete
expandedProviders
[
Number
(
k
)])
Object
.
keys
(
apiKeyVisible
).
forEach
((
k
)
=>
delete
apiKeyVisible
[
Number
(
k
)])
Object
.
assign
(
expandedProviders
,
newExpanded
)
Object
.
assign
(
apiKeyVisible
,
newVisible
)
}
function
addWebSearchProvider
()
{
const
idx
=
webSearchConfig
.
providers
.
length
webSearchConfig
.
providers
.
push
({
type
:
'
brave
'
,
api_key
:
''
,
api_key_configured
:
false
,
quota_limit
:
DEFAULT_WEB_SEARCH_QUOTA_LIMIT
,
subscribed_at
:
null
,
proxy_id
:
null
,
expires_at
:
null
,
}
as
WebSearchProviderConfig
)
expandedProviders
[
idx
]
=
true
}
function
formatSubscribedAt
(
ts
:
number
|
null
):
string
{
if
(
!
ts
)
return
''
// Use UTC to avoid timezone drift on repeated edits
const
d
=
new
Date
(
ts
*
1000
)
const
y
=
d
.
getUTCFullYear
()
const
m
=
String
(
d
.
getUTCMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
const
day
=
String
(
d
.
getUTCDate
()).
padStart
(
2
,
'
0
'
)
return
`${y
}
-${m
}
-${day
}
`
}
function
parseSubscribedAt
(
dateStr
:
string
):
number
|
null
{
if
(
!
dateStr
)
return
null
// Parse as UTC to match formatSubscribedAt
return
Math
.
floor
(
new
Date
(
dateStr
+
'
T00:00:00Z
'
).
getTime
()
/
1000
)
}
function
quotaPercentage
(
provider
:
WebSearchProviderConfig
):
number
{
if
(
!
provider
.
quota_limit
||
provider
.
quota_limit
<=
0
)
return
0
return
((
provider
.
quota_used
??
0
)
/
provider
.
quota_limit
)
*
100
}
async
function
resetWebSearchUsage
(
idx
:
number
)
{
const
provider
=
webSearchConfig
.
providers
[
idx
]
if
(
!
provider
)
return
if
(
!
confirm
(
t
(
'
admin.settings.webSearchEmulation.resetUsageConfirm
'
)))
return
try
{
await
adminAPI
.
settings
.
resetWebSearchUsage
({
provider_type
:
provider
.
type
}
)
provider
.
quota_used
=
0
appStore
.
showSuccess
(
t
(
'
admin.settings.webSearchEmulation.resetUsageSuccess
'
))
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
}
async
function
copyApiKey
(
idx
:
number
)
{
const
key
=
webSearchConfig
.
providers
[
idx
]?.
api_key
if
(
!
key
)
{
appStore
.
showError
(
t
(
'
admin.settings.webSearchEmulation.apiKeyPlaceholder
'
))
return
}
try
{
await
navigator
.
clipboard
.
writeText
(
key
)
appStore
.
showSuccess
(
t
(
'
admin.settings.webSearchEmulation.copied
'
))
}
catch
{
appStore
.
showError
(
t
(
'
common.error
'
))
}
}
async
function
testWebSearchProvider
()
{
wsTestLoading
.
value
=
true
wsTestResult
.
value
=
null
try
{
const
query
=
wsTestQuery
.
value
.
trim
()
||
t
(
'
admin.settings.webSearchEmulation.testDefaultQuery
'
)
wsTestResult
.
value
=
await
adminAPI
.
settings
.
testWebSearchEmulation
(
query
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
wsTestLoading
.
value
=
false
}
}
async
function
loadWebSearchConfig
()
{
try
{
const
[
resp
,
proxiesResp
]
=
await
Promise
.
all
([
adminAPI
.
settings
.
getWebSearchEmulationConfig
(),
adminAPI
.
proxies
.
list
().
catch
(()
=>
({
items
:
[]
as
Proxy
[]
}
)),
])
if
(
resp
)
{
webSearchConfig
.
enabled
=
resp
.
enabled
||
false
webSearchConfig
.
providers
=
resp
.
providers
||
[]
}
webSearchProxies
.
value
=
proxiesResp
.
items
||
[]
}
catch
(
err
:
unknown
)
{
// 404 is expected when config hasn't been created yet; show error for other failures
const
status
=
(
err
as
{
status
?:
number
}
)?.
status
if
(
status
!==
404
&&
status
!==
undefined
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
}
}
async
function
saveWebSearchConfig
():
Promise
<
boolean
>
{
try
{
for
(
const
p
of
webSearchConfig
.
providers
)
{
const
raw
=
p
.
quota_limit
if
(
raw
!=
null
&&
Number
(
raw
)
!==
0
&&
Number
(
raw
)
<
1
)
{
appStore
.
showError
(
t
(
'
admin.settings.webSearchEmulation.quotaLimitMustBePositive
'
))
return
false
}
}
const
providers
=
webSearchConfig
.
providers
.
map
((
p
:
WebSearchProviderConfig
)
=>
({
...
p
,
quota_limit
:
Number
(
p
.
quota_limit
)
>
0
?
Number
(
p
.
quota_limit
)
:
null
,
}
))
await
adminAPI
.
settings
.
updateWebSearchEmulationConfig
({
enabled
:
webSearchConfig
.
enabled
,
providers
,
}
)
return
true
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
return
false
}
}
const
defaultSubscriptionGroupOptions
=
computed
<
DefaultSubscriptionGroupOption
[]
>
(()
=>
subscriptionGroups
.
value
.
map
((
group
)
=>
({
value
:
group
.
id
,
...
...
@@ -2825,6 +3304,16 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
}
}
// Quota notify email helpers
const
addQuotaNotifyEmail
=
()
=>
{
if
(
!
form
.
account_quota_notify_emails
)
{
form
.
account_quota_notify_emails
=
[]
}
form
.
account_quota_notify_emails
.
push
({
email
:
''
,
disabled
:
false
,
verified
:
true
}
)
}
const
currentOrigin
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
origin
:
''
// LinuxDo OAuth redirect URL suggestion
const
linuxdoRedirectUrlSuggestion
=
computed
(()
=>
{
if
(
typeof
window
===
'
undefined
'
)
return
''
...
...
@@ -2960,6 +3449,9 @@ async function loadSettings() {
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
// Load web search emulation config separately
await
loadWebSearchConfig
()
}
catch
(
error
:
unknown
)
{
loadFailed
.
value
=
true
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
admin.settings.failedToLoad
'
)))
...
...
@@ -3150,6 +3642,8 @@ async function saveSettings() {
payment_max_pending_orders
:
Number
(
form
.
payment_max_pending_orders
)
||
0
,
payment_order_timeout_minutes
:
Number
(
form
.
payment_order_timeout_minutes
)
||
0
,
payment_balance_disabled
:
form
.
payment_balance_disabled
,
payment_balance_recharge_multiplier
:
Number
(
form
.
payment_balance_recharge_multiplier
)
||
1
,
payment_recharge_fee_rate
:
Number
(
form
.
payment_recharge_fee_rate
)
||
0
,
payment_enabled_types
:
form
.
payment_enabled_types
,
payment_load_balance_strategy
:
form
.
payment_load_balance_strategy
,
payment_product_name_prefix
:
form
.
payment_product_name_prefix
,
...
...
@@ -3161,6 +3655,12 @@ async function saveSettings() {
payment_cancel_rate_limit_window
:
Number
(
form
.
payment_cancel_rate_limit_window
)
||
1
,
payment_cancel_rate_limit_unit
:
form
.
payment_cancel_rate_limit_unit
,
payment_cancel_rate_limit_window_mode
:
form
.
payment_cancel_rate_limit_window_mode
,
// Balance & quota notification
balance_low_notify_enabled
:
form
.
balance_low_notify_enabled
,
balance_low_notify_threshold
:
Number
(
form
.
balance_low_notify_threshold
)
||
0
,
balance_low_notify_recharge_url
:
(
form
.
balance_low_notify_recharge_url
=
form
.
balance_low_notify_recharge_url
||
currentOrigin
),
account_quota_notify_enabled
:
form
.
account_quota_notify_enabled
,
account_quota_notify_emails
:
(
form
.
account_quota_notify_emails
||
[]).
filter
((
e
)
=>
e
.
email
.
trim
()
!==
''
),
}
const
updated
=
await
adminAPI
.
settings
.
updateSettings
(
payload
)
...
...
@@ -3181,10 +3681,14 @@ async function saveSettings() {
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
// Save web search emulation config separately (errors handled internally)
const
wsOk
=
await
saveWebSearchConfig
()
// Refresh cached settings so sidebar/header update immediately
await
appStore
.
fetchPublicSettings
(
true
)
await
adminSettingsStore
.
fetch
(
true
)
if
(
wsOk
)
{
appStore
.
showSuccess
(
t
(
'
admin.settings.settingsSaved
'
))
}
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
admin.settings.failedToSave
'
)))
}
finally
{
...
...
@@ -3624,12 +4128,26 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) {
}
}
async
function
handleToggleField
(
provider
:
ProviderInstance
,
field
:
'
enabled
'
|
'
refund_enabled
'
)
{
const
newValue
=
field
===
'
enabled
'
?
!
provider
.
enabled
:
!
provider
.
refund_enabled
async
function
handleToggleField
(
provider
:
ProviderInstance
,
field
:
'
enabled
'
|
'
refund_enabled
'
|
'
allow_user_refund
'
)
{
let
newValue
:
boolean
if
(
field
===
'
enabled
'
)
newValue
=
!
provider
.
enabled
else
if
(
field
===
'
refund_enabled
'
)
newValue
=
!
provider
.
refund_enabled
else
newValue
=
!
provider
.
allow_user_refund
const
payload
:
Record
<
string
,
boolean
>
=
{
[
field
]:
newValue
}
// Cascade: turning off refund_enabled also turns off allow_user_refund
if
(
field
===
'
refund_enabled
'
&&
!
newValue
)
{
payload
.
allow_user_refund
=
false
}
try
{
await
adminAPI
.
payment
.
updateProvider
(
provider
.
id
,
{
[
field
]:
newValue
}
)
await
adminAPI
.
payment
.
updateProvider
(
provider
.
id
,
payload
)
if
(
field
===
'
enabled
'
)
provider
.
enabled
=
newValue
else
provider
.
refund_enabled
=
newValue
else
if
(
field
===
'
refund_enabled
'
)
{
provider
.
refund_enabled
=
newValue
if
(
!
newValue
)
provider
.
allow_user_refund
=
false
}
else
{
provider
.
allow_user_refund
=
newValue
}
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
),
paymentErrorMap
.
value
))
}
}
...
...
frontend/src/views/admin/UsageView.vue
View file @
0b746501
...
...
@@ -495,7 +495,7 @@ const exportToExcel = async () => {
log
.
cache_read_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_creation_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
rate_multiplier
?.
toPrecision
(
4
)
||
'
1.00
'
,
(
log
.
account_rate_multiplier
??
1
).
toPrecision
(
4
),
log
.
total_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
actual_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
(
log
.
total_cost
*
(
log
.
account_rate_multiplier
??
1
)).
toFixed
(
6
),
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
(
(
log
.
account_stats_cost
??
log
.
total_cost
)
*
(
log
.
account_rate_multiplier
??
1
)).
toFixed
(
6
),
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
log
.
request_id
||
''
,
log
.
user_agent
||
''
,
log
.
ip_address
||
''
])
if
(
rows
.
length
)
{
...
...
frontend/src/views/admin/orders/AdminOrdersView.vue
View file @
0b746501
...
...
@@ -35,7 +35,7 @@
{{
t
(
'
payment.admin.retry
'
)
}}
</button>
<template
v-if=
"row.status === 'REFUND_REQUESTED'"
>
<span
v-if=
"row.refund_amount"
class=
"rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
>
$
{{
row
.
refund_amount
.
toFixed
(
2
)
}}
</span>
<span
v-if=
"row.refund_amount"
class=
"rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
>
{{
row
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}
{{
row
.
refund_amount
.
toFixed
(
2
)
}}
</span>
<button
@
click=
"openRefundDialog(row)"
class=
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20"
>
<Icon
name=
"check"
size=
"sm"
/>
{{
t
(
'
payment.admin.approveRefund
'
)
}}
...
...
@@ -62,14 +62,14 @@
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.orderId') }}
</p><p
class=
"font-mono text-sm font-medium text-gray-900 dark:text-white"
>
#{{ selectedOrder.id }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.orderNo') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ selectedOrder.out_trade_no }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.status') }}
</p><OrderStatusBadge
:status=
"selectedOrder.status"
/></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.amount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{ selectedOrder.amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.payAmount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{ selectedOrder.pay_amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.amount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}
{{ selectedOrder.amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.payAmount') }}
</p><p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{ selectedOrder.pay_amount.toFixed(2) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.paymentMethod') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.feeRate') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
(
selectedOrder.fee_rate
* 100).toFixed(1)
}}%
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.feeRate') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ selectedOrder.fee_rate }}%
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.createdAt') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ formatDateTime(selectedOrder.created_at) }}
</p></div>
<div><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.expiresAt') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ formatDateTime(selectedOrder.expires_at) }}
</p></div>
<div
v-if=
"selectedOrder.paid_at"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.paidAt') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ formatDateTime(selectedOrder.paid_at) }}
</p></div>
<div
v-if=
"selectedOrder.refund_amount"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.refundAmount') }}
</p><p
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
$
{{ selectedOrder.refund_amount.toFixed(2) }}
</p></div>
<div
v-if=
"selectedOrder.refund_amount"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.refundAmount') }}
</p><p
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}
{{ selectedOrder.refund_amount.toFixed(2) }}
</p></div>
<div
v-if=
"selectedOrder.refund_reason"
class=
"col-span-2"
><p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.refundReason') }}
</p><p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ selectedOrder.refund_reason }}
</p></div>
<!-- Refund request info -->
<div
v-if=
"selectedOrder.refund_requested_at"
class=
"col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600"
>
...
...
frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
View file @
0b746501
...
...
@@ -42,7 +42,7 @@
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.methods.
'
+
method
.
type
,
method
.
type
)
}}
</span>
</div>
<div
class=
"text-right"
>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
method
.
amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{
method
.
amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"ml-2 text-xs text-gray-500 dark:text-gray-400"
>
(
{{
method
.
count
}}
)
</span>
</div>
</div>
...
...
@@ -57,7 +57,7 @@
<span
:class=
"['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]"
>
{{
idx
+
1
}}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
user
.
email
}}
</span>
</div>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
user
.
amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{
user
.
amount
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
...
...
frontend/src/views/admin/orders/AdminPaymentPlansView.vue
View file @
0b746501
...
...
@@ -29,7 +29,7 @@
</
template
>
<
template
#cell-price=
"{ value, row }"
>
<div
class=
"text-sm"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
(
value
??
0
)
.
toFixed
(
2
)
}}
</span>
<span
v-if=
"row.original_price"
class=
"ml-1 text-xs text-gray-400 line-through"
>
$
{{
row
.
original_price
.
toFixed
(
2
)
}}
</span>
</div>
</
template
>
...
...
@@ -67,86 +67,14 @@
</div>
<!-- Plan Edit Dialog -->
<BaseDialog
:show=
"showPlanDialog"
:title=
"editingPlan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')"
width=
"wide"
@
close=
"showPlanDialog = false"
>
<form
id=
"plan-form"
@
submit.prevent=
"handleSavePlan"
class=
"space-y-4"
>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{ t('payment.admin.planName') }}
</label>
<input
v-model=
"planForm.name"
type=
"text"
class=
"input"
required
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('payment.admin.group') }}
</label>
<Select
v-model=
"planForm.group_id"
:options=
"groupOptions"
class=
"w-full"
>
<
template
#selected=
"{ option }"
>
<span
v-if=
"option?.platform"
:class=
"platformTextClass(String(option.platform))"
>
{{
option
.
label
}}
</span>
<span
v-else
>
{{
option
?.
label
||
t
(
'
payment.admin.selectGroup
'
)
}}
</span>
</
template
>
<
template
#option=
"{ option, selected }"
>
<span
class=
"flex-1 truncate text-left"
:class=
"option.platform ? platformTextClass(String(option.platform)) : ''"
>
{{
option
.
label
}}
</span>
<Icon
v-if=
"selected"
name=
"check"
size=
"sm"
class=
"text-primary-500"
:stroke-width=
"2"
/>
</
template
>
</Select>
</div>
</div>
<!-- Group Info Preview -->
<div
v-if=
"selectedGroupInfo"
class=
"rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"mb-2 flex items-center gap-2"
>
<GroupBadge
:name=
"selectedGroupInfo.name"
:platform=
"selectedGroupInfo.platform"
:rate-multiplier=
"selectedGroupInfo.rate_multiplier"
/>
</div>
<div
class=
"grid grid-cols-2 gap-2 text-xs"
>
<div><span
class=
"text-gray-500"
>
{{ t('payment.admin.dailyLimit') }}:
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}
</span></div>
<div><span
class=
"text-gray-500"
>
{{ t('payment.admin.weeklyLimit') }}:
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}
</span></div>
<div><span
class=
"text-gray-500"
>
{{ t('payment.admin.monthlyLimit') }}:
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}
</span></div>
</div>
</div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.planDescription') }}
</label><textarea
v-model=
"planForm.description"
rows=
"2"
class=
"input"
></textarea></div>
<div
class=
"grid grid-cols-3 gap-4"
>
<div><label
class=
"input-label"
>
{{ t('payment.admin.price') }}
</label><input
v-model.number=
"planForm.price"
type=
"number"
step=
"0.01"
min=
"0"
class=
"input"
required
/></div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.originalPrice') }}
</label><input
v-model.number=
"planForm.original_price"
type=
"number"
step=
"0.01"
min=
"0"
class=
"input"
/></div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.sortOrder') }}
</label><input
v-model.number=
"planForm.sort_order"
type=
"number"
min=
"0"
class=
"input"
/></div>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div><label
class=
"input-label"
>
{{ t('payment.admin.validityDays') }}
</label><input
v-model.number=
"planForm.validity_days"
type=
"number"
min=
"1"
class=
"input"
required
/></div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.validityUnit') }}
</label><Select
v-model=
"planForm.validity_unit"
:options=
"validityUnitOptions"
/></div>
</div>
<div>
<label
class=
"input-label"
>
{{ t('payment.admin.features') }}
</label>
<textarea
v-model=
"planFeaturesText"
rows=
"3"
class=
"input"
:placeholder=
"t('payment.admin.featuresPlaceholder')"
></textarea>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.featuresHint') }}
</p>
</div>
<div
class=
"flex items-center gap-3"
>
<label
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('payment.admin.forSale') }}
</label>
<button
type=
"button"
:class=
"[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@
click=
"planForm.for_sale = !planForm.for_sale"
>
<span
:class=
"[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"showPlanDialog = false"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"plan-form"
:disabled=
"planSaving"
class=
"btn btn-primary"
>
{{
planSaving
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<PlanEditDialog
:show=
"showPlanDialog"
:plan=
"editingPlan"
:groups=
"groups"
@
close=
"showPlanDialog = false"
@
saved=
"loadPlans"
/>
<ConfirmDialog
:show=
"showDeletePlanDialog"
:title=
"t('payment.admin.deletePlan')"
:message=
"t('payment.admin.deletePlanConfirm')"
:confirm-text=
"t('common.delete')"
danger
@
confirm=
"handleDeletePlan"
@
cancel=
"showDeletePlanDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminPaymentAPI
}
from
'
@/api/admin/payment
'
...
...
@@ -157,11 +85,10 @@ import type { AdminGroup } from '@/types'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
PlanEditDialog
from
'
./PlanEditDialog.vue
'
import
{
platformTextClass
}
from
'
@/utils/platformColors
'
const
{
t
}
=
useI18n
()
...
...
@@ -190,21 +117,6 @@ function getPlanNameClass(groupId: number): string {
return
group
?
platformTextClass
(
group
.
platform
)
:
'
text-gray-900 dark:text-white
'
}
const
groupOptions
=
computed
(()
=>
[
{
value
:
0
,
label
:
t
(
'
payment.admin.selectGroup
'
),
platform
:
''
},
...
groups
.
value
.
filter
(
g
=>
g
.
subscription_type
===
'
subscription
'
)
.
map
(
g
=>
({
value
:
g
.
id
,
label
:
`
${
g
.
name
}
—
${
g
.
platform
}
(
${
g
.
rate_multiplier
}
x)`
,
platform
:
g
.
platform
,
})),
])
const
selectedGroupInfo
=
computed
(()
=>
{
if
(
!
planForm
.
group_id
)
return
null
return
groups
.
value
.
find
(
g
=>
g
.
id
===
planForm
.
group_id
)
||
null
})
// ==================== Plans ====================
...
...
@@ -212,17 +124,8 @@ const plansLoading = ref(false)
const
plans
=
ref
<
SubscriptionPlan
[]
>
([])
const
showPlanDialog
=
ref
(
false
)
const
showDeletePlanDialog
=
ref
(
false
)
const
planSaving
=
ref
(
false
)
const
editingPlan
=
ref
<
SubscriptionPlan
|
null
>
(
null
)
const
deletingPlanId
=
ref
<
number
|
null
>
(
null
)
const
planForm
=
reactive
({
name
:
''
,
group_id
:
0
,
description
:
''
,
price
:
0
,
original_price
:
0
,
validity_days
:
30
,
validity_unit
:
'
days
'
,
for_sale
:
true
,
sort_order
:
0
})
const
planFeaturesText
=
ref
(
''
)
const
validityUnitOptions
=
computed
(()
=>
[
{
value
:
'
days
'
,
label
:
t
(
'
payment.admin.days
'
)
},
{
value
:
'
weeks
'
,
label
:
t
(
'
payment.admin.weeks
'
)
},
{
value
:
'
months
'
,
label
:
t
(
'
payment.admin.months
'
)
},
])
const
planColumns
=
computed
(():
Column
[]
=>
[
{
key
:
'
id
'
,
label
:
'
ID
'
},
...
...
@@ -253,43 +156,9 @@ async function loadPlans() {
function
openPlanEdit
(
plan
:
SubscriptionPlan
|
null
)
{
editingPlan
.
value
=
plan
if
(
plan
)
{
Object
.
assign
(
planForm
,
{
name
:
plan
.
name
,
group_id
:
plan
.
group_id
,
description
:
plan
.
description
,
price
:
plan
.
price
,
original_price
:
plan
.
original_price
||
0
,
validity_days
:
plan
.
validity_days
,
validity_unit
:
plan
.
validity_unit
||
'
days
'
,
for_sale
:
plan
.
for_sale
,
sort_order
:
plan
.
sort_order
})
planFeaturesText
.
value
=
(
plan
.
features
||
[]).
join
(
'
\n
'
)
}
else
{
Object
.
assign
(
planForm
,
{
name
:
''
,
group_id
:
0
,
description
:
''
,
price
:
0
,
original_price
:
0
,
validity_days
:
30
,
validity_unit
:
'
days
'
,
for_sale
:
true
,
sort_order
:
0
})
planFeaturesText
.
value
=
''
}
showPlanDialog
.
value
=
true
}
/** Build request payload with snake_case keys matching backend JSON tags */
function
buildPlanPayload
()
{
const
features
=
planFeaturesText
.
value
.
split
(
'
\n
'
).
map
(
f
=>
f
.
trim
()).
filter
(
Boolean
).
join
(
'
\n
'
)
return
{
name
:
planForm
.
name
,
group_id
:
planForm
.
group_id
,
description
:
planForm
.
description
,
price
:
planForm
.
price
,
original_price
:
planForm
.
original_price
||
0
,
validity_days
:
planForm
.
validity_days
,
validity_unit
:
planForm
.
validity_unit
,
for_sale
:
planForm
.
for_sale
,
sort_order
:
planForm
.
sort_order
,
features
,
}
}
async
function
handleSavePlan
()
{
planSaving
.
value
=
true
try
{
const
data
=
buildPlanPayload
()
if
(
editingPlan
.
value
)
{
await
adminPaymentAPI
.
updatePlan
(
editingPlan
.
value
.
id
,
data
)
}
else
{
await
adminPaymentAPI
.
createPlan
(
data
)
}
appStore
.
showSuccess
(
t
(
'
common.saved
'
));
showPlanDialog
.
value
=
false
;
loadPlans
()
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
planSaving
.
value
=
false
}
}
/** Quick toggle for_sale from the list */
async
function
toggleForSale
(
plan
:
SubscriptionPlan
)
{
...
...
frontend/src/views/admin/orders/PlanEditDialog.vue
0 → 100644
View file @
0b746501
<
template
>
<BaseDialog
:show=
"show"
:title=
"plan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')"
width=
"wide"
@
close=
"emit('close')"
>
<form
id=
"plan-form"
@
submit.prevent=
"handleSavePlan"
class=
"space-y-4"
>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
payment.admin.planName
'
)
}}
<span
class=
"text-red-500"
>
*
</span></label>
<input
v-model=
"planForm.name"
type=
"text"
class=
"input"
required
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
payment.admin.group
'
)
}}
<span
class=
"text-red-500"
>
*
</span></label>
<Select
v-model=
"planForm.group_id"
:options=
"groupOptions"
:placeholder=
"t('payment.admin.selectGroup')"
class=
"w-full"
>
<template
#selected
="
{ option }">
<span
v-if=
"option?.platform"
:class=
"platformTextClass(String(option.platform))"
>
{{
option
.
label
}}
</span>
<span
v-else
>
{{
option
?.
label
||
t
(
'
payment.admin.selectGroup
'
)
}}
</span>
</
template
>
<
template
#option=
"{ option, selected }"
>
<span
class=
"flex-1 truncate text-left"
:class=
"option.platform ? platformTextClass(String(option.platform)) : ''"
>
{{
option
.
label
}}
</span>
<Icon
v-if=
"selected"
name=
"check"
size=
"sm"
class=
"text-primary-500"
:stroke-width=
"2"
/>
</
template
>
</Select>
</div>
</div>
<!-- Group Info Preview -->
<div
v-if=
"selectedGroupInfo"
class=
"rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"mb-2 flex items-center gap-2"
>
<GroupBadge
:name=
"selectedGroupInfo.name"
:platform=
"selectedGroupInfo.platform"
:rate-multiplier=
"selectedGroupInfo.rate_multiplier"
/>
</div>
<div
class=
"grid grid-cols-2 gap-2 text-xs"
>
<div><span
class=
"text-gray-500"
>
{{ t('payment.admin.dailyLimit') }}:
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}
</span></div>
<div><span
class=
"text-gray-500"
>
{{ t('payment.admin.weeklyLimit') }}:
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}
</span></div>
<div><span
class=
"text-gray-500"
>
{{ t('payment.admin.monthlyLimit') }}:
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}
</span></div>
</div>
</div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.planDescription') }}
<span
class=
"text-red-500"
>
*
</span></label><textarea
v-model=
"planForm.description"
rows=
"2"
class=
"input"
required
></textarea></div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div><label
class=
"input-label"
>
{{ t('payment.admin.price') }}
<span
class=
"text-red-500"
>
*
</span></label><input
v-model.number=
"planForm.price"
type=
"number"
step=
"0.01"
min=
"0.01"
class=
"input"
required
/></div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.originalPrice') }}
</label><input
v-model.number=
"planForm.original_price"
type=
"number"
step=
"0.01"
min=
"0"
class=
"input"
/></div>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div><label
class=
"input-label"
>
{{ t('payment.admin.validityDays') }}
<span
class=
"text-red-500"
>
*
</span></label><input
v-model.number=
"planForm.validity_days"
type=
"number"
min=
"1"
class=
"input"
required
/></div>
<div><label
class=
"input-label"
>
{{ t('payment.admin.validityUnit') }}
<span
class=
"text-red-500"
>
*
</span></label><Select
v-model=
"planForm.validity_unit"
:options=
"validityUnitOptions"
/></div>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div><label
class=
"input-label"
>
{{ t('payment.admin.sortOrder') }}
</label><input
v-model.number=
"planForm.sort_order"
type=
"number"
min=
"0"
class=
"input"
/></div>
</div>
<div>
<label
class=
"input-label"
>
{{ t('payment.admin.features') }}
</label>
<textarea
v-model=
"planFeaturesText"
rows=
"3"
class=
"input"
:placeholder=
"t('payment.admin.featuresPlaceholder')"
></textarea>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.featuresHint') }}
</p>
</div>
<div
class=
"flex items-center gap-3"
>
<label
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ t('payment.admin.forSale') }}
</label>
<button
type=
"button"
:class=
"[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@
click=
"planForm.for_sale = !planForm.for_sale"
>
<span
:class=
"[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"plan-form"
:disabled=
"saving"
class=
"btn btn-primary"
>
{{
saving
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminPaymentAPI
}
from
'
@/api/admin/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
type
{
SubscriptionPlan
}
from
'
@/types/payment
'
import
type
{
AdminGroup
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
{
platformTextClass
}
from
'
@/utils/platformColors
'
const
props
=
defineProps
<
{
show
:
boolean
plan
:
SubscriptionPlan
|
null
groups
:
AdminGroup
[]
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
saved
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
saving
=
ref
(
false
)
const
planForm
=
reactive
({
name
:
''
,
group_id
:
null
as
number
|
null
,
description
:
''
,
price
:
0
,
original_price
:
0
,
validity_days
:
30
,
validity_unit
:
'
days
'
,
sort_order
:
0
,
for_sale
:
true
})
const
planFeaturesText
=
ref
(
''
)
const
validityUnitOptions
=
computed
(()
=>
[
{
value
:
'
days
'
,
label
:
t
(
'
payment.admin.days
'
)
},
{
value
:
'
weeks
'
,
label
:
t
(
'
payment.admin.weeks
'
)
},
{
value
:
'
months
'
,
label
:
t
(
'
payment.admin.months
'
)
},
])
const
groupOptions
=
computed
(()
=>
props
.
groups
.
filter
(
g
=>
g
.
subscription_type
===
'
subscription
'
)
.
map
(
g
=>
({
value
:
g
.
id
,
label
:
`
${
g
.
name
}
—
${
g
.
platform
}
(
${
g
.
rate_multiplier
}
x)`
,
platform
:
g
.
platform
,
})),
)
const
selectedGroupInfo
=
computed
(()
=>
{
if
(
!
planForm
.
group_id
)
return
null
return
props
.
groups
.
find
(
g
=>
g
.
id
===
planForm
.
group_id
)
||
null
})
// Reset form when dialog opens
watch
(()
=>
props
.
show
,
(
visible
)
=>
{
if
(
!
visible
)
return
if
(
props
.
plan
)
{
Object
.
assign
(
planForm
,
{
name
:
props
.
plan
.
name
,
group_id
:
props
.
plan
.
group_id
,
description
:
props
.
plan
.
description
,
price
:
props
.
plan
.
price
,
original_price
:
props
.
plan
.
original_price
||
0
,
validity_days
:
props
.
plan
.
validity_days
,
validity_unit
:
props
.
plan
.
validity_unit
||
'
days
'
,
sort_order
:
props
.
plan
.
sort_order
||
0
,
for_sale
:
props
.
plan
.
for_sale
})
planFeaturesText
.
value
=
(
props
.
plan
.
features
||
[]).
join
(
'
\n
'
)
}
else
{
Object
.
assign
(
planForm
,
{
name
:
''
,
group_id
:
null
,
description
:
''
,
price
:
0
,
original_price
:
0
,
validity_days
:
30
,
validity_unit
:
'
days
'
,
sort_order
:
0
,
for_sale
:
true
})
planFeaturesText
.
value
=
''
}
})
/** Build request payload with snake_case keys matching backend JSON tags */
function
buildPlanPayload
()
{
const
features
=
planFeaturesText
.
value
.
split
(
'
\n
'
).
map
(
f
=>
f
.
trim
()).
filter
(
Boolean
).
join
(
'
\n
'
)
return
{
name
:
planForm
.
name
,
group_id
:
planForm
.
group_id
,
description
:
planForm
.
description
,
price
:
planForm
.
price
,
original_price
:
planForm
.
original_price
||
0
,
validity_days
:
planForm
.
validity_days
,
validity_unit
:
planForm
.
validity_unit
,
sort_order
:
planForm
.
sort_order
,
for_sale
:
planForm
.
for_sale
,
features
,
}
}
async
function
handleSavePlan
()
{
if
(
!
planForm
.
group_id
)
{
appStore
.
showError
(
t
(
'
payment.admin.groupRequired
'
))
return
}
if
(
!
planForm
.
price
||
planForm
.
price
<=
0
)
{
appStore
.
showError
(
t
(
'
payment.admin.priceRequired
'
))
return
}
if
(
!
planForm
.
validity_days
||
planForm
.
validity_days
<
1
)
{
appStore
.
showError
(
t
(
'
payment.admin.validityDaysRequired
'
))
return
}
saving
.
value
=
true
try
{
const
data
=
buildPlanPayload
()
if
(
props
.
plan
)
{
await
adminPaymentAPI
.
updatePlan
(
props
.
plan
.
id
,
data
)
}
else
{
await
adminPaymentAPI
.
createPlan
(
data
)
}
appStore
.
showSuccess
(
t
(
'
common.saved
'
))
emit
(
'
close
'
)
emit
(
'
saved
'
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
saving
.
value
=
false
}
}
</
script
>
Prev
1
…
7
8
9
10
11
12
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