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:
mlogclub
2026-06-26 14:46:01 +08:00
parent 12d188fc7e
commit f827a7471c
32 changed files with 215 additions and 268 deletions
+2
View File
@@ -1,3 +1,5 @@
language: zh-CN
server: server:
port: 8083 port: 8083
cors: cors:
+2
View File
@@ -1,3 +1,5 @@
language: zh-CN
server: server:
port: 8083 port: 8083
cors: cors:
+2
View File
@@ -1,3 +1,5 @@
language: zh-CN
server: server:
port: 8083 port: 8083
cors: cors:
+2
View File
@@ -1,3 +1,5 @@
language: zh-CN
server: server:
port: 8083 port: 8083
cors: cors:
+2
View File
@@ -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,
-1
View File
@@ -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)
+2
View File
@@ -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"))
+33 -4
View File
@@ -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{
+3 -2
View File
@@ -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,
}) })
+12
View File
@@ -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"`
+4 -3
View File
@@ -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"`
} }
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+16 -21
View File
@@ -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)
} }
} }
+9 -45
View File
@@ -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 ""
}
-4
View File
@@ -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>
+1 -3
View File
@@ -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">
-42
View File
@@ -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>
)
}
+15 -14
View File
@@ -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"
-2
View File
@@ -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>
+3 -3
View File
@@ -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 \/>/);
}); });
}); });
-2
View File
@@ -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>
-2
View File
@@ -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 />
+7 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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]
) )
-11
View File
@@ -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",
-4
View File
@@ -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
} }
+17 -9
View File
@@ -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()
+52 -24
View File
@@ -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() {
+1
View File
@@ -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
File diff suppressed because one or more lines are too long