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>,
|
pub claude_config_dir: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub codex_config_dir: Option<String>,
|
pub codex_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_show_in_tray() -> bool {
|
fn default_show_in_tray() -> bool {
|
||||||
@@ -32,6 +34,7 @@ impl Default for AppSettings {
|
|||||||
minimize_to_tray_on_close: true,
|
minimize_to_tray_on_close: true,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
codex_config_dir: None,
|
codex_config_dir: None,
|
||||||
|
language: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,13 @@ impl AppSettings {
|
|||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.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 {
|
pub fn load() -> Self {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ConfirmDialog } from "./components/ConfirmDialog";
|
|||||||
import { AppSwitcher } from "./components/AppSwitcher";
|
import { AppSwitcher } from "./components/AppSwitcher";
|
||||||
import SettingsModal from "./components/SettingsModal";
|
import SettingsModal from "./components/SettingsModal";
|
||||||
import { UpdateBadge } from "./components/UpdateBadge";
|
import { UpdateBadge } from "./components/UpdateBadge";
|
||||||
import LanguageSwitcher from "./components/LanguageSwitcher";
|
|
||||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||||
import { buttonStyles } from "./lib/styles";
|
import { buttonStyles } from "./lib/styles";
|
||||||
import { useDarkMode } from "./hooks/useDarkMode";
|
import { useDarkMode } from "./hooks/useDarkMode";
|
||||||
@@ -308,7 +307,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<LanguageSwitcher />
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
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) {
|
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>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
showInTray: true,
|
showInTray: true,
|
||||||
minimizeToTrayOnClose: true,
|
minimizeToTrayOnClose: true,
|
||||||
claudeConfigDir: undefined,
|
claudeConfigDir: undefined,
|
||||||
codexConfigDir: undefined,
|
codexConfigDir: undefined,
|
||||||
|
language: persistedLanguage,
|
||||||
});
|
});
|
||||||
|
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
|
||||||
|
persistedLanguage,
|
||||||
|
);
|
||||||
const [configPath, setConfigPath] = useState<string>("");
|
const [configPath, setConfigPath] = useState<string>("");
|
||||||
const [version, setVersion] = useState<string>("");
|
const [version, setVersion] = useState<string>("");
|
||||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||||
@@ -73,6 +93,12 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
(loadedSettings as any)?.minimizeToTrayOnClose ??
|
(loadedSettings as any)?.minimizeToTrayOnClose ??
|
||||||
(loadedSettings as any)?.minimize_to_tray_on_close ??
|
(loadedSettings as any)?.minimize_to_tray_on_close ??
|
||||||
true;
|
true;
|
||||||
|
const storedLanguage = normalizeLanguage(
|
||||||
|
typeof (loadedSettings as any)?.language === "string"
|
||||||
|
? (loadedSettings as any).language
|
||||||
|
: persistedLanguage,
|
||||||
|
);
|
||||||
|
|
||||||
setSettings({
|
setSettings({
|
||||||
showInTray,
|
showInTray,
|
||||||
minimizeToTrayOnClose,
|
minimizeToTrayOnClose,
|
||||||
@@ -84,7 +110,12 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
typeof (loadedSettings as any)?.codexConfigDir === "string"
|
typeof (loadedSettings as any)?.codexConfigDir === "string"
|
||||||
? (loadedSettings as any).codexConfigDir
|
? (loadedSettings as any).codexConfigDir
|
||||||
: undefined,
|
: undefined,
|
||||||
|
language: storedLanguage,
|
||||||
});
|
});
|
||||||
|
setInitialLanguage(storedLanguage);
|
||||||
|
if (i18n.language !== storedLanguage) {
|
||||||
|
void i18n.changeLanguage(storedLanguage);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("console.loadSettingsFailed"), error);
|
console.error(t("console.loadSettingsFailed"), error);
|
||||||
}
|
}
|
||||||
@@ -125,6 +156,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
const selectedLanguage = settings.language === "en" ? "en" : "zh";
|
||||||
const payload: Settings = {
|
const payload: Settings = {
|
||||||
...settings,
|
...settings,
|
||||||
claudeConfigDir:
|
claudeConfigDir:
|
||||||
@@ -135,15 +167,42 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
|
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
|
||||||
? settings.codexConfigDir.trim()
|
? settings.codexConfigDir.trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
|
language: selectedLanguage,
|
||||||
};
|
};
|
||||||
await window.api.saveSettings(payload);
|
await window.api.saveSettings(payload);
|
||||||
setSettings(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();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("console.saveSettingsFailed"), 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 () => {
|
const handleCheckUpdate = async () => {
|
||||||
if (hasUpdate && updateHandle) {
|
if (hasUpdate && updateHandle) {
|
||||||
if (isPortable) {
|
if (isPortable) {
|
||||||
@@ -291,7 +350,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) handleCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -306,7 +365,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
{t("settings.title")}
|
{t("settings.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={handleCancel}
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
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" />
|
<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 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>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<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">
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
|
||||||
<button
|
<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"
|
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")}
|
{t("common.cancel")}
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ import { initReactI18next } from "react-i18next";
|
|||||||
import en from "./locales/en.json";
|
import en from "./locales/en.json";
|
||||||
import zh from "./locales/zh.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 = {
|
const resources = {
|
||||||
en: {
|
en: {
|
||||||
translation: en,
|
translation: en,
|
||||||
@@ -15,7 +45,7 @@ const resources = {
|
|||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources,
|
resources,
|
||||||
lng: "zh", // 默认语言调整为中文
|
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
|
||||||
fallbackLng: "en", // 如果缺少中文翻译则退回英文
|
fallbackLng: "en", // 如果缺少中文翻译则退回英文
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
|
|||||||
@@ -62,6 +62,11 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "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",
|
"windowBehavior": "Window Behavior",
|
||||||
"minimizeToTray": "Minimize to tray on close",
|
"minimizeToTray": "Minimize to tray on close",
|
||||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||||
|
|||||||
@@ -62,6 +62,11 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
|
"general": "通用",
|
||||||
|
"language": "界面语言",
|
||||||
|
"languageDescription": "选择 CC Switch 的显示语言,下次启动会自动记住你的偏好。",
|
||||||
|
"languageOptionChinese": "中文",
|
||||||
|
"languageOptionEnglish": "English",
|
||||||
"windowBehavior": "窗口行为",
|
"windowBehavior": "窗口行为",
|
||||||
"minimizeToTray": "关闭时最小化到托盘",
|
"minimizeToTray": "关闭时最小化到托盘",
|
||||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||||
|
|||||||
@@ -30,4 +30,6 @@ export interface Settings {
|
|||||||
claudeConfigDir?: string;
|
claudeConfigDir?: string;
|
||||||
// 覆盖 Codex 配置目录(可选)
|
// 覆盖 Codex 配置目录(可选)
|
||||||
codexConfigDir?: string;
|
codexConfigDir?: string;
|
||||||
|
// 首选语言(可选,默认中文)
|
||||||
|
language?: "en" | "zh";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user