diff --git a/.gitignore b/.gitignore index c50b514a981..8d2e3c10ad7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ data/ findings.md progress.md task_plan.md +token_estimator_test.go diff --git a/controller/channel-test.go b/controller/channel-test.go index bdd67d27a90..db0326d3a00 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -150,6 +150,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, } } cache.WriteContext(c) + c.Set("id", 1) //c.Request.Header.Set("Authorization", "Bearer "+channel.Key) c.Request.Header.Set("Content-Type", "application/json") @@ -274,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, return testResult{ context: c, localErr: err, - newAPIError: types.NewError(err, types.ErrorCodeModelPriceError), + newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)), } } @@ -756,11 +757,15 @@ func TestChannel(c *gin.Context) { tik := time.Now() result := testChannel(channel, testModel, endpointType, isStream) if result.localErr != nil { - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "success": false, "message": result.localErr.Error(), "time": 0.0, - }) + } + if result.newAPIError != nil { + resp["error_code"] = result.newAPIError.GetErrorCode() + } + c.JSON(http.StatusOK, resp) return } tok := time.Now() @@ -769,9 +774,10 @@ func TestChannel(c *gin.Context) { consumedTime := float64(milliseconds) / 1000.0 if result.newAPIError != nil { c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": result.newAPIError.Error(), - "time": consumedTime, + "success": false, + "message": result.newAPIError.Error(), + "time": consumedTime, + "error_code": result.newAPIError.GetErrorCode(), }) return } diff --git a/controller/relay.go b/controller/relay.go index 7c607738834..869476c07ad 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -151,7 +151,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta) if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeModelPriceError) + newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)) return } diff --git a/controller/user.go b/controller/user.go index 8916e8ea734..c9729994d7d 100644 --- a/controller/user.go +++ b/controller/user.go @@ -52,10 +52,15 @@ func Login(c *gin.Context) { } err = user.ValidateAndFill() if err != nil { - c.JSON(http.StatusOK, gin.H{ - "message": err.Error(), - "success": false, - }) + switch { + case errors.Is(err, model.ErrDatabase): + common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err)) + common.ApiErrorI18n(c, i18n.MsgDatabaseError) + case errors.Is(err, model.ErrUserEmptyCredentials): + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + default: + common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError) + } return } @@ -572,9 +577,6 @@ func UpdateUser(c *gin.Context) { common.ApiError(c, err) return } - if originUser.Quota != updatedUser.Quota { - model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota))) - } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -841,6 +843,8 @@ func CreateUser(c *gin.Context) { type ManageRequest struct { Id int `json:"id"` Action string `json:"action"` + Value int `json:"value"` + Mode string `json:"mode"` } // ManageUser Only admin user can do this @@ -907,6 +911,47 @@ func ManageUser(c *gin.Context) { return } user.Role = common.RoleCommonUser + case "add_quota": + switch req.Mode { + case "add": + if req.Value <= 0 { + common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero) + return + } + if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil { + common.ApiError(c, err) + return + } + model.RecordLog(user.Id, model.LogTypeManage, + fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value))) + case "subtract": + if req.Value <= 0 { + common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero) + return + } + if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil { + common.ApiError(c, err) + return + } + model.RecordLog(user.Id, model.LogTypeManage, + fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value))) + case "override": + oldQuota := user.Quota + if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil { + common.ApiError(c, err) + return + } + model.RecordLog(user.Id, model.LogTypeManage, + fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value))) + default: + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return } if err := user.Update(false); err != nil { diff --git a/i18n/keys.go b/i18n/keys.go index 3ca26855573..f18d5352d2c 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -28,6 +28,18 @@ const ( MsgBatchTooMany = "common.batch_too_many" ) +// Auth middleware messages +const ( + MsgAuthNotLoggedIn = "auth.not_logged_in" + MsgAuthAccessTokenInvalid = "auth.access_token_invalid" + MsgAuthUserInfoInvalid = "auth.user_info_invalid" + MsgAuthUserIdNotProvided = "auth.user_id_not_provided" + MsgAuthUserIdFormatError = "auth.user_id_format_error" + MsgAuthUserIdMismatch = "auth.user_id_mismatch" + MsgAuthUserBanned = "auth.user_banned" + MsgAuthInsufficientPrivilege = "auth.insufficient_privilege" +) + // Token related messages const ( MsgTokenNameTooLong = "token.name_too_long" @@ -101,6 +113,7 @@ const ( MsgUserTelegramIdEmpty = "user.telegram_id_empty" MsgUserTelegramNotBound = "user.telegram_not_bound" MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty" + MsgUserQuotaChangeZero = "user.quota_change_zero" ) // Quota related messages diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 4b707eadade..022c72b3c13 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -2,7 +2,7 @@ # Common messages common.invalid_params: "Invalid parameters" -common.database_error: "Database error, please try again later" +common.database_error: "Database error, please contact the administrator" common.retry_later: "Please try again later" common.generate_failed: "Generation failed" common.not_found: "Not found" @@ -23,6 +23,16 @@ common.already_exists: "Already exists" common.name_cannot_be_empty: "Name cannot be empty" common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}" +# Auth middleware messages +auth.not_logged_in: "Unauthorized, not logged in and no access token provided" +auth.access_token_invalid: "Unauthorized, invalid access token" +auth.user_info_invalid: "Unauthorized, invalid user info" +auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided" +auth.user_id_format_error: "Unauthorized, New-Api-User header format error" +auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user" +auth.user_banned: "User has been banned" +auth.insufficient_privilege: "Unauthorized, insufficient privileges" + # Token messages token.name_too_long: "Token name is too long" token.quota_negative: "Quota value cannot be negative" @@ -91,6 +101,7 @@ user.wechat_id_empty: "WeChat ID is empty!" user.telegram_id_empty: "Telegram ID is empty!" user.telegram_not_bound: "This Telegram account is not bound" user.linux_do_id_empty: "Linux DO ID is empty!" +user.quota_change_zero: "Quota change amount cannot be zero" # Quota messages quota.negative: "Quota cannot be negative!" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index e9f30b23e2b..852c3aa8b84 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -3,7 +3,7 @@ # Common messages common.invalid_params: "无效的参数" -common.database_error: "数据库错误,请稍后重试" +common.database_error: "数据库出错,请联系管理员" common.retry_later: "请稍后重试" common.generate_failed: "生成失败" common.not_found: "未找到" @@ -24,6 +24,16 @@ common.already_exists: "已存在" common.name_cannot_be_empty: "名称不能为空" common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条" +# Auth middleware messages +auth.not_logged_in: "无权进行此操作,未登录且未提供 access token" +auth.access_token_invalid: "无权进行此操作,access token 无效" +auth.user_info_invalid: "无权进行此操作,用户信息无效" +auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User" +auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误" +auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配" +auth.user_banned: "用户已被封禁" +auth.insufficient_privilege: "无权进行此操作,权限不足" + # Token messages token.name_too_long: "令牌名称过长" token.quota_negative: "额度值不能为负数" @@ -92,6 +102,7 @@ user.wechat_id_empty: "WeChat id 为空!" user.telegram_id_empty: "Telegram id 为空!" user.telegram_not_bound: "该 Telegram 账户未绑定" user.linux_do_id_empty: "Linux DO id 为空!" +user.quota_change_zero: "额度变更量不能为0" # Quota messages quota.negative: "额度不能为负数!" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index 1231c0e2480..5a24bff7762 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -3,7 +3,7 @@ # Common messages common.invalid_params: "無效的參數" -common.database_error: "資料庫錯誤,請稍後重試" +common.database_error: "資料庫出錯,請聯繫管理員" common.retry_later: "請稍後重試" common.generate_failed: "生成失敗" common.not_found: "未找到" @@ -24,6 +24,16 @@ common.already_exists: "已存在" common.name_cannot_be_empty: "名稱不能為空" common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條" +# Auth middleware messages +auth.not_logged_in: "無權進行此操作,未登入且未提供 access token" +auth.access_token_invalid: "無權進行此操作,access token 無效" +auth.user_info_invalid: "無權進行此操作,使用者資訊無效" +auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User" +auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤" +auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配" +auth.user_banned: "使用者已被封禁" +auth.insufficient_privilege: "無權進行此操作,權限不足" + # Token messages token.name_too_long: "令牌名稱過長" token.quota_negative: "額度值不能為負數" @@ -92,6 +102,7 @@ user.wechat_id_empty: "WeChat id 為空!" user.telegram_id_empty: "Telegram id 為空!" user.telegram_not_bound: "該 Telegram 帳號未綁定" user.linux_do_id_empty: "Linux DO id 為空!" +user.quota_change_zero: "額度變更量不能為0" # Quota messages quota.negative: "額度不能為負數!" diff --git a/middleware/auth.go b/middleware/auth.go index 342e7f49812..23d933fbe0c 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "errors" "fmt" "net" "net/http" @@ -9,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" @@ -17,6 +19,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func validUserInfo(username string, role int) bool { @@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) { if accessToken == "" { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "无权进行此操作,未登录且未提供 access token", + "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn), }) c.Abort() return } - user := model.ValidateAccessToken(accessToken) + user, authErr := model.ValidateAccessToken(accessToken) + if authErr != nil { + if errors.Is(authErr, model.ErrDatabase) { + common.SysLog("ValidateAccessToken database error: " + authErr.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgDatabaseError), + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid), + }) + } + c.Abort() + return + } if user != nil && user.Username != "" { if !validUserInfo(user.Username, user.Role) { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无权进行此操作,用户信息无效", + "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid), }) c.Abort() return @@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) { } else { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无权进行此操作,access token 无效", + "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid), }) c.Abort() return @@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) { if apiUserIdStr == "" { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "无权进行此操作,未提供 New-Api-User", + "message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided), }) c.Abort() return @@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) { if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "无权进行此操作,New-Api-User 格式错误", + "message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError), }) c.Abort() return @@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) { if id != apiUserId { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "无权进行此操作,New-Api-User 与登录用户不匹配", + "message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch), }) c.Abort() return @@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) { if status.(int) == common.UserStatusDisabled { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "用户已被封禁", + "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned), }) c.Abort() return @@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) { if role.(int) < minRole { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无权进行此操作,权限不足", + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), }) c.Abort() return @@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) { if !validUserInfo(username.(string), role.(int)) { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无权进行此操作,用户信息无效", + "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid), }) c.Abort() return @@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) { if key == "" { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "未提供 Authorization 请求头", + "message": common.TranslateMessage(c, i18n.MsgTokenNotProvided), }) c.Abort() return @@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) { token, err := model.GetTokenByKey(key, false) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "无效的令牌", - }) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgTokenInvalid), + }) + } else { + common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgDatabaseError), + }) + } c.Abort() return } userCache, err := model.GetUserCache(token.UserId) if err != nil { + common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err)) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, - "message": err.Error(), + "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) c.Abort() return @@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) { if userCache.Status != common.UserStatusEnabled { c.JSON(http.StatusForbidden, gin.H{ "success": false, - "message": "用户已被封禁", + "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned), }) c.Abort() return @@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) { } } if err != nil { - abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error()) + if errors.Is(err, model.ErrDatabase) { + common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error()) + abortWithOpenAiMessage(c, http.StatusInternalServerError, + common.TranslateMessage(c, i18n.MsgDatabaseError)) + } else { + abortWithOpenAiMessage(c, http.StatusUnauthorized, + common.TranslateMessage(c, i18n.MsgTokenInvalid)) + } return } @@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) { userCache, err := model.GetUserCache(token.UserId) if err != nil { - abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error()) + common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err)) + abortWithOpenAiMessage(c, http.StatusInternalServerError, + common.TranslateMessage(c, i18n.MsgDatabaseError)) return } userEnabled := userCache.Status == common.UserStatusEnabled if !userEnabled { - abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁") + abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned)) return } diff --git a/model/errors.go b/model/errors.go new file mode 100644 index 00000000000..a942a5bc4e4 --- /dev/null +++ b/model/errors.go @@ -0,0 +1,26 @@ +package model + +import "errors" + +// Common errors +var ( + ErrDatabase = errors.New("database error") +) + +// User auth errors +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserEmptyCredentials = errors.New("empty credentials") +) + +// Token auth errors +var ( + ErrTokenNotProvided = errors.New("token not provided") + ErrTokenInvalid = errors.New("token invalid") +) + +// Redemption errors +var ErrRedeemFailed = errors.New("redeem.failed") + +// 2FA errors +var ErrTwoFANotEnabled = errors.New("2fa not enabled") diff --git a/model/redemption.go b/model/redemption.go index c45ed39f96b..bb03d1dbfa7 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -12,9 +12,6 @@ import ( "gorm.io/gorm" ) -// ErrRedeemFailed is returned when redemption fails due to database error -var ErrRedeemFailed = errors.New("redeem.failed") - const ( RedemptionTypeQuota = "quota" RedemptionTypeSubscription = "subscription" @@ -24,7 +21,6 @@ var ( redemptionUserGroupCacheUpdater = UpdateUserGroupCache redemptionUserCacheInvalidator = invalidateUserCache ) - type Redemption struct { Id int `json:"id"` UserId int `json:"user_id"` diff --git a/model/token.go b/model/token.go index b7989ad171f..8cfcd6189b9 100644 --- a/model/token.go +++ b/model/token.go @@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi func ValidateUserToken(key string) (token *Token, err error) { if key == "" { - return nil, errors.New("未提供令牌") + return nil, ErrTokenNotProvided } token, err = GetTokenByKey(key, false) if err == nil { - if token.Status == common.TokenStatusExhausted { - keyPrefix := key[:3] - keySuffix := key[len(key)-3:] - return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]") - } else if token.Status == common.TokenStatusExpired { - return token, errors.New("该令牌已过期") - } - if token.Status != common.TokenStatusEnabled { - return token, errors.New("该令牌状态不可用") + if token.Status == common.TokenStatusExhausted || + token.Status == common.TokenStatusExpired || + token.Status != common.TokenStatusEnabled { + return token, ErrTokenInvalid } if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() { if !common.RedisEnabled { @@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) { common.SysLog("failed to update token status" + err.Error()) } } - return token, errors.New("该令牌已过期") + return token, ErrTokenInvalid } if !token.UnlimitedQuota && token.RemainQuota <= 0 { if !common.RedisEnabled { - // in this case, we can make sure the token is exhausted token.Status = common.TokenStatusExhausted err := token.SelectUpdate() if err != nil { common.SysLog("failed to update token status" + err.Error()) } } - keyPrefix := key[:3] - keySuffix := key[len(key)-3:] - return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota) + return token, ErrTokenInvalid } return token, nil } common.SysLog("ValidateUserToken: failed to get token: " + err.Error()) if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("无效的令牌") - } else { - return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员") + return nil, ErrTokenInvalid } + return nil, fmt.Errorf("%w: %v", ErrDatabase, err) } func GetTokenByIds(id int, userId int) (*Token, error) { diff --git a/model/twofa.go b/model/twofa.go index e63c66629d7..a2d0c7e1f68 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -10,8 +10,6 @@ import ( "gorm.io/gorm" ) -var ErrTwoFANotEnabled = errors.New("用户未启用2FA") - // TwoFA 用户2FA设置表 type TwoFA struct { Id int `json:"id" gorm:"primaryKey"` diff --git a/model/user.go b/model/user.go index 1210b5435d0..79e63e8fd59 100644 --- a/model/user.go +++ b/model/user.go @@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error { "username": newUser.Username, "display_name": newUser.DisplayName, "group": newUser.Group, - "quota": newUser.Quota, "remark": newUser.Remark, } if updatePassword { @@ -598,13 +597,19 @@ func (user *User) ValidateAndFill() (err error) { password := user.Password username := strings.TrimSpace(user.Username) if username == "" || password == "" { - return errors.New("用户名或密码为空") + return ErrUserEmptyCredentials + } + // find by username or email + err = DB.Where("username = ? OR email = ?", username, username).First(user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvalidCredentials + } + return fmt.Errorf("%w: %v", ErrDatabase, err) } - // find buy username or email - DB.Where("username = ? OR email = ?", username, username).First(user) okay := common.ValidatePasswordAndHash(password, user.Password) if !okay || user.Status != common.UserStatusEnabled { - return errors.New("用户名或密码错误,或用户已被封禁") + return ErrInvalidCredentials } return nil } @@ -755,16 +760,20 @@ func IsAdmin(userId int) bool { // return user.Status == common.UserStatusEnabled, nil //} -func ValidateAccessToken(token string) (user *User) { +func ValidateAccessToken(token string) (*User, error) { if token == "" { - return nil + return nil, nil } token = strings.Replace(token, "Bearer ", "", 1) - user = &User{} - if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 { - return user + user := &User{} + err := DB.Where("access_token = ?", token).First(user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("%w: %v", ErrDatabase, err) } - return nil + return user, nil } // GetUserQuota gets quota from Redis first, falls back to DB if needed @@ -896,7 +905,7 @@ func increaseUserQuota(id int, quota int) (err error) { return err } -func DecreaseUserQuota(id int, quota int) (err error) { +func DecreaseUserQuota(id int, quota int, db bool) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } @@ -906,7 +915,7 @@ func DecreaseUserQuota(id int, quota int) (err error) { common.SysLog("failed to decrease user quota: " + err.Error()) } }) - if common.BatchUpdateEnabled { + if !db && common.BatchUpdateEnabled { addNewRecord(BatchUpdateTypeUserQuota, id, -quota) return nil } @@ -928,7 +937,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) { if delta > 0 { return IncreaseUserQuota(id, delta, false) } else { - return DecreaseUserQuota(id, -delta) + return DecreaseUserQuota(id, -delta, false) } } diff --git a/relay/helper/price.go b/relay/helper/price.go index e9c3b463763..8ba0ee8f084 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -5,6 +5,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -13,6 +14,21 @@ import ( "github.com/gin-gonic/gin" ) +func modelPriceNotConfiguredError(modelName string, userId int) error { + if model.IsAdmin(userId) { + return fmt.Errorf( + "模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+ + "Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.", + modelName, modelName, + ) + } + return fmt.Errorf( + "模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+ + "Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.", + modelName, modelName, + ) +} + // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration const claudeCacheCreation1hMultiplier = 6 / 3.75 @@ -75,7 +91,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens acceptUnsetRatio = true } if !acceptUnsetRatio { - return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) + return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId) } } completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) @@ -161,7 +177,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types acceptUnsetRatio = true } if !ratioSuccess && !acceptUnsetRatio { - return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) + return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId) } } } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 09e490d9e38..58324aa7cec 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -143,7 +143,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { info.OriginModelName = originModelName info.PriceData = originPriceData - return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest)) } service.PostTextConsumeQuota(c, info, usageDto, nil) diff --git a/service/funding_source.go b/service/funding_source.go index b06b510cfb9..96dc75d62c9 100644 --- a/service/funding_source.go +++ b/service/funding_source.go @@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error { if amount <= 0 { return nil } - if err := model.DecreaseUserQuota(w.userId, amount); err != nil { + if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil { return err } w.consumed = amount @@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error { return nil } if delta > 0 { - return model.DecreaseUserQuota(w.userId, delta) + return model.DecreaseUserQuota(w.userId, delta, false) } return model.IncreaseUserQuota(w.userId, -delta, false) } diff --git a/service/quota.go b/service/quota.go index 9dc84ab4be9..4150c44434b 100644 --- a/service/quota.go +++ b/service/quota.go @@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu } else { // Wallet if quota > 0 { - err = model.DecreaseUserQuota(relayInfo.UserId, quota) + err = model.DecreaseUserQuota(relayInfo.UserId, quota, false) } else { err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) } diff --git a/service/task_billing.go b/service/task_billing.go index e5c406ddc2c..6cf7a965c8e 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -90,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error { return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta)) } if delta > 0 { - return model.DecreaseUserQuota(task.UserId, delta) + return model.DecreaseUserQuota(task.UserId, delta, false) } return model.IncreaseUserQuota(task.UserId, -delta, false) } diff --git a/types/error.go b/types/error.go index 6af39f7e9f0..9717401ae7b 100644 --- a/types/error.go +++ b/types/error.go @@ -390,6 +390,12 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions { } } +func ErrOptionWithStatusCode(statusCode int) NewAPIErrorOptions { + return func(e *NewAPIError) { + e.StatusCode = statusCode + } +} + func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions { return func(e *NewAPIError) { if common.DebugEnabled { diff --git a/web/src/components/playground/MessageContent.jsx b/web/src/components/playground/MessageContent.jsx index c02ab3a2df9..94f494bb31f 100644 --- a/web/src/components/playground/MessageContent.jsx +++ b/web/src/components/playground/MessageContent.jsx @@ -21,8 +21,9 @@ import React, { useRef, useEffect } from 'react'; import { Typography, TextArea, Button } from '@douyinfe/semi-ui'; import MarkdownRenderer from '../common/markdown/MarkdownRenderer'; import ThinkingContent from './ThinkingContent'; -import { Loader2, Check, X } from 'lucide-react'; +import { Loader2, Check, X, Settings, AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { isAdmin } from '../../helpers/utils'; const MessageContent = ({ message, @@ -64,6 +65,44 @@ const MessageContent = ({ errorText = t('请求发生错误'); } + if (message.errorCode === 'model_price_error') { + return ( +
+
+
+ + + {t('模型价格未配置')} + +
+ + {errorText} + + {isAdmin() && ( + + )} +
+
+ ); + } + return (
{errorText} diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index 490cf54be11..e7f57453229 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -30,6 +30,7 @@ import { Banner, } from '@douyinfe/semi-ui'; import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons'; +import { Settings } from 'lucide-react'; import { copy, showError, showInfo, showSuccess } from '../../../../helpers'; import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants'; @@ -168,17 +169,43 @@ const ModelTestModal = ({ } return ( -
- - {testResult.success ? t('成功') : t('失败')} - - {testResult.success && ( - - {t('请求时长: ${time}s').replace( - '${time}', - testResult.time.toFixed(2), +
+
+ + {testResult.success ? t('成功') : t('失败')} + + {testResult.success && ( + + {t('请求时长: ${time}s').replace( + '${time}', + testResult.time.toFixed(2), + )} + + )} +
+ {!testResult.success && testResult.message && ( +
+ + {testResult.message} + + {testResult.errorCode === 'model_price_error' && ( + )} - +
)}
); diff --git a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx index d5fbfc83db0..e7a467badb3 100644 --- a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx +++ b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx @@ -25,8 +25,12 @@ import { showError, showSuccess, renderQuota, - renderQuotaWithPrompt, + getCurrencyConfig, } from '../../../../helpers'; +import { + quotaToDisplayAmount, + displayAmountToQuota, +} from '../../../../helpers/quota'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { REDEMPTION_TYPES, @@ -45,6 +49,7 @@ import { Avatar, Row, Col, + InputNumber, } from '@douyinfe/semi-ui'; import { IconCreditCard, @@ -63,10 +68,12 @@ const EditRedemptionModal = (props) => { const [subscriptionPlans, setSubscriptionPlans] = useState([]); const isMobile = useIsMobile(); const formApiRef = useRef(null); + const [showQuotaInput, setShowQuotaInput] = useState(false); const getInitValues = () => ({ name: '', quota: 100000, + amount: Number(quotaToDisplayAmount(100000).toFixed(6)), count: 1, expired_time: null, redemption_type: REDEMPTION_TYPES.QUOTA, @@ -103,6 +110,7 @@ const EditRedemptionModal = (props) => { } else { data.expired_time = new Date(data.expired_time * 1000); } + data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6)); formApiRef.current?.setValues({ ...getInitValues(), ...data }); } else { showError(message); @@ -144,7 +152,10 @@ const EditRedemptionModal = (props) => { localInputs.redemption_type = localInputs.redemption_type || REDEMPTION_TYPES.QUOTA; localInputs.count = parseInt(localInputs.count) || 0; - localInputs.quota = parseInt(localInputs.quota) || 0; + localInputs.quota = + localInputs.redemption_type === REDEMPTION_TYPES.SUBSCRIPTION + ? parseInt(localInputs.quota, 10) || 0 + : displayAmountToQuota(localInputs.amount); localInputs.subscription_plan_id = parseInt(localInputs.subscription_plan_id, 10) || 0; if ( @@ -155,11 +166,19 @@ const EditRedemptionModal = (props) => { setLoading(false); return; } + if ( + localInputs.redemption_type === REDEMPTION_TYPES.QUOTA && + localInputs.quota <= 0 + ) { + showError(t('请输入金额')); + setLoading(false); + return; + } if (!isEdit && (!name || name === '')) { name = localInputs.redemption_type === REDEMPTION_TYPES.SUBSCRIPTION ? getSubscriptionPlanTitle(localInputs.subscription_plan_id) - : renderQuota(values.quota); + : renderQuota(localInputs.quota); } localInputs.name = name; if (!localInputs.expired_time) { @@ -383,37 +402,68 @@ const EditRedemptionModal = (props) => { /> ) : ( - - + { - const num = parseInt(v, 10); - return num > 0 - ? Promise.resolve() - : Promise.reject(t('额度必须大于0')); - }, - }, - ]} - extraText={renderQuotaWithPrompt( - Number(values.quota) || 0, - )} - data={[ - { value: 500000, label: '1$' }, - { value: 5000000, label: '10$' }, - { value: 25000000, label: '50$' }, - { value: 50000000, label: '100$' }, - { value: 250000000, label: '500$' }, - { value: 500000000, label: '1000$' }, - ]} + onChange={(val) => { + const amount = val === '' || val == null ? 0 : val; + formApiRef.current?.setValue('amount', amount); + formApiRef.current?.setValue( + 'quota', + displayAmountToQuota(amount), + ); + }} showClear /> +
setShowQuotaInput((v) => !v)} + > + {showQuotaInput + ? `▾ ${t('收起原生额度输入')}` + : `▸ ${t('使用原生额度输入')}`} +
+
+ { + const num = parseInt(v, 10); + return num > 0 + ? Promise.resolve() + : Promise.reject(t('额度必须大于0')); + }, + }, + ]} + onChange={(val) => { + const quota = val === '' || val == null ? 0 : val; + formApiRef.current?.setValue('quota', quota); + formApiRef.current?.setValue( + 'amount', + Number( + quotaToDisplayAmount(quota).toFixed(6), + ), + ); + }} + style={{ width: '100%' }} + showClear + /> +
)} {!isEdit && ( diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index 93664580c38..9112658bd54 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -24,10 +24,14 @@ import { showSuccess, timestamp2string, renderGroupOption, - renderQuotaWithPrompt, + getCurrencyConfig, getModelCategories, selectFilter, } from '../../../../helpers'; +import { + quotaToDisplayAmount, + displayAmountToQuota, +} from '../../../../helpers/quota'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { Button, @@ -41,6 +45,7 @@ import { Form, Col, Row, + InputNumber, } from '@douyinfe/semi-ui'; import { IconCreditCard, @@ -62,11 +67,13 @@ const EditTokenModal = (props) => { const formApiRef = useRef(null); const [models, setModels] = useState([]); const [groups, setGroups] = useState([]); + const [showQuotaInput, setShowQuotaInput] = useState(false); const isEdit = props.editingToken.id !== undefined; const getInitValues = () => ({ name: '', remain_quota: 0, + remain_amount: 0, expired_time: -1, unlimited_quota: true, model_limits_enabled: false, @@ -162,6 +169,9 @@ const EditTokenModal = (props) => { } else { data.model_limits = []; } + data.remain_amount = Number( + quotaToDisplayAmount(data.remain_quota || 0).toFixed(6), + ); if (formApiRef.current) { formApiRef.current.setValues({ ...getInitValues(), ...data }); } @@ -209,7 +219,14 @@ const EditTokenModal = (props) => { setLoading(true); if (isEdit) { let { tokenCount: _tc, ...localInputs } = values; - localInputs.remain_quota = parseInt(localInputs.remain_quota); + localInputs.remain_quota = localInputs.unlimited_quota + ? 0 + : displayAmountToQuota(localInputs.remain_amount); + if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) { + showError(t('请输入金额')); + setLoading(false); + return; + } if (localInputs.expired_time !== -1) { let time = Date.parse(localInputs.expired_time); if (isNaN(time)) { @@ -245,7 +262,14 @@ const EditTokenModal = (props) => { } else { localInputs.name = baseName; } - localInputs.remain_quota = parseInt(localInputs.remain_quota); + localInputs.remain_quota = localInputs.unlimited_quota + ? 0 + : displayAmountToQuota(localInputs.remain_amount); + if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) { + showError(t('请输入金额')); + setLoading(false); + break; + } if (localInputs.expired_time !== -1) { let time = Date.parse(localInputs.expired_time); @@ -497,28 +521,63 @@ const EditTokenModal = (props) => {
- { + const amount = val === '' || val == null ? 0 : val; + formApiRef.current?.setValue('remain_amount', amount); + formApiRef.current?.setValue( + 'remain_quota', + displayAmountToQuota(amount), + ); + }} + style={{ width: '100%' }} + showClear /> + +
setShowQuotaInput((v) => !v)} + > + {showQuotaInput + ? `▾ ${t('收起原生额度输入')}` + : `▸ ${t('使用原生额度输入')}`} +
+
+ { + const quota = val === '' || val == null ? 0 : val; + formApiRef.current?.setValue('remain_quota', quota); + formApiRef.current?.setValue( + 'remain_amount', + Number(quotaToDisplayAmount(quota).toFixed(6)), + ); + }} + style={{ width: '100%' }} + showClear + /> +
+ { const { t } = useTranslation(); const userId = props.editingUser.id; const [loading, setLoading] = useState(true); - const [addQuotaModalOpen, setIsModalOpen] = useState(false); - const [addQuotaLocal, setAddQuotaLocal] = useState(''); - const [addAmountLocal, setAddAmountLocal] = useState(''); + const [adjustModalOpen, setAdjustModalOpen] = useState(false); + const [adjustQuotaLocal, setAdjustQuotaLocal] = useState(''); + const [adjustAmountLocal, setAdjustAmountLocal] = useState(''); + const [adjustMode, setAdjustMode] = useState('add'); + const [adjustLoading, setAdjustLoading] = useState(false); const isMobile = useIsMobile(); const [groupOptions, setGroupOptions] = useState([]); const [bindingModalVisible, setBindingModalVisible] = useState(false); const formApiRef = useRef(null); + const [showAdjustQuotaRaw, setShowAdjustQuotaRaw] = useState(false); + const [showQuotaInput, setShowQuotaInput] = useState(false); + const [inputs, setInputs] = useState(null); const isEdit = Boolean(userId); @@ -85,6 +91,7 @@ const EditUserModal = (props) => { linux_do_id: '', email: '', quota: 0, + quota_amount: 0, group: 'default', remark: '', }); @@ -107,13 +114,22 @@ const EditUserModal = (props) => { const { success, message, data } = res.data; if (success) { data.password = ''; - formApiRef.current?.setValues({ ...getInitValues(), ...data }); + data.quota_amount = Number( + quotaToDisplayAmount(data.quota || 0).toFixed(6), + ); + setInputs({ ...getInitValues(), ...data }); } else { showError(message); } setLoading(false); }; + useEffect(() => { + if (inputs && formApiRef.current) { + formApiRef.current.setValues(inputs); + } + }, [inputs]); + useEffect(() => { loadUser(); if (userId) fetchGroups(); @@ -132,8 +148,8 @@ const EditUserModal = (props) => { const submit = async (values) => { setLoading(true); let payload = { ...values }; - if (typeof payload.quota === 'string') - payload.quota = parseInt(payload.quota) || 0; + delete payload.quota; + delete payload.quota_amount; if (userId) { payload.id = parseInt(userId); } @@ -150,11 +166,60 @@ const EditUserModal = (props) => { setLoading(false); }; - /* --------------------- quota helper -------------------- */ - const addLocalQuota = () => { - const current = parseInt(formApiRef.current?.getValue('quota') || 0); - const delta = parseInt(addQuotaLocal) || 0; - formApiRef.current?.setValue('quota', current + delta); + /* --------------------- atomic quota adjust -------------------- */ + const adjustQuota = async () => { + const quotaVal = parseInt(adjustQuotaLocal) || 0; + if (quotaVal <= 0 && adjustMode !== 'override') return; + if (adjustMode === 'override' && (adjustQuotaLocal === '' || adjustQuotaLocal == null)) return; + setAdjustLoading(true); + try { + const res = await API.post('/api/user/manage', { + id: parseInt(userId), + action: 'add_quota', + mode: adjustMode, + value: adjustMode === 'override' ? quotaVal : Math.abs(quotaVal), + }); + const { success, message } = res.data; + if (success) { + showSuccess(t('调整额度成功')); + setAdjustModalOpen(false); + setAdjustQuotaLocal(''); + setAdjustAmountLocal(''); + const userRes = await API.get(`/api/user/${userId}`); + if (userRes.data.success) { + const data = userRes.data.data; + data.password = ''; + data.quota_amount = Number( + quotaToDisplayAmount(data.quota || 0).toFixed(6), + ); + setInputs({ ...getInitValues(), ...data }); + } + props.refresh(); + } else { + showError(message); + } + } catch (e) { + showError(e.message); + } + setAdjustLoading(false); + }; + + const getPreviewText = () => { + const current = formApiRef.current?.getValue('quota') || 0; + const val = parseInt(adjustQuotaLocal) || 0; + let result; + switch (adjustMode) { + case 'add': + result = current + Math.abs(val); + return `${t('当前额度')}:${renderQuota(current)},+${renderQuota(Math.abs(val))} = ${renderQuota(result)}`; + case 'subtract': + result = current - Math.abs(val); + return `${t('当前额度')}:${renderQuota(current)},-${renderQuota(Math.abs(val))} = ${renderQuota(result)}`; + case 'override': + return `${t('当前额度')}:${renderQuota(current)} → ${renderQuota(val)}`; + default: + return ''; + } }; /* --------------------------- UI --------------------------- */ @@ -305,24 +370,47 @@ const EditUserModal = (props) => { - + + + +
setShowQuotaInput((v) => !v)} + > + {showQuotaInput + ? `▾ ${t('收起原生额度输入')}` + : `▸ ${t('使用原生额度输入')}`} +
+
+ +
+
)} @@ -372,81 +460,102 @@ const EditUserModal = (props) => { formApiRef={formApiRef} /> - {/* 添加额度模态框 */} + {/* 调整额度模态框 */} { - addLocalQuota(); - setIsModalOpen(false); - setAddQuotaLocal(''); - setAddAmountLocal(''); - }} + visible={adjustModalOpen} + onOk={adjustQuota} onCancel={() => { - setIsModalOpen(false); + setAdjustModalOpen(false); + setAdjustQuotaLocal(''); + setAdjustAmountLocal(''); + setAdjustMode('add'); }} + confirmLoading={adjustLoading} closable={null} title={
- - {t('添加额度')} + + {t('调整额度')}
} >
- {(() => { - const current = formApiRef.current?.getValue('quota') || 0; - return ( - - {`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`} - - ); - })()} + + {getPreviewText()} +
- {getCurrencyConfig().type !== 'TOKENS' && ( -
-
- {t('金额')} - - {' '} - ({t('仅用于换算,实际保存的是额度')}) - -
- { - setAddAmountLocal(val); - setAddQuotaLocal( - val != null && val !== '' - ? displayAmountToQuota(Math.abs(val)) * Math.sign(val) - : '', - ); - }} - style={{ width: '100%' }} - showClear - /> +
+
+ {t('操作')} +
+ { + setAdjustMode(e.target.value); + setAdjustQuotaLocal(''); + setAdjustAmountLocal(''); + }} + style={{ width: '100%' }} + > + {t('添加')} + {t('减少')} + {t('覆盖')} + +
+
+
+ {t('金额')}
- )} -
+ { + const amount = val === '' || val == null ? '' : val; + setAdjustAmountLocal(amount); + setAdjustQuotaLocal( + amount === '' + ? '' + : adjustMode === 'override' + ? displayAmountToQuota(amount) + : displayAmountToQuota(Math.abs(amount)), + ); + }} + style={{ width: '100%' }} + showClear + /> +
+
setShowAdjustQuotaRaw((v) => !v)} + > + {showAdjustQuotaRaw + ? `▾ ${t('收起原生额度输入')}` + : `▸ ${t('使用原生额度输入')}`} +
+
{t('额度')}
{ - setAddQuotaLocal(val); - setAddAmountLocal( - val != null && val !== '' - ? Number( - ( - quotaToDisplayAmount(Math.abs(val)) * Math.sign(val) - ).toFixed(2), - ) - : '', + const quota = val === '' || val == null ? '' : val; + setAdjustQuotaLocal(quota); + setAdjustAmountLocal( + quota === '' + ? '' + : adjustMode === 'override' + ? Number(quotaToDisplayAmount(quota).toFixed(6)) + : Number(quotaToDisplayAmount(Math.abs(quota)).toFixed(6)), ); }} style={{ width: '100%' }} diff --git a/web/src/helpers/quota.js b/web/src/helpers/quota.js index 2733af2482c..1d08aa68c41 100644 --- a/web/src/helpers/quota.js +++ b/web/src/helpers/quota.js @@ -1,3 +1,21 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ import { getCurrencyConfig } from './render'; export const getQuotaPerUnit = () => { @@ -7,19 +25,23 @@ export const getQuotaPerUnit = () => { export const quotaToDisplayAmount = (quota) => { const q = Number(quota || 0); - if (!Number.isFinite(q) || q <= 0) return 0; + if (!Number.isFinite(q) || q === 0) return 0; + const sign = Math.sign(q); + const abs = Math.abs(q); const { type, rate } = getCurrencyConfig(); if (type === 'TOKENS') return q; - const usd = q / getQuotaPerUnit(); - if (type === 'USD') return usd; - return usd * (rate || 1); + const usd = abs / getQuotaPerUnit(); + if (type === 'USD') return sign * usd; + return sign * usd * (rate || 1); }; export const displayAmountToQuota = (amount) => { const val = Number(amount || 0); - if (!Number.isFinite(val) || val <= 0) return 0; + if (!Number.isFinite(val) || val === 0) return 0; + const sign = Math.sign(val); + const abs = Math.abs(val); const { type, rate } = getCurrencyConfig(); if (type === 'TOKENS') return Math.round(val); - const usd = type === 'USD' ? val : val / (rate || 1); - return Math.round(usd * getQuotaPerUnit()); + const usd = type === 'USD' ? abs : abs / (rate || 1); + return sign * Math.round(usd * getQuotaPerUnit()); }; diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 37ee5010b20..e0208683f7c 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -890,7 +890,7 @@ export const useChannelsData = () => { return Promise.resolve(); } - const { success, message, time } = res.data; + const { success, message, time, error_code } = res.data; // 更新测试结果 setModelTestResults((prev) => ({ @@ -900,6 +900,7 @@ export const useChannelsData = () => { message, time: time || 0, timestamp: Date.now(), + errorCode: error_code || null, }, })); @@ -927,7 +928,7 @@ export const useChannelsData = () => { ); } } else { - showError(`${t('模型')} ${model}: ${message}`); + showError(message); } } catch (error) { // 处理网络错误 @@ -939,9 +940,10 @@ export const useChannelsData = () => { message: error.message || t('网络错误'), time: 0, timestamp: Date.now(), + errorCode: null, }, })); - showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`); + showError(error.message || t('测试失败')); } finally { // 从正在测试的模型集合中移除 setTestingModels((prev) => { diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 8ec50cf45d9..f072084b63a 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -196,10 +196,17 @@ export const useApiRequest = ( if (!response.ok) { let errorBody = ''; + let parsedError = null; try { errorBody = await response.text(); + const errorJson = JSON.parse(errorBody); + if (errorJson?.error) { + parsedError = errorJson.error; + } } catch (e) { - errorBody = '无法读取错误响应体'; + if (!errorBody) { + errorBody = '无法读取错误响应体'; + } } const errorInfo = handleApiError( @@ -215,9 +222,13 @@ export const useApiRequest = ( })); setActiveDebugTab(DEBUG_TABS.RESPONSE); - throw new Error( - `HTTP error! status: ${response.status}, body: ${errorBody}`, + const err = new Error( + parsedError?.message || + `HTTP error! status: ${response.status}, body: ${errorBody}`, ); + err.errorCode = parsedError?.code || null; + err.errorType = parsedError?.type || null; + throw err; } const data = await response.json(); @@ -277,6 +288,7 @@ export const useApiRequest = ( newMessages[newMessages.length - 1] = { ...lastMessage, content: t('请求发生错误: ') + error.message, + errorCode: error.errorCode || null, status: MESSAGE_STATUS.ERROR, ...autoCollapseState, }; @@ -379,7 +391,20 @@ export const useApiRequest = ( // 只有在流没有正常完成且连接状态异常时才处理错误 if (!isStreamComplete && source.readyState !== 2) { console.error('SSE Error:', e); - const errorMessage = e.data || t('请求发生错误'); + let errorMessage = e.data || t('请求发生错误'); + let errorCode = null; + + if (e.data) { + try { + const errorJson = JSON.parse(e.data); + if (errorJson?.error) { + errorMessage = errorJson.error.message || errorMessage; + errorCode = errorJson.error.code || null; + } + } catch (_) { + // not JSON, use raw data as error message + } + } const errorInfo = handleApiError(new Error(errorMessage)); errorInfo.readyState = source.readyState; @@ -393,8 +418,19 @@ export const useApiRequest = ( })); setActiveDebugTab(DEBUG_TABS.RESPONSE); - streamMessageUpdate(errorMessage, 'content'); - completeMessage(MESSAGE_STATUS.ERROR); + setMessage((prevMessage) => { + const newMessages = [...prevMessage]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage && lastMessage.status !== MESSAGE_STATUS.COMPLETE && lastMessage.status !== MESSAGE_STATUS.ERROR) { + newMessages[newMessages.length - 1] = { + ...lastMessage, + content: (lastMessage.content || '') + errorMessage, + errorCode: errorCode, + status: MESSAGE_STATUS.ERROR, + }; + } + return newMessages; + }); sseSourceRef.current = null; source.close(); } @@ -446,6 +482,7 @@ export const useApiRequest = ( [ setDebugData, setActiveDebugTab, + setMessage, streamMessageUpdate, completeMessage, t, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 940907f1707..7a8bbbb66cd 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -410,7 +410,7 @@ "以下上游数据可能不可信:": "The following upstream data may not be reliable: ", "以下文件解析失败,已忽略:{{list}}": "The following files failed to parse and have been ignored: {{list}}", "以及": "and", - "仪表盘设置": "Dashboard Settings", + "仪表盘设置": "Dashboard", "价格": "Pricing", "价格摘要": "Price Summary", "价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later", @@ -684,7 +684,7 @@ "其他": "Other", "其他注册选项": "Other registration options", "其他登录选项": "Other login options", - "其他设置": "Other Settings", + "其他设置": "Other", "其他详情": "Other details", "内存 阈值 (%)": "Memory Threshold (%)", "内存使用率超过此值时拒绝请求": "Reject requests when memory usage exceeds this value", @@ -705,7 +705,7 @@ "分类名称": "Category Name", "分组": "Group", "分组JSON设置": "Group JSON Settings", - "分组与模型定价设置": "Group and Model Pricing Settings", + "分组与模型定价设置": "Group & Model Pricing", "分组价格": "Group price", "分组倍率": "Group ratio", "分组倍率设置": "Group ratio settings", @@ -831,6 +831,8 @@ "原密码": "Original Password", "原生格式": "Native format", "原生额度": "Raw quota", + "使用原生额度输入": "Use raw quota input", + "收起原生额度输入": "Hide raw quota input", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Deduplication completed: {{before}} keys before deduplication, {{after}} keys after deduplication", "参与官方同步": "Participate in official sync", "参数": "parameter", @@ -1447,7 +1449,7 @@ "思考预算占比": "Thinking budget ratio", "性能指标": "Performance Indicators", "性能监控": "Performance Monitor", - "性能设置": "Performance Settings", + "性能设置": "Performance", "总 GPU 小时": "Total GPU Hours", "总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Total price: text price {{textPrice}} + audio price {{audioPrice}} = {{symbol}}{{total}}", "总分配内存": "Total Allocated Memory", @@ -1604,7 +1606,7 @@ "支付方式名称": "Pay Method Name", "支付方式类型": "Pay Method Type", "支付渠道": "Payment Channels", - "支付设置": "Payment Settings", + "支付设置": "Payment", "支付请求失败": "Payment request failed", "支付金额": "Payment Amount", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", @@ -2013,7 +2015,7 @@ "模型消耗趋势": "Model consumption trend", "模型版本": "Model version", "模型的详细描述和基本特性": "Detailed description and basic characteristics of the model", - "模型相关设置": "Model related settings", + "模型相关设置": "Model Related", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:", "模型管理": "Model Management", "模型组": "Model group", @@ -2026,7 +2028,7 @@ "模型部署": "Model Deployment", "模型部署服务未启用": "Model deployment service is not enabled", "模型部署管理": "Model Deployment Management", - "模型部署设置": "Model Deployment Settings", + "模型部署设置": "Model Deployment", "模型配置": "Model Configuration", "模型重定向": "Model mapping", "模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:", @@ -2176,6 +2178,14 @@ "添加键值对": "Add key-value pair", "添加问答": "Add FAQ", "添加额度": "Add quota", + "减少": "Subtract", + "覆盖": "Override", + "调整额度": "Adjust Quota", + "调整额度成功": "Quota adjusted successfully", + "当前额度": "Current quota", + "变更": "Change", + "预计结果": "Estimated result", + "正数为增加,负数为减少": "Positive to add, negative to subtract", "清理不活跃缓存": "Clean up inactive cache", "清理失败": "Cleanup failed", "清理方式": "Cleanup Mode", @@ -2542,7 +2552,7 @@ "系统文档和帮助信息": "System documentation and help information", "系统消息": "System message", "系统管理功能": "System management functions", - "系统设置": "System Settings", + "系统设置": "System", "系统访问令牌": "System Access Token", "索引": "Index", "紧凑列表": "Compact list", @@ -2572,7 +2582,7 @@ "绘图": "Drawing", "绘图任务记录": "Drawing task records", "绘图日志": "Drawing Logs", - "绘图设置": "Drawing settings", + "绘图设置": "Drawing", "统一的": "The Unified", "统计Tokens": "Statistical Tokens", "统计已重置": "Statistics reset", @@ -2650,7 +2660,7 @@ "聊天区域": "Chat Area", "聊天应用名称": "Chat Application Name", "聊天应用名称已存在,请使用其他名称": "Chat application name already exists, please use another name", - "聊天设置": "Chat settings", + "聊天设置": "Chat", "聊天配置": "Chat configuration", "聊天链接配置错误,请联系管理员": "Chat link configuration error, please contact administrator", "联系我们": "Contact Us", @@ -2901,6 +2911,7 @@ "请求参数无效": "Invalid request parameters", "请求发生错误": "An error occurred with the request", "请求发生错误: ": "An error occurred with the request: ", + "模型价格未配置": "Model Price Not Configured", "请求后端接口失败:": "Failed to request the backend interface: ", "请求失败": "Request failed", "请求头覆盖": "Request header override", @@ -3187,7 +3198,7 @@ "过期时间不能早于当前时间!": "Expiration time cannot be earlier than the current time!", "过期时间快捷设置": "Expiration time quick settings", "过期时间格式错误!": "Expiration time format error!", - "运营设置": "Operation Settings", + "运营设置": "Operation", "运行中": "Running", "运行命令 (Command)": "Command", "运行时长": "Runtime Duration", @@ -3275,7 +3286,7 @@ "通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!", "通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test successful, model ${model} took ${time.toFixed(2)} seconds.", "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test successful, took ${time.toFixed(2)} seconds.", - "速率限制设置": "Rate limit settings", + "速率限制设置": "Rate Limit", "逻辑": "Logic", "邀请": "Invitations", "邀请人": "Inviter", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index bbec562ff2a..b870cb37c92 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -695,7 +695,7 @@ "分类名称": "Nom de la catégorie", "分组": "Groupe", "分组JSON设置": "Group JSON Settings", - "分组与模型定价设置": "Groupe et tarification", + "分组与模型定价设置": "Groupes & tarification des modèles", "分组价格": "Prix de groupe", "分组倍率": "Ratio", "分组倍率设置": "Ratio de groupe", @@ -821,6 +821,8 @@ "原密码": "Mot de passe original", "原生格式": "Format natif", "原生额度": "Quota brut", + "使用原生额度输入": "Saisir le quota brut", + "收起原生额度输入": "Masquer la saisie du quota brut", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Doublons supprimés : {{before}} clés avant, {{after}} clés après", "参与官方同步": "Participer à la synchronisation officielle", "参数": "paramètre", @@ -1437,7 +1439,7 @@ "思考预算占比": "Ratio du budget de la pensée", "性能指标": "Indicateurs de performance", "性能监控": "Surveillance des performances", - "性能设置": "Paramètres de performance", + "性能设置": "Performance", "总 GPU 小时": "Total GPU Hours", "总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Prix total : prix du texte {{textPrice}} + prix de l'audio {{audioPrice}} = {{symbol}}{{total}}", "总分配内存": "Mémoire totale allouée", @@ -1985,7 +1987,7 @@ "模型消耗趋势": "Tendance de la consommation des modèles", "模型版本": "Version du modèle", "模型的详细描述和基本特性": "Description détaillée et caractéristiques de base du modèle", - "模型相关设置": "Paramètres liés au modèle", + "模型相关设置": "Modèle associé", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "La communauté des modèles a besoin de la contribution de tous. Si vous trouvez des données incorrectes ou si vous souhaitez contribuer à de nouvelles données de modèle, veuillez visiter :", "模型管理": "Modèles", "模型组": "Groupe de modèles", @@ -1998,7 +2000,7 @@ "模型部署": "Model Deployment", "模型部署服务未启用": "Model deployment service is not enabled", "模型部署管理": "Model Deployment Management", - "模型部署设置": "Model Deployment Settings", + "模型部署设置": "Déploiement de modèles", "模型配置": "Configuration du modèle", "模型重定向": "Redirection de modèle", "模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "Les modèles suivants provenant de la redirection n'ont pas été ajoutés à la liste « Modèles », l'appel échouera faute de modèle disponible :", @@ -2144,6 +2146,14 @@ "添加键值对": "Ajouter une paire clé-valeur", "添加问答": "Ajouter une FAQ", "添加额度": "Ajouter un quota", + "减少": "Soustraire", + "覆盖": "Remplacer", + "调整额度": "Ajuster le quota", + "调整额度成功": "Quota ajusté avec succès", + "当前额度": "Quota actuel", + "变更": "Modification", + "预计结果": "Résultat estimé", + "正数为增加,负数为减少": "Positif pour ajouter, négatif pour soustraire", "清理不活跃缓存": "Nettoyer le cache inactif", "清理失败": "Échec du nettoyage", "清理方式": "Mode de nettoyage", @@ -2861,6 +2871,7 @@ "请求参数无效": "Invalid request parameters", "请求发生错误": "Une erreur s'est produite lors de la demande", "请求发生错误: ": "Une erreur s'est produite lors de la demande : ", + "模型价格未配置": "Prix du modèle non configuré", "请求后端接口失败:": "Échec de la requête de l'interface backend : ", "请求失败": "Échec de la demande", "请求头覆盖": "Remplacement des en-têtes de demande", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 11ac9365cd9..18262d09257 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -401,7 +401,7 @@ "以下上游数据可能不可信:": "以下のアップストリームデータは信頼できない可能性があります:", "以下文件解析失败,已忽略:{{list}}": "以下のファイルは解析に失敗したため無視されました:{{list}}", "以及": "および", - "仪表盘设置": "ダッシュボード設定", + "仪表盘设置": "ダッシュボード", "价格": "料金", "价格摘要": "価格概要", "价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later", @@ -665,7 +665,7 @@ "其他": "その他", "其他注册选项": "その他のサインアップオプション", "其他登录选项": "その他のログインオプション", - "其他设置": "その他の設定", + "其他设置": "その他", "其他详情": "Other details", "内存 阈值 (%)": "メモリしきい値 (%)", "内存使用率超过此值时拒绝请求": "メモリ使用率がこの値を超えた場合にリクエストを拒否", @@ -686,7 +686,7 @@ "分类名称": "分類名称", "分组": "グループ", "分组JSON设置": "グループJSON設定", - "分组与模型定价设置": "グループとモデルの料金設定", + "分组与模型定价设置": "グループ&モデル料金設定", "分组价格": "グループ料金", "分组倍率": "グループレート", "分组倍率设置": "グループ倍率設定", @@ -812,6 +812,8 @@ "原密码": "現在のパスワード", "原生格式": "ネイティブ形式", "原生额度": "生クォータ", + "使用原生额度输入": "生クォータで入力", + "收起原生额度输入": "生クォータ入力を非表示", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "重複排除完了:重複排除前 {{before}} 個のAPIキー、重複排除後 {{after}} 個のAPIキー", "参与官方同步": "公式との同期", "参数": "パラメータ", @@ -1420,7 +1422,7 @@ "思考预算占比": "思考予算の割合", "性能指标": "性能指標", "性能监控": "パフォーマンス監視", - "性能设置": "パフォーマンス設定", + "性能设置": "パフォーマンス", "总 GPU 小时": "Total GPU Hours", "总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "合計料金:テキスト料金 {{textPrice}} + オーディオ料金 {{audioPrice}} = {{symbol}}{{total}}", "总分配内存": "総割り当てメモリ", @@ -1569,7 +1571,7 @@ "支付方式名称": "決済方法名", "支付方式类型": "決済方法タイプ", "支付渠道": "決済チャネル", - "支付设置": "決済設定", + "支付设置": "決済", "支付请求失败": "決済リクエストに失敗しました", "支付金额": "決済金額", "支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能", @@ -1968,7 +1970,7 @@ "模型消耗趋势": "モデル消費推移", "模型版本": "モデルバージョン", "模型的详细描述和基本特性": "モデルの詳細な説明と基本的な特徴", - "模型相关设置": "モデル関連設定", + "模型相关设置": "モデル関連", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "モデルコミュニティは皆様の協力によって維持されています。データに誤りがある場合や、新規モデルデータをコントリビュートしたい場合は、以下にアクセスしてください:", "模型管理": "モデル管理", "模型组": "モデルグループ", @@ -1981,7 +1983,7 @@ "模型部署": "Model Deployment", "模型部署服务未启用": "Model deployment service is not enabled", "模型部署管理": "Model Deployment Management", - "模型部署设置": "Model Deployment Settings", + "模型部署设置": "モデルデプロイ", "模型配置": "モデル設定", "模型重定向": "モデルマッピング", "模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:", @@ -2127,6 +2129,14 @@ "添加键值对": "キー/値ペア追加", "添加问答": "FAQ追加", "添加额度": "残高追加", + "减少": "減少", + "覆盖": "上書き", + "调整额度": "残高調整", + "调整额度成功": "残高の調整に成功しました", + "当前额度": "現在の残高", + "变更": "変更", + "预计结果": "予想結果", + "正数为增加,负数为减少": "正の数で追加、負の数で減少", "清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ", "清理失败": "クリーンアップに失敗しました", "清理方式": "クリーンアップモード", @@ -2487,7 +2497,7 @@ "系统文档和帮助信息": "システムのドキュメントとヘルプ", "系统消息": "システムメッセージ", "系统管理功能": "システム管理機能", - "系统设置": "システム設定", + "系统设置": "システム", "系统访问令牌": "システムアクセストークン", "索引": "インデックス", "紧凑列表": "コンパクトリスト", @@ -2516,7 +2526,7 @@ "绘图": "画像生成", "绘图任务记录": "画像生成タスク履歴", "绘图日志": "画像生成履歴", - "绘图设置": "画像生成設定", + "绘图设置": "画像生成", "统一的": "統合型", "统计Tokens": "トークン統計", "统计已重置": "統計がリセットされました", @@ -2593,7 +2603,7 @@ "聊天区域": "チャットエリア", "聊天应用名称": "チャットアプリ名", "聊天应用名称已存在,请使用其他名称": "このチャットアプリ名はすでに存在します。別の名称を入力してください", - "聊天设置": "チャット設定", + "聊天设置": "チャット", "聊天配置": "チャット設定", "聊天链接配置错误,请联系管理员": "チャットURLの設定でエラーが発生しました。管理者にお問い合わせください", "联系我们": "お問い合わせ", @@ -2842,6 +2852,7 @@ "请求参数无效": "Invalid request parameters", "请求发生错误": "リクエストでエラーが発生しました", "请求发生错误: ": "リクエストでエラーが発生しました:", + "模型价格未配置": "モデル価格が未設定", "请求后端接口失败:": "バックエンドAPIリクエストに失敗しました:", "请求失败": "リクエストに失敗しました", "请求头覆盖": "リクエストヘッダーの上書き", @@ -3119,7 +3130,7 @@ "过期时间不能早于当前时间!": "有効期限は現在時刻より前に設定できません", "过期时间快捷设置": "有効期限クイック設定", "过期时间格式错误!": "有効期限のフォーマットが正しくありません", - "运营设置": "運用設定", + "运营设置": "運用", "运行中": "Running", "运行命令 (Command)": "Command", "运行时长": "Runtime Duration", @@ -3205,7 +3216,7 @@ "通道 ${name} 余额更新成功!": "チャネル「${name}」のクォータを更新しました。", "通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "チャネル「${name}」のテストに成功しました。モデル「${model}」の所要時間 ${time.toFixed(2)} 秒。", "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "チャネル「${name}」のテストに成功しました。所要時間 ${time.toFixed(2)} 秒。", - "速率限制设置": "レート制限設定", + "速率限制设置": "レート制限", "逻辑": "ロジック", "邀请": "招待", "邀请人": "招待元", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index aa7127b7bcb..ed261834198 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -408,7 +408,7 @@ "以下上游数据可能不可信:": "Следующие upstream данные могут быть недостоверными:", "以下文件解析失败,已忽略:{{list}}": "Не удалось проанализировать следующие файлы, они проигнорированы: {{list}}", "以及": "а также", - "仪表盘设置": "Настройки панели управления", + "仪表盘设置": "Панель управления", "价格": "Цена", "价格摘要": "Сводка цен", "价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later", @@ -680,7 +680,7 @@ "其他": "Другое", "其他注册选项": "Другие варианты регистрации", "其他登录选项": "Другие варианты входа", - "其他设置": "Другие настройки", + "其他设置": "Прочее", "其他详情": "Другие детали", "内存 阈值 (%)": "Порог памяти (%)", "内存使用率超过此值时拒绝请求": "Отклонять запросы, когда использование памяти превышает это значение", @@ -701,7 +701,7 @@ "分类名称": "Название категории", "分组": "Группа", "分组JSON设置": "Group JSON Settings", - "分组与模型定价设置": "Настройки групп и ценообразования моделей", + "分组与模型定价设置": "Группы и цены моделей", "分组价格": "Цена группы", "分组倍率": "Коэффициент группы", "分组倍率设置": "Настройки коэффициента группы", @@ -827,6 +827,8 @@ "原密码": "Старый пароль", "原生格式": "Нативный формат", "原生额度": "Исходный лимит", + "使用原生额度输入": "Ввод в исходных единицах", + "收起原生额度输入": "Скрыть ввод в исходных единицах", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Дедупликация завершена: до дедупликации {{before}} ключей, после дедупликации {{after}} ключей", "参与官方同步": "Участвовать в официальной синхронизации", "参数": "Параметры", @@ -1449,7 +1451,7 @@ "思考预算占比": "Доля бюджета на размышления", "性能指标": "Показатели производительности", "性能监控": "Мониторинг производительности", - "性能设置": "Настройки производительности", + "性能设置": "Производительность", "总 GPU 小时": "Total GPU Hours", "总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Общая цена: цена текста {{textPrice}} + цена аудио {{audioPrice}} = {{symbol}}{{total}}", "总分配内存": "Общая выделенная память", @@ -1598,7 +1600,7 @@ "支付方式名称": "Название метода оплаты", "支付方式类型": "Тип метода оплаты", "支付渠道": "Платежные каналы", - "支付设置": "Настройки оплаты", + "支付设置": "Оплата", "支付请求失败": "Запрос на оплату не удался", "支付金额": "Сумма оплаты", "支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения", @@ -1997,7 +1999,7 @@ "模型消耗趋势": "Тенденции потребления моделей", "模型版本": "Версия модели", "模型的详细描述和基本特性": "Подробное описание и основные характеристики модели", - "模型相关设置": "Настройки, связанные с моделью", + "模型相关设置": "Модели", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "Сообщество моделей требует совместного поддержания всеми. Если вы обнаружили ошибки в данных или хотите внести новые данные о моделях, посетите:", "模型管理": "Управление моделями", "模型组": "Группа моделей", @@ -2010,7 +2012,7 @@ "模型部署": "Model Deployment", "模型部署服务未启用": "Model deployment service is not enabled", "模型部署管理": "Model Deployment Management", - "模型部署设置": "Model Deployment Settings", + "模型部署设置": "Развёртывание моделей", "模型配置": "Конфигурация модели", "模型重定向": "Перенаправление модели", "模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "Следующие модели из перенаправления ещё не добавлены в список «Модели», из-за отсутствия доступных моделей вызовы завершатся ошибкой:", @@ -2156,6 +2158,14 @@ "添加键值对": "Добавить пару ключ-значение", "添加问答": "Добавить вопрос-ответ", "添加额度": "Добавить лимит", + "减少": "Уменьшить", + "覆盖": "Заменить", + "调整额度": "Скорректировать квоту", + "调整额度成功": "Квота успешно скорректирована", + "当前额度": "Текущая квота", + "变更": "Изменение", + "预计结果": "Ожидаемый результат", + "正数为增加,负数为减少": "Положительное для увеличения, отрицательное для уменьшения", "清理不活跃缓存": "Очистить неактивный кэш", "清理失败": "Ошибка очистки", "清理方式": "Режим очистки", @@ -2520,7 +2530,7 @@ "系统文档和帮助信息": "Системная документация и справочная информация", "系统消息": "Системные сообщения", "系统管理功能": "Функции системного управления", - "系统设置": "Системные настройки", + "系统设置": "Система", "系统访问令牌": "Токен доступа к системе", "索引": "Индекс", "紧凑列表": "Компактный список", @@ -2549,7 +2559,7 @@ "绘图": "Рисование", "绘图任务记录": "Записи задач рисования", "绘图日志": "Журнал рисования", - "绘图设置": "Настройки рисования", + "绘图设置": "Рисование", "统一的": "Единый", "统计Tokens": "Статистика токенов", "统计已重置": "Статистика сброшена", @@ -2626,7 +2636,7 @@ "聊天区域": "Область чата", "聊天应用名称": "Название чат-приложения", "聊天应用名称已存在,请使用其他名称": "Название чат-приложения уже существует, используйте другое название", - "聊天设置": "Настройки чата", + "聊天设置": "Чат", "聊天配置": "Конфигурация чата", "聊天链接配置错误,请联系管理员": "Ошибка конфигурации ссылки чата, свяжитесь с администратором", "联系我们": "Свяжитесь с нами", @@ -2875,6 +2885,7 @@ "请求参数无效": "Invalid request parameters", "请求发生错误": "Произошла ошибка запроса", "请求发生错误: ": "Произошла ошибка запроса: ", + "模型价格未配置": "Цена модели не настроена", "请求后端接口失败:": "Не удалось запросить внутренний интерфейс:", "请求失败": "Запрос не удался", "请求头覆盖": "Переопределение заголовков запроса", @@ -3152,7 +3163,7 @@ "过期时间不能早于当前时间!": "Время истечения не может быть раньше текущего времени!", "过期时间快捷设置": "Быстрая настройка времени истечения", "过期时间格式错误!": "Ошибка формата времени истечения!", - "运营设置": "Операционные настройки", + "运营设置": "Операции", "运行中": "Running", "运行命令 (Command)": "Command", "运行时长": "Runtime Duration", @@ -3238,7 +3249,7 @@ "通道 ${name} 余额更新成功!": "Баланс канала ${name} успешно обновлен!", "通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Канал ${name} успешно протестирован, модель ${model} заняла ${time.toFixed(2)} секунд.", "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Канал ${name} успешно протестирован, заняло ${time.toFixed(2)} секунд.", - "速率限制设置": "Настройки ограничения скорости", + "速率限制设置": "Ограничение скорости", "逻辑": "Логика", "邀请": "Приглашение", "邀请人": "Пригласивший", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 7497cf7f3e0..f9e211d29e6 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -402,7 +402,7 @@ "以下上游数据可能不可信:": "Dữ liệu thượng nguồn sau đây có thể không đáng tin cậy: ", "以下文件解析失败,已忽略:{{list}}": "Các tệp sau không phân tích được và đã bị bỏ qua: {{list}}", "以及": "và", - "仪表盘设置": "Cài đặt bảng điều khiển", + "仪表盘设置": "Bảng điều khiển", "价格": "Giá cả", "价格摘要": "Tóm tắt giá", "价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later", @@ -666,7 +666,7 @@ "其他": "Khác", "其他注册选项": "Tùy chọn đăng ký khác", "其他登录选项": "Tùy chọn đăng nhập khác", - "其他设置": "Cài đặt khác", + "其他设置": "Khác", "其他详情": "Other details", "内存 阈值 (%)": "Ngưỡng bộ nhớ (%)", "内存使用率超过此值时拒绝请求": "Từ chối yêu cầu khi sử dụng bộ nhớ vượt quá giá trị này", @@ -687,7 +687,7 @@ "分类名称": "Tên danh mục", "分组": "Nhóm", "分组JSON设置": "Group JSON Settings", - "分组与模型定价设置": "Cài đặt giá nhóm và mô hình", + "分组与模型定价设置": "Nhóm & định giá mô hình", "分组价格": "Giá nhóm", "分组倍率": "Tỷ lệ nhóm", "分组倍率设置": "Cài đặt tỷ lệ nhóm", @@ -813,6 +813,8 @@ "原密码": "Mật khẩu cũ", "原生格式": "Định dạng gốc", "原生额度": "Hạn mức gốc", + "使用原生额度输入": "Nhập hạn mức gốc", + "收起原生额度输入": "Ẩn nhập hạn mức gốc", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Hoàn tất loại bỏ trùng lặp: {{before}} khóa trước khi loại bỏ, {{after}} khóa sau khi loại bỏ", "参与官方同步": "Tham gia đồng bộ chính thức", "参数": "tham số", @@ -1421,7 +1423,7 @@ "思考预算占比": "Tỷ lệ ngân sách tư duy", "性能指标": "Chỉ số hiệu suất", "性能监控": "Giám sát hiệu suất", - "性能设置": "Cài đặt hiệu suất", + "性能设置": "Hiệu suất", "总 GPU 小时": "Total GPU Hours", "总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Tổng giá: giá văn bản {{textPrice}} + giá âm thanh {{audioPrice}} = {{symbol}}{{total}}", "总分配内存": "Tổng bộ nhớ đã phân bổ", @@ -1570,7 +1572,7 @@ "支付方式名称": "Tên phương thức thanh toán", "支付方式类型": "Loại phương thức thanh toán", "支付渠道": "Kênh thanh toán", - "支付设置": "Cài đặt thanh toán", + "支付设置": "Thanh toán", "支付请求失败": "Yêu cầu thanh toán thất bại", "支付金额": "Số tiền thanh toán", "支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh", @@ -1982,7 +1984,7 @@ "模型版本": "Phiên bản mô hình", "模型状态": "Trạng thái mô hình", "模型的详细描述和基本特性": "Mô tả chi tiết và các đặc điểm cơ bản của mô hình", - "模型相关设置": "Cài đặt liên quan đến mô hình", + "模型相关设置": "Mô hình liên quan", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "Cộng đồng mô hình cần sự đóng góp của mọi người. Nếu bạn phát hiện dữ liệu sai hoặc muốn đóng góp dữ liệu mô hình mới, vui lòng truy cập:", "模型管理": "Quản lý mô hình", "模型类型": "Loại mô hình", @@ -1999,7 +2001,7 @@ "模型部署": "Model Deployment", "模型部署服务未启用": "Model deployment service is not enabled", "模型部署管理": "Model Deployment Management", - "模型部署设置": "Model Deployment Settings", + "模型部署设置": "Triển khai mô hình", "模型配置": "Cấu hình mô hình", "模型重定向": "Chuyển hướng mô hình", "模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:", @@ -2221,6 +2223,14 @@ "添加键值对": "Thêm cặp khóa-giá trị", "添加问答": "Thêm hỏi đáp", "添加额度": "Thêm hạn ngạch", + "减少": "Giảm", + "覆盖": "Ghi đè", + "调整额度": "Điều chỉnh hạn ngạch", + "调整额度成功": "Điều chỉnh hạn ngạch thành công", + "当前额度": "Hạn ngạch hiện tại", + "变更": "Thay đổi", + "预计结果": "Kết quả dự kiến", + "正数为增加,负数为减少": "Số dương để tăng, số âm để giảm", "清理": "Dọn dẹp", "清理不活跃缓存": "Xóa cache không hoạt động", "清理历史日志": "Dọn dẹp nhật ký lịch sử", @@ -2764,7 +2774,7 @@ "系统监控": "Giám sát hệ thống", "系统管理": "Quản lý hệ thống", "系统管理功能": "Chức năng quản lý hệ thống", - "系统设置": "Cài đặt hệ thống", + "系统设置": "Hệ thống", "系统访问令牌": "Mã thông báo truy cập hệ thống", "系统负载": "Tải hệ thống", "系统通知": "Thông báo hệ thống", @@ -2817,7 +2827,7 @@ "绘图任务记录": "Hồ sơ tác vụ vẽ", "绘图日志": "Nhật ký vẽ", "绘图模型": "Mô hình vẽ", - "绘图设置": "Cài đặt vẽ", + "绘图设置": "Vẽ", "统一的": "Cổng thống nhất", "统计": "Thống kê", "统计Tokens": "Thống kê Tokens", @@ -2908,7 +2918,7 @@ "聊天区域": "Khu vực trò chuyện", "聊天应用名称": "Tên ứng dụng trò chuyện", "聊天应用名称已存在,请使用其他名称": "Tên ứng dụng trò chuyện đã tồn tại, vui lòng sử dụng tên khác", - "聊天设置": "Cài đặt trò chuyện", + "聊天设置": "Trò chuyện", "聊天配置": "Cấu hình trò chuyện", "聊天链接配置错误,请联系管理员": "Lỗi cấu hình liên kết trò chuyện, vui lòng liên hệ quản trị viên", "联系": "Liên hệ", @@ -3233,6 +3243,7 @@ "请求参数无效": "Invalid request parameters", "请求发生错误": "Đã xảy ra lỗi yêu cầu", "请求发生错误: ": "Đã xảy ra lỗi yêu cầu: ", + "模型价格未配置": "Giá mô hình chưa được cấu hình", "请求后端接口失败:": "Yêu cầu giao diện phụ trợ thất bại: ", "请求失败": "Yêu cầu thất bại", "请求失败,请重试": "Yêu cầu thất bại, vui lòng thử lại", @@ -3597,7 +3608,7 @@ "过期时间不能早于当前时间!": "Thời gian hết hạn không thể sớm hơn thời gian hiện tại!", "过期时间快捷设置": "Cài đặt nhanh thời gian hết hạn", "过期时间格式错误!": "Lỗi định dạng thời gian hết hạn!", - "运营设置": "Cài đặt vận hành", + "运营设置": "Vận hành", "运行中": "Đang chạy", "运行命令 (Command)": "Command", "运行时长": "Runtime Duration", @@ -3721,7 +3732,7 @@ "通道管理": "Quản lý kênh", "通道类型": "Loại kênh", "通道设置": "Cài đặt kênh", - "速率限制设置": "Cài đặt giới hạn tốc độ", + "速率限制设置": "Giới hạn tốc độ", "逻辑": "Logic", "邀请": "Mời", "邀请人": "Người mời", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index cc3fbd2d39e..52b82a94858 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -286,7 +286,7 @@ "以下上游数据可能不可信:": "以下上游数据可能不可信:", "以下文件解析失败,已忽略:{{list}}": "以下文件解析失败,已忽略:{{list}}", "以及": "以及", - "仪表盘设置": "仪表盘设置", + "仪表盘设置": "仪表盘", "价格": "价格", "价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}}": "价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}}", "价格:${{price}} * {{ratioType}}:{{ratio}}": "价格:${{price}} * {{ratioType}}:{{ratio}}", @@ -1610,6 +1610,14 @@ "添加键值对": "添加键值对", "添加问答": "添加问答", "添加额度": "添加额度", + "减少": "减少", + "覆盖": "覆盖", + "调整额度": "调整额度", + "调整额度成功": "调整额度成功", + "当前额度": "当前额度", + "变更": "变更", + "预计结果": "预计结果", + "正数为增加,负数为减少": "正数为增加,负数为减少", "清理方式": "清理方式", "清理日志文件": "清理日志文件", "清空": "清空", @@ -2151,6 +2159,7 @@ "请求参数无效": "请求参数无效", "请求发生错误": "请求发生错误", "请求发生错误: ": "请求发生错误: ", + "模型价格未配置": "模型价格未配置", "请求后端接口失败:": "请求后端接口失败:", "请求失败": "请求失败", "请求头覆盖": "请求头覆盖", @@ -2746,6 +2755,8 @@ "请输入总额度": "请输入总额度", "0 表示不限": "0 表示不限", "原生额度": "原生额度", + "使用原生额度输入": "使用原生额度输入", + "收起原生额度输入": "收起原生额度输入", "升级分组": "升级分组", "不升级": "不升级", "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index ac853e4b8bc..bddb971324c 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -602,7 +602,7 @@ "分类名称": "分類名稱", "分组": "分組", "分组JSON设置": "分組 JSON 設定", - "分组与模型定价设置": "分組與模型定價設定", + "分组与模型定价设置": "分組與模型定價", "分组价格": "分組價格", "分组倍率": "分組倍率", "分组倍率设置": "分組倍率設定", @@ -719,6 +719,8 @@ "原密码": "原密碼", "原生格式": "原生格式", "原生额度": "原生額度", + "使用原生额度输入": "使用原生額度輸入", + "收起原生额度输入": "收起原生額度輸入", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "去重完成:去重前 {{before}} 個密鑰,去重後 {{after}} 個密鑰", "参与官方同步": "參與官方同步", "参数": "參數", @@ -1905,6 +1907,14 @@ "添加键值对": "添加鍵值對", "添加问答": "添加問答", "添加额度": "添加額度", + "减少": "減少", + "覆盖": "覆蓋", + "调整额度": "調整額度", + "调整额度成功": "調整額度成功", + "当前额度": "當前額度", + "变更": "變更", + "预计结果": "預計結果", + "正数为增加,负数为减少": "正數為增加,負數為減少", "清理不活跃缓存": "清理不活躍快取", "清理失败": "清理失敗", "清理方式": "清理方式", @@ -2553,6 +2563,7 @@ "请求参数无效": "請求參數無效", "请求发生错误": "請求發生錯誤", "请求发生错误: ": "請求發生錯誤: ", + "模型价格未配置": "模型價格未配置", "请求后端接口失败:": "請求後端接口失敗:", "请求失败": "請求失敗", "请求头覆盖": "請求頭覆蓋",