feat(locale): 实现前端多语言覆盖与生效语言逻辑
- 新增 effectiveLanguage 字段,统一界面语言解析依据 - 支持前端语言覆盖(德语/西班牙语/法语)优先显示,回退设备语言 - setUiLanguage 方法实现前端或设备语言切换逻辑与持久化 - 当设备语言发生变化时自动清除前端语言覆盖,保证同步一致 - BottomNav 组件和 ConnectionPlaceholder 组件调整使用 effectiveLanguage 获取语言包 - 多语言数据更新,新增德语、西班牙语、法语、韩语支持及语言选项扩展
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? "更新说明");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user