Compare commits

..

28 Commits

Author SHA1 Message Date
CaIon 78e4cb3cad feat(web): redesign group ratio rules with collapsible grouped layout
Rewrite GroupGroupRatioRules and GroupSpecialUsableRules to group rules
by user group in collapsible sections instead of a flat table. Default
collapsed to reduce visual clutter when many rules exist. Fix i18n
translations for ja, zh-TW with proper native text; add missing keys.
2026-04-08 17:09:42 +08:00
forsakenyang c734db34e8 feat: add minimax image generation relay support (#4103) 2026-04-08 16:57:44 +08:00
星野梦月 a18ea3cc16 feat: 支持强制使用 AUTH LOGIN 以解决 outlook 等邮箱的发件问题 (#4112)
* feat: 支持强制使用 AUTH LOGIN 以解决 outlook 等邮箱的发件问题

* fix: 修复通过 SSL 发送邮件时绕过 AUTH LOGIN 的问题

* fix: remove redundant branch, delete test file, add i18n translations

- Remove redundant else-if branch in SendEmail since auth is already
  computed via getSMTPAuth()
- Delete option_smtp_auth_test.go as requested
- Add i18n translations for '强制使用 AUTH LOGIN' checkbox
2026-04-08 16:53:10 +08:00
CaIon aafbd78887 feat(dashboard): add copy button next to API link in API info panel
Closes #4058
2026-04-08 16:39:50 +08:00
CaIon 77897a8101 feat(dashboard): enhance chart axes and update sorting logic 2026-04-08 15:57:26 +08:00
Calcium-Ion 9b4ffb0875 Merge pull request #4142 from seefs001/fix/skip_failure_option
fix: 修复 失败后不重试 配置项写到内存被覆盖
2026-04-08 15:45:02 +08:00
CaIon 606a4eee96 feat(dashboard): add admin user analytics and fix chart labels
- Add GET /api/data/users endpoint for user-grouped quota data (admin only)
- Add user consumption ranking (horizontal bar, top 10) and user consumption
  trend (area chart) tabs visible only to admin users
- Fix mislabeled "消耗趋势" tab to "调用趋势" (shows call counts, not quota)
- Add processUserData helper for user ranking and trend data extraction
- Add i18n keys for new tabs across all 7 locales
2026-04-08 15:44:01 +08:00
Calcium-Ion 9ffb85a36b Merge pull request #4068 from feitianbubu/seedance-support-duration
Seedance support duration
2026-04-08 15:01:25 +08:00
Seefs c3b8fa29b2 fix: 修复 失败后不重试 配置项写到内存被覆盖 2026-04-08 14:01:27 +08:00
Calcium-Ion a057eddac1 Merge pull request #4131 from binorxin/add-error-logs
chore: 添加 启用错误日志记录到env配置中
2026-04-08 13:46:18 +08:00
Calcium-Ion 1110403750 Merge pull request #4136 from QuantumNous/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/bedrockruntime-1.50.4
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime from 1.50.0 to 1.50.4
2026-04-08 13:43:34 +08:00
Calcium-Ion 3a2aecbc01 Merge pull request #4123 from bbbugg/fix/enabled-api
fix(pricing): add filtering for pricing based on usable groups
2026-04-08 13:43:02 +08:00
Calcium-Ion 49648d8b80 Merge pull request #4128 from zuiho-kai/fix/claude-stream-usage-overwrite
fix: Claude 流式断流时不再整份覆盖 usage,保留 cache 计费字段
2026-04-08 13:42:39 +08:00
Seefs 59d5aef393 fix: 修复 失败后不重试 配置项写到内存被覆盖 2026-04-08 13:41:31 +08:00
Seefs 48695e0e6f Merge pull request #3350 from goodmorning10/feat/error-boundary
feat: add ErrorBoundary to prevent full-page crashes
2026-04-08 12:21:11 +08:00
Seefs e96ca77542 Merge branch 'main' into feat/error-boundary 2026-04-08 12:20:50 +08:00
Seefs 1ad2557668 Merge pull request #3488 from clansty/feature/channel-affinity-include-model
feat: add IncludeModelName option to channel affinity rules
2026-04-08 11:54:31 +08:00
dependabot[bot] ded3bb9cb1 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime
Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.0 to 1.50.4.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.50.0...service/ssm/v1.50.4)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime
  dependency-version: 1.50.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 01:23:22 +00:00
borx cf1b485389 add 添加 启用错误日志记录到env配置中 2026-04-07 21:12:13 +08:00
Clansty 741aaf4436 fix: wrap scope tag labels with t() for i18n support 2026-04-07 20:00:34 +08:00
zuiho c66636a0c7 fix: 采纳 CodeRabbit 建议,!Done 时也用 fallback 覆盖占位 CompletionTokens
message_start 阶段可能给 CompletionTokens 非零占位值,
只检查 == 0 不够,加上 !Done && fallback > current 条件。
2026-04-07 17:52:11 +08:00
zuiho f7cdc727df fix: Claude 流式断流时不再整份覆盖 usage,保留 cache 计费字段
HandleStreamFinalResponse 在 !Done 时调用 ResponseText2Usage 整份覆盖
claudeInfo.Usage,导致 message_start 已获取的 CacheReadInputTokens、
CacheCreationInputTokens 等字段丢失,prompt 退化为占位值 1。

修复:
- 只补缺失的 CompletionTokens/PromptTokens,保留已有 cache 数据
- PromptTokens 兜底改用 info.GetEstimatePromptTokens()(与其他渠道对齐)

Fixes #4127
2026-04-07 17:41:08 +08:00
bbbugg 07843d7898 fix(pricing): add filtering for pricing based on usable groups 2026-04-07 15:56:28 +08:00
irongit 559c98f261 feat(web): add ErrorBoundary to prevent full-page crashes 2026-04-06 22:32:19 +08:00
feitianbubu b713e277cd feat: metadata correct parse 2026-04-03 15:28:08 +08:00
feitianbubu 08a5243bbc feat: TaskSubmitReq support Duration 2026-04-03 15:00:23 +08:00
Clansty 116e0b8f1c feat: add include_model_name UI switch to channel affinity settings 2026-03-29 02:48:37 +08:00
Clansty 70560d5371 feat: add IncludeModelName option to channel affinity rules for per-model affinity tracking 2026-03-29 02:22:24 +08:00
41 changed files with 1376 additions and 333 deletions
+2
View File
@@ -19,6 +19,8 @@
# HOSTNAME=your-hostname
# 数据库相关配置
# 启用错误日志记录
# ERROR_LOG_ENABLED=true
# 数据库连接字符串
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
# 日志数据库连接字符串
+1
View File
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = ""
var SMTPPort = 587
var SMTPSSLEnabled = false
var SMTPForceAuthLogin = false
var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""
+15 -4
View File
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
}
func shouldUseSMTPLoginAuth() bool {
if SMTPForceAuthLogin {
return true
}
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
}
func getSMTPAuth() smtp.Auth {
if shouldUseSMTPLoginAuth() {
return LoginAuth(SMTPAccount, SMTPToken)
}
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
}
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
auth := getSMTPAuth()
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil {
return err
}
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
+26
View File
@@ -1,6 +1,7 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin"
)
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
}
if len(usableGroup) == 0 {
return []model.Pricing{}
}
filtered := make([]model.Pricing, 0, len(pricing))
for _, item := range pricing {
if common.StringsContains(item.EnableGroup, "all") {
filtered = append(filtered, item)
continue
}
for _, group := range item.EnableGroup {
if _, ok := usableGroup[group]; ok {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func GetPricing(c *gin.Context) {
pricing := model.GetPricing()
userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
}
usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
+15
View File
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
return
}
func GetQuotaDatesByUser(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": dates,
})
}
func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+5 -5
View File
@@ -8,9 +8,9 @@ require (
github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3
github.com/gin-contrib/cors v1.7.2
@@ -63,9 +63,9 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
+10 -10
View File
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+4 -1
View File
@@ -62,6 +62,7 @@ func InitOptionMap() {
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
@@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
+10
View File
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
return quotaDatas, err
}
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
+10 -1
View File
@@ -809,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
+7 -1
View File
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return request, nil
if info.RelayMode != constant.RelayModeImagesGenerations {
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
return oaiImage2MiniMaxImageRequest(request), nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info)
}
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat {
case types.RelayFormatClaude:
+137
View File
@@ -0,0 +1,137 @@
package minimax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
)
func TestGetRequestURLForImageGeneration(t *testing.T) {
t.Parallel()
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.minimax.chat",
},
}
got, err := GetRequestURL(info)
if err != nil {
t.Fatalf("GetRequestURL returned error: %v", err)
}
want := "https://api.minimax.chat/v1/image_generation"
if got != want {
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
}
}
func TestConvertImageRequest(t *testing.T) {
t.Parallel()
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
OriginModelName: "image-01",
}
request := dto.ImageRequest{
Model: "image-01",
Prompt: "a red fox in snowfall",
Size: "1536x1024",
ResponseFormat: "url",
N: uintPtr(2),
}
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
if err != nil {
t.Fatalf("ConvertImageRequest returned error: %v", err)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload["model"] != "image-01" {
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
}
if payload["prompt"] != request.Prompt {
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
}
if payload["n"] != float64(2) {
t.Fatalf("n = %#v, want 2", payload["n"])
}
if payload["aspect_ratio"] != "3:2" {
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
}
if payload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
}
}
func TestDoResponseForImageGeneration(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
StartTime: time.Unix(1700000000, 0),
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: httptest.NewRecorder().Result().Body,
}
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
adaptor := &Adaptor{}
usage, err := adaptor.DoResponse(c, resp, info)
if err != nil {
t.Fatalf("DoResponse returned error: %v", err)
}
if usage == nil {
t.Fatalf("DoResponse returned nil usage")
}
body := recorder.Body.String()
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
}
if strings.Contains(body, `"image_urls"`) {
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
}
}
type nopReadCloser struct {
*strings.Reader
}
func (n nopReadCloser) Close() error {
return nil
}
func ioNopCloser(body string) nopReadCloser {
return nopReadCloser{Reader: strings.NewReader(body)}
}
func uintPtr(v uint) *uint {
return &v
}
+4
View File
@@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat",
"abab5.5-chat",
"abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview",
"speech-2.5-turbo-preview",
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
}
var ChannelName = "minimax"
+213
View File
@@ -0,0 +1,213 @@
package minimax
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type MiniMaxImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
}
type MiniMaxImageResponse struct {
ID string `json:"id"`
Data struct {
ImageURLs []string `json:"image_urls"`
ImageBase64 []string `json:"image_base64"`
} `json:"data"`
Metadata map[string]any `json:"metadata"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
minimaxRequest := MiniMaxImageRequest{
Model: request.Model,
Prompt: request.Prompt,
ResponseFormat: responseFormat,
N: 1,
AigcWatermark: request.Watermark,
}
if request.Model == "" {
minimaxRequest.Model = "image-01"
}
if request.N != nil && *request.N > 0 {
minimaxRequest.N = int(*request.N)
}
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
minimaxRequest.AspectRatio = aspectRatio
}
if raw, ok := request.Extra["prompt_optimizer"]; ok {
var promptOptimizer bool
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
minimaxRequest.PromptOptimizer = &promptOptimizer
}
}
return minimaxRequest
}
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
if raw, ok := request.Extra["aspect_ratio"]; ok {
var aspectRatio string
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
return aspectRatio
}
}
switch request.Size {
case "1024x1024":
return "1:1"
case "1792x1024":
return "16:9"
case "1024x1792":
return "9:16"
case "1536x1024", "1248x832":
return "3:2"
case "1024x1536", "832x1248":
return "2:3"
case "1152x864":
return "4:3"
case "864x1152":
return "3:4"
case "1344x576":
return "21:9"
}
width, height, ok := parseImageSize(request.Size)
if !ok {
return ""
}
ratio := reduceAspectRatio(width, height)
switch ratio {
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
return ratio
default:
return ""
}
}
func parseImageSize(size string) (int, int, bool) {
parts := strings.Split(size, "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func reduceAspectRatio(width, height int) string {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func normalizeMiniMaxResponseFormat(responseFormat string) string {
switch strings.ToLower(responseFormat) {
case "", "url":
return "url"
case "b64_json", "base64":
return "base64"
default:
return responseFormat
}
}
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
imageResponse := &dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, imageURL := range response.Data.ImageURLs {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
}
for _, imageBase64 := range response.Data.ImageBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
}
if len(response.Metadata) > 0 {
metadata, err := common.Marshal(response.Metadata)
if err != nil {
return nil, err
}
imageResponse.Metadata = metadata
}
return imageResponse, nil
}
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
var minimaxResponse MiniMaxImageResponse
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if minimaxResponse.BaseResp.StatusCode != 0 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: minimaxResponse.BaseResp.StatusMsg,
Type: "minimax_image_error",
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
}, resp.StatusCode)
}
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
jsonResponse, err := common.Marshal(openAIResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
if _, err := c.Writer.Write(jsonResponse); err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}
+2
View File
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default:
+16
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -690,6 +691,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq
aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias
}{
Alias: (*Alias)(t),
@@ -699,6 +701,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err
}
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
t.Duration = durationInt
} else {
var durationStr string
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
if v, err := strconv.Atoi(durationStr); err == nil {
t.Duration = v
}
}
}
}
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
+3 -1
View File
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
+1
View File
@@ -293,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
+17 -4
View File
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++
continue
}
if rule.IncludeUsingGroup {
if rule.IncludeModelName {
if len(parts) < 3 {
unknown++
continue
}
}
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++
}
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
+1 -1
View File
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"`
}
+5 -4
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "react-template",
@@ -10,7 +11,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.12.0",
"axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -776,7 +777,7 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
@@ -1104,13 +1105,13 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
@@ -0,0 +1,52 @@
import React from 'react';
import { Empty, Button } from '@douyinfe/semi-ui';
import {
IllustrationFailure,
IllustrationFailureDark,
} from '@douyinfe/semi-illustrations';
import { withTranslation } from 'react-i18next';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<div className='flex flex-col justify-center items-center h-screen p-8'>
<Empty
image={
<IllustrationFailure style={{ width: 250, height: 250 }} />
}
darkModeImage={
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
}
description={t('页面渲染出错,请刷新页面重试')}
/>
<Button
theme='solid'
type='primary'
style={{ marginTop: 16 }}
onClick={() => window.location.reload()}
>
{t('刷新页面')}
</Button>
</div>
);
}
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);
+13 -6
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } from 'lucide-react';
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
import {
IllustrationConstruction,
IllustrationConstructionDark,
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
</Tag>
</div>
</div>
<div
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
<div className='flex items-center gap-1 mb-1'>
<span
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</span>
<Copy
size={14}
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
onClick={() => handleCopyUrl(api.url)}
/>
</div>
<div className='text-gray-500'>{api.description}</div>
</div>
+16 -1
View File
@@ -29,6 +29,9 @@ const ChartsPanel = ({
spec_model_line,
spec_pie,
spec_rank_bar,
spec_user_rank,
spec_user_trend,
isAdminUser,
CARD_PROPS,
CHART_CONFIG,
FLEX_CENTER_GAP2,
@@ -51,9 +54,15 @@ const ChartsPanel = ({
onChange={setActiveChartTab}
>
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
</Tabs>
</div>
}
@@ -72,6 +81,12 @@ const ChartsPanel = ({
{activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
{activeChartTab === '5' && isAdminUser && (
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
)}
{activeChartTab === '6' && isAdminUser && (
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
)}
</div>
</Card>
);
+15
View File
@@ -86,12 +86,22 @@ const Dashboard = () => {
);
// ========== 数据处理 ==========
const loadUserData = async () => {
if (dashboardData.isAdminUser) {
const userData = await dashboardData.loadUserQuotaData();
if (userData && userData.length > 0) {
dashboardCharts.updateUserChartData(userData);
}
}
};
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await loadUserData();
await dashboardData.loadUptimeData();
};
@@ -100,10 +110,12 @@ const Dashboard = () => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
await loadUserData();
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
await loadUserData();
};
// ========== 数据准备 ==========
@@ -182,6 +194,9 @@ const Dashboard = () => {
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
spec_user_rank={dashboardCharts.spec_user_rank}
spec_user_trend={dashboardCharts.spec_user_trend}
isAdminUser={dashboardData.isAdminUser}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+4 -1
View File
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
import App from '../../App';
import FooterBar from './Footer';
import { ToastContainer } from 'react-toastify';
import ErrorBoundary from '../common/ErrorBoundary';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
@@ -216,7 +217,9 @@ const PageLayout = () => {
position: 'relative',
}}
>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</Content>
{!shouldHideFooter && (
<Layout.Footer
@@ -91,6 +91,7 @@ const SystemSetting = () => {
EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
SMTPForceAuthLogin: '',
EmailDomainWhitelist: [],
TelegramOAuthEnabled: '',
TelegramBotToken: '',
@@ -182,6 +183,7 @@ const SystemSetting = () => {
case 'EmailDomainRestrictionEnabled':
case 'EmailAliasRestrictionEnabled':
case 'SMTPSSLEnabled':
case 'SMTPForceAuthLogin':
case 'LinuxDOOAuthEnabled':
case 'discord.enabled':
case 'oidc.enabled':
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
>
{t('启用SMTP SSL')}
</Form.Checkbox>
<Form.Checkbox
field='SMTPForceAuthLogin'
noLabel
onChange={(e) =>
handleCheckboxChange('SMTPForceAuthLogin', e)
}
>
{t('强制使用 AUTH LOGIN')}
</Form.Checkbox>
</Col>
</Row>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
+55
View File
@@ -387,3 +387,58 @@ export const generateChartTimePoints = (
return chartTimePoints;
};
// ========== 用户维度数据处理 ==========
export const processUserData = (data, dataExportDefaultTime, limit = 10) => {
const userQuotaTotal = new Map();
data.forEach((item) => {
const prev = userQuotaTotal.get(item.username) || 0;
userQuotaTotal.set(item.username, prev + item.quota);
});
const sorted = Array.from(userQuotaTotal.entries()).sort(
(a, b) => b[1] - a[1],
);
const topUsers = sorted.slice(0, limit).map(([u]) => u);
const topUserSet = new Set(topUsers);
const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({
User: username,
Quota: quota,
}));
const showYear = isDataCrossYear(data.map((item) => item.created_at));
const timeUserMap = new Map();
const allTimePoints = new Set();
data.forEach((item) => {
const timeKey = timestamp2string1(
item.created_at,
dataExportDefaultTime,
showYear,
);
allTimePoints.add(timeKey);
const user = topUserSet.has(item.username) ? item.username : null;
if (!user) return;
const key = `${timeKey}-${user}`;
const prev = timeUserMap.get(key) || { quota: 0 };
timeUserMap.set(key, { quota: prev.quota + item.quota });
});
const sortedTimePoints = Array.from(allTimePoints).sort();
const trendData = [];
sortedTimePoints.forEach((time) => {
topUsers.forEach((user) => {
const key = `${time}-${user}`;
const val = timeUserMap.get(key);
trendData.push({
Time: time,
User: user,
Quota: val?.quota || 0,
});
});
});
return { rankingData, trendData, topUsers };
};
+131 -6
View File
@@ -34,8 +34,14 @@ import {
updateChartSpec,
updateMapValue,
initializeMaps,
processUserData,
} from '../../helpers/dashboard';
const USER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
export const useDashboardCharts = (
dataExportDefaultTime,
setTrendData,
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
},
});
// 模型消耗趋势折线图
const [spec_model_line, setSpecModelLine] = useState({
type: 'line',
data: [
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
},
title: {
visible: true,
text: t('模型消耗趋势'),
text: t('调用趋势'),
subtext: '',
},
tooltip: {
@@ -215,7 +220,6 @@ export const useDashboardCharts = (
},
});
// 模型调用次数排行柱状图
const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar',
data: [
@@ -259,6 +263,82 @@ export const useDashboardCharts = (
},
});
// ========== Admin: 用户消耗排行 ==========
const [spec_user_rank, setSpecUserRank] = useState({
type: 'bar',
data: [{ id: 'userRankData', values: [] }],
xField: 'rawQuota',
yField: 'User',
seriesField: 'User',
direction: 'horizontal',
legends: { visible: false },
title: {
visible: true,
text: t('用户消耗排行'),
subtext: '',
},
bar: {
state: { hover: { stroke: '#000', lineWidth: 1 } },
},
label: {
visible: true,
position: 'outside',
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
},
axes: [{
orient: 'left',
type: 'band',
label: { visible: true },
}, {
orient: 'bottom',
type: 'linear',
visible: false,
}],
tooltip: {
mark: {
content: [{
key: (datum) => datum['User'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
}],
},
},
color: { type: 'ordinal', range: USER_COLORS },
});
// ========== Admin: 用户消耗趋势 ==========
const [spec_user_trend, setSpecUserTrend] = useState({
type: 'area',
data: [{ id: 'userTrendData', values: [] }],
xField: 'Time',
yField: 'rawQuota',
seriesField: 'User',
stack: false,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: t('用户消耗趋势'),
subtext: '',
},
axes: [{
orient: 'left',
label: {
formatMethod: (value) => renderQuota(value, 2),
},
}],
area: { style: { fillOpacity: 0.15 } },
line: { style: { lineWidth: 2 } },
point: { visible: false },
tooltip: {
mark: {
content: [{
key: (datum) => datum['User'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
}],
},
},
color: { type: 'ordinal', range: USER_COLORS },
});
// ========== 数据处理函数 ==========
const generateModelColors = useCallback((uniqueModels, modelColors) => {
const newModelColors = {};
@@ -426,6 +506,51 @@ export const useDashboardCharts = (
],
);
// ========== 用户维度图表数据处理 ==========
const updateUserChartData = useCallback(
(data) => {
const { rankingData, trendData: userTrend } = processUserData(
data,
dataExportDefaultTime,
10,
);
const userRankValues = rankingData.map((item) => ({
User: item.User,
rawQuota: item.Quota,
Quota: getQuotaWithUnit(item.Quota, 4),
})).sort((a, b) => b.rawQuota - a.rawQuota);
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
setSpecUserRank((prev) => ({
...prev,
data: [{ id: 'userRankData', values: userRankValues }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalUserQuota, 2)}`,
},
}));
const userTrendValues = userTrend.map((item) => ({
Time: item.Time,
User: item.User,
rawQuota: item.Quota,
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
}));
setSpecUserTrend((prev) => ({
...prev,
data: [{ id: 'userTrendData', values: userTrendValues }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalUserQuota, 2)}`,
},
}));
},
[dataExportDefaultTime, t],
);
// ========== 初始化图表主题 ==========
useEffect(() => {
initVChartSemiTheme({
@@ -434,14 +559,14 @@ export const useDashboardCharts = (
}, []);
return {
// 图表规格
spec_pie,
spec_line,
spec_model_line,
spec_rank_bar,
// 函数
spec_user_rank,
spec_user_trend,
updateChartData,
updateUserChartData,
generateModelColors,
};
};
+22
View File
@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
}
}, [activeUptimeTab]);
const loadUserQuotaData = useCallback(async () => {
if (!isAdminUser) return [];
try {
const { start_timestamp, end_timestamp } = inputs;
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
return data || [];
} else {
showError(message);
return [];
}
} catch (err) {
console.error(err);
return [];
}
}, [inputs, isAdminUser]);
const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
showSearchModal,
handleCloseModal,
loadQuotaData,
loadUserQuotaData,
loadUptimeData,
getUserData,
refresh,
+12
View File
@@ -443,6 +443,7 @@
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "After invalidation, the subscription becomes invalid immediately. History is not affected. Continue?",
"作用域": "Scope",
"作用域:包含分组": "Scope: Include Group",
"作用域:包含模型名称": "Scope: Include Model Name",
"作用域:包含规则名称": "Scope: Include Rule Name",
"你似乎并没有修改什么": "You seem to have not modified anything",
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "You can manually add them under “Custom model names”, click Fill and submit, or use the actions below to handle them automatically.",
@@ -772,6 +773,7 @@
"刷新统计": "Refresh Stats",
"刷新缓存统计": "Refresh Cache Statistics",
"刷新缓存统计失败": "Failed to refresh cache statistics",
"刷新页面": "Reload Page",
"前往 io.net API Keys": "Go to io.net API Keys",
"前往设置": "Go to Settings",
"前往设置页面": "Go to Settings Page",
@@ -923,6 +925,7 @@
"启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation",
"启用Ping间隔": "Enable Ping interval",
"启用SMTP SSL": "Enable SMTP SSL",
"强制使用 AUTH LOGIN": "Force AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "Enable SSRF Protection (Recommended for server security)",
"启用供应商": "Enable Provider",
"启用全部": "Enable all",
@@ -1360,6 +1363,7 @@
"开启后,将定期发送ping数据保持连接活跃": "After enabling, ping data will be sent periodically to keep the connection active",
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "When enabled, all requests will be directly forwarded to the upstream without any processing (redirects and channel adaptation will also be disabled). Please enable with caution.",
"开启后,模型名称会参与 cache key(不同模型隔离)。": "When enabled, the model name is included in the cache key (isolates different models).",
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "When enabled, if this rule matches and the request fails, no channel switch retry will occur.",
"开启后,规则名称会参与 cache key(不同规则隔离)。": "When enabled, the rule name will be part of the cache key (isolated by rule).",
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "When enabled, requests to Claude through this channel will force append ?beta=true (no need for clients to pass this parameter manually)",
@@ -1898,6 +1902,7 @@
"条件规则": "Condition Rules",
"条件项设置": "Condition Item Settings",
"条日志已清理!": "logs have been cleared!",
"条规则": "rules",
"条,共": "of",
"来源": "Source",
"来源于 IO.NET 部署": "From IO.NET Deployment",
@@ -1985,6 +1990,7 @@
"模型定价,需要登录访问": "Model pricing, requires login to access",
"模型广场": "Model Marketplace",
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
"模型排行": "Model ranking",
"模型支持的接口端点信息": "Model supported API endpoint information",
"模型数据分析": "Model Data Analysis",
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
@@ -2143,6 +2149,7 @@
"添加公告": "Add Notice",
"添加分类": "Add Category",
"添加分组": "Add Group",
"添加分组规则": "Add Group Rules",
"添加后提交": "Submit after adding",
"添加启动参数": "Add Startup Args",
"添加启动命令": "Add Startup Command",
@@ -2277,6 +2284,8 @@
"用户每周期最多请求完成次数": "User max successful request times per period",
"用户每周期最多请求次数": "User max request times per period",
"用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
"用户消耗排行": "User consumption ranking",
"用户消耗趋势": "User consumption trend",
"用户的基本账户信息": "User basic account information",
"用户管理": "User Management",
"用户组": "User group",
@@ -2367,6 +2376,7 @@
"确认冲突项修改": "Confirm conflict item modification",
"确认删除": "Confirm deletion",
"确认删除模型": "Confirm Delete Model",
"确认删除该分组的所有规则?": "Delete all rules for this group?",
"确认删除该分组?": "Confirm delete this group?",
"确认删除该规则?": "Confirm delete this rule?",
"确认取消密码登录": "Confirm cancel password login",
@@ -3064,6 +3074,7 @@
"调用次数": "Call Count",
"调用次数分布": "Models call distribution",
"调用次数排行": "Models call ranking",
"调用趋势": "Call trend",
"调试信息": "Debug information",
"谨慎": "Cautious",
"豆包": "Doubao",
@@ -3411,6 +3422,7 @@
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Audio output: {{tokens}} / 1M * model ratio {{modelRatio}} * audio ratio {{audioRatio}} * audio completion ratio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"页脚": "Footer",
"页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
"页面渲染出错,请刷新页面重试": "An error occurred while rendering the page. Please refresh and try again.",
"顶栏管理": "Header Management",
"项": "items",
"项目": "Project",
+12
View File
@@ -438,6 +438,7 @@
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "Après invalidation, l'abonnement devient immédiatement invalide. L'historique n'est pas affecté. Continuer ?",
"作用域": "Portée",
"作用域:包含分组": "Portée : inclure le groupe",
"作用域:包含模型名称": "Portée : inclure le nom du modèle",
"作用域:包含规则名称": "Portée : inclure le nom de la règle",
"你似乎并没有修改什么": "Vous ne semblez rien avoir modifié",
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Vous pouvez les ajouter manuellement dans « Noms de modèles personnalisés », cliquer sur Remplir puis soumettre, ou utiliser directement les actions ci-dessous pour les traiter automatiquement.",
@@ -768,6 +769,7 @@
"刷新统计": "Actualiser les statistiques",
"刷新缓存统计": "Actualiser les statistiques du cache",
"刷新缓存统计失败": "Échec de l'actualisation des statistiques du cache",
"刷新页面": "Recharger la page",
"前往 io.net API Keys": "Go to io.net API Keys",
"前往设置": "Go to Settings",
"前往设置页面": "Go to Settings Page",
@@ -918,6 +920,7 @@
"启用Gemini思考后缀适配": "Activer l'adaptation du suffixe de la pensée Gemini",
"启用Ping间隔": "Activer l'intervalle de ping",
"启用SMTP SSL": "Activer SMTP SSL",
"强制使用 AUTH LOGIN": "Forcer AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "Activer la protection SSRF (recommandé pour la sécurité du serveur)",
"启用供应商": "Activer le fournisseur",
"启用全部": "Activer tout",
@@ -1359,6 +1362,7 @@
"开启后,将定期发送ping数据保持连接活跃": "Après activation, des données ping seront envoyées périodiquement pour maintenir la connexion active",
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Après activation, toutes les requêtes seront directement transmises en amont sans aucun traitement (la redirection et l'adaptation de canal seront également désactivées), veuillez activer avec prudence",
"开启后,模型名称会参与 cache key(不同模型隔离)。": "Lorsque activé, le nom du modèle est inclus dans la clé de cache (isole les différents modèles).",
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "Une fois activé, si cette règle est déclenchée et que la requête échoue, aucune nouvelle tentative sur un autre canal ne sera effectuée.",
"开启后,规则名称会参与 cache key(不同规则隔离)。": "Une fois activé, le nom de la règle fera partie de la clé de cache (isolation par règle).",
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "Une fois activé, les requêtes à Claude via ce canal ajouteront automatiquement ?beta=true (pas besoin de le passer manuellement côté client)",
@@ -1882,6 +1886,7 @@
"条件规则": "Règles de condition",
"条件项设置": "Paramètres des éléments de condition",
"条日志已清理!": "les journaux ont été effacés !",
"条规则": "rules",
"条,共": "sur",
"来源": "Source",
"来源于 IO.NET 部署": "From IO.NET Deployment",
@@ -1967,6 +1972,7 @@
"模型定价,需要登录访问": "Tarification du modèle, nécessite une connexion pour y accéder",
"模型广场": "Marché des modèles",
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
"模型排行": "Classement des modèles",
"模型支持的接口端点信息": "Informations sur les points de terminaison de l'API pris en charge par le modèle",
"模型数据分析": "Analyse des données du modèle",
"模型映射必须是合法的 JSON 格式!": "Le mappage de modèles doit être au format JSON valide !",
@@ -2122,6 +2128,7 @@
"添加公告": "Ajouter un avis",
"添加分类": "Ajouter une catégorie",
"添加分组": "Add Group",
"添加分组规则": "Add Group Rules",
"添加后提交": "Soumettre après ajout",
"添加启动参数": "Add Startup Args",
"添加启动命令": "Add Startup Command",
@@ -2252,6 +2259,8 @@
"用户每周期最多请求完成次数": "Nombre maximal de requêtes utilisateur réussies par période",
"用户每周期最多请求次数": "Nombre maximal de requêtes utilisateur par période",
"用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'",
"用户消耗排行": "Classement de consommation des utilisateurs",
"用户消耗趋势": "Tendance de consommation des utilisateurs",
"用户的基本账户信息": "Informations de base du compte utilisateur",
"用户管理": "Utilisateurs",
"用户组": "Groupe d'utilisateurs",
@@ -2343,6 +2352,7 @@
"确认冲突项修改": "Confirmer la modification de l'élément de conflit",
"确认删除": "Confirmer la suppression",
"确认删除模型": "Confirm Delete Model",
"确认删除该分组的所有规则?": "Delete all rules for this group?",
"确认删除该分组?": "Confirm delete this group?",
"确认删除该规则?": "Confirm delete this rule?",
"确认取消密码登录": "Confirmer l'annulation de la connexion par mot de passe",
@@ -3037,6 +3047,7 @@
"调用次数": "Nombre d'appels",
"调用次数分布": "Distribution des appels de modèles",
"调用次数排行": "Classement des appels de modèles",
"调用趋势": "Tendance des appels",
"调试信息": "Informations de débogage",
"谨慎": "Prudent",
"豆包": "Doubao",
@@ -3376,6 +3387,7 @@
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Sortie audio : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio audio {{audioRatio}} * ratio de complétion audio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"页脚": "Pied de page",
"页面未找到,请检查您的浏览器地址是否正确": "Page non trouvée, veuillez vérifier si l'adresse de votre navigateur est correcte",
"页面渲染出错,请刷新页面重试": "Une erreur est survenue lors du rendu de la page. Veuillez rafraîchir et réessayer.",
"顶栏管理": "En-tête",
"项": "éléments",
"项目": "Élément",
+13 -1
View File
@@ -434,6 +434,7 @@
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "無効化するとこのサブスクリプションは直ちに失効します。履歴には影響しません。続行しますか?",
"作用域": "スコープ",
"作用域:包含分组": "スコープ:グループを含む",
"作用域:包含模型名称": "スコープ:モデル名を含む",
"作用域:包含规则名称": "スコープ:ルール名を含む",
"你似乎并没有修改什么": "何も変更されていないようです",
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "You can manually add them under “Custom model names”, click Fill and submit, or use the actions below to handle them automatically.",
@@ -516,7 +517,7 @@
"保存 Turnstile 设置": "Turnstile 設定を保存",
"保存 WeChat Server 设置": "WeChatサーバー設定を保存",
"保存分组倍率设置": "グループ倍率設定を保存",
"保存分组相关设置": "グループ設定を保存",
"保存分组相关设置": "グループ関連設定を保存",
"保存备用码": "バックアップコード",
"保存备用码以备不时之需": "万一に備え保存",
"保存失败": "保存に失敗しました",
@@ -759,6 +760,7 @@
"刷新统计": "統計を更新",
"刷新缓存统计": "キャッシュ統計を更新",
"刷新缓存统计失败": "キャッシュ統計の更新に失敗しました",
"刷新页面": "ページを更新",
"前往 io.net API Keys": "Go to io.net API Keys",
"前往设置": "Go to Settings",
"前往设置页面": "Go to Settings Page",
@@ -909,6 +911,7 @@
"启用Gemini思考后缀适配": "Gemini思考サフィックスモードを有効にする",
"启用Ping间隔": "Ping間隔を有効にする",
"启用SMTP SSL": "SMTP SSLを有効にする",
"强制使用 AUTH LOGIN": "AUTH LOGINを強制する",
"启用SSRF防护(推荐开启以保护服务器安全)": "SSRF保護を有効にする(サーバーを保護するため、有効化を推奨します)",
"启用供应商": "プロバイダーを有効化",
"启用全部": "すべてを有効にする",
@@ -1342,6 +1345,7 @@
"开启后,将定期发送ping数据保持连接活跃": "有効にすると、接続をアクティブに保つためにpingデータが定期的に送信されます",
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "有効にすると、すべてのリクエストは直接アップストリームにパススルーされ、いかなる処理も行われません(リダイレクトとチャネルの自動調整も無効になります)。有効にする際はご注意ください",
"开启后,模型名称会参与 cache key(不同模型隔离)。": "有効にすると、モデル名がキャッシュキーに含まれます(異なるモデルを分離)。",
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "有効にすると、このルールがヒットしてリクエストが失敗した場合、チャネル切り替えリトライは行われません。",
"开启后,规则名称会参与 cache key(不同规则隔离)。": "有効にすると、ルール名がキャッシュキーに含まれます(ルールごとに隔離)。",
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "有効にすると、このチャネルでClaudeにリクエストする際に?beta=trueが強制追加されます(クライアント側で手動パラメータ渡し不要)",
@@ -1865,6 +1869,7 @@
"条件规则": "条件ルール",
"条件项设置": "条件項目設定",
"条日志已清理!": "件のログがクリアされました",
"条规则": "件のルール",
"条,共": "件、合計",
"来源": "ソース",
"来源于 IO.NET 部署": "From IO.NET Deployment",
@@ -1950,6 +1955,7 @@
"模型定价,需要登录访问": "モデル料金(アクセスにはログインが必要です)",
"模型广场": "モデルマーケットプレイス",
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
"模型排行": "モデルランキング",
"模型支持的接口端点信息": "モデルが対応するAPIエンドポイント情報",
"模型数据分析": "モデルデータ分析",
"模型映射必须是合法的 JSON 格式!": "モデルマッピングは、有効なJSON形式である必要があります",
@@ -2105,6 +2111,7 @@
"添加公告": "お知らせ追加",
"添加分类": "分類追加",
"添加分组": "グループを追加",
"添加分组规则": "グループルールを追加",
"添加后提交": "Submit after adding",
"添加启动参数": "Add Startup Args",
"添加启动命令": "Add Startup Command",
@@ -2235,6 +2242,8 @@
"用户每周期最多请求完成次数": "期間ごとのユーザー最大成功リクエスト数",
"用户每周期最多请求次数": "期間ごとのユーザー最大リクエスト数",
"用户注册时看到的网站名称,比如'我的网站'": "ユーザーがサインアップ時に表示されるウェブサイト名です。例:「マイサイト」",
"用户消耗排行": "ユーザー消費ランキング",
"用户消耗趋势": "ユーザー消費推移",
"用户的基本账户信息": "ユーザーの基本アカウント情報",
"用户管理": "ユーザー管理",
"用户组": "ユーザーグループ",
@@ -2324,6 +2333,7 @@
"确认冲突项修改": "競合項目の変更の確認",
"确认删除": "削除の確認",
"确认删除模型": "Confirm Delete Model",
"确认删除该分组的所有规则?": "このグループの全ルールを削除しますか?",
"确认删除该分组?": "このグループを削除しますか?",
"确认删除该规则?": "このルールを削除しますか?",
"确认取消密码登录": "パスワードログイン無効化の確認",
@@ -3018,6 +3028,7 @@
"调用次数": "呼び出し回数",
"调用次数分布": "呼び出し回数分布",
"调用次数排行": "呼び出し回数ランキング",
"调用趋势": "呼び出し推移",
"调试信息": "デバッグ情報",
"谨慎": "注意",
"豆包": "豆包",
@@ -3357,6 +3368,7 @@
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音声出力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 音声倍率 {{audioRatio}} * 音声補完倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"页脚": "フッター",
"页面未找到,请检查您的浏览器地址是否正确": "ページが見つかりませんでした。ブラウザのアドレスが正しいかご確認ください",
"页面渲染出错,请刷新页面重试": "ページのレンダリング中にエラーが発生しました。ページを更新して再試行してください。",
"顶栏管理": "トップバー管理",
"项": "件",
"项目": "プロジェクト",
+12
View File
@@ -441,6 +441,7 @@
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?",
"作用域": "Область действия",
"作用域:包含分组": "Область действия: включить группу",
"作用域:包含模型名称": "Область действия: включить имя модели",
"作用域:包含规则名称": "Область действия: включить имя правила",
"你似乎并没有修改什么": "Похоже, вы ничего не изменили",
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Вы можете добавить их вручную в разделе «Пользовательские названия моделей», нажать «Заполнить», затем отправить или воспользоваться действиями ниже для автоматической обработки.",
@@ -774,6 +775,7 @@
"刷新统计": "Обновить статистику",
"刷新缓存统计": "Обновить статистику кэша",
"刷新缓存统计失败": "Не удалось обновить статистику кэша",
"刷新页面": "Обновить страницу",
"前往 io.net API Keys": "Go to io.net API Keys",
"前往设置": "Go to Settings",
"前往设置页面": "Go to Settings Page",
@@ -924,6 +926,7 @@
"启用Gemini思考后缀适配": "Включить адаптацию суффикса мышления Gemini",
"启用Ping间隔": "Включить интервал Ping",
"启用SMTP SSL": "Включить SMTP SSL",
"强制使用 AUTH LOGIN": "Принудительно AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "Включить защиту SSRF (рекомендуется включить для защиты безопасности сервера)",
"启用供应商": "Включить поставщика",
"启用全部": "Включить все",
@@ -1371,6 +1374,7 @@
"开启后,将定期发送ping数据保持连接活跃": "После включения будет периодически отправляться ping-данные для поддержания активности соединения",
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "После включения все запросы будут напрямую передаваться upstream без какой-либо обработки (перенаправление и адаптация каналов также будут отключены), включайте с осторожностью",
"开启后,模型名称会参与 cache key(不同模型隔离)。": "При включении имя модели включается в ключ кэша (изолирует разные модели).",
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "При включении, если правило сработало и запрос не удался, переключение канала для повтора не выполняется.",
"开启后,规则名称会参与 cache key(不同规则隔离)。": "При включении имя правила будет частью ключа кэша (изоляция по правилам).",
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "При включении запросы к Claude через этот канал будут принудительно дополнены ?beta=true (клиенту не нужно передавать этот параметр вручную)",
@@ -1894,6 +1898,7 @@
"条件规则": "Правила условий",
"条件项设置": "Настройки элементов условий",
"条日志已清理!": "записей журнала очищено!",
"条规则": "rules",
"条,共": "записей, всего",
"来源": "Источник",
"来源于 IO.NET 部署": "From IO.NET Deployment",
@@ -1979,6 +1984,7 @@
"模型定价,需要登录访问": "Ценообразование моделей, требуется вход для доступа",
"模型广场": "Площадка моделей",
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
"模型排行": "Рейтинг моделей",
"模型支持的接口端点信息": "Информация о конечных точках интерфейса, поддерживаемых моделью",
"模型数据分析": "Анализ данных моделей",
"模型映射必须是合法的 JSON 格式!": "Сопоставление моделей должно быть в допустимом формате JSON!",
@@ -2134,6 +2140,7 @@
"添加公告": "Добавить объявление",
"添加分类": "Добавить категорию",
"添加分组": "Add Group",
"添加分组规则": "Add Group Rules",
"添加后提交": "Отправить после добавления",
"添加启动参数": "Add Startup Args",
"添加启动命令": "Add Startup Command",
@@ -2264,6 +2271,8 @@
"用户每周期最多请求完成次数": "Максимальное количество выполненных запросов пользователя за период",
"用户每周期最多请求次数": "Максимальное количество запросов пользователя за период",
"用户注册时看到的网站名称,比如'我的网站'": "Название сайта, которое видят пользователи при регистрации, например 'Мой сайт'",
"用户消耗排行": "Рейтинг потребления пользователей",
"用户消耗趋势": "Тенденция потребления пользователей",
"用户的基本账户信息": "Основная информация об аккаунте пользователя",
"用户管理": "Управление пользователями",
"用户组": "Группа пользователей",
@@ -2357,6 +2366,7 @@
"确认冲突项修改": "Подтвердить изменение конфликтующих элементов",
"确认删除": "Подтвердить удаление",
"确认删除模型": "Confirm Delete Model",
"确认删除该分组的所有规则?": "Delete all rules for this group?",
"确认删除该分组?": "Confirm delete this group?",
"确认删除该规则?": "Confirm delete this rule?",
"确认取消密码登录": "Подтвердить отмену входа по паролю",
@@ -3051,6 +3061,7 @@
"调用次数": "Количество вызовов",
"调用次数分布": "Распределение количества вызовов",
"调用次数排行": "Рейтинг количества вызовов",
"调用趋势": "Тенденция вызовов",
"调试信息": "Отладочная информация",
"谨慎": "Осторожно",
"豆包": "Doubao",
@@ -3390,6 +3401,7 @@
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Аудиовывод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * аудио-коэффициент {{audioRatio}} * коэффициент аудиозавершения {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"页脚": "Подвал",
"页面未找到,请检查您的浏览器地址是否正确": "Страница не найдена, пожалуйста, проверьте правильность адреса в браузере",
"页面渲染出错,请刷新页面重试": "Произошла ошибка при отрисовке страницы. Пожалуйста, обновите страницу и попробуйте снова.",
"顶栏管理": "Управление верхней панелью",
"项": "элементов",
"项目": "Проект",
+12
View File
@@ -435,6 +435,7 @@
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "Sau khi vô hiệu, đăng ký sẽ mất hiệu lực ngay. Lịch sử không bị ảnh hưởng. Tiếp tục?",
"作用域": "Phạm vi",
"作用域:包含分组": "Phạm vi: Bao gồm nhóm",
"作用域:包含模型名称": "Phạm vi: Bao gồm tên mô hình",
"作用域:包含规则名称": "Phạm vi: Bao gồm tên quy tắc",
"你似乎并没有修改什么": "Bạn dường như không sửa đổi gì cả",
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "You can manually add them under “Custom model names”, click Fill and submit, or use the actions below to handle them automatically.",
@@ -760,6 +761,7 @@
"刷新统计": "Làm mới thống kê",
"刷新缓存统计": "Làm mới thống kê bộ nhớ đệm",
"刷新缓存统计失败": "Làm mới thống kê bộ nhớ đệm thất bại",
"刷新页面": "Tải lại trang",
"前往 io.net API Keys": "Go to io.net API Keys",
"前往设置": "Go to Settings",
"前往设置页面": "Go to Settings Page",
@@ -910,6 +912,7 @@
"启用Gemini思考后缀适配": "Bật thích ứng hậu tố tư duy Gemini",
"启用Ping间隔": "Bật khoảng thời gian Ping",
"启用SMTP SSL": "Bật SMTP SSL",
"强制使用 AUTH LOGIN": "Buộc AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "Bật bảo vệ SSRF (Khuyên dùng để bảo mật máy chủ)",
"启用供应商": "Bật nhà cung cấp",
"启用全部": "Bật tất cả",
@@ -1343,6 +1346,7 @@
"开启后,将定期发送ping数据保持连接活跃": "Sau khi bật, dữ liệu ping sẽ được gửi định kỳ để giữ kết nối hoạt động",
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Khi bật, tất cả các yêu cầu sẽ được chuyển tiếp trực tiếp đến thượng nguồn mà không cần xử lý (chuyển hướng và thích ứng kênh cũng sẽ bị vô hiệu hóa). Vui lòng bật một cách thận trọng.",
"开启后,模型名称会参与 cache key(不同模型隔离)。": "Khi bật, tên mô hình sẽ được bao gồm trong cache key (cách ly các mô hình khác nhau).",
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "Khi bật, nếu quy tắc này trúng và yêu cầu thất bại, sẽ không chuyển kênh để thử lại.",
"开启后,规则名称会参与 cache key(不同规则隔离)。": "Khi bật, tên quy tắc sẽ tham gia vào cache key (cách ly theo quy tắc).",
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "Khi bật, yêu cầu đến Claude qua kênh này sẽ tự động thêm ?beta=true (client không cần truyền thủ công)",
@@ -1866,6 +1870,7 @@
"条件规则": "Quy tắc điều kiện",
"条件项设置": "Cài đặt mục điều kiện",
"条日志已清理!": "nhật ký đã được xóa!",
"条规则": "rules",
"条,共": "của",
"来源": "Nguồn",
"来源于 IO.NET 部署": "From IO.NET Deployment",
@@ -1960,6 +1965,7 @@
"模型库": "Thư viện mô hình",
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
"模型排序": "Sắp xếp mô hình",
"模型排行": "Xếp hạng mô hình",
"模型支持的接口端点信息": "Thông tin điểm cuối API được mô hình hỗ trợ",
"模型数据分析": "Phân tích dữ liệu mô hình",
"模型映射": "Ánh xạ mô hình",
@@ -2182,6 +2188,7 @@
"添加分类": "Thêm danh mục",
"添加分组": "Thêm nhóm",
"添加分组倍率": "Thêm tỷ lệ nhóm",
"添加分组规则": "Add Group Rules",
"添加后提交": "Submit after adding",
"添加启动参数": "Add Startup Args",
"添加启动命令": "Add Startup Command",
@@ -2411,6 +2418,8 @@
"用户注册": "Đăng ký người dùng",
"用户注册时看到的网站名称,比如'我的网站'": "Tên trang web người dùng nhìn thấy khi đăng ký, ví dụ: 'Trang web của tôi'",
"用户注册设置": "Cài đặt đăng ký người dùng",
"用户消耗排行": "Xếp hạng tiêu thụ người dùng",
"用户消耗趋势": "Xu hướng tiêu thụ người dùng",
"用户登录": "Đăng nhập người dùng",
"用户的基本账户信息": "Thông tin tài khoản cơ bản của người dùng",
"用户管理": "Quản lý người dùng",
@@ -2553,6 +2562,7 @@
"确认冲突项修改": "Xác nhận sửa đổi mục xung đột",
"确认删除": "Xác nhận xóa",
"确认删除模型": "Confirm Delete Model",
"确认删除该分组的所有规则?": "Delete all rules for this group?",
"确认删除该分组?": "Confirm delete this group?",
"确认删除该规则?": "Confirm delete this rule?",
"确认取消密码登录": "Xác nhận hủy đăng nhập mật khẩu",
@@ -3470,6 +3480,7 @@
"调用次数": "Số lần gọi",
"调用次数分布": "Phân phối số lần gọi",
"调用次数排行": "Xếp hạng số lần gọi",
"调用趋势": "Xu hướng cuộc gọi",
"调试信息": "Thông tin gỡ lỗi",
"谨慎": "Thận trọng",
"豆包": "Doubao",
@@ -3925,6 +3936,7 @@
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu ra âm thanh: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số âm thanh {{audioRatio}} * hệ số hoàn thành âm thanh {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"页脚": "Chân trang",
"页面未找到,请检查您的浏览器地址是否正确": "Không tìm thấy trang, vui lòng kiểm tra xem địa chỉ trình duyệt của bạn có chính xác không",
"页面渲染出错,请刷新页面重试": "Đã xảy ra lỗi khi hiển thị trang. Vui lòng tải lại trang và thử lại.",
"顶栏管理": "Quản lý thanh tiêu đề",
"项": "mục",
"项目": "Dự án",
+8 -1
View File
@@ -680,6 +680,7 @@
"启用Gemini思考后缀适配": "启用Gemini思考后缀适配",
"启用Ping间隔": "启用Ping间隔",
"启用SMTP SSL": "启用SMTP SSL",
"强制使用 AUTH LOGIN": "强制使用 AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)",
"启用全部": "启用全部",
"启用后可接入 io.net GPU 资源": "启用后可接入 io.net GPU 资源",
@@ -2314,6 +2315,10 @@
"调用次数": "调用次数",
"调用次数分布": "调用次数分布",
"调用次数排行": "调用次数排行",
"调用趋势": "调用趋势",
"模型排行": "模型排行",
"用户消耗排行": "用户消耗排行",
"用户消耗趋势": "用户消耗趋势",
"调试信息": "调试信息",
"谨慎": "谨慎",
"警告": "警告",
@@ -2980,6 +2985,8 @@
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
"无法读取剪贴板": "无法读取剪贴板",
"页面渲染出错,请刷新页面重试": "页面渲染出错,请刷新页面重试",
"刷新页面": "刷新页面"
}
}
+11 -1
View File
@@ -449,7 +449,7 @@
"保存 Turnstile 设置": "儲存 Turnstile 設定",
"保存 WeChat Server 设置": "儲存 WeChat Server 設定",
"保存分组倍率设置": "儲存分組倍率設定",
"保存分组相关设置": "存分組相關設定",
"保存分组相关设置": "存分組相關設定",
"保存备用码": "儲存備用碼",
"保存备用码以备不时之需": "儲存備用碼以備不時之需",
"保存失败": "儲存失敗",
@@ -670,6 +670,7 @@
"刷新容器信息": "刷新容器資訊",
"刷新日志": "刷新日誌",
"刷新统计": "刷新統計",
"刷新页面": "重新整理頁面",
"前往 io.net API Keys": "前往 io.net API Keys",
"前往设置": "前往設定",
"前往设置页面": "前往設定頁面",
@@ -797,6 +798,7 @@
"启用Gemini思考后缀适配": "啟用Gemini思考後綴相容",
"启用Ping间隔": "啟用Ping間隔",
"启用SMTP SSL": "啟用SMTP SSL",
"强制使用 AUTH LOGIN": "強制使用 AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "啟用SSRF防護(推薦開啟以保護伺服器安全)",
"启用全部": "啟用全部",
"启用后可接入 io.net GPU 资源": "啟用後可接入 io.net GPU 資源",
@@ -1657,6 +1659,7 @@
"条": "條",
"条 - 第": "條 - 第",
"条日志已清理!": "條日誌已清理!",
"条规则": "條規則",
"条,共": "條,共",
"来源": "來源",
"来源于 IO.NET 部署": "來源於 IO.NET 部署",
@@ -1743,6 +1746,7 @@
"模型定价,需要登录访问": "模型定價,需要登錄訪問",
"模型广场": "模型廣場",
"模型拉取失败: {{error}}": "模型拉取失敗: {{error}}",
"模型排行": "模型排行",
"模型支持的接口端点信息": "模型支援的接口端點資訊",
"模型数据分析": "模型數據分析",
"模型映射必须是合法的 JSON 格式!": "模型映射必須是合法的 JSON 格式!",
@@ -1884,6 +1888,7 @@
"添加公告": "添加公告",
"添加分类": "添加分類",
"添加分组": "新增分組",
"添加分组规则": "新增分組規則",
"添加后提交": "添加後提交",
"添加启动参数": "添加啟動參數",
"添加启动命令": "添加啟動命令",
@@ -2006,6 +2011,8 @@
"用户每周期最多请求完成次数": "使用者每週期最多請求完成次數",
"用户每周期最多请求次数": "使用者每週期最多請求次數",
"用户注册时看到的网站名称,比如'我的网站'": "使用者註冊時看到的網站名稱,比如'我的網站'",
"用户消耗排行": "用戶消耗排行",
"用户消耗趋势": "用戶消耗趨勢",
"用户的基本账户信息": "使用者的基本帳號資訊",
"用户管理": "使用者管理",
"用户组": "使用者組",
@@ -2089,6 +2096,7 @@
"确认冲突项修改": "確認衝突項修改",
"确认删除": "確認刪除",
"确认删除模型": "確認刪除模型",
"确认删除该分组的所有规则?": "確認刪除該分組的所有規則?",
"确认删除该分组?": "確認刪除該分組?",
"确认删除该规则?": "確認刪除該規則?",
"确认取消密码登录": "確認取消密碼登錄",
@@ -2719,6 +2727,7 @@
"调用次数": "調用次數",
"调用次数分布": "調用次數分佈",
"调用次数排行": "調用次數排行",
"调用趋势": "調用趨勢",
"调试信息": "除錯訊息",
"谨慎": "謹慎",
"豆包": "豆包",
@@ -3041,6 +3050,7 @@
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音訊輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音訊倍率 {{audioRatio}} * 音訊補全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"页脚": "頁腳",
"页面未找到,请检查您的浏览器地址是否正确": "頁面未找到,請檢查您的瀏覽器位址是否正確",
"页面渲染出错,请刷新页面重试": "頁面渲染出錯,請重新整理頁面重試",
"顶栏管理": "頂欄管理",
"项目": "項目",
"项目内容": "項目內容",
@@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
},
"skip_retry_on_failure": false,
"include_using_group": true,
"include_model_name": false,
"include_rule_name": true
}
]`;
@@ -191,6 +192,36 @@ const parseOptionalObjectJson = (jsonString, label) => {
}
};
const buildChannelAffinityRulePayload = ({
values,
isEdit,
editingRuleId,
rulesLength,
modelRegex,
pathRegex,
keySources,
userAgentInclude,
paramOverrideTemplate,
}) => ({
id: isEdit ? editingRuleId : rulesLength,
name: (values?.name || '').trim(),
model_regex: modelRegex,
path_regex: pathRegex,
key_sources: keySources,
value_regex: (values?.value_regex || '').trim(),
ttl_seconds: Number(values?.ttl_seconds || 0),
include_using_group: !!values?.include_using_group,
include_model_name: !!values?.include_model_name,
include_rule_name: !!values?.include_rule_name,
skip_retry_on_failure: !!values?.skip_retry_on_failure,
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
...(paramOverrideTemplate
? { param_override_template: paramOverrideTemplate }
: {}),
});
export default function SettingsChannelAffinity(props) {
const { t } = useTranslation();
const { Text } = Typography;
@@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: Number(r.ttl_seconds || 0),
skip_retry_on_failure: !!r.skip_retry_on_failure,
include_using_group: r.include_using_group ?? true,
include_model_name: !!r.include_model_name,
include_rule_name: r.include_rule_name ?? true,
param_override_template_json: r.param_override_template
? stringifyPretty(r.param_override_template)
@@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
const templates = [
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
].map(
(tpl) => {
const baseTemplate = cloneChannelAffinityTemplate(tpl);
const name = makeUniqueName(existingNames, tpl.name);
existingNames.add(name);
return { ...baseTemplate, name };
},
);
].map((tpl) => {
const baseTemplate = cloneChannelAffinityTemplate(tpl);
const name = makeUniqueName(existingNames, tpl.name);
existingNames.add(name);
return { ...baseTemplate, name };
});
const next = [...(rules || []), ...templates].map((r, idx) => ({
...(r || {}),
@@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
title: t('作用域'),
render: (_, record) => {
const tags = [];
if (record?.include_using_group) tags.push('分组');
if (record?.include_rule_name) tags.push('规则');
if (record?.include_using_group) tags.push(t('分组'));
if (record?.include_model_name) tags.push(t('模型'));
if (record?.include_rule_name) tags.push(t('规则'));
if (tags.length === 0) return '-';
return tags.map((x) => (
<Tag key={x} style={{ marginRight: 4 }}>
@@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: 0,
skip_retry_on_failure: false,
include_using_group: true,
include_model_name: false,
include_rule_name: true,
};
setEditingRule(nextRule);
@@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
return showError(t(paramTemplateValidation.message));
}
const rulePayload = {
id: isEdit ? editingRule.id : rules.length,
name: (values.name || '').trim(),
model_regex: modelRegex,
path_regex: normalizeStringList(values.path_regex_text),
key_sources: keySourcesValidation.value,
value_regex: (values.value_regex || '').trim(),
ttl_seconds: Number(values.ttl_seconds || 0),
include_using_group: !!values.include_using_group,
include_rule_name: !!values.include_rule_name,
...(values.skip_retry_on_failure
? { skip_retry_on_failure: true }
: {}),
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
...(paramTemplateValidation.value
? { param_override_template: paramTemplateValidation.value }
: {}),
};
const rulePayload = buildChannelAffinityRulePayload({
values,
isEdit,
editingRuleId: editingRule?.id,
rulesLength: rules.length,
modelRegex,
pathRegex: normalizeStringList(values.path_regex_text),
keySources: keySourcesValidation.value,
userAgentInclude,
paramOverrideTemplate: paramTemplateValidation.value,
});
if (!rulePayload.name) return showError(t('名称不能为空'));
@@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Col xs={24} sm={8}>
<Form.Switch
field='include_using_group'
label={t('作用域:包含分组')}
@@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
)}
</Text>
</Col>
<Col xs={24} sm={12}>
<Col xs={24} sm={8}>
<Form.Switch
field='include_model_name'
label={t('作用域:包含模型名称')}
/>
<Text type='tertiary' size='small'>
{t('开启后,模型名称会参与 cache key(不同模型隔离)。')}
</Text>
</Col>
<Col xs={24} sm={8}>
<Form.Switch
field='include_rule_name'
label={t('作用域:包含规则名称')}
@@ -1,14 +1,21 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Collapsible,
Input,
InputNumber,
Select,
Tag,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import {
IconPlus,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
@@ -57,14 +64,106 @@ export function serializeGroupGroupRatio(rules) {
: JSON.stringify(nested, null, 2);
}
function GroupSection({ groupName, items, groupOptions, onUpdate, onRemove, onAdd, t }) {
const [open, setOpen] = useState(false);
return (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className='flex items-center justify-between cursor-pointer'
style={{
padding: '8px 12px',
background: 'var(--semi-color-fill-0)',
}}
onClick={() => setOpen(!open)}
>
<div className='flex items-center gap-2'>
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
<Text strong>{groupName}</Text>
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
</div>
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
<Button
icon={<IconPlus />}
size='small'
theme='borderless'
onClick={() => onAdd(groupName)}
/>
<Popconfirm
title={t('确认删除该分组的所有规则?')}
onConfirm={() => items.forEach((item) => onRemove(item._id))}
position='left'
>
<Button
icon={<IconDelete />}
size='small'
type='danger'
theme='borderless'
/>
</Popconfirm>
</div>
</div>
<Collapsible isOpen={open} keepDOM>
<div style={{ padding: '8px 12px' }}>
{items.map((rule) => (
<div
key={rule._id}
className='flex items-center gap-2'
style={{ marginBottom: 6 }}
>
<Select
size='small'
filter
value={rule.usingGroup || undefined}
placeholder={t('选择使用分组')}
optionList={groupOptions}
onChange={(v) => onUpdate(rule._id, 'usingGroup', v)}
style={{ flex: 1 }}
allowCreate
position='bottomLeft'
/>
<InputNumber
size='small'
min={0}
step={0.1}
value={rule.ratio}
style={{ width: 100 }}
onChange={(v) => onUpdate(rule._id, 'ratio', v ?? 0)}
/>
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => onRemove(rule._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
</div>
))}
</div>
</Collapsible>
</div>
);
}
export default function GroupGroupRatioRules({
value,
groupNames = [],
onChange,
}) {
const { t } = useTranslation();
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
const [newGroupName, setNewGroupName] = useState('');
const emitChange = useCallback(
(newRules) => {
@@ -76,21 +175,11 @@ export default function GroupGroupRatioRules({
const updateRule = useCallback(
(id, field, val) => {
const next = rules.map((r) =>
r._id === id ? { ...r, [field]: val } : r,
);
emitChange(next);
emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
},
[rules, emitChange],
);
const addRule = useCallback(() => {
emitChange([
...rules,
{ _id: uid(), userGroup: '', usingGroup: '', ratio: 1 },
]);
}, [rules, emitChange]);
const removeRule = useCallback(
(id) => {
emitChange(rules.filter((r) => r._id !== id));
@@ -98,107 +187,99 @@ export default function GroupGroupRatioRules({
[rules, emitChange],
);
const addRuleToGroup = useCallback(
(groupName) => {
emitChange([
...rules,
{ _id: uid(), userGroup: groupName, usingGroup: '', ratio: 1 },
]);
},
[rules, emitChange],
);
const addNewGroup = useCallback(() => {
const name = newGroupName.trim();
if (!name) return;
emitChange([
...rules,
{ _id: uid(), userGroup: name, usingGroup: '', ratio: 1 },
]);
setNewGroupName('');
}, [rules, emitChange, newGroupName]);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const columns = useMemo(
() => [
{
title: t('用户分组'),
dataIndex: 'userGroup',
key: 'userGroup',
width: 200,
render: (_, record) => (
const grouped = useMemo(() => {
const map = {};
const order = [];
rules.forEach((r) => {
if (!r.userGroup) return;
if (!map[r.userGroup]) {
map[r.userGroup] = [];
order.push(r.userGroup);
}
map[r.userGroup].push(r);
});
return order.map((name) => ({ name, items: map[name] }));
}, [rules]);
if (grouped.length === 0 && rules.length === 0) {
return (
<div>
<Text type='tertiary' className='block text-center py-4'>
{t('暂无规则,点击下方按钮添加')}
</Text>
<div className='mt-2 flex justify-center gap-2'>
<Select
size='small'
filter
value={record.userGroup || undefined}
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'userGroup', v)}
style={{ width: '100%' }}
allowCreate
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
),
},
{
title: t('使用分组'),
dataIndex: 'usingGroup',
key: 'usingGroup',
width: 200,
render: (_, record) => (
<Select
size='small'
filter
value={record.usingGroup || undefined}
placeholder={t('选择使用分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'usingGroup', v)}
style={{ width: '100%' }}
allowCreate
position='bottomLeft'
/>
),
},
{
title: t('倍率'),
dataIndex: 'ratio',
key: 'ratio',
width: 140,
render: (_, record) => (
<InputNumber
size='small'
min={0}
step={0.1}
value={record.ratio}
style={{ width: '100%' }}
onChange={(v) => updateRule(record._id, 'ratio', v ?? 0)}
/>
),
},
{
title: '',
key: 'actions',
width: 50,
render: (_, record) => (
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => removeRule(record._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
),
},
],
[t, groupOptions, updateRule, removeRule],
);
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
return (
<div>
<CardTable
columns={columns}
dataSource={rules}
rowKey='_id'
hidePagination
size='small'
empty={
<Text type='tertiary'>
{t('暂无规则,点击下方按钮添加')}
</Text>
}
/>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
{t('添加规则')}
<div className='space-y-2'>
{grouped.map((group) => (
<GroupSection
key={group.name}
groupName={group.name}
items={group.items}
groupOptions={groupOptions}
onUpdate={updateRule}
onRemove={removeRule}
onAdd={addRuleToGroup}
t={t}
/>
))}
<div className='mt-3 flex justify-center gap-2'>
<Select
size='small'
filter
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
@@ -1,15 +1,38 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Collapsible,
Input,
Select,
Tag,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import {
IconPlus,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
@@ -21,12 +44,8 @@ const OP_REMOVE = 'remove';
const OP_APPEND = 'append';
function parsePrefix(rawKey) {
if (rawKey.startsWith('+:')) {
return { op: OP_ADD, groupName: rawKey.slice(2) };
}
if (rawKey.startsWith('-:')) {
return { op: OP_REMOVE, groupName: rawKey.slice(2) };
}
if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) };
if (rawKey.startsWith('-:')) return { op: OP_REMOVE, groupName: rawKey.slice(2) };
return { op: OP_APPEND, groupName: rawKey };
}
@@ -38,11 +57,7 @@ function toRawKey(op, groupName) {
function parseJSON(str) {
if (!str || !str.trim()) return {};
try {
return JSON.parse(str);
} catch {
return {};
}
try { return JSON.parse(str); } catch { return {}; }
}
function flattenRules(nested) {
@@ -68,17 +83,14 @@ function nestRules(rules) {
rules.forEach(({ userGroup, op, targetGroup, description }) => {
if (!userGroup || !targetGroup) return;
if (!result[userGroup]) result[userGroup] = {};
const key = toRawKey(op, targetGroup);
result[userGroup][key] = description;
result[userGroup][toRawKey(op, targetGroup)] = description;
});
return result;
}
export function serializeGroupSpecialUsable(rules) {
const nested = nestRules(rules);
return Object.keys(nested).length === 0
? ''
: JSON.stringify(nested, null, 2);
return Object.keys(nested).length === 0 ? '' : JSON.stringify(nested, null, 2);
}
const OP_TAG_MAP = {
@@ -87,14 +99,118 @@ const OP_TAG_MAP = {
[OP_APPEND]: { color: 'blue', label: '追加' },
};
function UsableGroupSection({ groupName, items, opOptions, onUpdate, onRemove, onAdd, t }) {
const [open, setOpen] = useState(false);
return (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className='flex items-center justify-between cursor-pointer'
style={{
padding: '8px 12px',
background: 'var(--semi-color-fill-0)',
}}
onClick={() => setOpen(!open)}
>
<div className='flex items-center gap-2'>
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
<Text strong>{groupName}</Text>
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
</div>
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
<Button
icon={<IconPlus />}
size='small'
theme='borderless'
onClick={() => onAdd(groupName)}
/>
<Popconfirm
title={t('确认删除该分组的所有规则?')}
onConfirm={() => items.forEach((item) => onRemove(item._id))}
position='left'
>
<Button
icon={<IconDelete />}
size='small'
type='danger'
theme='borderless'
/>
</Popconfirm>
</div>
</div>
<Collapsible isOpen={open} keepDOM>
<div style={{ padding: '8px 12px' }}>
{items.map((rule) => (
<div
key={rule._id}
className='flex items-center gap-2'
style={{ marginBottom: 6 }}
>
<Select
size='small'
value={rule.op}
optionList={opOptions}
onChange={(v) => onUpdate(rule._id, 'op', v)}
style={{ width: 120 }}
renderSelectedItem={(optionNode) => {
const info = OP_TAG_MAP[optionNode.value] || {};
return <Tag size='small' color={info.color}>{optionNode.label}</Tag>;
}}
/>
<Input
size='small'
value={rule.targetGroup}
placeholder={t('分组名称')}
onChange={(v) => onUpdate(rule._id, 'targetGroup', v)}
style={{ flex: 1 }}
/>
{rule.op !== OP_REMOVE ? (
<Input
size='small'
value={rule.description}
placeholder={t('分组描述')}
onChange={(v) => onUpdate(rule._id, 'description', v)}
style={{ flex: 1 }}
/>
) : (
<div style={{ flex: 1 }}>
<Text type='tertiary' size='small'>-</Text>
</div>
)}
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => onRemove(rule._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
</div>
))}
</div>
</Collapsible>
</div>
);
}
export default function GroupSpecialUsableRules({
value,
groupNames = [],
onChange,
}) {
const { t } = useTranslation();
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
const [newGroupName, setNewGroupName] = useState('');
const emitChange = useCallback(
(newRules) => {
@@ -106,41 +222,46 @@ export default function GroupSpecialUsableRules({
const updateRule = useCallback(
(id, field, val) => {
const next = rules.map((r) => {
if (r._id !== id) return r;
const updated = { ...r, [field]: val };
if (field === 'op' && val === OP_REMOVE) {
updated.description = 'remove';
} else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
if (updated.description === 'remove') updated.description = '';
}
return updated;
});
emitChange(next);
emitChange(
rules.map((r) => {
if (r._id !== id) return r;
const updated = { ...r, [field]: val };
if (field === 'op' && val === OP_REMOVE) updated.description = 'remove';
else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
if (updated.description === 'remove') updated.description = '';
}
return updated;
}),
);
},
[rules, emitChange],
);
const addRule = useCallback(() => {
emitChange([
...rules,
{
_id: uid(),
userGroup: '',
op: OP_APPEND,
targetGroup: '',
description: '',
},
]);
}, [rules, emitChange]);
const removeRule = useCallback(
(id) => {
emitChange(rules.filter((r) => r._id !== id));
(id) => emitChange(rules.filter((r) => r._id !== id)),
[rules, emitChange],
);
const addRuleToGroup = useCallback(
(groupName) => {
emitChange([
...rules,
{ _id: uid(), userGroup: groupName, op: OP_APPEND, targetGroup: '', description: '' },
]);
},
[rules, emitChange],
);
const addNewGroup = useCallback(() => {
const name = newGroupName.trim();
if (!name) return;
emitChange([
...rules,
{ _id: uid(), userGroup: name, op: OP_APPEND, targetGroup: '', description: '' },
]);
setNewGroupName('');
}, [rules, emitChange, newGroupName]);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
@@ -155,120 +276,74 @@ export default function GroupSpecialUsableRules({
[t],
);
const columns = useMemo(
() => [
{
title: t('用户分组'),
dataIndex: 'userGroup',
key: 'userGroup',
width: 180,
render: (_, record) => (
const grouped = useMemo(() => {
const map = {};
const order = [];
rules.forEach((r) => {
if (!r.userGroup) return;
if (!map[r.userGroup]) {
map[r.userGroup] = [];
order.push(r.userGroup);
}
map[r.userGroup].push(r);
});
return order.map((name) => ({ name, items: map[name] }));
}, [rules]);
if (grouped.length === 0 && rules.length === 0) {
return (
<div>
<Text type='tertiary' className='block text-center py-4'>
{t('暂无规则,点击下方按钮添加')}
</Text>
<div className='mt-2 flex justify-center gap-2'>
<Select
size='small'
filter
value={record.userGroup || undefined}
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'userGroup', v)}
style={{ width: '100%' }}
allowCreate
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
),
},
{
title: t('操作'),
dataIndex: 'op',
key: 'op',
width: 140,
render: (_, record) => (
<Select
size='small'
value={record.op}
optionList={opOptions}
onChange={(v) => updateRule(record._id, 'op', v)}
style={{ width: '100%' }}
renderSelectedItem={(optionNode) => {
const tagInfo = OP_TAG_MAP[optionNode.value] || {};
return (
<Tag size='small' color={tagInfo.color}>
{optionNode.label}
</Tag>
);
}}
/>
),
},
{
title: t('目标分组'),
dataIndex: 'targetGroup',
key: 'targetGroup',
width: 180,
render: (_, record) => (
<Input
size='small'
value={record.targetGroup}
placeholder={t('分组名称')}
onChange={(v) => updateRule(record._id, 'targetGroup', v)}
/>
),
},
{
title: t('描述'),
dataIndex: 'description',
key: 'description',
render: (_, record) =>
record.op === OP_REMOVE ? (
<Text type='tertiary' size='small'>-</Text>
) : (
<Input
size='small'
value={record.description}
placeholder={t('分组描述')}
onChange={(v) => updateRule(record._id, 'description', v)}
/>
),
},
{
title: '',
key: 'actions',
width: 50,
render: (_, record) => (
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => removeRule(record._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
),
},
],
[t, groupOptions, opOptions, updateRule, removeRule],
);
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
return (
<div>
<CardTable
columns={columns}
dataSource={rules}
rowKey='_id'
hidePagination
size='small'
empty={
<Text type='tertiary'>
{t('暂无规则,点击下方按钮添加')}
</Text>
}
/>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
{t('添加规则')}
<div className='space-y-2'>
{grouped.map((group) => (
<UsableGroupSection
key={group.name}
groupName={group.name}
items={group.items}
opOptions={opOptions}
onUpdate={updateRule}
onRemove={removeRule}
onAdd={addRuleToGroup}
t={t}
/>
))}
<div className='mt-3 flex justify-center gap-2'>
<Select
size='small'
filter
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>