Refactor BottomNav to use localized labels for navigation items; added support for dynamic language selection based on device state. Updated locale files for English, Traditional Chinese, and Simplified Chinese to include bottom navigation labels.

This commit is contained in:
yangy
2026-05-11 17:42:18 +08:00
parent 68f06344dc
commit ae4ce8bb07
7 changed files with 552 additions and 192 deletions
+166 -108
View File
@@ -39,6 +39,20 @@ import {
getFilterShortName,
buildPeqSvgCurveData,
} from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
type PeqEqUi = NonNullable<NonNullable<(typeof localeZh)["peq"]>["eqUi"]>;
function eqInterp(template: string | undefined, vars: Record<string, string | number>): string {
if (!template) return "";
let s = template;
for (const [k, v] of Object.entries(vars)) {
s = s.split(`{{${k}}}`).join(String(v));
}
return s;
}
/* ── iOS Toggle ── */
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -94,7 +108,15 @@ function normalizeFilterType(type: string | number | undefined): string {
/* ── Frequency Response Chart ── */
function FreqChart({
bands, rawCurve, selectedBand, abMode, onAbToggle, onCopyMode, onBandDrag, onBandSelect,
bands,
rawCurve,
selectedBand,
abMode,
onAbToggle,
onCopyMode,
onBandDrag,
onBandSelect,
eqUi,
}: {
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
rawCurve: number[] | null;
@@ -104,6 +126,7 @@ function FreqChart({
onCopyMode: (from: "A" | "B", to: "A" | "B") => void;
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
onBandSelect: (idx: number) => void;
eqUi: PeqEqUi;
}) {
const H = 140;
const chartContainerRef = useRef<HTMLDivElement | null>(null);
@@ -252,7 +275,7 @@ function FreqChart({
});
const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
const copyButtonText = abMode === "A" ? "复制到 B" : "复制到 A";
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
@@ -315,16 +338,16 @@ function FreqChart({
[targetMode]: { ...prev[abMode] },
}));
onAbToggle(targetMode);
toast.success(`已复制到 ${targetMode}`);
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
}}>
{copyButtonText}
</button>
<button
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
onClick={() => toast.info("已加载至设备")}
onClick={() => toast.info(eqUi.toastChartLoadedDevice)}
>
{eqUi.chartLoadToDevice}
</button>
</div>
@@ -544,6 +567,19 @@ export default function EQPage() {
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice();
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
const eqUi = useMemo((): PeqEqUi => {
const lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
const peq = pack.peq as typeof localeZh.peq;
return (peq.eqUi ?? (localeZh.peq as typeof localeZh.peq).eqUi) as PeqEqUi;
}, [deviceState?.language]);
const peqCardLabels = useMemo(() => {
const lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return pack.peq ?? localeZh.peq;
}, [deviceState?.language]);
const [bandsByMode, setBandsByMode] = useState<Record<"A" | "B", typeof DEFAULT_BANDS>>({
A: cloneBands(DEFAULT_BANDS),
B: cloneBands(DEFAULT_BANDS),
@@ -660,11 +696,11 @@ export default function EQPage() {
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
setCatalogBrandsError(msg);
toast.error("获取品牌列表失败");
toast.error(eqUi.toastBrandsFail);
} finally {
setCatalogBrandsLoading(false);
}
}, []);
}, [eqUi]);
const searchCatalogByKeyword = useCallback(async (keyword: string) => {
const q = keyword.trim();
@@ -699,11 +735,11 @@ export default function EQPage() {
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
setCatalogModelsError(msg);
toast.error("获取型号列表失败");
toast.error(eqUi.toastModelsFail);
} finally {
setCatalogModelsLoading(false);
}
}, []);
}, [eqUi]);
const openAddHeadsetCatalog = () => {
setIsBrandDrawerOpen(true);
@@ -792,76 +828,80 @@ export default function EQPage() {
setIsBatchEditDialogOpen(true);
};
const parseBatchEditText = (raw: string) => {
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length < 11) {
throw new Error("格式错误:至少需要 11 行(Preamp + 10 个滤波器)");
}
const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i);
if (!preampMatch) {
throw new Error("格式错误:第 1 行应为 Preamp:-5.96dB");
}
const preamp = Number(preampMatch[1]);
if (!Number.isFinite(preamp)) {
throw new Error("格式错误:Preamp 数值无效");
}
const parsedFilters = new Array<{
enabled: boolean;
type: string;
freq: number;
gain: number;
q: number;
}>(10);
for (let i = 1; i <= 10; i++) {
const line = lines[i];
const match = line.match(
/^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i
);
if (!match) {
throw new Error(`格式错误:第 ${i + 1} 行不符合 Filter 格式`);
const parseBatchEditText = useCallback(
(raw: string) => {
const t = eqUi;
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length < 11) {
throw new Error(eqInterp(t.errMinLines, {}));
}
const filterNo = Number(match[1]);
if (filterNo < 1 || filterNo > 10) {
throw new Error(`格式错误:Filter 编号 ${filterNo} 超出范围`);
const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i);
if (!preampMatch) {
throw new Error(eqInterp(t.errPreampFirstLine, {}));
}
const preamp = Number(preampMatch[1]);
if (!Number.isFinite(preamp)) {
throw new Error(eqInterp(t.errPreampValue, {}));
}
const type = normalizeFilterType(match[3].toUpperCase());
if (!FILTER_TYPES.includes(type)) {
throw new Error(`格式错误:Filter ${filterNo} 的类型无效(${match[3]}`);
const parsedFilters = new Array<{
enabled: boolean;
type: string;
freq: number;
gain: number;
q: number;
}>(10);
for (let i = 1; i <= 10; i++) {
const line = lines[i];
const match = line.match(
/^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i
);
if (!match) {
throw new Error(eqInterp(t.errFilterLine, { line: String(i + 1) }));
}
const filterNo = Number(match[1]);
if (filterNo < 1 || filterNo > 10) {
throw new Error(eqInterp(t.errFilterNoRange, { no: String(filterNo) }));
}
const type = normalizeFilterType(match[3].toUpperCase());
if (!FILTER_TYPES.includes(type)) {
throw new Error(eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] }));
}
const freq = Number(match[4]);
const gain = Number(match[5]);
const q = Number(match[6]);
if (![freq, gain, q].every(Number.isFinite)) {
throw new Error(eqInterp(t.errFilterNumbers, { no: String(filterNo) }));
}
parsedFilters[filterNo - 1] = {
enabled: match[2].toUpperCase() === "ON",
type,
freq: Number(freq.toFixed(2)),
gain: Number(gain.toFixed(2)),
q: Number(q.toFixed(2)),
};
}
const freq = Number(match[4]);
const gain = Number(match[5]);
const q = Number(match[6]);
if (![freq, gain, q].every(Number.isFinite)) {
throw new Error(`格式错误:Filter ${filterNo} 的数值无效`);
if (parsedFilters.some((item) => !item)) {
throw new Error(eqInterp(t.errFilterAll, {}));
}
parsedFilters[filterNo - 1] = {
enabled: match[2].toUpperCase() === "ON",
type,
freq: Number(freq.toFixed(2)),
gain: Number(gain.toFixed(2)),
q: Number(q.toFixed(2)),
return {
preamp: Number(preamp.toFixed(2)),
filters: parsedFilters as typeof bands,
};
}
if (parsedFilters.some((item) => !item)) {
throw new Error("格式错误:Filter 1-10 必须全部提供");
}
return {
preamp: Number(preamp.toFixed(2)),
filters: parsedFilters as typeof bands,
};
};
},
[eqUi],
);
const handleSaveBatchEdit = () => {
try {
@@ -884,9 +924,9 @@ export default function EQPage() {
schedulePeqSync(updatedPeq, parsed.filters, 0);
setSelectedBand(0);
setIsBatchEditDialogOpen(false);
toast.success("批量编辑已应用");
toast.success(eqUi.toastBatchApplied);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量编辑格式错误");
toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError);
}
};
@@ -917,23 +957,23 @@ export default function EQPage() {
const target = peqItems[headphoneIdx];
if (!target?.name) return;
const confirmed = window.confirm(`是否删除耳机「${target.name}」?`);
const confirmed = window.confirm(eqInterp(eqUi.deleteConfirm, { name: target.name }));
if (!confirmed) return;
try {
if (isDemoMode || !api) {
const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx);
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) });
toast.success("已删除耳机");
toast.success(eqUi.toastDeleted);
return;
}
await api.removePeq([target.name]);
const latest = await api.getPeqState();
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
toast.success("已删除耳机");
toast.success(eqUi.toastDeleted);
} catch {
toast.error("删除耳机失败");
toast.error(eqUi.toastDeleteFail);
}
};
@@ -1385,7 +1425,7 @@ export default function EQPage() {
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
<Edit3 size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80"></span>
<span className="text-[14px] text-white/80">{peqCardLabels.edit}</span>
</button>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
@@ -1394,7 +1434,7 @@ export default function EQPage() {
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
<Headphones size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80"></span>
<span className="text-[14px] text-white/80">{peqCardLabels.headset}</span>
</button>
</div>
@@ -1484,6 +1524,7 @@ export default function EQPage() {
onCopyMode={copyModeParams}
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
onBandSelect={setSelectedBand}
eqUi={eqUi}
/>
{/* ── Band grid (2 rows × 5) ── */}
@@ -1513,7 +1554,7 @@ export default function EQPage() {
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
{/* Filter type selector */}
<div className="flex items-center justify-between">
<span className="text-[14px] text-white/50 font-medium"></span>
<span className="text-[14px] text-white/50 font-medium">{peqCardLabels.filters?.label ?? "滤波器"}</span>
<div
ref={filterMenuRef}
className="relative z-40 w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all"
@@ -1596,10 +1637,10 @@ export default function EQPage() {
{/* Q 值 */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium">Q </span>
<span className="text-[13px] text-white/45 font-medium">{peqCardLabels.q ?? "Q值"}</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
{band.q.toFixed(2)} dB
{band.q.toFixed(2)}
</span>
</div>
<CyanSlider value={band.q} min={0.1} max={10} step={0.01}
@@ -1611,7 +1652,7 @@ export default function EQPage() {
<div className="ios-list-group p-4 relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="flex items-baseline gap-1">
<span className="text-[13px] text-white/45"></span>
<span className="text-[13px] text-white/45">{peqCardLabels.preamp}</span>
<span className="text-[28px] font-bold ml-2" style={{ color: "#00FFF6" }}>
{preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)}
</span>
@@ -1719,7 +1760,7 @@ export default function EQPage() {
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[28px] text-white/90 font-semibold"></h3>
<h3 className="text-[28px] text-white/90 font-semibold">{eqUi.batchEditTitle}</h3>
<button
type="button"
className="text-white/45 hover:text-white/75 transition-colors"
@@ -1729,7 +1770,7 @@ export default function EQPage() {
</button>
</div>
<p className="mb-3 text-[13px] text-white/55">
11 1 Preamp 2-11 Filter 1-10
{eqUi.batchEditHint}
</p>
<textarea
value={batchEditText}
@@ -1743,14 +1784,14 @@ export default function EQPage() {
className="px-5 py-2 rounded-full text-[24px] text-white/80 bg-[#3f4349] hover:bg-[#4a4f56] transition-colors"
onClick={() => setIsBatchEditDialogOpen(false)}
>
{eqUi.cancel}
</button>
<button
type="button"
className="px-5 py-2 rounded-full text-[24px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
onClick={handleSaveBatchEdit}
>
{eqUi.save}
</button>
</div>
</div>
@@ -1762,7 +1803,7 @@ export default function EQPage() {
<button
type="button"
className="absolute inset-0 bg-black/55"
aria-label="关闭"
aria-label={eqUi.closeDrawer}
onClick={() => setIsBrandDrawerOpen(false)}
/>
<div
@@ -1775,7 +1816,8 @@ export default function EQPage() {
<div className="flex shrink-0 items-stretch rounded-t-[12px] bg-[#2c2c2e] px-1 pt-1">
{(["brands", "models", "target"] as const).map((tab) => {
const active = brandDrawerTab === tab;
const label = tab === "brands" ? "Brands" : tab === "models" ? "Models" : "Target";
const label =
tab === "brands" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
return (
<button
key={tab}
@@ -1801,14 +1843,14 @@ export default function EQPage() {
type="search"
value={brandSearchQuery}
onChange={(e) => setBrandSearchQuery(e.target.value)}
placeholder="搜索品牌或型号"
placeholder={eqUi.searchPlaceholder}
className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
{brandSearchQuery.trim() && catalogSearchLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.searching}</div>
)}
{!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogSearchError}</div>
@@ -1817,7 +1859,7 @@ export default function EQPage() {
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noSearchResults}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
@@ -1844,13 +1886,13 @@ export default function EQPage() {
</button>
))}
{!brandSearchQuery.trim() && catalogBrandsLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogBrandsError}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noBrandMatch}</div>
)}
{!brandSearchQuery.trim() &&
!catalogBrandsLoading &&
@@ -1880,22 +1922,25 @@ export default function EQPage() {
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
{eqUi.brandLabel}<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
{selectedCatalogModelFromSearch && (
<span className="ml-2 text-black/45">{selectedCatalogModelFromSearch}</span>
<span className="ml-2 text-black/45">
{eqUi.modelFromSearchLabel}
{selectedCatalogModelFromSearch}
</span>
)}
</div>
{!selectedCatalogBrand && (
<div className="py-10 text-center text-[14px] text-black/45"> Brands </div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectBrandFirst}</div>
)}
{!!selectedCatalogBrand && catalogModelsLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogModelsError}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noModelsForBrand}</div>
)}
{!!selectedCatalogBrand &&
!catalogModelsLoading &&
@@ -1923,9 +1968,18 @@ export default function EQPage() {
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55 space-y-0.5">
<div><span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span></div>
<div><span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span></div>
<div>Form<span className="font-semibold text-black/80">{selectedCatalogModelForm || "全部"}</span></div>
<div>
{eqUi.targetBrand}
<span className="font-semibold text-black/80">{selectedCatalogBrand || ""}</span>
</div>
<div>
{eqUi.targetModel}
<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span>
</div>
<div>
{eqUi.targetForm}
<span className="font-semibold text-black/80">{selectedCatalogModelForm || eqUi.formAll}</span>
</div>
</div>
<div className="px-5 pt-4 pb-3">
<button
@@ -2024,9 +2078,9 @@ export default function EQPage() {
}
setIsBrandDrawerOpen(false);
toast.success("新耳机 EQ 已上报");
toast.success(eqUi.toastNewEqOk);
} catch {
toast.error("获取曲线失败");
toast.error(eqUi.toastCurveFail);
} finally {
setIsConfirmingTarget(false);
}
@@ -2039,14 +2093,14 @@ export default function EQPage() {
aria-hidden="true"
/>
)}
{isConfirmingTarget ? "loading..." : "confirm"}
{isConfirmingTarget ? eqUi.confirmLoading : eqUi.confirmButton}
</button>
</div>
{!selectedCatalogModelName && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectModelFirst}</div>
)}
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"> Target</div>
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noTargets}</div>
)}
{!!selectedCatalogModelName &&
availableCatalogTargets.map((target) => {
@@ -2059,12 +2113,16 @@ export default function EQPage() {
style={active ? { background: "rgba(0,255,246,0.14)" } : undefined}
onClick={() => {
setSelectedCatalogTarget(target.name);
toast.success(`已选 Target${target.name}`);
toast.success(eqInterp(eqUi.toastTargetSelected, { name: target.name }));
}}
>
<div className="text-[15px] text-black/85 font-medium">{target.name}</div>
<div className="mt-1 text-[12px] text-black/55">
Bass boost: fc {target.bassBoost.fc}, q {target.bassBoost.q}, gain {target.bassBoost.gain}dB
{eqInterp(eqUi.bassBoost, {
fc: String(target.bassBoost.fc),
q: String(target.bassBoost.q),
gain: String(target.bassBoost.gain),
})}
</div>
</button>
);