feat(locale): 实现前端多语言覆盖与生效语言逻辑

- 新增 effectiveLanguage 字段,统一界面语言解析依据
- 支持前端语言覆盖(德语/西班牙语/法语)优先显示,回退设备语言
- setUiLanguage 方法实现前端或设备语言切换逻辑与持久化
- 当设备语言发生变化时自动清除前端语言覆盖,保证同步一致
- BottomNav 组件和 ConnectionPlaceholder 组件调整使用 effectiveLanguage 获取语言包
- 多语言数据更新,新增德语、西班牙语、法语、韩语支持及语言选项扩展
This commit is contained in:
eafonyang
2026-10-21 13:38:50 +08:00
parent 2c82d8511c
commit acfcffa60d
24 changed files with 3136 additions and 79 deletions
+3 -3
View File
@@ -71,7 +71,7 @@ import { Streamdown } from "streamdown";
import { toast } from "sonner";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
import { resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
import { resolveLocalePack } from "@/locales/resolveLocale";
type AILocale = NonNullable<(typeof localeZh)["ai"]>;
@@ -224,9 +224,9 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
const [routePath, setLocation] = useLocation();
const isLgUp = useIsLgUp();
const { setOpen: setAiPanelOpen } = useAIDrawer();
const { isConnected, deviceState, api, updateSetting } = useDevice();
const { isConnected, deviceState, api, updateSetting, effectiveLanguage } = useDevice();
const mac = deviceState?.mac ?? "";
const language = resolveDeviceLanguage(deviceState?.language);
const language = effectiveLanguage;
const deviceName = deviceState?.device?.trim() || "Luxsin X9";
const aiText = useMemo(() => resolveAILocale(language), [language]);
+2 -2
View File
@@ -178,10 +178,10 @@ function bootVolumeRowLabel(dacText: LocaleDac["dac"]): string {
export default function AudioPage() {
const [location, setLocation] = useLocation();
const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
const { deviceState, updateSetting, refresh, isConnected, api, effectiveLanguage } = useDevice();
const ds = deviceState;
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
const localeData = resolveLocalePack(ds?.language) as LocaleDac;
const localeData = resolveLocalePack(effectiveLanguage) as LocaleDac;
const dacText = localeData.dac ?? {};
// balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0)
+3 -3
View File
@@ -17,12 +17,12 @@ type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
export default function ChangelogPage() {
const [, setLocation] = useLocation();
const { deviceState } = useDevice();
const { effectiveLanguage } = useDevice();
const homeText = useMemo((): HomeLocale => {
const pack = resolveLocalePack(deviceState?.language);
const pack = resolveLocalePack(effectiveLanguage);
return (pack.home ?? {}) as HomeLocale;
}, [deviceState?.language]);
}, [effectiveLanguage]);
const pageTitle = usePageTitleWithConnection(homeText.changelog ?? "更新说明");
+5 -4
View File
@@ -110,16 +110,17 @@ export default function EQPage() {
upgradePeqChange,
upgradePeqApply,
syncPeqCatalog,
effectiveLanguage,
} = useDevice();
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
const peqOn = (deviceState?.peqEnable ?? 0) === 1;
const eqOn = peqOn && !bypassOn;
const eqUi = useMemo((): PeqEqUi => {
const pack = resolveLocalePack(deviceState?.language);
const pack = resolveLocalePack(effectiveLanguage);
const peq = pack.peq as typeof localeZh.peq;
return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi;
}, [deviceState?.language]);
}, [effectiveLanguage]);
const firmwareVersion = useMemo(
() => parseFirmwareVersion(deviceState?.version),
@@ -128,9 +129,9 @@ export default function EQPage() {
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
const peqCardLabels = useMemo(() => {
const pack = resolveLocalePack(deviceState?.language);
const pack = resolveLocalePack(effectiveLanguage);
return pack.peq ?? localeEn.peq;
}, [deviceState?.language]);
}, [effectiveLanguage]);
const eqPageTitle = usePageTitleWithConnection("HP-EQ");
+5 -5
View File
@@ -19,7 +19,7 @@ import { parseFirmwareVersion, parseHearingData } from "@/lib/luxsinApi";
import { isFirmwareFeatureAvailable } from "@/config/firmwareFeatures";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
import { resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
import { resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
import { toast } from "sonner";
@@ -224,12 +224,12 @@ function CyanSlider({ value, min, max, step = 0.1, disabled = false, onChange, o
export default function EffectsPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting } = useDevice();
const { deviceState, updateSetting, effectiveLanguage } = useDevice();
const effectText = useMemo((): EffectLocale => {
const pack = resolveLocalePack(deviceState?.language);
const pack = resolveLocalePack(effectiveLanguage);
return (pack.effect ?? localeEn.effect) as EffectLocale;
}, [deviceState?.language]);
}, [effectiveLanguage]);
const pageTitle = usePageTitleWithConnection(effectText.pageTitle ?? "Effects");
@@ -349,7 +349,7 @@ export default function EffectsPage() {
const [delayInputTarget, setDelayInputTarget] = useState<DelayInputTarget | null>(null);
const [delayInputValue, setDelayInputValue] = useState("");
const delayInputEnglish = resolveDeviceLanguage(deviceState?.language) === 0;
const delayInputEnglish = effectiveLanguage === 0;
const maxDelayMs = SUBWOOFER_DELAY_MAX / SUBWOOFER_DELAY_MS_DIVISOR;
const openDelayInput = (target: DelayInputTarget) => {
+4 -7
View File
@@ -71,8 +71,7 @@ import { useLocation } from "wouter";
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { toast } from "sonner";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
@@ -202,7 +201,7 @@ function ListRow({
}
export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, isDemoMode, ip } = useDevice();
const { isConnected, deviceState, setVolume, updateSetting, api, isDemoMode, ip, effectiveLanguage } = useDevice();
const [, setLocation] = useLocation();
const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef<number | null>(null);
@@ -244,10 +243,8 @@ export default function Home() {
const homeText = useMemo((): HomeLocale => {
if (!deviceState) return {} as HomeLocale;
const loc =
deviceState.language === 0 ? localeEn : deviceState.language === 1 ? localeZhHK : localeZh;
return (loc.home ?? {}) as HomeLocale;
}, [deviceState]);
return (resolveLocalePack(effectiveLanguage).home ?? {}) as HomeLocale;
}, [deviceState, effectiveLanguage]);
const volumeConfirmCopy = useMemo(() => {
if (volumeConfirm === "min") {
+3 -3
View File
@@ -210,12 +210,12 @@ function IOCircleButton({
export default function IOPage() {
const [, setLocation] = useLocation();
const { deviceState, setInput, setOutput } = useDevice();
const { deviceState, setInput, setOutput, effectiveLanguage } = useDevice();
const sourceText = useMemo((): SourceLocale => {
const pack = resolveLocalePack(deviceState?.language);
const pack = resolveLocalePack(effectiveLanguage);
return (pack.source ?? {}) as SourceLocale;
}, [deviceState?.language]);
}, [effectiveLanguage]);
const inputOptions = useMemo(
() => withLabels(INPUT_OPTION_DEFS, sourceText.inputOptions, FALLBACK_INPUT_LABELS),
+18 -2
View File
@@ -216,7 +216,7 @@ export default function SelectPage() {
const [location, setLocation] = useLocation();
const { updateSetting, deviceState } = useDevice();
const { updateSetting, effectiveLanguage, setUiLanguage } = useDevice();
const [state, setState] = useState<{
@@ -238,7 +238,7 @@ export default function SelectPage() {
const pageTitle = usePageTitleWithConnection(state?.title ?? "");
const dacVolumeDirectText = resolveLocalePack(deviceState?.language).dac?.dacVolumeDirect;
const dacVolumeDirectText = resolveLocalePack(effectiveLanguage).dac?.dacVolumeDirect;
@@ -316,6 +316,22 @@ export default function SelectPage() {
// 语言选择:设备语言(0/1/2)下发设备,de/es/fr(3/4/5)仅前端切换,
// 由 setUiLanguage 内部区分,避免向不支持多语言的设备下发无效值。
if (key === "language") {
setSaving(true);
try {
await setUiLanguage(idx);
} finally {
setSaving(false);
}
selectPageState = null;
setLocation(back);
return;
}
const apiField = KEY_TO_SETTING[key];
if (apiField) {
+7 -22
View File
@@ -20,7 +20,7 @@ import {
} from "@/lib/luxsinApi";
import { navigateToSelect } from "./SelectPage";
import localeEn from "@/locales/data-en.json";
import { DEFAULT_DEVICE_LANGUAGE, resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
import { DEFAULT_DEVICE_LANGUAGE, isKnownLanguage, LANGUAGE_NATIVE_LABELS, resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -106,21 +106,9 @@ function optionLabels(items: Array<{ index: number; label: string }> | undefined
return sorted.map((item) => item.label);
}
/** Device language index: 0=en, 1=zh-HK, 2=zh-CN */
const SUPPORTED_LANGUAGE_INDICES = new Set([0, 1, 2]);
function languageLabels(
items: Array<{ index: number; label: string }> | undefined,
fallback: string[],
) {
const filtered = (items ?? []).filter((o) => SUPPORTED_LANGUAGE_INDICES.has(o.index));
if (filtered.length === 0) return fallback;
return [...filtered].sort((a, b) => a.index - b.index).map((o) => o.label);
}
export default function SystemPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting, refresh, isConnected, api, ip } = useDevice();
const { deviceState, updateSetting, refresh, isConnected, api, ip, effectiveLanguage } = useDevice();
const ds = deviceState;
// 页面加载且已连接时刷新数据
@@ -170,10 +158,9 @@ export default function SystemPage() {
const screenOffIdx = ds?.screenOff ?? 2;
const sleepIdx = ds?.sleep ?? 0;
const autoHomeIdx = ds?.autoHome ?? 1;
const rawLangIdx = resolveDeviceLanguage(ds?.language);
const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : DEFAULT_DEVICE_LANGUAGE;
const langIdx = isKnownLanguage(effectiveLanguage) ? effectiveLanguage : DEFAULT_DEVICE_LANGUAGE;
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
const localeData = resolveLocalePack(ds?.language) as LocaleSettings;
const localeData = resolveLocalePack(effectiveLanguage) as LocaleSettings;
const settingsText = localeData.settings;
const ui = settingsText.systemPage;
const SCREEN_LIGHT_OPTIONS = optionLabels(settingsText.screenBrightness?.options, ["亮", "中等", "暗"]);
@@ -181,11 +168,9 @@ export default function SystemPage() {
const SCREEN_OFF_OPTIONS = optionLabels(settingsText.turnOffScreen?.options, ["常亮", "30秒", "1分钟", "3分钟", "5分钟"]);
const SLEEP_OPTIONS = optionLabels(settingsText.sleepTime?.options ?? settingsText.sleep?.options, ["关闭", "1分钟", "5分钟", "10分钟"]);
const AUTO_HOME_OPTIONS = optionLabels(settingsText.Automatic?.options, ["关闭", "20秒", "40秒", "60秒"]);
const LANG_OPTIONS = languageLabels(settingsText.language?.options, [
"English",
"繁體中文",
"简体中文",
]);
// 语言选项固定显示各语言的母语名称,与当前界面语言无关;
// 数组下标即语言索引(0=en … 7=vi),与 SelectPage 回传的值一致。
const LANG_OPTIONS = [...LANGUAGE_NATIVE_LABELS];
const sleepRowLabel =
settingsText.sleepTime?.label ??
+3 -3
View File
@@ -32,13 +32,13 @@ const VU_STYLES: VUStyle[] = Array.from({ length: 16 }, (_, i) => ({
export default function VUPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting } = useDevice();
const { deviceState, updateSetting, effectiveLanguage } = useDevice();
const currentVU = deviceState?.vu ?? 0;
const vuPageText = useMemo((): VUPageLocale => {
const loc = resolveLocalePack(deviceState?.language);
const loc = resolveLocalePack(effectiveLanguage);
return (loc.vuPage ?? localeEn.vuPage ?? {}) as VUPageLocale;
}, [deviceState?.language]);
}, [effectiveLanguage]);
const pageTitle = usePageTitleWithConnection(vuPageText.title ?? "VU");