Files
controller-v2/client/src/pages/SelectPage.tsx
T
eafonyang acfcffa60d feat(locale): 实现前端多语言覆盖与生效语言逻辑
- 新增 effectiveLanguage 字段,统一界面语言解析依据
- 支持前端语言覆盖(德语/西班牙语/法语)优先显示,回退设备语言
- setUiLanguage 方法实现前端或设备语言切换逻辑与持久化
- 当设备语言发生变化时自动清除前端语言覆盖,保证同步一致
- BottomNav 组件和 ConnectionPlaceholder 组件调整使用 effectiveLanguage 获取语言包
- 多语言数据更新,新增德语、西班牙语、法语、韩语支持及语言选项扩展
2026-10-21 13:41:20 +08:00

612 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
SELECT PAGE — Universal full-screen option picker
Design: iOS Settings style selection list
Usage: Navigate to /select with state object
State params:
title - Page title shown in header
options - Array of option strings
selected - Currently selected index (number)
back - Route to navigate back to after selection
key - Identifier for which setting this controls
============================================================ */
import { useLocation } from "wouter";
import { ChevronLeft, Check } from "lucide-react";
import { useDevice } from "@/contexts/DeviceContext";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
const iisModeImageUrl = (index: number) =>
`${import.meta.env.BASE_URL}images/IIS/iis_app${index + 1}.png`;
type DacVolumeDirectLocale = {
label?: string;
confirmTitle?: string;
confirm0dB?: string;
confirm12dB?: string;
confirmOk?: string;
confirmCancel?: string;
};
type LocaleRoot = {
dac?: { dacVolumeDirect?: DacVolumeDirectLocale };
};
/* ── Key → API field mapping ── */
const KEY_TO_SETTING: Record<string, string> = {
effect_value: "effect_value",
crossfeed_value: "crossfeed_value",
hearing_select: "hearing_select",
subwoofer_rate: "subwoofer_rate",
subwoofer_mix_type: "subwoofer_mix_type",
language: "language",
analogGain: "analogGain",
soundStep: "soundStep",
bootSound: "bootSound",
filterCharacteristic: "pcm",
mutePolar: "hdmimutepolar",
IISMode: "hdmiType",
xlr: "xlr",
dacGain: "dacGain",
dacImpedance: "dacImpedance",
hpMaxVolume: "hpMaxVolume",
hpStartupVolume: "hpStartupVolume",
dacVolumeDirect: "dacVolumeDirect",
dacArc: "dacArc",
vu: "vu",
input: "input",
output: "output",
screenLight: "screenLight",
screenOff: "screenOff",
buttonLight: "buttonLight",
autoHome: "autoHome",
sleep: "sleep",
headphone: "peqSelect",
};
// Global state for select page (to avoid URL params)
export let selectPageState: {
title: string;
options: string[];
selected: number;
back: string;
key: string;
} | null = null;
// Last select result for pages that need local state update
export let selectPageResult: { key: string; selected: number } | null = null;
export function consumeSelectPageResult() {
const result = selectPageResult;
selectPageResult = null;
return result;
}
// Scroll position of the calling page, captured before navigating to /select.
// The select page is short, so the browser clamps window scroll to 0 while on
// it; without restoring, the calling page (e.g. Effects) reopens at the top.
let selectReturnScroll: number | null = null;
/** Consume the stored scroll position (once). Returns null when absent. */
export function consumeSelectReturnScroll(): number | null {
const y = selectReturnScroll;
selectReturnScroll = null;
return y;
}
// Helper function to set select page state
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
selectReturnScroll = window.scrollY;
selectPageState = { title, options, selected, back, key };
}
export default function SelectPage() {
const [location, setLocation] = useLocation();
const { updateSetting, effectiveLanguage, setUiLanguage } = useDevice();
const [state, setState] = useState<{
title: string;
options: string[];
selected: number;
back: string;
key: string;
} | null>(() => selectPageState);
const [saving, setSaving] = useState(false);
const [passthroughConfirmIdx, setPassthroughConfirmIdx] = useState<number | null>(null);
const pageTitle = usePageTitleWithConnection(state?.title ?? "");
const dacVolumeDirectText = resolveLocalePack(effectiveLanguage).dac?.dacVolumeDirect;
const passthroughConfirmCopy = useMemo(() => {
if (passthroughConfirmIdx === null) return null;
const message =
passthroughConfirmIdx === 1
? dacVolumeDirectText?.confirm0dB
: passthroughConfirmIdx === 2
? dacVolumeDirectText?.confirm12dB
: undefined;
return {
title: dacVolumeDirectText?.confirmTitle ?? "提示",
message:
message ??
(passthroughConfirmIdx === 1
? "直通模式会将音量调到最大,并且不可调节"
: "直通模式会将音量调到-12dB,并且不可调节"),
ok: dacVolumeDirectText?.confirmOk ?? "确认",
cancel: dacVolumeDirectText?.confirmCancel ?? "取消",
};
}, [passthroughConfirmIdx, dacVolumeDirectText]);
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
useEffect(() => {
if (location === "/select" && selectPageState) {
setState({ ...selectPageState });
}
}, [location]);
if (!state) {
return null;
}
const { title, options, selected: selectedIdx, back, key } = state;
const applySelect = async (idx: number) => {
if (saving) return;
selectPageResult = { key, selected: idx };
// 语言选择:设备语言(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) {
setSaving(true);
try {
await updateSetting({ [apiField]: idx });
} finally {
setSaving(false);
}
}
selectPageState = null;
setLocation(back);
};
const handleSelect = async (idx: number) => {
if (saving) return;
if (key === "dacVolumeDirect" && (idx === 1 || idx === 2) && idx !== selectedIdx) {
setPassthroughConfirmIdx(idx);
return;
}
await applySelect(idx);
};
const handlePassthroughConfirm = async () => {
if (passthroughConfirmIdx === null) return;
const idx = passthroughConfirmIdx;
setPassthroughConfirmIdx(null);
await applySelect(idx);
};
return (
<div className="min-h-screen bg-black">
{/* ── Header ── */}
<div className="page-header">
<button
onClick={() => setLocation(back)}
className="mr-4 text-white/60 active:text-white transition-colors">
<ChevronLeft size={24} />
</button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{pageTitle}</h1>
<div className="w-8" />
</div>
<div className="px-4 pt-4 pb-24">
{key === "IISMode" ? (
<div className="grid w-full grid-cols-2 gap-3">
{options.map((opt, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={idx}
type="button"
disabled={saving}
className={cn(
"flex flex-col items-center gap-2 rounded-xl border-2 p-1.5 transition-all active:scale-[0.98] disabled:opacity-60",
isSelected
? "border-[#00FFF6] shadow-[0_0_14px_rgba(0,255,246,0.35)]"
: "border-white/10",
)}
onClick={() => void handleSelect(idx)}
aria-pressed={isSelected}
aria-label={opt}
>
<img
src={iisModeImageUrl(idx)}
alt={opt}
className="block w-full h-auto rounded-lg bg-black/40"
loading="lazy"
/>
<span
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center shrink-0",
isSelected
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
: "border-2 border-white/20",
)}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</span>
</button>
);
})}
</div>
) : (
<div className="ios-list-group">
{options.map((opt, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={idx}
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
onClick={() => void handleSelect(idx)}
disabled={saving}
>
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
{opt}
</span>
{isSelected && (
<Check size={18} style={{ color: "#00FFF6" }} />
)}
</button>
);
})}
</div>
)}
</div>
<AlertDialog
open={passthroughConfirmIdx !== null}
onOpenChange={(open) => {
if (!open) setPassthroughConfirmIdx(null);
}}
>
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
{passthroughConfirmCopy?.title}
</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{passthroughConfirmCopy?.message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
{passthroughConfirmCopy?.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
disabled={saving}
onClick={() => void handlePassthroughConfirm()}
>
{passthroughConfirmCopy?.ok}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}