refactor: remove LocaleSwitcher component and update locale handling
- Removed LocaleSwitcher from NavUser, SiteHeader, and WorkbenchHeader components. - Updated tests to reflect the removal of LocaleSwitcher. - Changed default locale from "en-US" to "zh-CN" in i18n configuration. - Refactored locale resolution logic to read configured locale without relying on browser language detection. - Updated AgentDesk SDK to support language configuration from public API. - Cleaned up unused auth options fetching in the auth API.
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
language: zh-CN
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8083
|
port: 8083
|
||||||
cors:
|
cors:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
language: zh-CN
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8083
|
port: 8083
|
||||||
cors:
|
cors:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
language: zh-CN
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8083
|
port: 8083
|
||||||
cors:
|
cors:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
language: zh-CN
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8083
|
port: 8083
|
||||||
cors:
|
cors:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"agent-desk/internal/ai/rag/vectordb"
|
"agent-desk/internal/ai/rag/vectordb"
|
||||||
"agent-desk/internal/oidcclient"
|
"agent-desk/internal/oidcclient"
|
||||||
"agent-desk/internal/pkg/config"
|
"agent-desk/internal/pkg/config"
|
||||||
|
"agent-desk/internal/pkg/i18nx"
|
||||||
"agent-desk/internal/pkg/logx"
|
"agent-desk/internal/pkg/logx"
|
||||||
"agent-desk/internal/services/cronx"
|
"agent-desk/internal/services/cronx"
|
||||||
"agent-desk/internal/wxwork"
|
"agent-desk/internal/wxwork"
|
||||||
@@ -20,6 +21,7 @@ func Init(configPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
config.SetCurrent(cfg)
|
config.SetCurrent(cfg)
|
||||||
|
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())
|
||||||
|
|
||||||
logx.Init(logx.Config{
|
logx.Init(logx.Config{
|
||||||
Level: cfg.Logger.Level,
|
Level: cfg.Logger.Level,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
func registerApiAuthRoutes(group *gin.RouterGroup) {
|
func registerApiAuthRoutes(group *gin.RouterGroup) {
|
||||||
group.POST("/login", api.Login)
|
group.POST("/login", api.Login)
|
||||||
group.POST("/logout", api.Logout)
|
group.POST("/logout", api.Logout)
|
||||||
group.GET("/options", api.AuthOptions)
|
|
||||||
group.GET("/profile", api.Profile)
|
group.GET("/profile", api.Profile)
|
||||||
group.GET("/wxwork_callback", api.WxWorkCallback)
|
group.GET("/wxwork_callback", api.WxWorkCallback)
|
||||||
group.POST("/wxwork_exchange", api.WxWorkExchange)
|
group.POST("/wxwork_exchange", api.WxWorkExchange)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
|
|
||||||
func NewServer() (*gin.Engine, error) {
|
func NewServer() (*gin.Engine, error) {
|
||||||
cfg := config.Current()
|
cfg := config.Current()
|
||||||
|
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
printBanner()
|
printBanner()
|
||||||
@@ -156,6 +157,7 @@ func addRouter(app *gin.Engine) {
|
|||||||
|
|
||||||
apiGroup := app.Group("/api")
|
apiGroup := app.Group("/api")
|
||||||
apiGroup.GET("/health", api.Health)
|
apiGroup.GET("/health", api.Health)
|
||||||
|
apiGroup.GET("/config", api.PublicConfig)
|
||||||
registerApiAuthRoutes(apiGroup.Group("/auth"))
|
registerApiAuthRoutes(apiGroup.Group("/auth"))
|
||||||
registerApiChannelRoutes(apiGroup.Group("/channel"))
|
registerApiChannelRoutes(apiGroup.Group("/channel"))
|
||||||
registerApiCustomerRoutes(apiGroup.Group("/customer"))
|
registerApiCustomerRoutes(apiGroup.Group("/customer"))
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
|
|||||||
|
|
||||||
expected := []string{
|
expected := []string{
|
||||||
http.MethodPost + " /api/auth/login",
|
http.MethodPost + " /api/auth/login",
|
||||||
|
http.MethodGet + " /api/config",
|
||||||
http.MethodGet + " /api/health",
|
http.MethodGet + " /api/health",
|
||||||
http.MethodGet + " /api/auth/oidc_login",
|
http.MethodGet + " /api/auth/oidc_login",
|
||||||
http.MethodGet + " /api/auth/oidc_callback",
|
http.MethodGet + " /api/auth/oidc_callback",
|
||||||
@@ -93,8 +94,9 @@ func TestNewServerHealthEndpointIsPublic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewServerExposesPublicAuthOptions(t *testing.T) {
|
func TestNewServerExposesPublicConfig(t *testing.T) {
|
||||||
config.SetCurrent(&config.Config{
|
config.SetCurrent(&config.Config{
|
||||||
|
Language: "zh-CN",
|
||||||
Storage: config.StorageConfig{
|
Storage: config.StorageConfig{
|
||||||
Local: config.LocalStorageConfig{
|
Local: config.LocalStorageConfig{
|
||||||
Root: "storage",
|
Root: "storage",
|
||||||
@@ -116,7 +118,7 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/options", nil))
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
|
||||||
@@ -125,8 +127,9 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
WxWorkEnabled bool `json:"wxworkEnabled"`
|
Language string `json:"language"`
|
||||||
OIDCEnabled bool `json:"oidcEnabled"`
|
WxWorkEnabled bool `json:"wxworkEnabled"`
|
||||||
|
OIDCEnabled bool `json:"oidcEnabled"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
@@ -135,6 +138,9 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) {
|
|||||||
if !body.Success {
|
if !body.Success {
|
||||||
t.Fatalf("success=false, body=%s", rec.Body.String())
|
t.Fatalf("success=false, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
|
if body.Data.Language != "zh-CN" {
|
||||||
|
t.Fatalf("language=%q want zh-CN", body.Data.Language)
|
||||||
|
}
|
||||||
if !body.Data.WxWorkEnabled {
|
if !body.Data.WxWorkEnabled {
|
||||||
t.Fatalf("wxworkEnabled=false want true")
|
t.Fatalf("wxworkEnabled=false want true")
|
||||||
}
|
}
|
||||||
@@ -146,6 +152,29 @@ func TestNewServerExposesPublicAuthOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewServerDoesNotExposeLegacyAuthOptions(t *testing.T) {
|
||||||
|
config.SetCurrent(&config.Config{
|
||||||
|
Storage: config.StorageConfig{
|
||||||
|
Local: config.LocalStorageConfig{
|
||||||
|
Root: "storage",
|
||||||
|
BaseURL: "/storage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app, err := NewServer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/options", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status=%d want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
|
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
|
||||||
config.SetCurrent(&config.Config{
|
config.SetCurrent(&config.Config{
|
||||||
Storage: config.StorageConfig{
|
Storage: config.StorageConfig{
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ func Login(ctx *gin.Context) {
|
|||||||
httpx.WriteJSON(ctx, ret)
|
httpx.WriteJSON(ctx, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthOptions(ctx *gin.Context) {
|
func PublicConfig(ctx *gin.Context) {
|
||||||
cfg := config.Current()
|
cfg := config.Current()
|
||||||
httpx.WriteJSON(ctx, &response.AuthOptionsResponse{
|
httpx.WriteJSON(ctx, &response.PublicConfigResponse{
|
||||||
|
Language: cfg.LanguageOrDefault(),
|
||||||
WxWorkEnabled: cfg.WxWork.Enabled,
|
WxWorkEnabled: cfg.WxWork.Enabled,
|
||||||
OIDCEnabled: cfg.OIDC.Enabled,
|
OIDCEnabled: cfg.OIDC.Enabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Language string `yaml:"language"`
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
DB DBConfig `yaml:"db"`
|
DB DBConfig `yaml:"db"`
|
||||||
Logger LoggerConfig `yaml:"logger"`
|
Logger LoggerConfig `yaml:"logger"`
|
||||||
@@ -21,6 +22,17 @@ type Config struct {
|
|||||||
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
|
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Config) LanguageOrDefault() string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(c.Language)) {
|
||||||
|
case "zh", "zh-cn", "zh_cn", "zh-hans":
|
||||||
|
return "zh-CN"
|
||||||
|
case "en", "en-us", "en_us":
|
||||||
|
return "en-US"
|
||||||
|
default:
|
||||||
|
return "zh-CN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WxWorkNotifyConfig struct {
|
type WxWorkNotifyConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
ToUsers []int64 `yaml:"toUsers"`
|
ToUsers []int64 `yaml:"toUsers"`
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ type LoginResponse struct {
|
|||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthOptionsResponse struct {
|
type PublicConfigResponse struct {
|
||||||
WxWorkEnabled bool `json:"wxworkEnabled"`
|
Language string `json:"language"`
|
||||||
OIDCEnabled bool `json:"oidcEnabled"`
|
WxWorkEnabled bool `json:"wxworkEnabled"`
|
||||||
|
OIDCEnabled bool `json:"oidcEnabled"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (e *I18nError) Error() string {
|
|||||||
if e == nil {
|
if e == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return e.Message(i18nx.LocaleZhCN)
|
return e.Message(i18nx.DefaultLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *I18nError) Unwrap() error {
|
func (e *I18nError) Unwrap() error {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func (e *Error) Error() string {
|
|||||||
if e == nil {
|
if e == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return e.Message(LocaleZhCN)
|
return e.Message(DefaultLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Error) Message(locale string) string {
|
func (e *Error) Message(locale string) string {
|
||||||
|
|||||||
@@ -9,27 +9,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeLocale(t *testing.T) {
|
func TestNormalizeLocale(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
in string
|
in string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "default for blank", in: "", want: LocaleEnUS},
|
{name: "default for blank", in: "", want: LocaleZhCN},
|
||||||
{name: "exact chinese", in: "zh-CN", want: LocaleZhCN},
|
{name: "exact chinese", in: "zh-CN", want: LocaleZhCN},
|
||||||
{name: "underscore chinese", in: "zh_CN", want: LocaleZhCN},
|
{name: "underscore chinese", in: "zh_CN", want: LocaleZhCN},
|
||||||
{name: "short chinese", in: "zh", want: LocaleZhCN},
|
{name: "short chinese", in: "zh", want: LocaleZhCN},
|
||||||
{name: "exact english", in: "en-US", want: LocaleEnUS},
|
{name: "exact english", in: "en-US", want: LocaleEnUS},
|
||||||
{name: "underscore english", in: "en_US", want: LocaleEnUS},
|
{name: "underscore english", in: "en_US", want: LocaleEnUS},
|
||||||
{name: "short english", in: "en", want: LocaleEnUS},
|
{name: "short english", in: "en", want: LocaleEnUS},
|
||||||
{name: "unsupported falls back", in: "fr-FR", want: LocaleEnUS},
|
{name: "unsupported falls back", in: "fr-FR", want: LocaleZhCN},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
tt := tt
|
tt := tt
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
if got := NormalizeLocale(tt.in); got != tt.want {
|
if got := NormalizeLocale(tt.in); got != tt.want {
|
||||||
t.Fatalf("NormalizeLocale(%q) = %q, want %q", tt.in, got, tt.want)
|
t.Fatalf("NormalizeLocale(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -37,36 +34,33 @@ func TestNormalizeLocale(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveLocaleFromHeaders(t *testing.T) {
|
func TestResolveLocaleUsesDefaultLocale(t *testing.T) {
|
||||||
t.Parallel()
|
SetDefaultLocale(LocaleZhCN)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
|
||||||
req.Header.Set("Accept-Language", "fr-FR, en-US;q=0.9, zh-CN;q=0.8")
|
req.Header.Set("Accept-Language", "fr-FR, en-US;q=0.9, zh-CN;q=0.8")
|
||||||
|
|
||||||
if got := ResolveRequestLocale(req); got != LocaleEnUS {
|
if got := ResolveRequestLocale(req); got != LocaleZhCN {
|
||||||
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS)
|
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleZhCN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveLocalePrefersXLocale(t *testing.T) {
|
func TestResolveLocaleIgnoresRequestLocaleHeaders(t *testing.T) {
|
||||||
t.Parallel()
|
SetDefaultLocale(LocaleZhCN)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
|
||||||
req.Header.Set("X-Locale", "en-US")
|
req.Header.Set("X-Locale", "en-US")
|
||||||
req.Header.Set("Accept-Language", "zh-CN")
|
req.Header.Set("Accept-Language", "zh-CN")
|
||||||
|
|
||||||
if got := ResolveRequestLocale(req); got != LocaleEnUS {
|
if got := ResolveRequestLocale(req); got != LocaleZhCN {
|
||||||
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS)
|
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleZhCN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTranslateFallsBackToEnglish(t *testing.T) {
|
func TestTranslateUsesConfiguredDefaultForUnsupportedLocale(t *testing.T) {
|
||||||
t.Parallel()
|
SetDefaultLocale(LocaleZhCN)
|
||||||
|
|
||||||
if got := TLocale(LocaleEnUS, "error.auth.expired"); got != "Your session has expired. Please sign in again." {
|
if got := TLocale(LocaleEnUS, "error.auth.expired"); got != "Your session has expired. Please sign in again." {
|
||||||
t.Fatalf("english translation = %q", got)
|
t.Fatalf("english translation = %q", got)
|
||||||
}
|
}
|
||||||
if got := TLocale("fr-FR", "error.auth.expired"); got != "Your session has expired. Please sign in again." {
|
if got := TLocale("fr-FR", "error.auth.expired"); got != "未登录或登录已过期" {
|
||||||
t.Fatalf("fallback translation = %q", got)
|
t.Fatalf("fallback translation = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +137,7 @@ func TestGetfFallsBackToKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMiddlewareStoresLocale(t *testing.T) {
|
func TestMiddlewareStoresLocale(t *testing.T) {
|
||||||
|
SetDefaultLocale(LocaleZhCN)
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(Middleware())
|
router.Use(Middleware())
|
||||||
@@ -159,7 +154,7 @@ func TestMiddlewareStoresLocale(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
if got := recorder.Body.String(); got != LocaleEnUS {
|
if got := recorder.Body.String(); got != LocaleZhCN {
|
||||||
t.Fatalf("middleware locale = %q, want %q", got, LocaleEnUS)
|
t.Fatalf("middleware locale = %q, want %q", got, LocaleZhCN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/text/language"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LocaleZhCN = "zh-CN"
|
LocaleZhCN = "zh-CN"
|
||||||
LocaleEnUS = "en-US"
|
LocaleEnUS = "en-US"
|
||||||
DefaultLocale = LocaleEnUS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedLocales = map[string]string{
|
var supportedLocales = map[string]string{
|
||||||
@@ -24,6 +22,12 @@ var supportedLocales = map[string]string{
|
|||||||
"en_us": LocaleEnUS,
|
"en_us": LocaleEnUS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var DefaultLocale = LocaleZhCN
|
||||||
|
|
||||||
|
func SetDefaultLocale(locale string) {
|
||||||
|
DefaultLocale = NormalizeLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
func NormalizeLocale(value string) string {
|
func NormalizeLocale(value string) string {
|
||||||
key := strings.ToLower(strings.TrimSpace(value))
|
key := strings.ToLower(strings.TrimSpace(value))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
@@ -35,19 +39,7 @@ func NormalizeLocale(value string) string {
|
|||||||
return DefaultLocale
|
return DefaultLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveRequestLocale(req *http.Request) string {
|
func ResolveRequestLocale(_ *http.Request) string {
|
||||||
if req == nil {
|
|
||||||
return DefaultLocale
|
|
||||||
}
|
|
||||||
if locale := normalizeSupportedLocale(req.Header.Get("X-Locale")); locale != "" {
|
|
||||||
return locale
|
|
||||||
}
|
|
||||||
if locale := resolveAcceptLanguage(req.Header.Get("Accept-Language")); locale != "" {
|
|
||||||
return locale
|
|
||||||
}
|
|
||||||
if locale := normalizeSupportedLocale(req.URL.Query().Get("locale")); locale != "" {
|
|
||||||
return locale
|
|
||||||
}
|
|
||||||
return DefaultLocale
|
return DefaultLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,31 +49,3 @@ func Middleware() gin.HandlerFunc {
|
|||||||
ctx.Next()
|
ctx.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSupportedLocale(value string) string {
|
|
||||||
key := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if locale, ok := supportedLocales[key]; ok {
|
|
||||||
return locale
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAcceptLanguage(value string) string {
|
|
||||||
tags, _, err := language.ParseAcceptLanguage(value)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, tag := range tags {
|
|
||||||
if locale := normalizeSupportedLocale(tag.String()); locale != "" {
|
|
||||||
return locale
|
|
||||||
}
|
|
||||||
base, _ := tag.Base()
|
|
||||||
if locale := normalizeSupportedLocale(base.String()); locale != "" {
|
|
||||||
return locale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { LocaleSwitcher } from "@/components/locale-switcher"
|
|
||||||
import { LoginForm } from "@/components/login-form"
|
import { LoginForm } from "@/components/login-form"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
@@ -6,9 +5,6 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
|
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm md:max-w-4xl">
|
<div className="w-full max-w-sm md:max-w-4xl">
|
||||||
<div className="mb-4 flex justify-end">
|
|
||||||
<LocaleSwitcher />
|
|
||||||
</div>
|
|
||||||
<Suspense fallback={<div className="min-h-96" />}>
|
<Suspense fallback={<div className="min-h-96" />}>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import { LocaleSwitcher } from "@/components/locale-switcher"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||||
import enUSMessages from "@/messages/en-US.json"
|
import enUSMessages from "@/messages/en-US.json"
|
||||||
@@ -39,7 +38,7 @@ export function LegalDocumentPage({ type }: { type: LegalPageType }) {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-svh bg-muted px-6 py-8 md:px-10">
|
<main className="min-h-svh bg-muted px-6 py-8 md:px-10">
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||||
<header className="flex items-center justify-between gap-4">
|
<header className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="flex items-center gap-2 font-medium">
|
||||||
<Image
|
<Image
|
||||||
src="/images/logo.svg"
|
src="/images/logo.svg"
|
||||||
@@ -51,7 +50,6 @@ export function LegalDocumentPage({ type }: { type: LegalPageType }) {
|
|||||||
/>
|
/>
|
||||||
<span>{t("app.brand")}</span>
|
<span>{t("app.brand")}</span>
|
||||||
</div>
|
</div>
|
||||||
<LocaleSwitcher />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card className="bg-card/95">
|
<Card className="bg-card/95">
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { LanguagesIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
|
||||||
import { SUPPORTED_LOCALES, type AppLocale } from "@/i18n/config"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
|
|
||||||
export function LocaleSwitcher() {
|
|
||||||
const t = useI18n()
|
|
||||||
const { locale, setLocale } = useAppLocale()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={<Button variant="outline" size="sm" />}
|
|
||||||
aria-label={t("common.language")}
|
|
||||||
>
|
|
||||||
<LanguagesIcon />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={locale}
|
|
||||||
onValueChange={(value) => setLocale(value as AppLocale)}
|
|
||||||
>
|
|
||||||
{SUPPORTED_LOCALES.map((option) => (
|
|
||||||
<DropdownMenuRadioItem key={option} value={option}>
|
|
||||||
{t(`locale.${option}`)}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,8 @@ import { startTransition, useEffect, useState } from "react"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { fetchAuthOptions, loginWithPassword, type AuthOptions } from "@/lib/api/auth"
|
import { loginWithPassword } from "@/lib/api/auth"
|
||||||
|
import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
|
||||||
import { useI18n } from "@/i18n/provider"
|
import { useI18n } from "@/i18n/provider"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -40,15 +41,15 @@ export function LoginForm({
|
|||||||
const { session } = useAuth()
|
const { session } = useAuth()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const [isWxWorkEnv, setIsWxWorkEnv] = useState(false)
|
const [isWxWorkEnv, setIsWxWorkEnv] = useState(false)
|
||||||
const [authOptions, setAuthOptions] = useState<AuthOptions | null>(null)
|
const [publicConfig, setPublicConfig] = useState<PublicConfig | null>(null)
|
||||||
const [authOptionsError, setAuthOptionsError] = useState<string | null>(null)
|
const [publicConfigError, setPublicConfigError] = useState<string | null>(null)
|
||||||
const nextPath = searchParams.get("next")
|
const nextPath = searchParams.get("next")
|
||||||
const wxworkError = searchParams.get("wxworkError")
|
const wxworkError = searchParams.get("wxworkError")
|
||||||
const oidcError = searchParams.get("oidcError")
|
const oidcError = searchParams.get("oidcError")
|
||||||
const redirectPath =
|
const redirectPath =
|
||||||
nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard"
|
nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard"
|
||||||
const enabledProviderCount =
|
const enabledProviderCount =
|
||||||
Number(authOptions?.wxworkEnabled) + Number(authOptions?.oidcEnabled)
|
Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -75,17 +76,17 @@ export function LoginForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
void fetchAuthOptions()
|
void fetchPublicConfig()
|
||||||
.then((options) => {
|
.then((options) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAuthOptions(options)
|
setPublicConfig(options)
|
||||||
setAuthOptionsError(null)
|
setPublicConfigError(null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAuthOptions(null)
|
setPublicConfig(null)
|
||||||
setAuthOptionsError(error instanceof Error ? error.message : "")
|
setPublicConfigError(error instanceof Error ? error.message : "")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ export function LoginForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authOptionsError) {
|
if (publicConfigError) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
@@ -124,7 +125,7 @@ export function LoginForm({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-lg font-semibold">{t("auth.optionsLoadFailed")}</h1>
|
<h1 className="text-lg font-semibold">{t("auth.optionsLoadFailed")}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{authOptionsError || t("api.requestFailed")}
|
{publicConfigError || t("api.requestFailed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -133,7 +134,7 @@ export function LoginForm({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authOptions) {
|
if (!publicConfig) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
@@ -206,7 +207,7 @@ export function LoginForm({
|
|||||||
enabledProviderCount === 1 ? "grid-cols-1" : "grid-cols-2"
|
enabledProviderCount === 1 ? "grid-cols-1" : "grid-cols-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{authOptions.wxworkEnabled ? (
|
{publicConfig.wxworkEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -228,7 +229,7 @@ export function LoginForm({
|
|||||||
<span>{t("auth.wxworkSignIn")}</span>
|
<span>{t("auth.wxworkSignIn")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{authOptions.oidcEnabled ? (
|
{publicConfig.oidcEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useState } from "react"
|
|||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { useI18n } from "@/i18n/provider"
|
import { useI18n } from "@/i18n/provider"
|
||||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||||
import { LocaleSwitcher } from "@/components/locale-switcher"
|
|
||||||
import { useNotifications } from "@/components/notification-provider"
|
import { useNotifications } from "@/components/notification-provider"
|
||||||
import { PaletteToggle } from "@/components/palette-toggle"
|
import { PaletteToggle } from "@/components/palette-toggle"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
@@ -104,7 +103,6 @@ export function NavUser({
|
|||||||
{t("nav.preferences")}
|
{t("nav.preferences")}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div className="flex items-center gap-2 px-2 pb-2">
|
<div className="flex items-center gap-2 px-2 pb-2">
|
||||||
<LocaleSwitcher />
|
|
||||||
<PaletteToggle />
|
<PaletteToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { describe, it } from "node:test";
|
|||||||
const source = await readFile(new URL("./site-header.tsx", import.meta.url), "utf8");
|
const source = await readFile(new URL("./site-header.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
describe("site header preferences", () => {
|
describe("site header preferences", () => {
|
||||||
it("renders locale, palette, and theme controls in the dashboard header", () => {
|
it("renders palette and theme controls without a locale switcher", () => {
|
||||||
assert.match(source, /import \{ LocaleSwitcher \} from "@\/components\/locale-switcher"/);
|
assert.doesNotMatch(source, /LocaleSwitcher/);
|
||||||
assert.match(source, /import \{ PaletteToggle \} from "@\/components\/palette-toggle"/);
|
assert.match(source, /import \{ PaletteToggle \} from "@\/components\/palette-toggle"/);
|
||||||
assert.match(source, /import \{ ThemeToggle \} from "@\/components\/theme-toggle"/);
|
assert.match(source, /import \{ ThemeToggle \} from "@\/components\/theme-toggle"/);
|
||||||
assert.match(source, /<LocaleSwitcher \/>[\s\S]*<PaletteToggle \/>[\s\S]*<ThemeToggle \/>/);
|
assert.match(source, /<PaletteToggle \/>[\s\S]*<ThemeToggle \/>/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
import { LocaleSwitcher } from "@/components/locale-switcher"
|
|
||||||
import { PaletteToggle } from "@/components/palette-toggle"
|
import { PaletteToggle } from "@/components/palette-toggle"
|
||||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
@@ -78,7 +77,6 @@ export function SiteHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||||
<LocaleSwitcher />
|
|
||||||
<PaletteToggle />
|
<PaletteToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||||
import { LocaleSwitcher } from "@/components/locale-switcher"
|
|
||||||
import { PaletteToggle } from "@/components/palette-toggle"
|
import { PaletteToggle } from "@/components/palette-toggle"
|
||||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
@@ -36,7 +35,6 @@ export function WorkbenchHeader() {
|
|||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<RealtimeConnectionStatus status={realtimeStatus} compact />
|
<RealtimeConnectionStatus status={realtimeStatus} compact />
|
||||||
</div>
|
</div>
|
||||||
<LocaleSwitcher />
|
|
||||||
<PaletteToggle />
|
<PaletteToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<WorkbenchUserMenu />
|
<WorkbenchUserMenu />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async function loadConfig() {
|
|||||||
test("normalizes supported locale aliases", async () => {
|
test("normalizes supported locale aliases", async () => {
|
||||||
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
|
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
|
||||||
|
|
||||||
assert.equal(DEFAULT_LOCALE, "en-US")
|
assert.equal(DEFAULT_LOCALE, "zh-CN")
|
||||||
assert.equal(normalizeLocale("zh-CN"), "zh-CN")
|
assert.equal(normalizeLocale("zh-CN"), "zh-CN")
|
||||||
assert.equal(normalizeLocale("zh_CN"), "zh-CN")
|
assert.equal(normalizeLocale("zh_CN"), "zh-CN")
|
||||||
assert.equal(normalizeLocale("zh"), "zh-CN")
|
assert.equal(normalizeLocale("zh"), "zh-CN")
|
||||||
@@ -35,26 +35,11 @@ test("normalizes supported locale aliases", async () => {
|
|||||||
assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE)
|
assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves browser locale from stored value before navigator languages", async () => {
|
test("reads the configured locale without browser language detection", async () => {
|
||||||
const { resolveBrowserLocale } = await loadConfig()
|
const { configureLocale, readStoredLocale } = await loadConfig()
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(readStoredLocale(), "zh-CN")
|
||||||
resolveBrowserLocale({
|
|
||||||
storedLocale: "en-US",
|
configureLocale("en-US")
|
||||||
navigatorLanguages: ["zh-CN"],
|
assert.equal(readStoredLocale(), "en-US")
|
||||||
}),
|
|
||||||
"en-US"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back through navigator languages", async () => {
|
|
||||||
const { resolveBrowserLocale } = await loadConfig()
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
resolveBrowserLocale({
|
|
||||||
storedLocale: "",
|
|
||||||
navigatorLanguages: ["fr-FR", "en"],
|
|
||||||
}),
|
|
||||||
"en-US"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-34
@@ -1,8 +1,7 @@
|
|||||||
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const
|
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const
|
||||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||||
|
|
||||||
export const DEFAULT_LOCALE: AppLocale = "en-US"
|
export const DEFAULT_LOCALE: AppLocale = "zh-CN"
|
||||||
export const LOCALE_STORAGE_KEY = "cs_ai_agent_locale"
|
|
||||||
|
|
||||||
const LOCALE_ALIASES: Record<string, AppLocale> = {
|
const LOCALE_ALIASES: Record<string, AppLocale> = {
|
||||||
zh: "zh-CN",
|
zh: "zh-CN",
|
||||||
@@ -37,40 +36,13 @@ export function isSupportedLocale(
|
|||||||
return SUPPORTED_LOCALES.includes(value as AppLocale)
|
return SUPPORTED_LOCALES.includes(value as AppLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBrowserLocale({
|
let configuredLocale: AppLocale = DEFAULT_LOCALE
|
||||||
storedLocale,
|
|
||||||
navigatorLanguages,
|
|
||||||
}: {
|
|
||||||
storedLocale?: string | null
|
|
||||||
navigatorLanguages?: readonly string[] | null
|
|
||||||
}): AppLocale {
|
|
||||||
if (isSupportedLocale(storedLocale)) {
|
|
||||||
return storedLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const locale of navigatorLanguages ?? []) {
|
|
||||||
const normalized = normalizeSupportedLocale(locale)
|
|
||||||
if (normalized) {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readStoredLocale(): AppLocale {
|
export function readStoredLocale(): AppLocale {
|
||||||
if (typeof window === "undefined") {
|
return configuredLocale
|
||||||
return DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
return resolveBrowserLocale({
|
|
||||||
storedLocale: window.localStorage.getItem(LOCALE_STORAGE_KEY),
|
|
||||||
navigatorLanguages: window.navigator.languages,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeStoredLocale(locale: AppLocale) {
|
export function configureLocale(locale: string | null | undefined): AppLocale {
|
||||||
if (typeof window === "undefined") {
|
configuredLocale = normalizeLocale(locale)
|
||||||
return
|
return configuredLocale
|
||||||
}
|
|
||||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-13
@@ -12,10 +12,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
DEFAULT_LOCALE,
|
||||||
type AppLocale,
|
type AppLocale,
|
||||||
readStoredLocale,
|
configureLocale,
|
||||||
writeStoredLocale,
|
|
||||||
} from "@/i18n/config"
|
} from "@/i18n/config"
|
||||||
import { translateMessage } from "@/i18n/messages"
|
import { translateMessage } from "@/i18n/messages"
|
||||||
|
import { fetchPublicConfig } from "@/lib/api/config"
|
||||||
|
|
||||||
type LocaleContextValue = {
|
type LocaleContextValue = {
|
||||||
locale: AppLocale
|
locale: AppLocale
|
||||||
@@ -34,11 +34,24 @@ export function AppI18nProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isLocaleReady, setIsLocaleReady] = useState(false)
|
const [isLocaleReady, setIsLocaleReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedLocale = readStoredLocale()
|
let cancelled = false
|
||||||
setLocaleState(storedLocale)
|
|
||||||
document.documentElement.lang = storedLocale
|
fetchPublicConfig()
|
||||||
document.title = translateMessage(storedLocale, "app.metadataTitle")
|
.then((config) => configureLocale(config.language))
|
||||||
setIsLocaleReady(true)
|
.catch(() => configureLocale(DEFAULT_LOCALE))
|
||||||
|
.then((configuredLocale) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLocaleState(configuredLocale)
|
||||||
|
document.documentElement.lang = configuredLocale
|
||||||
|
document.title = translateMessage(configuredLocale, "app.metadataTitle")
|
||||||
|
setIsLocaleReady(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,12 +62,7 @@ export function AppI18nProvider({ children }: { children: ReactNode }) {
|
|||||||
() => ({
|
() => ({
|
||||||
locale,
|
locale,
|
||||||
t: (key, values) => translateMessage(locale, key, values),
|
t: (key, values) => translateMessage(locale, key, values),
|
||||||
setLocale: (nextLocale) => {
|
setLocale: () => {},
|
||||||
setLocaleState(nextLocale)
|
|
||||||
writeStoredLocale(nextLocale)
|
|
||||||
document.documentElement.lang = nextLocale
|
|
||||||
document.title = translateMessage(nextLocale, "app.metadataTitle")
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[locale]
|
[locale]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ export type LoginRequest = {
|
|||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthOptions = {
|
|
||||||
wxworkEnabled: boolean
|
|
||||||
oidcEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAuthOptions() {
|
|
||||||
return request<AuthOptions>("/api/auth/options", {
|
|
||||||
skipAuth: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginWithPassword(payload: LoginRequest) {
|
export async function loginWithPassword(payload: LoginRequest) {
|
||||||
const data = await request<AuthSession>("/api/auth/login", {
|
const data = await request<AuthSession>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expireSession, readSession } from "@/lib/auth"
|
import { expireSession, readSession } from "@/lib/auth"
|
||||||
import { readStoredLocale } from "@/i18n/config"
|
|
||||||
import { translateCurrentMessage } from "@/i18n/messages"
|
import { translateCurrentMessage } from "@/i18n/messages"
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
@@ -50,9 +49,6 @@ function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolea
|
|||||||
) {
|
) {
|
||||||
authHeaders.set("Content-Type", "application/json")
|
authHeaders.set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
const locale = readStoredLocale()
|
|
||||||
authHeaders.set("Accept-Language", locale)
|
|
||||||
authHeaders.set("X-Locale", locale)
|
|
||||||
return authHeaders
|
return authHeaders
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,21 @@ async function loadSdk(config) {
|
|||||||
const sandbox = {
|
const sandbox = {
|
||||||
URL,
|
URL,
|
||||||
console,
|
console,
|
||||||
fetch: async () => ({
|
fetch: async (url) => ({
|
||||||
json: async () => ({
|
json: async () =>
|
||||||
success: true,
|
String(url).endsWith("/api/config")
|
||||||
data: {
|
? {
|
||||||
title: "\u5728\u7ebf\u5ba2\u670d",
|
success: true,
|
||||||
themeColor: "#2563eb",
|
data: {
|
||||||
},
|
language: "en-US",
|
||||||
}),
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
themeColor: "#2563eb",
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
document: {
|
document: {
|
||||||
body,
|
body,
|
||||||
@@ -95,7 +102,7 @@ async function loadSdk(config) {
|
|||||||
return sandbox
|
return sandbox
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushPromises(count = 5) {
|
async function flushPromises(count = 10) {
|
||||||
for (let i = 0; i < count; i += 1) {
|
for (let i = 0; i < count; i += 1) {
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
}
|
}
|
||||||
@@ -133,6 +140,7 @@ test("launcher click creates chat iframe with a freshly resolved userToken", asy
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.ok(launcher)
|
assert.ok(launcher)
|
||||||
|
assert.equal(launcher.children.at(-1)?.textContent, "Support")
|
||||||
|
|
||||||
launcher.click()
|
launcher.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
type NormalizedAgentDeskConfig = AgentDeskConfig & {
|
type NormalizedAgentDeskConfig = AgentDeskConfig & {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
channelId: string
|
channelId: string
|
||||||
|
language: string
|
||||||
position: "left" | "right"
|
position: "left" | "right"
|
||||||
themeColor: string
|
themeColor: string
|
||||||
width: string
|
width: string
|
||||||
@@ -38,22 +39,23 @@ type WidgetConfigResponse = {
|
|||||||
>>
|
>>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWidgetLocale() {
|
type PublicConfigResponse = {
|
||||||
try {
|
success?: boolean
|
||||||
const stored = window.localStorage?.getItem("cs_ai_agent_locale")
|
data?: {
|
||||||
const language = stored || document.documentElement.lang || window.navigator?.language || ""
|
language?: string
|
||||||
return language.toLowerCase().startsWith("zh") ? "zh-CN" : "en-US"
|
|
||||||
} catch {
|
|
||||||
return "en-US"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultWidgetTitle() {
|
function normalizeWidgetLanguage(language: string | undefined) {
|
||||||
return getWidgetLocale() === "en-US" ? "Support" : "\u5728\u7ebf\u5ba2\u670d"
|
return String(language || "").toLowerCase().startsWith("en") ? "en-US" : "zh-CN"
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLauncherText() {
|
function getDefaultWidgetTitle(config?: NormalizedAgentDeskConfig | null) {
|
||||||
return getWidgetLocale() === "en-US" ? "Support" : "\u5ba2\u670d"
|
return normalizeWidgetLanguage(config?.language) === "en-US" ? "Support" : "\u5728\u7ebf\u5ba2\u670d"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLauncherText(config?: NormalizedAgentDeskConfig | null) {
|
||||||
|
return normalizeWidgetLanguage(config?.language) === "en-US" ? "Support" : "\u5ba2\u670d"
|
||||||
}
|
}
|
||||||
|
|
||||||
type FrameMessage =
|
type FrameMessage =
|
||||||
@@ -65,8 +67,9 @@ type FrameMessage =
|
|||||||
(function () {
|
(function () {
|
||||||
const DEFAULT_CONFIG: Pick<
|
const DEFAULT_CONFIG: Pick<
|
||||||
NormalizedAgentDeskConfig,
|
NormalizedAgentDeskConfig,
|
||||||
"position" | "themeColor" | "width"
|
"language" | "position" | "themeColor" | "width"
|
||||||
> = {
|
> = {
|
||||||
|
language: "zh-CN",
|
||||||
position: "right",
|
position: "right",
|
||||||
themeColor: "#0f6cbd",
|
themeColor: "#0f6cbd",
|
||||||
width: "380px",
|
width: "380px",
|
||||||
@@ -103,6 +106,7 @@ type FrameMessage =
|
|||||||
delete merged.apiBaseUrl
|
delete merged.apiBaseUrl
|
||||||
}
|
}
|
||||||
merged.channelId = String(merged.channelId || "")
|
merged.channelId = String(merged.channelId || "")
|
||||||
|
merged.language = normalizeWidgetLanguage(String(merged.language || "zh-CN"))
|
||||||
if (merged.externalId) {
|
if (merged.externalId) {
|
||||||
merged.externalId = String(merged.externalId)
|
merged.externalId = String(merged.externalId)
|
||||||
}
|
}
|
||||||
@@ -209,6 +213,28 @@ type FrameMessage =
|
|||||||
.catch(() => config)
|
.catch(() => config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchPublicConfig(config: NormalizedAgentDeskConfig) {
|
||||||
|
const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "")
|
||||||
|
if (!baseUrl || typeof fetch !== "function") {
|
||||||
|
return Promise.resolve(config)
|
||||||
|
}
|
||||||
|
return fetch(`${baseUrl}/api/config`, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
.then((response) => response.json() as Promise<PublicConfigResponse>)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!payload || payload.success === false) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
return normalizeConfig({
|
||||||
|
...config,
|
||||||
|
language: payload.data?.language || config.language,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => config)
|
||||||
|
}
|
||||||
|
|
||||||
function clearFrameTimers() {
|
function clearFrameTimers() {
|
||||||
if (state.frameHideTimer) {
|
if (state.frameHideTimer) {
|
||||||
window.clearTimeout(state.frameHideTimer)
|
window.clearTimeout(state.frameHideTimer)
|
||||||
@@ -369,7 +395,7 @@ type FrameMessage =
|
|||||||
|
|
||||||
state.frame = document.createElement("iframe")
|
state.frame = document.createElement("iframe")
|
||||||
state.frame.dataset.agentDeskWidget = "frame"
|
state.frame.dataset.agentDeskWidget = "frame"
|
||||||
state.frame.title = state.config.title || getDefaultWidgetTitle()
|
state.frame.title = state.config.title || getDefaultWidgetTitle(state.config)
|
||||||
state.frame.src = state.frameUrl.toString()
|
state.frame.src = state.frameUrl.toString()
|
||||||
applyFrameLayout()
|
applyFrameLayout()
|
||||||
state.frame.style.display = "block"
|
state.frame.style.display = "block"
|
||||||
@@ -435,7 +461,7 @@ type FrameMessage =
|
|||||||
const text = document.createElement("span")
|
const text = document.createElement("span")
|
||||||
button.type = "button"
|
button.type = "button"
|
||||||
button.dataset.agentDeskWidget = "launcher"
|
button.dataset.agentDeskWidget = "launcher"
|
||||||
button.setAttribute("aria-label", config.title || getDefaultWidgetTitle())
|
button.setAttribute("aria-label", config.title || getDefaultWidgetTitle(config))
|
||||||
icon.setAttribute("viewBox", "0 0 24 24")
|
icon.setAttribute("viewBox", "0 0 24 24")
|
||||||
icon.setAttribute("fill", "none")
|
icon.setAttribute("fill", "none")
|
||||||
icon.setAttribute("stroke", "currentColor")
|
icon.setAttribute("stroke", "currentColor")
|
||||||
@@ -451,7 +477,7 @@ type FrameMessage =
|
|||||||
path.setAttribute("d", pathData)
|
path.setAttribute("d", pathData)
|
||||||
icon.appendChild(path)
|
icon.appendChild(path)
|
||||||
})
|
})
|
||||||
text.textContent = getLauncherText()
|
text.textContent = getLauncherText(config)
|
||||||
text.style.display = "block"
|
text.style.display = "block"
|
||||||
button.style.position = "fixed"
|
button.style.position = "fixed"
|
||||||
button.style.bottom = "24px"
|
button.style.bottom = "24px"
|
||||||
@@ -504,15 +530,17 @@ type FrameMessage =
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.configLoading = true
|
state.configLoading = true
|
||||||
fetchWidgetConfig(state.config).then((nextConfig) => {
|
fetchPublicConfig(state.config)
|
||||||
state.configLoading = false
|
.then((nextConfig) => fetchWidgetConfig(nextConfig))
|
||||||
state.config = normalizeConfig(nextConfig)
|
.then((nextConfig) => {
|
||||||
if (state.button?.parentNode) {
|
state.configLoading = false
|
||||||
state.button.parentNode.removeChild(state.button)
|
state.config = normalizeConfig(nextConfig)
|
||||||
state.button = null
|
if (state.button?.parentNode) {
|
||||||
}
|
state.button.parentNode.removeChild(state.button)
|
||||||
createLauncher()
|
state.button = null
|
||||||
})
|
}
|
||||||
|
createLauncher()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AgentDeskConfig = {
|
|||||||
getUserToken?: () => string | Promise<string>
|
getUserToken?: () => string | Promise<string>
|
||||||
title?: string
|
title?: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
language?: string
|
||||||
position?: "left" | "right"
|
position?: "left" | "right"
|
||||||
themeColor?: string
|
themeColor?: string
|
||||||
width?: string
|
width?: string
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user