Commit c4447460 by CaIon

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
parent 732d501f
...@@ -218,6 +218,39 @@ func ApiSuccess(c *gin.Context, data any) { ...@@ -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) { func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
requestBody, err := GetRequestBody(c) requestBody, err := GetRequestBody(c)
if err != nil { if err != nil {
......
...@@ -62,4 +62,7 @@ const ( ...@@ -62,4 +62,7 @@ const (
// ContextKeyAdminRejectReason stores an admin-only reject/block reason extracted from upstream responses. // 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. // It is not returned to end users, but can be persisted into consume/error logs for debugging.
ContextKeyAdminRejectReason ContextKey = "admin_reject_reason" ContextKeyAdminRejectReason ContextKey = "admin_reject_reason"
// ContextKeyLanguage stores the user's language preference for i18n
ContextKeyLanguage ContextKey = "language"
) )
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"unicode/utf8" "unicode/utf8"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -66,28 +66,19 @@ func AddRedemption(c *gin.Context) { ...@@ -66,28 +66,19 @@ func AddRedemption(c *gin.Context) {
return return
} }
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 { if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
"success": false,
"message": "兑换码名称长度必须在1-20之间",
})
return return
} }
if redemption.Count <= 0 { if redemption.Count <= 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive)
"success": false,
"message": "兑换码个数必须大于0",
})
return return
} }
if redemption.Count > 100 { if redemption.Count > 100 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
"success": false,
"message": "一次兑换码批量生成的个数不能大于 100",
})
return return
} }
if err := validateExpiredTime(redemption.ExpiredTime); err != nil { if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return return
} }
var keys []string var keys []string
...@@ -106,7 +97,7 @@ func AddRedemption(c *gin.Context) { ...@@ -106,7 +97,7 @@ func AddRedemption(c *gin.Context) {
common.SysError("failed to insert redemption: " + err.Error()) common.SysError("failed to insert redemption: " + err.Error())
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "创建兑换码失败,请稍后重试", "message": i18n.T(c, i18n.MsgRedemptionCreateFailed),
"data": keys, "data": keys,
}) })
return return
...@@ -149,8 +140,8 @@ func UpdateRedemption(c *gin.Context) { ...@@ -149,8 +140,8 @@ func UpdateRedemption(c *gin.Context) {
return return
} }
if statusOnly == "" { if statusOnly == "" {
if err := validateExpiredTime(redemption.ExpiredTime); err != nil { if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return return
} }
// If you add more fields, please also update redemption.Update() // If you add more fields, please also update redemption.Update()
...@@ -188,9 +179,9 @@ func DeleteInvalidRedemption(c *gin.Context) { ...@@ -188,9 +179,9 @@ func DeleteInvalidRedemption(c *gin.Context) {
return return
} }
func validateExpiredTime(expired int64) error { func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
if expired != 0 && expired < common.GetTimestamp() { if expired != 0 && expired < common.GetTimestamp() {
return errors.New("过期时间不能早于当前时间") return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid)
} }
return nil return true, ""
} }
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -108,10 +108,7 @@ func GetTokenUsage(c *gin.Context) { ...@@ -108,10 +108,7 @@ func GetTokenUsage(c *gin.Context) {
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false) token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
if err != nil { if err != nil {
common.SysError("failed to get token by key: " + err.Error()) common.SysError("failed to get token by key: " + err.Error())
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
"success": false,
"message": "获取令牌信息失败,请稍后重试",
})
return return
} }
...@@ -145,36 +142,24 @@ func AddToken(c *gin.Context) { ...@@ -145,36 +142,24 @@ func AddToken(c *gin.Context) {
return return
} }
if len(token.Name) > 50 { if len(token.Name) > 50 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
"success": false,
"message": "令牌名称过长",
})
return return
} }
// 非无限额度时,检查额度值是否超出有效范围 // 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota { if !token.UnlimitedQuota {
if token.RemainQuota < 0 { if token.RemainQuota < 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
"success": false,
"message": "额度值不能为负数",
})
return return
} }
maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
if token.RemainQuota > maxQuotaValue { if token.RemainQuota > maxQuotaValue {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
"success": false,
"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
})
return return
} }
} }
key, err := common.GenerateKey() key, err := common.GenerateKey()
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
"success": false,
"message": "生成令牌失败",
})
common.SysLog("failed to generate token key: " + err.Error()) common.SysLog("failed to generate token key: " + err.Error())
return return
} }
...@@ -230,26 +215,17 @@ func UpdateToken(c *gin.Context) { ...@@ -230,26 +215,17 @@ func UpdateToken(c *gin.Context) {
return return
} }
if len(token.Name) > 50 { if len(token.Name) > 50 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
"success": false,
"message": "令牌名称过长",
})
return return
} }
if !token.UnlimitedQuota { if !token.UnlimitedQuota {
if token.RemainQuota < 0 { if token.RemainQuota < 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
"success": false,
"message": "额度值不能为负数",
})
return return
} }
maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
if token.RemainQuota > maxQuotaValue { if token.RemainQuota > maxQuotaValue {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
"success": false,
"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
})
return return
} }
} }
...@@ -260,17 +236,11 @@ func UpdateToken(c *gin.Context) { ...@@ -260,17 +236,11 @@ func UpdateToken(c *gin.Context) {
} }
if token.Status == common.TokenStatusEnabled { if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
"success": false,
"message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期",
})
return return
} }
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota { if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable)
"success": false,
"message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度",
})
return return
} }
} }
...@@ -307,10 +277,7 @@ type TokenBatch struct { ...@@ -307,10 +277,7 @@ type TokenBatch struct {
func DeleteTokenBatch(c *gin.Context) { func DeleteTokenBatch(c *gin.Context) {
tokenBatch := TokenBatch{} tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorI18n(c, i18n.MsgInvalidParams)
"success": false,
"message": "参数错误",
})
return return
} }
userId := c.GetInt("id") userId := c.GetInt("id")
......
...@@ -14,6 +14,7 @@ type UserSetting struct { ...@@ -14,6 +14,7 @@ type UserSetting struct {
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包) BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
} }
var ( var (
......
...@@ -32,8 +32,10 @@ require ( ...@@ -32,8 +32,10 @@ require (
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mewkiz/flac v1.0.13 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/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/samber/hot v0.11.0
github.com/samber/lo v1.52.0 github.com/samber/lo v1.52.0
github.com/shirou/gopsutil v3.21.11+incompatible github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
...@@ -48,7 +50,10 @@ require ( ...@@ -48,7 +50,10 @@ require (
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.45.0
golang.org/x/image v0.23.0 golang.org/x/image v0.23.0
golang.org/x/net v0.47.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/mysql v1.4.3
gorm.io/driver/postgres v1.5.2 gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.2 gorm.io/gorm v1.25.2
...@@ -115,7 +120,6 @@ require ( ...@@ -115,7 +120,6 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/go-singleflightx v0.3.2 // 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/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
...@@ -127,10 +131,7 @@ require ( ...@@ -127,10 +131,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.3 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.21.0 // indirect golang.org/x/arch v0.21.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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 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/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
......
...@@ -213,6 +213,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq ...@@ -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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
...@@ -329,6 +331,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= ...@@ -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/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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/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= ...@@ -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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 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.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.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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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= 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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
......
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
}
This diff is collapsed. Click to expand it.
...@@ -14,6 +14,7 @@ import ( ...@@ -14,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/controller" "github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
...@@ -151,6 +152,7 @@ func main() { ...@@ -151,6 +152,7 @@ func main() {
//server.Use(gzip.Gzip(gzip.DefaultCompression)) //server.Use(gzip.Gzip(gzip.DefaultCompression))
server.Use(middleware.RequestId()) server.Use(middleware.RequestId())
server.Use(middleware.PoweredBy()) server.Use(middleware.PoweredBy())
server.Use(middleware.I18n())
middleware.SetUpLogger(server) middleware.SetUpLogger(server)
// Initialize session store // Initialize session store
store := cookie.NewStore([]byte(common.SessionSecret)) store := cookie.NewStore([]byte(common.SessionSecret))
...@@ -278,5 +280,14 @@ func InitResources() error { ...@@ -278,5 +280,14 @@ func InitResources() error {
// 启动系统监控 // 启动系统监控
common.StartSystemMonitor() 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 return nil
} }
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
}
...@@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next'; ...@@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next';
import UserInfoHeader from './personal/components/UserInfoHeader'; import UserInfoHeader from './personal/components/UserInfoHeader';
import AccountManagement from './personal/cards/AccountManagement'; import AccountManagement from './personal/cards/AccountManagement';
import NotificationSettings from './personal/cards/NotificationSettings'; import NotificationSettings from './personal/cards/NotificationSettings';
import PreferencesSettings from './personal/cards/PreferencesSettings';
import CheckinCalendar from './personal/cards/CheckinCalendar'; import CheckinCalendar from './personal/cards/CheckinCalendar';
import EmailBindModal from './personal/modals/EmailBindModal'; import EmailBindModal from './personal/modals/EmailBindModal';
import WeChatBindModal from './personal/modals/WeChatBindModal'; import WeChatBindModal from './personal/modals/WeChatBindModal';
...@@ -463,6 +464,7 @@ const PersonalSetting = () => { ...@@ -463,6 +464,7 @@ const PersonalSetting = () => {
{/* 账户管理和其他设置 */} {/* 账户管理和其他设置 */}
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'> <div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
{/* 左侧:账户管理设置 */} {/* 左侧:账户管理设置 */}
<div className='flex flex-col gap-4 md:gap-6'>
<AccountManagement <AccountManagement
t={t} t={t}
userState={userState} userState={userState}
...@@ -482,6 +484,10 @@ const PersonalSetting = () => { ...@@ -482,6 +484,10 @@ const PersonalSetting = () => {
onPasskeyDelete={handleRemovePasskey} onPasskeyDelete={handleRemovePasskey}
/> />
{/* 偏好设置(语言等) */}
<PreferencesSettings t={t} />
</div>
{/* 右侧:其他设置 */} {/* 右侧:其他设置 */}
<NotificationSettings <NotificationSettings
t={t} t={t}
......
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, 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,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { reducer, initialState } from './reducer'; import { reducer, initialState } from './reducer';
export const UserContext = React.createContext({ export const UserContext = React.createContext({
...@@ -27,6 +28,21 @@ export const UserContext = React.createContext({ ...@@ -27,6 +28,21 @@ export const UserContext = React.createContext({
export const UserProvider = ({ children }) => { export const UserProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState); 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 ( return (
<UserContext.Provider value={[state, dispatch]}> <UserContext.Provider value={[state, dispatch]}>
......
...@@ -146,10 +146,41 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { ...@@ -146,10 +146,41 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
}, [navigate, t, userDispatch]); }, [navigate, t, userDispatch]);
const handleLanguageChange = useCallback( const handleLanguageChange = useCallback(
(lang) => { async (lang) => {
// Change language immediately for responsive UX
i18n.changeLanguage(lang); 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( const handleThemeToggle = useCallback(
......
...@@ -2787,6 +2787,12 @@ ...@@ -2787,6 +2787,12 @@
"GC execution failed": "GC execution failed", "GC execution failed": "GC execution failed",
"Cache Directory": "Cache Directory", "Cache Directory": "Cache Directory",
"Available": "Available", "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."
} }
} }
...@@ -2732,6 +2732,12 @@ ...@@ -2732,6 +2732,12 @@
"套餐名称": "套餐名称", "套餐名称": "套餐名称",
"应付金额": "应付金额", "应付金额": "应付金额",
"支付": "支付", "支付": "支付",
"管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。" "管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。",
"偏好设置": "偏好设置",
"界面语言和其他个人偏好": "界面语言和其他个人偏好",
"语言偏好": "语言偏好",
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "选择您的首选界面语言,设置将自动保存并同步到所有设备",
"语言偏好已保存": "语言偏好已保存",
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。"
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment