2
0
Эх сурвалжийг харах

feat(i18n): add backend multi-language support with user language preference

- Add go-i18n library for internationalization
- Create i18n package with translation keys and YAML locale files (zh/en)
- Implement i18n middleware for language detection from user settings and Accept-Language header
- Add Language field to UserSetting DTO
- Update API response helpers with i18n support (ApiErrorI18n, ApiSuccessI18n)
- Migrate hardcoded messages in token, redemption, and user controllers
- Add frontend language preference settings component
- Sync language preference across header selector and user settings
- Auto-restore user language preference on login
CaIon 1 долоо хоног өмнө
parent
commit
f60fce6584

+ 33 - 0
common/gin.go

@@ -218,6 +218,39 @@ func ApiSuccess(c *gin.Context, data any) {
 	})
 }
 
+// ApiErrorI18n returns a translated error message based on the user's language preference
+// key is the i18n message key, args is optional template data
+func ApiErrorI18n(c *gin.Context, key string, args ...map[string]any) {
+	msg := TranslateMessage(c, key, args...)
+	c.JSON(http.StatusOK, gin.H{
+		"success": false,
+		"message": msg,
+	})
+}
+
+// ApiSuccessI18n returns a translated success message based on the user's language preference
+func ApiSuccessI18n(c *gin.Context, key string, data any, args ...map[string]any) {
+	msg := TranslateMessage(c, key, args...)
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": msg,
+		"data":    data,
+	})
+}
+
+// TranslateMessage is a helper function that calls i18n.T
+// This function is defined here to avoid circular imports
+// The actual implementation will be set during init
+var TranslateMessage func(c *gin.Context, key string, args ...map[string]any) string
+
+func init() {
+	// Default implementation that returns the key as-is
+	// This will be replaced by i18n.T during i18n initialization
+	TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string {
+		return key
+	}
+}
+
 func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
 	requestBody, err := GetRequestBody(c)
 	if err != nil {

+ 3 - 0
constant/context_key.go

@@ -62,4 +62,7 @@ const (
 	// ContextKeyAdminRejectReason stores an admin-only reject/block reason extracted from upstream responses.
 	// It is not returned to end users, but can be persisted into consume/error logs for debugging.
 	ContextKeyAdminRejectReason ContextKey = "admin_reject_reason"
+
+	// ContextKeyLanguage stores the user's language preference for i18n
+	ContextKeyLanguage ContextKey = "language"
 )

+ 12 - 21
controller/redemption.go

@@ -1,12 +1,12 @@
 package controller
 
 import (
-	"errors"
 	"net/http"
 	"strconv"
 	"unicode/utf8"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/i18n"
 	"github.com/QuantumNous/new-api/model"
 
 	"github.com/gin-gonic/gin"
@@ -66,28 +66,19 @@ func AddRedemption(c *gin.Context) {
 		return
 	}
 	if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "兑换码名称长度必须在1-20之间",
-		})
+		common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
 		return
 	}
 	if redemption.Count <= 0 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "兑换码个数必须大于0",
-		})
+		common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive)
 		return
 	}
 	if redemption.Count > 100 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "一次兑换码批量生成的个数不能大于 100",
-		})
+		common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
 		return
 	}
-	if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
-		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+	if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
 		return
 	}
 	var keys []string
@@ -106,7 +97,7 @@ func AddRedemption(c *gin.Context) {
 			common.SysError("failed to insert redemption: " + err.Error())
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
-				"message": "创建兑换码失败,请稍后重试",
+				"message": i18n.T(c, i18n.MsgRedemptionCreateFailed),
 				"data":    keys,
 			})
 			return
@@ -149,8 +140,8 @@ func UpdateRedemption(c *gin.Context) {
 		return
 	}
 	if statusOnly == "" {
-		if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
-			c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
+			c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
 			return
 		}
 		// If you add more fields, please also update redemption.Update()
@@ -188,9 +179,9 @@ func DeleteInvalidRedemption(c *gin.Context) {
 	return
 }
 
-func validateExpiredTime(expired int64) error {
+func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
 	if expired != 0 && expired < common.GetTimestamp() {
-		return errors.New("过期时间不能早于当前时间")
+		return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid)
 	}
-	return nil
+	return true, ""
 }

+ 12 - 45
controller/token.go

@@ -1,12 +1,12 @@
 package controller
 
 import (
-	"fmt"
 	"net/http"
 	"strconv"
 	"strings"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/i18n"
 	"github.com/QuantumNous/new-api/model"
 
 	"github.com/gin-gonic/gin"
@@ -108,10 +108,7 @@ func GetTokenUsage(c *gin.Context) {
 	token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
 	if err != nil {
 		common.SysError("failed to get token by key: " + err.Error())
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "获取令牌信息失败,请稍后重试",
-		})
+		common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
 		return
 	}
 
@@ -145,36 +142,24 @@ func AddToken(c *gin.Context) {
 		return
 	}
 	if len(token.Name) > 50 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "令牌名称过长",
-		})
+		common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
 		return
 	}
 	// 非无限额度时,检查额度值是否超出有效范围
 	if !token.UnlimitedQuota {
 		if token.RemainQuota < 0 {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "额度值不能为负数",
-			})
+			common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
 			return
 		}
 		maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
 		if token.RemainQuota > maxQuotaValue {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
-			})
+			common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
 			return
 		}
 	}
 	key, err := common.GenerateKey()
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "生成令牌失败",
-		})
+		common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
 		common.SysLog("failed to generate token key: " + err.Error())
 		return
 	}
@@ -230,26 +215,17 @@ func UpdateToken(c *gin.Context) {
 		return
 	}
 	if len(token.Name) > 50 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "令牌名称过长",
-		})
+		common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
 		return
 	}
 	if !token.UnlimitedQuota {
 		if token.RemainQuota < 0 {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "额度值不能为负数",
-			})
+			common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
 			return
 		}
 		maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
 		if token.RemainQuota > maxQuotaValue {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
-			})
+			common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
 			return
 		}
 	}
@@ -260,17 +236,11 @@ func UpdateToken(c *gin.Context) {
 	}
 	if token.Status == common.TokenStatusEnabled {
 		if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期",
-			})
+			common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
 			return
 		}
 		if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度",
-			})
+			common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable)
 			return
 		}
 	}
@@ -307,10 +277,7 @@ type TokenBatch struct {
 func DeleteTokenBatch(c *gin.Context) {
 	tokenBatch := TokenBatch{}
 	if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "参数错误",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	userId := c.GetInt("id")

+ 87 - 223
controller/user.go

@@ -11,6 +11,7 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/i18n"
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/service"
@@ -29,28 +30,19 @@ type LoginRequest struct {
 
 func Login(c *gin.Context) {
 	if !common.PasswordLoginEnabled {
-		c.JSON(http.StatusOK, gin.H{
-			"message": "管理员关闭了密码登录",
-			"success": false,
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
 		return
 	}
 	var loginRequest LoginRequest
 	err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"message": "无效的参数",
-			"success": false,
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	username := loginRequest.Username
 	password := loginRequest.Password
 	if username == "" || password == "" {
-		c.JSON(http.StatusOK, gin.H{
-			"message": "无效的参数",
-			"success": false,
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	user := model.User{
@@ -74,15 +66,12 @@ func Login(c *gin.Context) {
 		session.Set("pending_user_id", user.Id)
 		err := session.Save()
 		if err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"message": "无法保存会话信息,请重试",
-				"success": false,
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
 			return
 		}
 
 		c.JSON(http.StatusOK, gin.H{
-			"message": "请输入两步验证码",
+			"message": i18n.T(c, i18n.MsgUserRequire2FA),
 			"success": true,
 			"data": map[string]interface{}{
 				"require_2fa": true,
@@ -104,10 +93,7 @@ func setupLogin(user *model.User, c *gin.Context) {
 	session.Set("group", user.Group)
 	err := session.Save()
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"message": "无法保存会话信息,请重试",
-			"success": false,
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
 		return
 	}
 	c.JSON(http.StatusOK, gin.H{
@@ -143,65 +129,41 @@ func Logout(c *gin.Context) {
 
 func Register(c *gin.Context) {
 	if !common.RegisterEnabled {
-		c.JSON(http.StatusOK, gin.H{
-			"message": "管理员关闭了新用户注册",
-			"success": false,
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
 		return
 	}
 	if !common.PasswordRegisterEnabled {
-		c.JSON(http.StatusOK, gin.H{
-			"message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册",
-			"success": false,
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserPasswordRegisterDisabled)
 		return
 	}
 	var user model.User
 	err := json.NewDecoder(c.Request.Body).Decode(&user)
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	if err := common.Validate.Struct(&user); err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "输入不合法 " + err.Error(),
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
 		return
 	}
 	if common.EmailVerificationEnabled {
 		if user.Email == "" || user.VerificationCode == "" {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserEmailVerificationRequired)
 			return
 		}
 		if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "验证码错误或已过期",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
 			return
 		}
 	}
 	exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email)
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "数据库错误,请稍后重试",
-		})
+		common.ApiErrorI18n(c, i18n.MsgDatabaseError)
 		common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
 		return
 	}
 	if exist {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "用户名已存在,或已注销",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserExists)
 		return
 	}
 	affCode := user.AffCode // this code is the inviter's code, not the user's own code
@@ -224,20 +186,14 @@ func Register(c *gin.Context) {
 	// 获取插入后的用户ID
 	var insertedUser model.User
 	if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "用户注册失败或用户ID获取失败",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed)
 		return
 	}
 	// 生成默认令牌
 	if constant.GenerateDefaultToken {
 		key, err := common.GenerateKey()
 		if err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "生成默认令牌失败",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserDefaultTokenFailed)
 			common.SysLog("failed to generate token key: " + err.Error())
 			return
 		}
@@ -316,10 +272,7 @@ func GetUser(c *gin.Context) {
 	}
 	myRole := c.GetInt("role")
 	if myRole <= user.Role && myRole != common.RoleRootUser {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无权获取同级或更高等级用户的信息",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
 		return
 	}
 	c.JSON(http.StatusOK, gin.H{
@@ -389,16 +342,10 @@ func TransferAffQuota(c *gin.Context) {
 	}
 	err = user.TransferAffQuotaToQuota(tran.Quota)
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "划转失败 " + err.Error(),
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()})
 		return
 	}
-	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "划转成功",
-	})
+	common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil)
 }
 
 func GetAffCode(c *gin.Context) {
@@ -601,20 +548,14 @@ func UpdateUser(c *gin.Context) {
 	var updatedUser model.User
 	err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
 	if err != nil || updatedUser.Id == 0 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	if updatedUser.Password == "" {
 		updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
 	}
 	if err := common.Validate.Struct(&updatedUser); err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "输入不合法 " + err.Error(),
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
 		return
 	}
 	originUser, err := model.GetUserById(updatedUser.Id, false)
@@ -624,17 +565,11 @@ func UpdateUser(c *gin.Context) {
 	}
 	myRole := c.GetInt("role")
 	if myRole <= originUser.Role && myRole != common.RoleRootUser {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无权更新同权限等级或更高权限等级的用户信息",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
 		return
 	}
 	if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无权将其他用户权限等级提升到大于等于自己的权限等级",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
 		return
 	}
 	if updatedUser.Password == "$I_LOVE_U" {
@@ -659,15 +594,12 @@ func UpdateSelf(c *gin.Context) {
 	var requestData map[string]interface{}
 	err := json.NewDecoder(c.Request.Body).Decode(&requestData)
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 
-	// 检查是否是sidebar_modules更新请求
-	if sidebarModules, exists := requestData["sidebar_modules"]; exists {
+	// 检查是否是用户设置更新请求 (sidebar_modules 或 language)
+	if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists {
 		userId := c.GetInt("id")
 		user, err := model.GetUserById(userId, false)
 		if err != nil {
@@ -686,17 +618,39 @@ func UpdateSelf(c *gin.Context) {
 		// 保存更新后的设置
 		user.SetSetting(currentSetting)
 		if err := user.Update(false); err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "更新设置失败: " + err.Error(),
-			})
+			common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
 			return
 		}
 
-		c.JSON(http.StatusOK, gin.H{
-			"success": true,
-			"message": "设置更新成功",
-		})
+		common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
+		return
+	}
+
+	// 检查是否是语言偏好更新请求
+	if language, langExists := requestData["language"]; langExists {
+		userId := c.GetInt("id")
+		user, err := model.GetUserById(userId, false)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		// 获取当前用户设置
+		currentSetting := user.GetSetting()
+
+		// 更新language字段
+		if langStr, ok := language.(string); ok {
+			currentSetting.Language = langStr
+		}
+
+		// 保存更新后的设置
+		user.SetSetting(currentSetting)
+		if err := user.Update(false); err != nil {
+			common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
+			return
+		}
+
+		common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
 		return
 	}
 
@@ -790,10 +744,7 @@ func DeleteUser(c *gin.Context) {
 	}
 	myRole := c.GetInt("role")
 	if myRole <= originUser.Role {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无权删除同权限等级或更高权限等级的用户",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
 		return
 	}
 	err = model.HardDeleteUserById(id)
@@ -811,10 +762,7 @@ func DeleteSelf(c *gin.Context) {
 	user, _ := model.GetUserById(id, false)
 
 	if user.Role == common.RoleRootUser {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "不能删除超级管理员账户",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
 		return
 	}
 
@@ -835,17 +783,11 @@ func CreateUser(c *gin.Context) {
 	err := json.NewDecoder(c.Request.Body).Decode(&user)
 	user.Username = strings.TrimSpace(user.Username)
 	if err != nil || user.Username == "" || user.Password == "" {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	if err := common.Validate.Struct(&user); err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "输入不合法 " + err.Error(),
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
 		return
 	}
 	if user.DisplayName == "" {
@@ -853,10 +795,7 @@ func CreateUser(c *gin.Context) {
 	}
 	myRole := c.GetInt("role")
 	if user.Role >= myRole {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无法创建权限大于等于自己的用户",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
 		return
 	}
 	// Even for admin users, we cannot fully trust them!
@@ -889,10 +828,7 @@ func ManageUser(c *gin.Context) {
 	err := json.NewDecoder(c.Request.Body).Decode(&req)
 
 	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 	user := model.User{
@@ -901,38 +837,26 @@ func ManageUser(c *gin.Context) {
 	// Fill attributes
 	model.DB.Unscoped().Where(&user).First(&user)
 	if user.Id == 0 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "用户不存在",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserNotExists)
 		return
 	}
 	myRole := c.GetInt("role")
 	if myRole <= user.Role && myRole != common.RoleRootUser {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无权更新同权限等级或更高权限等级的用户信息",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
 		return
 	}
 	switch req.Action {
 	case "disable":
 		user.Status = common.UserStatusDisabled
 		if user.Role == common.RoleRootUser {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无法禁用超级管理员用户",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserCannotDisableRootUser)
 			return
 		}
 	case "enable":
 		user.Status = common.UserStatusEnabled
 	case "delete":
 		if user.Role == common.RoleRootUser {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无法删除超级管理员用户",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
 			return
 		}
 		if err := user.Delete(); err != nil {
@@ -944,33 +868,21 @@ func ManageUser(c *gin.Context) {
 		}
 	case "promote":
 		if myRole != common.RoleRootUser {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "普通管理员用户无法提升其他用户为管理员",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
 			return
 		}
 		if user.Role >= common.RoleAdminUser {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "该用户已经是管理员",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserAlreadyAdmin)
 			return
 		}
 		user.Role = common.RoleAdminUser
 	case "demote":
 		if user.Role == common.RoleRootUser {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无法降级超级管理员用户",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser)
 			return
 		}
 		if user.Role == common.RoleCommonUser {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "该用户已经是普通用户",
-			})
+			common.ApiErrorI18n(c, i18n.MsgUserAlreadyCommon)
 			return
 		}
 		user.Role = common.RoleCommonUser
@@ -996,10 +908,7 @@ func EmailBind(c *gin.Context) {
 	email := c.Query("email")
 	code := c.Query("code")
 	if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "验证码错误或已过期",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
 		return
 	}
 	session := sessions.Default(c)
@@ -1075,10 +984,7 @@ func TopUp(c *gin.Context) {
 	id := c.GetInt("id")
 	lock := getTopUpLock(id)
 	if !lock.TryLock() {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "充值处理中,请稍后重试",
-		})
+		common.ApiErrorI18n(c, i18n.MsgUserTopUpProcessing)
 		return
 	}
 	defer lock.Unlock()
@@ -1117,46 +1023,31 @@ type UpdateUserSettingRequest struct {
 func UpdateUserSetting(c *gin.Context) {
 	var req UpdateUserSettingRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
 		return
 	}
 
 	// 验证预警类型
 	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无效的预警类型",
-		})
+		common.ApiErrorI18n(c, i18n.MsgSettingInvalidType)
 		return
 	}
 
 	// 验证预警阈值
 	if req.QuotaWarningThreshold <= 0 {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "预警阈值必须大于0",
-		})
+		common.ApiErrorI18n(c, i18n.MsgQuotaThresholdGtZero)
 		return
 	}
 
 	// 如果是webhook类型,验证webhook地址
 	if req.QuotaWarningType == dto.NotifyTypeWebhook {
 		if req.WebhookUrl == "" {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "Webhook地址不能为空",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingWebhookEmpty)
 			return
 		}
 		// 验证URL格式
 		if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无效的Webhook地址",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingWebhookInvalid)
 			return
 		}
 	}
@@ -1165,10 +1056,7 @@ func UpdateUserSetting(c *gin.Context) {
 	if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
 		// 验证邮箱格式
 		if !strings.Contains(req.NotificationEmail, "@") {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无效的邮箱地址",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingEmailInvalid)
 			return
 		}
 	}
@@ -1176,26 +1064,17 @@ func UpdateUserSetting(c *gin.Context) {
 	// 如果是Bark类型,验证Bark URL
 	if req.QuotaWarningType == dto.NotifyTypeBark {
 		if req.BarkUrl == "" {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "Bark推送URL不能为空",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlEmpty)
 			return
 		}
 		// 验证URL格式
 		if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无效的Bark推送URL",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlInvalid)
 			return
 		}
 		// 检查是否是HTTP或HTTPS
 		if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "Bark推送URL必须以http://或https://开头",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
 			return
 		}
 	}
@@ -1203,33 +1082,21 @@ func UpdateUserSetting(c *gin.Context) {
 	// 如果是Gotify类型,验证Gotify URL和Token
 	if req.QuotaWarningType == dto.NotifyTypeGotify {
 		if req.GotifyUrl == "" {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "Gotify服务器地址不能为空",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlEmpty)
 			return
 		}
 		if req.GotifyToken == "" {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "Gotify令牌不能为空",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingGotifyTokenEmpty)
 			return
 		}
 		// 验证URL格式
 		if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "无效的Gotify服务器地址",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlInvalid)
 			return
 		}
 		// 检查是否是HTTP或HTTPS
 		if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": "Gotify服务器地址必须以http://或https://开头",
-			})
+			common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
 			return
 		}
 	}
@@ -1289,8 +1156,5 @@ func UpdateUserSetting(c *gin.Context) {
 		return
 	}
 
-	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "设置已更新",
-	})
+	common.ApiSuccessI18n(c, i18n.MsgSettingSaved, nil)
 }

+ 1 - 0
dto/user_settings.go

@@ -14,6 +14,7 @@ type UserSetting struct {
 	RecordIpLog           bool    `json:"record_ip_log,omitempty"`                  // 是否记录请求和错误日志IP
 	SidebarModules        string  `json:"sidebar_modules,omitempty"`                // SidebarModules 左侧边栏模块配置
 	BillingPreference     string  `json:"billing_preference,omitempty"`             // BillingPreference 扣费策略(订阅/钱包)
+	Language              string  `json:"language,omitempty"`                       // Language 用户语言偏好 (zh, en)
 }
 
 var (

+ 6 - 5
go.mod

@@ -32,8 +32,10 @@ require (
 	github.com/jinzhu/copier v0.4.0
 	github.com/joho/godotenv v1.5.1
 	github.com/mewkiz/flac v1.0.13
+	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/pkg/errors v0.9.1
 	github.com/pquerna/otp v1.5.0
+	github.com/samber/hot v0.11.0
 	github.com/samber/lo v1.52.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
 	github.com/shopspring/decimal v1.4.0
@@ -48,7 +50,10 @@ require (
 	golang.org/x/crypto v0.45.0
 	golang.org/x/image v0.23.0
 	golang.org/x/net v0.47.0
-	golang.org/x/sync v0.18.0
+	golang.org/x/sync v0.19.0
+	golang.org/x/sys v0.38.0
+	golang.org/x/text v0.32.0
+	gopkg.in/yaml.v3 v3.0.1
 	gorm.io/driver/mysql v1.4.3
 	gorm.io/driver/postgres v1.5.2
 	gorm.io/gorm v1.25.2
@@ -115,7 +120,6 @@ require (
 	github.com/prometheus/procfs v0.15.1 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/samber/go-singleflightx v0.3.2 // indirect
-	github.com/samber/hot v0.11.0 // indirect
 	github.com/stretchr/objx v0.5.2 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.0 // indirect
@@ -127,10 +131,7 @@ require (
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	golang.org/x/arch v0.21.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
-	golang.org/x/sys v0.38.0 // indirect
-	golang.org/x/text v0.31.0 // indirect
 	google.golang.org/protobuf v1.36.5 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/libc v1.66.10 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.11.0 // indirect

+ 7 - 0
go.sum

@@ -213,6 +213,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
+github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
 github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -329,6 +331,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
 golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
 golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
 golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -349,9 +353,12 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
 golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
 golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

+ 176 - 0
i18n/i18n.go

@@ -0,0 +1,176 @@
+package i18n
+
+import (
+	"embed"
+	"strings"
+	"sync"
+
+	"github.com/gin-gonic/gin"
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+	"golang.org/x/text/language"
+	"gopkg.in/yaml.v3"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+)
+
+const (
+	LangZh      = "zh"
+	LangEn      = "en"
+	DefaultLang = LangEn // Fallback to English if language not supported
+)
+
+//go:embed locales/*.yaml
+var localeFS embed.FS
+
+var (
+	bundle     *i18n.Bundle
+	localizers = make(map[string]*i18n.Localizer)
+	mu         sync.RWMutex
+	initOnce   sync.Once
+)
+
+// Init initializes the i18n bundle and loads all translation files
+func Init() error {
+	var initErr error
+	initOnce.Do(func() {
+		bundle = i18n.NewBundle(language.Chinese)
+		bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
+
+		// Load embedded translation files
+		files := []string{"locales/zh.yaml", "locales/en.yaml"}
+		for _, file := range files {
+			_, err := bundle.LoadMessageFileFS(localeFS, file)
+			if err != nil {
+				initErr = err
+				return
+			}
+		}
+
+		// Pre-create localizers for supported languages
+		localizers[LangZh] = i18n.NewLocalizer(bundle, LangZh)
+		localizers[LangEn] = i18n.NewLocalizer(bundle, LangEn)
+
+		// Set the TranslateMessage function in common package
+		common.TranslateMessage = T
+	})
+	return initErr
+}
+
+// GetLocalizer returns a localizer for the specified language
+func GetLocalizer(lang string) *i18n.Localizer {
+	lang = normalizeLang(lang)
+
+	mu.RLock()
+	loc, ok := localizers[lang]
+	mu.RUnlock()
+
+	if ok {
+		return loc
+	}
+
+	// Create new localizer for unknown language (fallback to default)
+	mu.Lock()
+	defer mu.Unlock()
+
+	// Double-check after acquiring write lock
+	if loc, ok = localizers[lang]; ok {
+		return loc
+	}
+
+	loc = i18n.NewLocalizer(bundle, lang, DefaultLang)
+	localizers[lang] = loc
+	return loc
+}
+
+// T translates a message key using the language from gin context
+func T(c *gin.Context, key string, args ...map[string]any) string {
+	lang := GetLangFromContext(c)
+	return Translate(lang, key, args...)
+}
+
+// Translate translates a message key for the specified language
+func Translate(lang, key string, args ...map[string]any) string {
+	loc := GetLocalizer(lang)
+
+	config := &i18n.LocalizeConfig{
+		MessageID: key,
+	}
+
+	if len(args) > 0 && args[0] != nil {
+		config.TemplateData = args[0]
+	}
+
+	msg, err := loc.Localize(config)
+	if err != nil {
+		// Return key as fallback if translation not found
+		return key
+	}
+	return msg
+}
+
+// GetLangFromContext extracts the language setting from gin context
+func GetLangFromContext(c *gin.Context) string {
+	if c == nil {
+		return DefaultLang
+	}
+
+	// Try to get language from context (set by middleware)
+	if lang := c.GetString(string(constant.ContextKeyLanguage)); lang != "" {
+		return normalizeLang(lang)
+	}
+
+	return DefaultLang
+}
+
+// ParseAcceptLanguage parses the Accept-Language header and returns the preferred language
+func ParseAcceptLanguage(header string) string {
+	if header == "" {
+		return DefaultLang
+	}
+
+	// Simple parsing: take the first language tag
+	parts := strings.Split(header, ",")
+	if len(parts) == 0 {
+		return DefaultLang
+	}
+
+	// Get the first language and remove quality value
+	firstLang := strings.TrimSpace(parts[0])
+	if idx := strings.Index(firstLang, ";"); idx > 0 {
+		firstLang = firstLang[:idx]
+	}
+
+	return normalizeLang(firstLang)
+}
+
+// normalizeLang normalizes language code to supported format
+func normalizeLang(lang string) string {
+	lang = strings.ToLower(strings.TrimSpace(lang))
+
+	// Handle common variations
+	switch {
+	case strings.HasPrefix(lang, "zh"):
+		return LangZh
+	case strings.HasPrefix(lang, "en"):
+		return LangEn
+	default:
+		return DefaultLang
+	}
+}
+
+// SupportedLanguages returns a list of supported language codes
+func SupportedLanguages() []string {
+	return []string{LangZh, LangEn}
+}
+
+// IsSupported checks if a language code is supported
+func IsSupported(lang string) bool {
+	lang = normalizeLang(lang)
+	for _, supported := range SupportedLanguages() {
+		if lang == supported {
+			return true
+		}
+	}
+	return false
+}

+ 270 - 0
i18n/keys.go

@@ -0,0 +1,270 @@
+package i18n
+
+// Message keys for i18n translations
+// Use these constants instead of hardcoded strings
+
+// Common error messages
+const (
+	MsgInvalidParams     = "common.invalid_params"
+	MsgDatabaseError     = "common.database_error"
+	MsgRetryLater        = "common.retry_later"
+	MsgGenerateFailed    = "common.generate_failed"
+	MsgNotFound          = "common.not_found"
+	MsgUnauthorized      = "common.unauthorized"
+	MsgForbidden         = "common.forbidden"
+	MsgInvalidId         = "common.invalid_id"
+	MsgIdEmpty           = "common.id_empty"
+	MsgFeatureDisabled   = "common.feature_disabled"
+	MsgOperationSuccess  = "common.operation_success"
+	MsgOperationFailed   = "common.operation_failed"
+	MsgUpdateSuccess     = "common.update_success"
+	MsgUpdateFailed      = "common.update_failed"
+	MsgCreateSuccess     = "common.create_success"
+	MsgCreateFailed      = "common.create_failed"
+	MsgDeleteSuccess     = "common.delete_success"
+	MsgDeleteFailed      = "common.delete_failed"
+	MsgAlreadyExists     = "common.already_exists"
+	MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
+)
+
+// Token related messages
+const (
+	MsgTokenNameTooLong          = "token.name_too_long"
+	MsgTokenQuotaNegative        = "token.quota_negative"
+	MsgTokenQuotaExceedMax       = "token.quota_exceed_max"
+	MsgTokenGenerateFailed       = "token.generate_failed"
+	MsgTokenGetInfoFailed        = "token.get_info_failed"
+	MsgTokenExpiredCannotEnable  = "token.expired_cannot_enable"
+	MsgTokenExhaustedCannotEable = "token.exhausted_cannot_enable"
+	MsgTokenInvalid              = "token.invalid"
+	MsgTokenNotProvided          = "token.not_provided"
+	MsgTokenExpired              = "token.expired"
+	MsgTokenExhausted            = "token.exhausted"
+	MsgTokenStatusUnavailable    = "token.status_unavailable"
+	MsgTokenDbError              = "token.db_error"
+)
+
+// Redemption related messages
+const (
+	MsgRedemptionNameLength        = "redemption.name_length"
+	MsgRedemptionCountPositive     = "redemption.count_positive"
+	MsgRedemptionCountMax          = "redemption.count_max"
+	MsgRedemptionCreateFailed      = "redemption.create_failed"
+	MsgRedemptionInvalid           = "redemption.invalid"
+	MsgRedemptionUsed              = "redemption.used"
+	MsgRedemptionExpired           = "redemption.expired"
+	MsgRedemptionFailed            = "redemption.failed"
+	MsgRedemptionNotProvided       = "redemption.not_provided"
+	MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
+)
+
+// User related messages
+const (
+	MsgUserPasswordLoginDisabled      = "user.password_login_disabled"
+	MsgUserRegisterDisabled           = "user.register_disabled"
+	MsgUserPasswordRegisterDisabled   = "user.password_register_disabled"
+	MsgUserUsernameOrPasswordEmpty    = "user.username_or_password_empty"
+	MsgUserUsernameOrPasswordError    = "user.username_or_password_error"
+	MsgUserEmailOrPasswordEmpty       = "user.email_or_password_empty"
+	MsgUserExists                     = "user.exists"
+	MsgUserNotExists                  = "user.not_exists"
+	MsgUserDisabled                   = "user.disabled"
+	MsgUserSessionSaveFailed          = "user.session_save_failed"
+	MsgUserRequire2FA                 = "user.require_2fa"
+	MsgUserEmailVerificationRequired  = "user.email_verification_required"
+	MsgUserVerificationCodeError      = "user.verification_code_error"
+	MsgUserInputInvalid               = "user.input_invalid"
+	MsgUserNoPermissionSameLevel      = "user.no_permission_same_level"
+	MsgUserNoPermissionHigherLevel    = "user.no_permission_higher_level"
+	MsgUserCannotCreateHigherLevel    = "user.cannot_create_higher_level"
+	MsgUserCannotDeleteRootUser       = "user.cannot_delete_root_user"
+	MsgUserCannotDisableRootUser      = "user.cannot_disable_root_user"
+	MsgUserCannotDemoteRootUser       = "user.cannot_demote_root_user"
+	MsgUserAlreadyAdmin               = "user.already_admin"
+	MsgUserAlreadyCommon              = "user.already_common"
+	MsgUserAdminCannotPromote         = "user.admin_cannot_promote"
+	MsgUserOriginalPasswordError      = "user.original_password_error"
+	MsgUserInviteQuotaInsufficient    = "user.invite_quota_insufficient"
+	MsgUserTransferQuotaMinimum       = "user.transfer_quota_minimum"
+	MsgUserTransferSuccess            = "user.transfer_success"
+	MsgUserTransferFailed             = "user.transfer_failed"
+	MsgUserTopUpProcessing            = "user.topup_processing"
+	MsgUserRegisterFailed             = "user.register_failed"
+	MsgUserDefaultTokenFailed         = "user.default_token_failed"
+	MsgUserAffCodeEmpty               = "user.aff_code_empty"
+	MsgUserEmailEmpty                 = "user.email_empty"
+	MsgUserGitHubIdEmpty              = "user.github_id_empty"
+	MsgUserDiscordIdEmpty             = "user.discord_id_empty"
+	MsgUserOidcIdEmpty                = "user.oidc_id_empty"
+	MsgUserWeChatIdEmpty              = "user.wechat_id_empty"
+	MsgUserTelegramIdEmpty            = "user.telegram_id_empty"
+	MsgUserTelegramNotBound           = "user.telegram_not_bound"
+	MsgUserLinuxDOIdEmpty             = "user.linux_do_id_empty"
+)
+
+// Quota related messages
+const (
+	MsgQuotaNegative        = "quota.negative"
+	MsgQuotaExceedMax       = "quota.exceed_max"
+	MsgQuotaInsufficient    = "quota.insufficient"
+	MsgQuotaWarningInvalid  = "quota.warning_invalid"
+	MsgQuotaThresholdGtZero = "quota.threshold_gt_zero"
+)
+
+// Subscription related messages
+const (
+	MsgSubscriptionNotEnabled       = "subscription.not_enabled"
+	MsgSubscriptionTitleEmpty       = "subscription.title_empty"
+	MsgSubscriptionPriceNegative    = "subscription.price_negative"
+	MsgSubscriptionPriceMax         = "subscription.price_max"
+	MsgSubscriptionPurchaseLimitNeg = "subscription.purchase_limit_negative"
+	MsgSubscriptionQuotaNegative    = "subscription.quota_negative"
+	MsgSubscriptionGroupNotExists   = "subscription.group_not_exists"
+	MsgSubscriptionResetCycleGtZero = "subscription.reset_cycle_gt_zero"
+	MsgSubscriptionPurchaseMax      = "subscription.purchase_max"
+	MsgSubscriptionInvalidId        = "subscription.invalid_id"
+	MsgSubscriptionInvalidUserId    = "subscription.invalid_user_id"
+)
+
+// Payment related messages
+const (
+	MsgPaymentNotConfigured    = "payment.not_configured"
+	MsgPaymentMethodNotExists  = "payment.method_not_exists"
+	MsgPaymentCallbackError    = "payment.callback_error"
+	MsgPaymentCreateFailed     = "payment.create_failed"
+	MsgPaymentStartFailed      = "payment.start_failed"
+	MsgPaymentAmountTooLow     = "payment.amount_too_low"
+	MsgPaymentStripeNotConfig  = "payment.stripe_not_configured"
+	MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
+	MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
+	MsgPaymentCreemNotConfig   = "payment.creem_not_configured"
+)
+
+// Topup related messages
+const (
+	MsgTopupNotProvided    = "topup.not_provided"
+	MsgTopupOrderNotExists = "topup.order_not_exists"
+	MsgTopupOrderStatus    = "topup.order_status"
+	MsgTopupFailed         = "topup.failed"
+	MsgTopupInvalidQuota   = "topup.invalid_quota"
+)
+
+// Channel related messages
+const (
+	MsgChannelNotExists           = "channel.not_exists"
+	MsgChannelIdFormatError       = "channel.id_format_error"
+	MsgChannelNoAvailableKey      = "channel.no_available_key"
+	MsgChannelGetListFailed       = "channel.get_list_failed"
+	MsgChannelGetTagsFailed       = "channel.get_tags_failed"
+	MsgChannelGetKeyFailed        = "channel.get_key_failed"
+	MsgChannelGetOllamaFailed     = "channel.get_ollama_failed"
+	MsgChannelQueryFailed         = "channel.query_failed"
+	MsgChannelNoValidUpstream     = "channel.no_valid_upstream"
+	MsgChannelUpstreamSaturated   = "channel.upstream_saturated"
+	MsgChannelGetAvailableFailed  = "channel.get_available_failed"
+)
+
+// Model related messages
+const (
+	MsgModelNameEmpty      = "model.name_empty"
+	MsgModelNameExists     = "model.name_exists"
+	MsgModelIdMissing      = "model.id_missing"
+	MsgModelGetListFailed  = "model.get_list_failed"
+	MsgModelGetFailed      = "model.get_failed"
+	MsgModelResetSuccess   = "model.reset_success"
+)
+
+// Vendor related messages
+const (
+	MsgVendorNameEmpty   = "vendor.name_empty"
+	MsgVendorNameExists  = "vendor.name_exists"
+	MsgVendorIdMissing   = "vendor.id_missing"
+)
+
+// Group related messages
+const (
+	MsgGroupNameTypeEmpty = "group.name_type_empty"
+	MsgGroupNameExists    = "group.name_exists"
+	MsgGroupIdMissing     = "group.id_missing"
+)
+
+// Checkin related messages
+const (
+	MsgCheckinDisabled     = "checkin.disabled"
+	MsgCheckinAlreadyToday = "checkin.already_today"
+	MsgCheckinFailed       = "checkin.failed"
+	MsgCheckinQuotaFailed  = "checkin.quota_failed"
+)
+
+// Passkey related messages
+const (
+	MsgPasskeyCreateFailed   = "passkey.create_failed"
+	MsgPasskeyLoginAbnormal  = "passkey.login_abnormal"
+	MsgPasskeyUpdateFailed   = "passkey.update_failed"
+	MsgPasskeyInvalidUserId  = "passkey.invalid_user_id"
+	MsgPasskeyVerifyFailed   = "passkey.verify_failed"
+)
+
+// 2FA related messages
+const (
+	MsgTwoFANotEnabled       = "twofa.not_enabled"
+	MsgTwoFAUserIdEmpty      = "twofa.user_id_empty"
+	MsgTwoFAAlreadyExists    = "twofa.already_exists"
+	MsgTwoFARecordIdEmpty    = "twofa.record_id_empty"
+	MsgTwoFACodeInvalid      = "twofa.code_invalid"
+)
+
+// Rate limit related messages
+const (
+	MsgRateLimitReached      = "rate_limit.reached"
+	MsgRateLimitTotalReached = "rate_limit.total_reached"
+)
+
+// Setting related messages
+const (
+	MsgSettingInvalidType      = "setting.invalid_type"
+	MsgSettingWebhookEmpty     = "setting.webhook_empty"
+	MsgSettingWebhookInvalid   = "setting.webhook_invalid"
+	MsgSettingEmailInvalid     = "setting.email_invalid"
+	MsgSettingBarkUrlEmpty     = "setting.bark_url_empty"
+	MsgSettingBarkUrlInvalid   = "setting.bark_url_invalid"
+	MsgSettingGotifyUrlEmpty   = "setting.gotify_url_empty"
+	MsgSettingGotifyTokenEmpty = "setting.gotify_token_empty"
+	MsgSettingGotifyUrlInvalid = "setting.gotify_url_invalid"
+	MsgSettingUrlMustHttp      = "setting.url_must_http"
+	MsgSettingSaved            = "setting.saved"
+)
+
+// Deployment related messages (io.net)
+const (
+	MsgDeploymentNotEnabled     = "deployment.not_enabled"
+	MsgDeploymentIdRequired     = "deployment.id_required"
+	MsgDeploymentContainerIdReq = "deployment.container_id_required"
+	MsgDeploymentNameEmpty      = "deployment.name_empty"
+	MsgDeploymentNameTaken      = "deployment.name_taken"
+	MsgDeploymentHardwareIdReq  = "deployment.hardware_id_required"
+	MsgDeploymentHardwareInvId  = "deployment.hardware_invalid_id"
+	MsgDeploymentApiKeyRequired = "deployment.api_key_required"
+	MsgDeploymentInvalidPayload = "deployment.invalid_payload"
+	MsgDeploymentNotFound       = "deployment.not_found"
+)
+
+// Performance related messages
+const (
+	MsgPerfDiskCacheCleared = "performance.disk_cache_cleared"
+	MsgPerfStatsReset       = "performance.stats_reset"
+	MsgPerfGcExecuted       = "performance.gc_executed"
+)
+
+// Ability related messages
+const (
+	MsgAbilityDbCorrupted   = "ability.db_corrupted"
+	MsgAbilityRepairRunning = "ability.repair_running"
+)
+
+// OAuth related messages
+const (
+	MsgOAuthInvalidCode = "oauth.invalid_code"
+	MsgOAuthGetUserErr  = "oauth.get_user_error"
+	MsgOAuthAccountUsed = "oauth.account_used"
+)

+ 225 - 0
i18n/locales/en.yaml

@@ -0,0 +1,225 @@
+# English translations
+
+# Common messages
+common.invalid_params: "Invalid parameters"
+common.database_error: "Database error, please try again later"
+common.retry_later: "Please try again later"
+common.generate_failed: "Generation failed"
+common.not_found: "Not found"
+common.unauthorized: "Unauthorized"
+common.forbidden: "Forbidden"
+common.invalid_id: "Invalid ID"
+common.id_empty: "ID is empty!"
+common.feature_disabled: "This feature is not enabled"
+common.operation_success: "Operation successful"
+common.operation_failed: "Operation failed"
+common.update_success: "Update successful"
+common.update_failed: "Update failed"
+common.create_success: "Creation successful"
+common.create_failed: "Creation failed"
+common.delete_success: "Deletion successful"
+common.delete_failed: "Deletion failed"
+common.already_exists: "Already exists"
+common.name_cannot_be_empty: "Name cannot be empty"
+
+# Token messages
+token.name_too_long: "Token name is too long"
+token.quota_negative: "Quota value cannot be negative"
+token.quota_exceed_max: "Quota value exceeds valid range, maximum is {{.Max}}"
+token.generate_failed: "Failed to generate token"
+token.get_info_failed: "Failed to get token info, please try again later"
+token.expired_cannot_enable: "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire"
+token.exhausted_cannot_enable: "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited"
+token.invalid: "Invalid token"
+token.not_provided: "Token not provided"
+token.expired: "This token has expired"
+token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
+token.status_unavailable: "This token status is unavailable"
+token.db_error: "Invalid token, database query error, please contact administrator"
+
+# Redemption messages
+redemption.name_length: "Redemption code name length must be between 1-20"
+redemption.count_positive: "Redemption code count must be greater than 0"
+redemption.count_max: "Maximum 100 redemption codes can be generated at once"
+redemption.create_failed: "Failed to create redemption code, please try again later"
+redemption.invalid: "Invalid redemption code"
+redemption.used: "This redemption code has been used"
+redemption.expired: "This redemption code has expired"
+redemption.failed: "Redemption failed, please try again later"
+redemption.not_provided: "Redemption code not provided"
+redemption.expire_time_invalid: "Expiration time cannot be earlier than current time"
+
+# User messages
+user.password_login_disabled: "Password login has been disabled by administrator"
+user.register_disabled: "New user registration has been disabled by administrator"
+user.password_register_disabled: "Password registration has been disabled by administrator, please use third-party account verification"
+user.username_or_password_empty: "Username or password is empty"
+user.username_or_password_error: "Username or password is incorrect, or user has been banned"
+user.email_or_password_empty: "Email or password is empty!"
+user.exists: "Username already exists or has been deleted"
+user.not_exists: "User does not exist"
+user.disabled: "This user has been disabled"
+user.session_save_failed: "Failed to save session, please try again"
+user.require_2fa: "Please enter two-factor authentication code"
+user.email_verification_required: "Email verification is enabled, please enter email address and verification code"
+user.verification_code_error: "Verification code is incorrect or has expired"
+user.input_invalid: "Invalid input {{.Error}}"
+user.no_permission_same_level: "No permission to access users of same or higher level"
+user.no_permission_higher_level: "No permission to update users of same or higher permission level"
+user.cannot_create_higher_level: "Cannot create users with permission level equal to or higher than yourself"
+user.cannot_delete_root_user: "Cannot delete super administrator account"
+user.cannot_disable_root_user: "Cannot disable super administrator user"
+user.cannot_demote_root_user: "Cannot demote super administrator user"
+user.already_admin: "This user is already an administrator"
+user.already_common: "This user is already a common user"
+user.admin_cannot_promote: "Regular administrators cannot promote other users to administrator"
+user.original_password_error: "Original password is incorrect"
+user.invite_quota_insufficient: "Invitation quota is insufficient!"
+user.transfer_quota_minimum: "Minimum transfer quota is {{.Min}}!"
+user.transfer_success: "Transfer successful"
+user.transfer_failed: "Transfer failed {{.Error}}"
+user.topup_processing: "Top-up is processing, please try again later"
+user.register_failed: "User registration failed or user ID retrieval failed"
+user.default_token_failed: "Failed to generate default token"
+user.aff_code_empty: "Affiliate code is empty!"
+user.email_empty: "Email is empty!"
+user.github_id_empty: "GitHub ID is empty!"
+user.discord_id_empty: "Discord ID is empty!"
+user.oidc_id_empty: "OIDC ID is empty!"
+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!"
+
+# Quota messages
+quota.negative: "Quota cannot be negative!"
+quota.exceed_max: "Quota value exceeds valid range"
+quota.insufficient: "Insufficient quota"
+quota.warning_invalid: "Invalid warning type"
+quota.threshold_gt_zero: "Warning threshold must be greater than 0"
+
+# Subscription messages
+subscription.not_enabled: "Subscription plan is not enabled"
+subscription.title_empty: "Subscription plan title cannot be empty"
+subscription.price_negative: "Price cannot be negative"
+subscription.price_max: "Price cannot exceed 9999"
+subscription.purchase_limit_negative: "Purchase limit cannot be negative"
+subscription.quota_negative: "Total quota cannot be negative"
+subscription.group_not_exists: "Upgrade group does not exist"
+subscription.reset_cycle_gt_zero: "Custom reset cycle must be greater than 0 seconds"
+subscription.purchase_max: "Purchase limit for this plan has been reached"
+subscription.invalid_id: "Invalid subscription ID"
+subscription.invalid_user_id: "Invalid user ID"
+
+# Payment messages
+payment.not_configured: "Payment information has not been configured by administrator"
+payment.method_not_exists: "Payment method does not exist"
+payment.callback_error: "Callback URL configuration error"
+payment.create_failed: "Failed to create order"
+payment.start_failed: "Failed to start payment"
+payment.amount_too_low: "Plan amount is too low"
+payment.stripe_not_configured: "Stripe is not configured or key is invalid"
+payment.webhook_not_configured: "Webhook is not configured"
+payment.price_id_not_configured: "StripePriceId is not configured for this plan"
+payment.creem_not_configured: "CreemProductId is not configured for this plan"
+
+# Topup messages
+topup.not_provided: "Payment order number not provided"
+topup.order_not_exists: "Top-up order does not exist"
+topup.order_status: "Top-up order status error"
+topup.failed: "Top-up failed, please try again later"
+topup.invalid_quota: "Invalid top-up quota"
+
+# Channel messages
+channel.not_exists: "Channel does not exist"
+channel.id_format_error: "Channel ID format error"
+channel.no_available_key: "No available channel keys"
+channel.get_list_failed: "Failed to get channel list, please try again later"
+channel.get_tags_failed: "Failed to get tags, please try again later"
+channel.get_key_failed: "Failed to get channel key"
+channel.get_ollama_failed: "Failed to get Ollama models"
+channel.query_failed: "Failed to query channel"
+channel.no_valid_upstream: "No valid upstream channel"
+channel.upstream_saturated: "Current group upstream load is saturated, please try again later"
+channel.get_available_failed: "Failed to get available channels for model {{.Model}} under group {{.Group}}"
+
+# Model messages
+model.name_empty: "Model name cannot be empty"
+model.name_exists: "Model name already exists"
+model.id_missing: "Model ID is missing"
+model.get_list_failed: "Failed to get model list, please try again later"
+model.get_failed: "Failed to get upstream models"
+model.reset_success: "Model ratio reset successful"
+
+# Vendor messages
+vendor.name_empty: "Vendor name cannot be empty"
+vendor.name_exists: "Vendor name already exists"
+vendor.id_missing: "Vendor ID is missing"
+
+# Group messages
+group.name_type_empty: "Group name and type cannot be empty"
+group.name_exists: "Group name already exists"
+group.id_missing: "Group ID is missing"
+
+# Checkin messages
+checkin.disabled: "Check-in feature is not enabled"
+checkin.already_today: "Already checked in today"
+checkin.failed: "Check-in failed, please try again later"
+checkin.quota_failed: "Check-in failed: quota update error"
+
+# Passkey messages
+passkey.create_failed: "Unable to create Passkey credential"
+passkey.login_abnormal: "Passkey login status is abnormal"
+passkey.update_failed: "Passkey credential update failed"
+passkey.invalid_user_id: "Invalid user ID"
+passkey.verify_failed: "Passkey verification failed, please try again or contact administrator"
+
+# 2FA messages
+twofa.not_enabled: "User has not enabled 2FA"
+twofa.user_id_empty: "User ID cannot be empty"
+twofa.already_exists: "User already has 2FA configured"
+twofa.record_id_empty: "2FA record ID cannot be empty"
+twofa.code_invalid: "Verification code or backup code is incorrect"
+
+# Rate limit messages
+rate_limit.reached: "You have reached the request limit: maximum {{.Max}} requests in {{.Minutes}} minutes"
+rate_limit.total_reached: "You have reached the total request limit: maximum {{.Max}} requests in {{.Minutes}} minutes, including failed attempts"
+
+# Setting messages
+setting.invalid_type: "Invalid warning type"
+setting.webhook_empty: "Webhook URL cannot be empty"
+setting.webhook_invalid: "Invalid Webhook URL"
+setting.email_invalid: "Invalid email address"
+setting.bark_url_empty: "Bark push URL cannot be empty"
+setting.bark_url_invalid: "Invalid Bark push URL"
+setting.gotify_url_empty: "Gotify server URL cannot be empty"
+setting.gotify_token_empty: "Gotify token cannot be empty"
+setting.gotify_url_invalid: "Invalid Gotify server URL"
+setting.url_must_http: "URL must start with http:// or https://"
+setting.saved: "Settings updated"
+
+# Deployment messages (io.net)
+deployment.not_enabled: "io.net model deployment is not enabled or API key is missing"
+deployment.id_required: "Deployment ID is required"
+deployment.container_id_required: "Container ID is required"
+deployment.name_empty: "Deployment name cannot be empty"
+deployment.name_taken: "Deployment name is not available, please choose a different name"
+deployment.hardware_id_required: "hardware_id parameter is required"
+deployment.hardware_invalid_id: "Invalid hardware_id parameter"
+deployment.api_key_required: "api_key is required"
+deployment.invalid_payload: "Invalid request payload"
+deployment.not_found: "Container details not found"
+
+# Performance messages
+performance.disk_cache_cleared: "Inactive disk cache has been cleared"
+performance.stats_reset: "Statistics have been reset"
+performance.gc_executed: "GC has been executed"
+
+# Ability messages
+ability.db_corrupted: "Database consistency has been compromised"
+ability.repair_running: "A repair task is already running, please try again later"
+
+# OAuth messages
+oauth.invalid_code: "Invalid authorization code"
+oauth.get_user_error: "Failed to get user information"
+oauth.account_used: "This account has been bound to another user"

+ 226 - 0
i18n/locales/zh.yaml

@@ -0,0 +1,226 @@
+# Chinese (Simplified) translations
+# 中文(简体)翻译文件
+
+# Common messages
+common.invalid_params: "无效的参数"
+common.database_error: "数据库错误,请稍后重试"
+common.retry_later: "请稍后重试"
+common.generate_failed: "生成失败"
+common.not_found: "未找到"
+common.unauthorized: "未授权"
+common.forbidden: "无权限"
+common.invalid_id: "无效的ID"
+common.id_empty: "ID 为空!"
+common.feature_disabled: "该功能未启用"
+common.operation_success: "操作成功"
+common.operation_failed: "操作失败"
+common.update_success: "更新成功"
+common.update_failed: "更新失败"
+common.create_success: "创建成功"
+common.create_failed: "创建失败"
+common.delete_success: "删除成功"
+common.delete_failed: "删除失败"
+common.already_exists: "已存在"
+common.name_cannot_be_empty: "名称不能为空"
+
+# Token messages
+token.name_too_long: "令牌名称过长"
+token.quota_negative: "额度值不能为负数"
+token.quota_exceed_max: "额度值超出有效范围,最大值为 {{.Max}}"
+token.generate_failed: "生成令牌失败"
+token.get_info_failed: "获取令牌信息失败,请稍后重试"
+token.expired_cannot_enable: "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期"
+token.exhausted_cannot_enable: "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度"
+token.invalid: "无效的令牌"
+token.not_provided: "未提供令牌"
+token.expired: "该令牌已过期"
+token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
+token.status_unavailable: "该令牌状态不可用"
+token.db_error: "无效的令牌,数据库查询出错,请联系管理员"
+
+# Redemption messages
+redemption.name_length: "兑换码名称长度必须在1-20之间"
+redemption.count_positive: "兑换码个数必须大于0"
+redemption.count_max: "一次兑换码批量生成的个数不能大于 100"
+redemption.create_failed: "创建兑换码失败,请稍后重试"
+redemption.invalid: "无效的兑换码"
+redemption.used: "该兑换码已被使用"
+redemption.expired: "该兑换码已过期"
+redemption.failed: "兑换失败,请稍后重试"
+redemption.not_provided: "未提供兑换码"
+redemption.expire_time_invalid: "过期时间不能早于当前时间"
+
+# User messages
+user.password_login_disabled: "管理员关闭了密码登录"
+user.register_disabled: "管理员关闭了新用户注册"
+user.password_register_disabled: "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册"
+user.username_or_password_empty: "用户名或密码为空"
+user.username_or_password_error: "用户名或密码错误,或用户已被封禁"
+user.email_or_password_empty: "邮箱地址或密码为空!"
+user.exists: "用户名已存在,或已注销"
+user.not_exists: "用户不存在"
+user.disabled: "该用户已被禁用"
+user.session_save_failed: "无法保存会话信息,请重试"
+user.require_2fa: "请输入两步验证码"
+user.email_verification_required: "管理员开启了邮箱验证,请输入邮箱地址和验证码"
+user.verification_code_error: "验证码错误或已过期"
+user.input_invalid: "输入不合法 {{.Error}}"
+user.no_permission_same_level: "无权获取同级或更高等级用户的信息"
+user.no_permission_higher_level: "无权更新同权限等级或更高权限等级的用户信息"
+user.cannot_create_higher_level: "无法创建权限大于等于自己的用户"
+user.cannot_delete_root_user: "不能删除超级管理员账户"
+user.cannot_disable_root_user: "无法禁用超级管理员用户"
+user.cannot_demote_root_user: "无法降级超级管理员用户"
+user.already_admin: "该用户已经是管理员"
+user.already_common: "该用户已经是普通用户"
+user.admin_cannot_promote: "普通管理员用户无法提升其他用户为管理员"
+user.original_password_error: "原密码错误"
+user.invite_quota_insufficient: "邀请额度不足!"
+user.transfer_quota_minimum: "转移额度最小为{{.Min}}!"
+user.transfer_success: "划转成功"
+user.transfer_failed: "划转失败 {{.Error}}"
+user.topup_processing: "充值处理中,请稍后重试"
+user.register_failed: "用户注册失败或用户ID获取失败"
+user.default_token_failed: "生成默认令牌失败"
+user.aff_code_empty: "affCode 为空!"
+user.email_empty: "email 为空!"
+user.github_id_empty: "GitHub id 为空!"
+user.discord_id_empty: "discord id 为空!"
+user.oidc_id_empty: "oidc id 为空!"
+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 为空!"
+
+# Quota messages
+quota.negative: "额度不能为负数!"
+quota.exceed_max: "额度值超出有效范围"
+quota.insufficient: "额度不足"
+quota.warning_invalid: "无效的预警类型"
+quota.threshold_gt_zero: "预警阈值必须大于0"
+
+# Subscription messages
+subscription.not_enabled: "套餐未启用"
+subscription.title_empty: "套餐标题不能为空"
+subscription.price_negative: "价格不能为负数"
+subscription.price_max: "价格不能超过9999"
+subscription.purchase_limit_negative: "购买上限不能为负数"
+subscription.quota_negative: "总额度不能为负数"
+subscription.group_not_exists: "升级分组不存在"
+subscription.reset_cycle_gt_zero: "自定义重置周期需大于0秒"
+subscription.purchase_max: "已达到该套餐购买上限"
+subscription.invalid_id: "无效的订阅ID"
+subscription.invalid_user_id: "无效的用户ID"
+
+# Payment messages
+payment.not_configured: "当前管理员未配置支付信息"
+payment.method_not_exists: "支付方式不存在"
+payment.callback_error: "回调地址配置错误"
+payment.create_failed: "创建订单失败"
+payment.start_failed: "拉起支付失败"
+payment.amount_too_low: "套餐金额过低"
+payment.stripe_not_configured: "Stripe 未配置或密钥无效"
+payment.webhook_not_configured: "Webhook 未配置"
+payment.price_id_not_configured: "该套餐未配置 StripePriceId"
+payment.creem_not_configured: "该套餐未配置 CreemProductId"
+
+# Topup messages
+topup.not_provided: "未提供支付单号"
+topup.order_not_exists: "充值订单不存在"
+topup.order_status: "充值订单状态错误"
+topup.failed: "充值失败,请稍后重试"
+topup.invalid_quota: "无效的充值额度"
+
+# Channel messages
+channel.not_exists: "渠道不存在"
+channel.id_format_error: "渠道ID格式错误"
+channel.no_available_key: "没有可用的渠道密钥"
+channel.get_list_failed: "获取渠道列表失败,请稍后重试"
+channel.get_tags_failed: "获取标签失败,请稍后重试"
+channel.get_key_failed: "获取渠道密钥失败"
+channel.get_ollama_failed: "获取Ollama模型失败"
+channel.query_failed: "查询渠道失败"
+channel.no_valid_upstream: "无有效上游渠道"
+channel.upstream_saturated: "当前分组上游负载已饱和,请稍后再试"
+channel.get_available_failed: "获取分组 {{.Group}} 下模型 {{.Model}} 的可用渠道失败"
+
+# Model messages
+model.name_empty: "模型名称不能为空"
+model.name_exists: "模型名称已存在"
+model.id_missing: "缺少模型 ID"
+model.get_list_failed: "获取模型列表失败,请稍后重试"
+model.get_failed: "获取上游模型失败"
+model.reset_success: "重置模型倍率成功"
+
+# Vendor messages
+vendor.name_empty: "供应商名称不能为空"
+vendor.name_exists: "供应商名称已存在"
+vendor.id_missing: "缺少供应商 ID"
+
+# Group messages
+group.name_type_empty: "组名称和类型不能为空"
+group.name_exists: "组名称已存在"
+group.id_missing: "缺少组 ID"
+
+# Checkin messages
+checkin.disabled: "签到功能未启用"
+checkin.already_today: "今日已签到"
+checkin.failed: "签到失败,请稍后重试"
+checkin.quota_failed: "签到失败:更新额度出错"
+
+# Passkey messages
+passkey.create_failed: "无法创建 Passkey 凭证"
+passkey.login_abnormal: "Passkey 登录状态异常"
+passkey.update_failed: "Passkey 凭证更新失败"
+passkey.invalid_user_id: "无效的用户 ID"
+passkey.verify_failed: "Passkey 验证失败,请重试或联系管理员"
+
+# 2FA messages
+twofa.not_enabled: "用户未启用2FA"
+twofa.user_id_empty: "用户ID不能为空"
+twofa.already_exists: "用户已存在2FA设置"
+twofa.record_id_empty: "2FA记录ID不能为空"
+twofa.code_invalid: "验证码或备用码不正确"
+
+# Rate limit messages
+rate_limit.reached: "您已达到请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次"
+rate_limit.total_reached: "您已达到总请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次,包括失败次数"
+
+# Setting messages
+setting.invalid_type: "无效的预警类型"
+setting.webhook_empty: "Webhook地址不能为空"
+setting.webhook_invalid: "无效的Webhook地址"
+setting.email_invalid: "无效的邮箱地址"
+setting.bark_url_empty: "Bark推送URL不能为空"
+setting.bark_url_invalid: "无效的Bark推送URL"
+setting.gotify_url_empty: "Gotify服务器地址不能为空"
+setting.gotify_token_empty: "Gotify令牌不能为空"
+setting.gotify_url_invalid: "无效的Gotify服务器地址"
+setting.url_must_http: "URL必须以http://或https://开头"
+setting.saved: "设置已更新"
+
+# Deployment messages (io.net)
+deployment.not_enabled: "io.net 模型部署功能未启用或 API 密钥缺失"
+deployment.id_required: "deployment ID 为必填项"
+deployment.container_id_required: "container ID 为必填项"
+deployment.name_empty: "deployment 名称不能为空"
+deployment.name_taken: "deployment 名称已被使用,请选择其他名称"
+deployment.hardware_id_required: "hardware_id 参数为必填项"
+deployment.hardware_invalid_id: "无效的 hardware_id 参数"
+deployment.api_key_required: "api_key 为必填项"
+deployment.invalid_payload: "无效的请求内容"
+deployment.not_found: "未找到容器详情"
+
+# Performance messages
+performance.disk_cache_cleared: "不活跃的磁盘缓存已清理"
+performance.stats_reset: "统计信息已重置"
+performance.gc_executed: "GC 已执行"
+
+# Ability messages
+ability.db_corrupted: "数据库一致性被破坏"
+ability.repair_running: "已经有一个修复任务在运行中,请稍后再试"
+
+# OAuth messages
+oauth.invalid_code: "无效的授权码"
+oauth.get_user_error: "获取用户信息失败"
+oauth.account_used: "该账户已被其他用户绑定"

+ 11 - 0
main.go

@@ -14,6 +14,7 @@ import (
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/controller"
+	"github.com/QuantumNous/new-api/i18n"
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/middleware"
 	"github.com/QuantumNous/new-api/model"
@@ -151,6 +152,7 @@ func main() {
 	//server.Use(gzip.Gzip(gzip.DefaultCompression))
 	server.Use(middleware.RequestId())
 	server.Use(middleware.PoweredBy())
+	server.Use(middleware.I18n())
 	middleware.SetUpLogger(server)
 	// Initialize session store
 	store := cookie.NewStore([]byte(common.SessionSecret))
@@ -278,5 +280,14 @@ func InitResources() error {
 	// 启动系统监控
 	common.StartSystemMonitor()
 
+	// Initialize i18n
+	err = i18n.Init()
+	if err != nil {
+		common.SysError("failed to initialize i18n: " + err.Error())
+		// Don't return error, i18n is not critical
+	} else {
+		common.SysLog("i18n initialized with languages: " + strings.Join(i18n.SupportedLanguages(), ", "))
+	}
+
 	return nil
 }

+ 50 - 0
middleware/i18n.go

@@ -0,0 +1,50 @@
+package middleware
+
+import (
+	"github.com/gin-gonic/gin"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/i18n"
+)
+
+// I18n middleware detects and sets the language preference for the request
+func I18n() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		lang := detectLanguage(c)
+		c.Set(string(constant.ContextKeyLanguage), lang)
+		c.Next()
+	}
+}
+
+// detectLanguage determines the language preference for the request
+// Priority: 1. User setting (if logged in) -> 2. Accept-Language header -> 3. Default language
+func detectLanguage(c *gin.Context) string {
+	// 1. Try to get language from user setting (set by auth middleware)
+	if userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting); ok {
+		if userSetting.Language != "" && i18n.IsSupported(userSetting.Language) {
+			return userSetting.Language
+		}
+	}
+
+	// 2. Parse Accept-Language header
+	acceptLang := c.GetHeader("Accept-Language")
+	if acceptLang != "" {
+		lang := i18n.ParseAcceptLanguage(acceptLang)
+		if i18n.IsSupported(lang) {
+			return lang
+		}
+	}
+
+	// 3. Return default language
+	return i18n.DefaultLang
+}
+
+// GetLanguage returns the current language from gin context
+func GetLanguage(c *gin.Context) string {
+	if lang := c.GetString(string(constant.ContextKeyLanguage)); lang != "" {
+		return lang
+	}
+	return i18n.DefaultLang
+}

+ 24 - 18
web/src/components/settings/PersonalSetting.jsx

@@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next';
 import UserInfoHeader from './personal/components/UserInfoHeader';
 import AccountManagement from './personal/cards/AccountManagement';
 import NotificationSettings from './personal/cards/NotificationSettings';
+import PreferencesSettings from './personal/cards/PreferencesSettings';
 import CheckinCalendar from './personal/cards/CheckinCalendar';
 import EmailBindModal from './personal/modals/EmailBindModal';
 import WeChatBindModal from './personal/modals/WeChatBindModal';
@@ -463,24 +464,29 @@ const PersonalSetting = () => {
           {/* 账户管理和其他设置 */}
           <div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
             {/* 左侧:账户管理设置 */}
-            <AccountManagement
-              t={t}
-              userState={userState}
-              status={status}
-              systemToken={systemToken}
-              setShowEmailBindModal={setShowEmailBindModal}
-              setShowWeChatBindModal={setShowWeChatBindModal}
-              generateAccessToken={generateAccessToken}
-              handleSystemTokenClick={handleSystemTokenClick}
-              setShowChangePasswordModal={setShowChangePasswordModal}
-              setShowAccountDeleteModal={setShowAccountDeleteModal}
-              passkeyStatus={passkeyStatus}
-              passkeySupported={passkeySupported}
-              passkeyRegisterLoading={passkeyRegisterLoading}
-              passkeyDeleteLoading={passkeyDeleteLoading}
-              onPasskeyRegister={handleRegisterPasskey}
-              onPasskeyDelete={handleRemovePasskey}
-            />
+            <div className='flex flex-col gap-4 md:gap-6'>
+              <AccountManagement
+                t={t}
+                userState={userState}
+                status={status}
+                systemToken={systemToken}
+                setShowEmailBindModal={setShowEmailBindModal}
+                setShowWeChatBindModal={setShowWeChatBindModal}
+                generateAccessToken={generateAccessToken}
+                handleSystemTokenClick={handleSystemTokenClick}
+                setShowChangePasswordModal={setShowChangePasswordModal}
+                setShowAccountDeleteModal={setShowAccountDeleteModal}
+                passkeyStatus={passkeyStatus}
+                passkeySupported={passkeySupported}
+                passkeyRegisterLoading={passkeyRegisterLoading}
+                passkeyDeleteLoading={passkeyDeleteLoading}
+                onPasskeyRegister={handleRegisterPasskey}
+                onPasskeyDelete={handleRemovePasskey}
+              />
+
+              {/* 偏好设置(语言等) */}
+              <PreferencesSettings t={t} />
+            </div>
 
             {/* 右侧:其他设置 */}
             <NotificationSettings

+ 186 - 0
web/src/components/settings/personal/cards/PreferencesSettings.jsx

@@ -0,0 +1,186 @@
+/*
+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 [email protected]
+*/
+
+import React, { useState, useEffect, useContext } from 'react';
+import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui';
+import { Languages } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { API, showSuccess, showError } from '../../../../helpers';
+import { UserContext } from '../../../../context/User';
+
+// Language options with native names and flags
+const languageOptions = [
+  { value: 'zh', label: '中文', flag: '🇨🇳' },
+  { value: 'en', label: 'English', flag: '🇺🇸' },
+  { value: 'fr', label: 'Français', flag: '🇫🇷' },
+  { value: 'ru', label: 'Русский', flag: '🇷🇺' },
+  { value: 'ja', label: '日本語', flag: '🇯🇵' },
+  { value: 'vi', label: 'Tiếng Việt', flag: '🇻🇳' },
+];
+
+const PreferencesSettings = ({ t }) => {
+  const { i18n } = useTranslation();
+  const [userState, userDispatch] = useContext(UserContext);
+  const [currentLanguage, setCurrentLanguage] = useState(i18n.language || 'zh');
+  const [loading, setLoading] = useState(false);
+
+  // Load saved language preference from user settings
+  useEffect(() => {
+    if (userState?.user?.setting) {
+      try {
+        const settings = JSON.parse(userState.user.setting);
+        if (settings.language) {
+          setCurrentLanguage(settings.language);
+          // Sync i18n with saved preference
+          if (i18n.language !== settings.language) {
+            i18n.changeLanguage(settings.language);
+          }
+        }
+      } catch (e) {
+        // Ignore parse errors
+      }
+    }
+  }, [userState?.user?.setting, i18n]);
+
+  const handleLanguagePreferenceChange = async (lang) => {
+    if (lang === currentLanguage) return;
+
+    setLoading(true);
+    const previousLang = currentLanguage;
+
+    try {
+      // Update language immediately for responsive UX
+      setCurrentLanguage(lang);
+      i18n.changeLanguage(lang);
+
+      // Save to backend
+      const res = await API.put('/api/user/self', {
+        language: lang,
+      });
+
+      if (res.data.success) {
+        showSuccess(t('语言偏好已保存'));
+        // Update user context with new setting
+        if (userState?.user?.setting) {
+          try {
+            const settings = JSON.parse(userState.user.setting);
+            settings.language = lang;
+            userDispatch({
+              type: 'login',
+              payload: {
+                ...userState.user,
+                setting: JSON.stringify(settings),
+              },
+            });
+          } catch (e) {
+            // Ignore
+          }
+        }
+      } else {
+        showError(res.data.message || t('保存失败'));
+        // Revert on error
+        setCurrentLanguage(previousLang);
+        i18n.changeLanguage(previousLang);
+      }
+    } catch (error) {
+      showError(t('保存失败,请重试'));
+      // Revert on error
+      setCurrentLanguage(previousLang);
+      i18n.changeLanguage(previousLang);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <Card className='!rounded-2xl shadow-sm border-0'>
+      {/* Card Header */}
+      <div className='flex items-center mb-4'>
+        <Avatar size='small' color='violet' className='mr-3 shadow-md'>
+          <Languages size={16} />
+        </Avatar>
+        <div>
+          <Typography.Text className='text-lg font-medium'>
+            {t('偏好设置')}
+          </Typography.Text>
+          <div className='text-xs text-gray-600 dark:text-gray-400'>
+            {t('界面语言和其他个人偏好')}
+          </div>
+        </div>
+      </div>
+
+      {/* Language Setting Card */}
+      <Card className='!rounded-xl border dark:border-gray-700'>
+        <div className='flex flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-4'>
+          <div className='flex items-start w-full sm:w-auto'>
+            <div className='w-12 h-12 rounded-full bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center mr-4 flex-shrink-0'>
+              <Languages
+                size={20}
+                className='text-violet-600 dark:text-violet-400'
+              />
+            </div>
+            <div>
+              <Typography.Title heading={6} className='mb-1'>
+                {t('语言偏好')}
+              </Typography.Title>
+              <Typography.Text type='tertiary' className='text-sm'>
+                {t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
+              </Typography.Text>
+            </div>
+          </div>
+          <Select
+            value={currentLanguage}
+            onChange={handleLanguagePreferenceChange}
+            style={{ width: 180 }}
+            loading={loading}
+            optionList={languageOptions.map((opt) => ({
+              value: opt.value,
+              label: (
+                <div className='flex items-center gap-2'>
+                  <span>{opt.flag}</span>
+                  <span>{opt.label}</span>
+                </div>
+              ),
+            }))}
+            renderSelectedItem={(optionNode) => {
+              const selected = languageOptions.find(
+                (opt) => opt.value === optionNode.value,
+              );
+              return (
+                <div className='flex items-center gap-2'>
+                  <span>{selected?.flag}</span>
+                  <span>{selected?.label}</span>
+                </div>
+              );
+            }}
+          />
+        </div>
+      </Card>
+
+      {/* Additional info */}
+      <div className='mt-4 text-xs text-gray-500 dark:text-gray-400'>
+        <Typography.Text type='tertiary'>
+          {t('提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。')}
+        </Typography.Text>
+      </div>
+    </Card>
+  );
+};
+
+export default PreferencesSettings;

+ 17 - 1
web/src/context/User/index.jsx

@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-import React from 'react';
+import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { reducer, initialState } from './reducer';
 
 export const UserContext = React.createContext({
@@ -27,6 +28,21 @@ export const UserContext = React.createContext({
 
 export const UserProvider = ({ children }) => {
   const [state, dispatch] = React.useReducer(reducer, initialState);
+  const { i18n } = useTranslation();
+
+  // Sync language preference when user data is loaded
+  useEffect(() => {
+    if (state.user?.setting) {
+      try {
+        const settings = JSON.parse(state.user.setting);
+        if (settings.language && settings.language !== i18n.language) {
+          i18n.changeLanguage(settings.language);
+        }
+      } catch (e) {
+        // Ignore parse errors
+      }
+    }
+  }, [state.user?.setting, i18n]);
 
   return (
     <UserContext.Provider value={[state, dispatch]}>

+ 33 - 2
web/src/hooks/common/useHeaderBar.js

@@ -146,10 +146,41 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   }, [navigate, t, userDispatch]);
 
   const handleLanguageChange = useCallback(
-    (lang) => {
+    async (lang) => {
+      // Change language immediately for responsive UX
       i18n.changeLanguage(lang);
+
+      // If user is logged in, save preference to backend
+      if (userState?.user?.id) {
+        try {
+          const res = await API.put('/api/user/self', {
+            language: lang,
+          });
+          if (res.data.success) {
+            // Update user context with new setting
+            if (userState?.user?.setting) {
+              try {
+                const settings = JSON.parse(userState.user.setting);
+                settings.language = lang;
+                userDispatch({
+                  type: 'login',
+                  payload: {
+                    ...userState.user,
+                    setting: JSON.stringify(settings),
+                  },
+                });
+              } catch (e) {
+                // Ignore parse errors
+              }
+            }
+          }
+        } catch (error) {
+          // Silently ignore errors - language was already changed locally
+          console.error('Failed to save language preference:', error);
+        }
+      }
     },
-    [i18n],
+    [i18n, userState, userDispatch],
   );
 
   const handleThemeToggle = useCallback(

+ 7 - 1
web/src/i18n/locales/en.json

@@ -2787,6 +2787,12 @@
     "GC execution failed": "GC execution failed",
     "Cache Directory": "Cache Directory",
     "Available": "Available",
-    "输出价格": "Output Price"
+    "输出价格": "Output Price",
+    "偏好设置": "Preferences",
+    "界面语言和其他个人偏好": "Interface language and other personal preferences",
+    "语言偏好": "Language Preference",
+    "选择您的首选界面语言,设置将自动保存并同步到所有设备": "Select your preferred interface language. Settings will be saved automatically and synced across all devices",
+    "语言偏好已保存": "Language preference saved",
+    "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages."
   }
 }

+ 7 - 1
web/src/i18n/locales/zh.json

@@ -2732,6 +2732,12 @@
     "套餐名称": "套餐名称",
     "应付金额": "应付金额",
     "支付": "支付",
-    "管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。"
+    "管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。",
+    "偏好设置": "偏好设置",
+    "界面语言和其他个人偏好": "界面语言和其他个人偏好",
+    "语言偏好": "语言偏好",
+    "选择您的首选界面语言,设置将自动保存并同步到所有设备": "选择您的首选界面语言,设置将自动保存并同步到所有设备",
+    "语言偏好已保存": "语言偏好已保存",
+    "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。"
   }
 }