feat: integrate language switcher into settings with modern segment control UI
- Move language switcher from header to settings modal for better organization - Implement modern segment control UI instead of radio buttons for language selection - Add language preference persistence in localStorage and backend settings - Support instant language preview with cancel/revert functionality - Remove standalone LanguageSwitcher component - Improve initial language detection logic (localStorage -> browser -> default) - Add proper i18n keys for language settings UI text
This commit is contained in:
@@ -15,6 +15,8 @@ pub struct AppSettings {
|
||||
pub claude_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -32,6 +34,7 @@ impl Default for AppSettings {
|
||||
minimize_to_tray_on_close: true,
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +58,13 @@ impl AppSettings {
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| matches!(*s, "en" | "zh"))
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { AppSwitcher } from "./components/AppSwitcher";
|
||||
import SettingsModal from "./components/SettingsModal";
|
||||
import { UpdateBadge } from "./components/UpdateBadge";
|
||||
import LanguageSwitcher from "./components/LanguageSwitcher";
|
||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||
import { buttonStyles } from "./lib/styles";
|
||||
import { useDarkMode } from "./hooks/useDarkMode";
|
||||
@@ -308,7 +307,6 @@ function App() {
|
||||
>
|
||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe } from "lucide-react";
|
||||
import { buttonStyles } from "../lib/styles";
|
||||
|
||||
const LanguageSwitcher: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === "en" ? "zh" : "en";
|
||||
i18n.changeLanguage(newLang);
|
||||
};
|
||||
|
||||
const titleKey =
|
||||
i18n.language === "en"
|
||||
? "header.switchToChinese"
|
||||
: "header.switchToEnglish";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className={buttonStyles.icon}
|
||||
title={t(titleKey)}
|
||||
aria-label={t(titleKey)}
|
||||
>
|
||||
<Globe size={18} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -25,13 +25,33 @@ interface SettingsModalProps {
|
||||
}
|
||||
|
||||
export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
|
||||
lang === "en" ? "en" : "zh";
|
||||
|
||||
const readPersistedLanguage = (): "zh" | "en" => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = window.localStorage.getItem("language");
|
||||
if (stored === "en" || stored === "zh") {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return normalizeLanguage(i18n.language);
|
||||
};
|
||||
|
||||
const persistedLanguage = readPersistedLanguage();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
claudeConfigDir: undefined,
|
||||
codexConfigDir: undefined,
|
||||
language: persistedLanguage,
|
||||
});
|
||||
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
|
||||
persistedLanguage,
|
||||
);
|
||||
const [configPath, setConfigPath] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||
@@ -73,6 +93,12 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
(loadedSettings as any)?.minimizeToTrayOnClose ??
|
||||
(loadedSettings as any)?.minimize_to_tray_on_close ??
|
||||
true;
|
||||
const storedLanguage = normalizeLanguage(
|
||||
typeof (loadedSettings as any)?.language === "string"
|
||||
? (loadedSettings as any).language
|
||||
: persistedLanguage,
|
||||
);
|
||||
|
||||
setSettings({
|
||||
showInTray,
|
||||
minimizeToTrayOnClose,
|
||||
@@ -84,7 +110,12 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
typeof (loadedSettings as any)?.codexConfigDir === "string"
|
||||
? (loadedSettings as any).codexConfigDir
|
||||
: undefined,
|
||||
language: storedLanguage,
|
||||
});
|
||||
setInitialLanguage(storedLanguage);
|
||||
if (i18n.language !== storedLanguage) {
|
||||
void i18n.changeLanguage(storedLanguage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t("console.loadSettingsFailed"), error);
|
||||
}
|
||||
@@ -125,6 +156,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
const selectedLanguage = settings.language === "en" ? "en" : "zh";
|
||||
const payload: Settings = {
|
||||
...settings,
|
||||
claudeConfigDir:
|
||||
@@ -135,15 +167,42 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
|
||||
? settings.codexConfigDir.trim()
|
||||
: undefined,
|
||||
language: selectedLanguage,
|
||||
};
|
||||
await window.api.saveSettings(payload);
|
||||
setSettings(payload);
|
||||
try {
|
||||
window.localStorage.setItem("language", selectedLanguage);
|
||||
} catch (error) {
|
||||
console.warn("[Settings] Failed to persist language preference", error);
|
||||
}
|
||||
setInitialLanguage(selectedLanguage);
|
||||
if (i18n.language !== selectedLanguage) {
|
||||
void i18n.changeLanguage(selectedLanguage);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t("console.saveSettingsFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: "zh" | "en") => {
|
||||
setSettings((prev) => ({ ...prev, language: lang }));
|
||||
if (i18n.language !== lang) {
|
||||
void i18n.changeLanguage(lang);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (settings.language !== initialLanguage) {
|
||||
setSettings((prev) => ({ ...prev, language: initialLanguage }));
|
||||
if (i18n.language !== initialLanguage) {
|
||||
void i18n.changeLanguage(initialLanguage);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
if (hasUpdate && updateHandle) {
|
||||
if (isPortable) {
|
||||
@@ -291,7 +350,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
if (e.target === e.currentTarget) handleCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -306,7 +365,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
{t("settings.title")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={handleCancel}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
@@ -315,6 +374,47 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
|
||||
{/* 设置内容 */}
|
||||
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
|
||||
{/* 通用设置 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t("settings.general")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{t("settings.language")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t("settings.languageDescription")}
|
||||
</p>
|
||||
<div className="mt-3 inline-flex p-0.5 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange("zh")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
|
||||
(settings.language ?? "zh") === "zh"
|
||||
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("settings.languageOptionChinese")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange("en")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
|
||||
settings.language === "en"
|
||||
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("settings.languageOptionEnglish")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 窗口行为设置 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
@@ -534,7 +634,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -4,6 +4,36 @@ import { initReactI18next } from "react-i18next";
|
||||
import en from "./locales/en.json";
|
||||
import zh from "./locales/zh.json";
|
||||
|
||||
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
|
||||
|
||||
const getInitialLanguage = (): "zh" | "en" => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("language");
|
||||
if (stored === "zh" || stored === "en") {
|
||||
return stored;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[i18n] Failed to read stored language preference", error);
|
||||
}
|
||||
}
|
||||
|
||||
const navigatorLang =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.language?.toLowerCase() ?? navigator.languages?.[0]?.toLowerCase()
|
||||
: undefined;
|
||||
|
||||
if (navigatorLang?.startsWith("zh")) {
|
||||
return "zh";
|
||||
}
|
||||
|
||||
if (navigatorLang?.startsWith("en")) {
|
||||
return "en";
|
||||
}
|
||||
|
||||
return DEFAULT_LANGUAGE;
|
||||
};
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: en,
|
||||
@@ -15,7 +45,7 @@ const resources = {
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: "zh", // 默认语言调整为中文
|
||||
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
|
||||
fallbackLng: "en", // 如果缺少中文翻译则退回英文
|
||||
|
||||
interpolation: {
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"languageDescription": "Choose the display language for CC Switch. This preference will be remembered next time you open the app.",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"windowBehavior": "Window Behavior",
|
||||
"minimizeToTray": "Minimize to tray on close",
|
||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"general": "通用",
|
||||
"language": "界面语言",
|
||||
"languageDescription": "选择 CC Switch 的显示语言,下次启动会自动记住你的偏好。",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"windowBehavior": "窗口行为",
|
||||
"minimizeToTray": "关闭时最小化到托盘",
|
||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||
|
||||
@@ -30,4 +30,6 @@ export interface Settings {
|
||||
claudeConfigDir?: string;
|
||||
// 覆盖 Codex 配置目录(可选)
|
||||
codexConfigDir?: string;
|
||||
// 首选语言(可选,默认中文)
|
||||
language?: "en" | "zh";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user