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
+23 -8
View File
@@ -1,13 +1,17 @@
/* ============================================================
BOTTOM NAV — Luxsin X9 Controller (Mobile)
Design: iOS/Android App-style bottom tab bar
5 tabs: 社区 / HP-EQ / 主页 / Effect / 我的AI
5 tabs — labels from locales/bottomNav, language = deviceState.language (同系统页)
- Active tab: cyan icon + top indicator bar
- Inactive: muted icon + label
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import { cn } from "@/lib/utils";
import { useAIDrawer } from "@/contexts/AIDrawerContext";
import { useIsLgUp } from "@/hooks/useIsLgUp";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
import {
Activity,
Bot,
@@ -16,25 +20,36 @@ import {
Sliders,
} from "lucide-react";
import { Link, useLocation } from "wouter";
import { useMemo } from "react";
type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>;
interface NavItem {
path: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
label: string;
labelKey: keyof BottomNavLocale;
}
const NAV_ITEMS: NavItem[] = [
{ path: "/community", icon: MessageSquare, label: "社区" },
{ path: "/eq", icon: Sliders, label: "HP-EQ" },
{ path: "/", icon: Home, label: "主页" },
{ path: "/effects", icon: Activity, label: "Effect" },
{ path: "/ai", icon: Bot, label: "我的AI" },
{ path: "/community", icon: MessageSquare, labelKey: "community" },
{ path: "/eq", icon: Sliders, labelKey: "hpEq" },
{ path: "/", icon: Home, labelKey: "home" },
{ path: "/effects", icon: Activity, labelKey: "effects" },
{ path: "/ai", icon: Bot, labelKey: "myAi" },
];
export default function BottomNav() {
const [location] = useLocation();
const isLgUp = useIsLgUp();
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
const { deviceState } = useDevice();
const navText = useMemo((): BottomNavLocale => {
const lang = deviceState?.language;
const pack =
lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return (pack.bottomNav ?? localeZh.bottomNav) as BottomNavLocale;
}, [deviceState?.language]);
return (
<nav
@@ -102,7 +117,7 @@ export default function BottomNav() {
textShadow: isActive ? "0 0 8px rgba(0,255,246,0.5)" : "none",
}}
>
{item.label}
{navText[item.labelKey]}
</span>
</div>
);
+63 -2
View File
@@ -1,6 +1,7 @@
{
"home": {
"audioSet": "Audio setting",
"systemSet": "System",
"power": "Power",
"input": "Input",
"output": "Output",
@@ -12,6 +13,16 @@
"powerOffToastDemo": "Demo mode: disconnected from the device.",
"powerOffToastSent": "Shutdown command sent."
},
"vuPage": {
"title": "VU meter gallery"
},
"bottomNav": {
"community": "Community",
"hpEq": "HP-EQ",
"home": "Home",
"effects": "Effects",
"myAi": "My AI"
},
"source": {
"label": "Input/Output",
"input": {
@@ -134,7 +145,7 @@
"Standard": "Standard equalizer preset",
"edit": "Batch Edit",
"headset": "Add HeadSet",
"preamp": "Preamp",
"preamp": "Total gain",
"filters": {
"label": "Filters",
"options": [
@@ -203,7 +214,57 @@
"gain": "GAIN",
"q": "Q value",
"loadDeviceBtn": "confirm",
"peqNoneItem": "Unconfigured"
"peqNoneItem": "Unconfigured",
"eqUi": {
"batchEditTitle": "Batch edit",
"batchEditHint": "11 lines total: line 1 is Preamp, lines 211 are Filter 110. Saving overwrites the current headphone preset.",
"cancel": "Cancel",
"save": "Save",
"tabBrands": "Brands",
"tabModels": "Models",
"tabTarget": "Target",
"searchPlaceholder": "Search brand or model",
"searching": "Searching…",
"noSearchResults": "No matching brand or model",
"loading": "Loading…",
"noBrandMatch": "No matching brands",
"selectBrandFirst": "Choose a brand on the Brands tab first",
"brandLabel": "Brand:",
"modelFromSearchLabel": "Model from search:",
"noModelsForBrand": "No models for this brand",
"targetBrand": "Brand:",
"targetModel": "Model:",
"targetForm": "Form:",
"formAll": "All",
"confirmLoading": "Working…",
"confirmButton": "Confirm",
"selectModelFirst": "Select a model first",
"noTargets": "No target curves available",
"closeDrawer": "Close",
"toastBatchApplied": "Batch edit applied",
"toastBatchParseError": "Invalid batch edit format",
"toastBrandsFail": "Failed to load brands",
"toastModelsFail": "Failed to load models",
"toastCurveFail": "Failed to load curve",
"toastNewEqOk": "New headphone EQ saved",
"toastTargetSelected": "Target selected: {{name}}",
"deleteConfirm": "Delete headphone \"{{name}}\"?",
"toastDeleted": "Headphone removed",
"toastDeleteFail": "Failed to delete headphone",
"bassBoost": "Bass boost: fc {{fc}}, q {{q}}, gain {{gain}} dB",
"errMinLines": "At least 11 lines required (Preamp + 10 filters)",
"errPreampFirstLine": "Line 1 must look like Preamp:-5.96dB",
"errPreampValue": "Invalid Preamp value",
"errFilterLine": "Line {{line}} does not match the Filter format",
"errFilterNoRange": "Filter number {{no}} is out of range",
"errFilterType": "Invalid type for filter {{no}} ({{type}})",
"errFilterNumbers": "Invalid numbers for filter {{no}}",
"errFilterAll": "Filters 110 must all be present",
"chartCopyTo": "Copy to {{mode}}",
"chartLoadToDevice": "Load to device",
"toastChartCopied": "Copied to {{mode}}",
"toastChartLoadedDevice": "Loaded to device"
}
},
"dac": {
"balance": "Left-right balance",
+62 -1
View File
@@ -1,6 +1,7 @@
{
"home": {
"audioSet": "音訊設定",
"systemSet": "系統設定",
"power": "電源",
"input": "輸入源",
"output": "輸出端口",
@@ -12,6 +13,16 @@
"powerOffToastDemo": "演示模式:已中斷與裝置的連線",
"powerOffToastSent": "已傳送關機指令"
},
"vuPage": {
"title": "VU 表庫"
},
"bottomNav": {
"community": "社區",
"hpEq": "HP-EQ",
"home": "主頁",
"effects": "Effect",
"myAi": "我的AI"
},
"source": {
"label": "輸入/輸出",
"input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" },
@@ -137,7 +148,57 @@
"gain": "GAIN",
"q": "Q值",
"loadDeviceBtn": "確認載入",
"peqNoneItem": "尚未設定"
"peqNoneItem": "尚未設定",
"eqUi": {
"batchEditTitle": "批次編輯",
"batchEditHint": "共 11 行:第 1 行為 Preamp,第 211 行為 Filter 1–10。儲存後將覆寫目前耳機的參數。",
"cancel": "取消",
"save": "儲存",
"tabBrands": "品牌",
"tabModels": "型號",
"tabTarget": "目標",
"searchPlaceholder": "搜尋品牌或型號",
"searching": "搜尋中…",
"noSearchResults": "找不到相關品牌或型號",
"loading": "載入中…",
"noBrandMatch": "沒有符合的品牌",
"selectBrandFirst": "請先在「品牌」頁選擇品牌",
"brandLabel": "品牌:",
"modelFromSearchLabel": "已選搜尋型號:",
"noModelsForBrand": "此品牌暫無型號",
"targetBrand": "品牌:",
"targetModel": "型號:",
"targetForm": "Form",
"formAll": "全部",
"confirmLoading": "處理中…",
"confirmButton": "確認",
"selectModelFirst": "請先選擇型號",
"noTargets": "暫無可用目標曲線",
"closeDrawer": "關閉",
"toastBatchApplied": "批次編輯已套用",
"toastBatchParseError": "批次編輯格式錯誤",
"toastBrandsFail": "取得品牌列表失敗",
"toastModelsFail": "取得型號列表失敗",
"toastCurveFail": "取得曲線失敗",
"toastNewEqOk": "新耳機 EQ 已上報",
"toastTargetSelected": "已選目標:{{name}}",
"deleteConfirm": "是否刪除耳機「{{name}}」?",
"toastDeleted": "已刪除耳機",
"toastDeleteFail": "刪除耳機失敗",
"bassBoost": "低頻增強:fc {{fc}}q {{q}}gain {{gain}}dB",
"errMinLines": "至少需要 11 行(Preamp + 10 個濾波器)",
"errPreampFirstLine": "第 1 行應為 Preamp:-5.96dB 格式",
"errPreampValue": "Preamp 數值無效",
"errFilterLine": "第 {{line}} 行不符合 Filter 格式",
"errFilterNoRange": "Filter 編號 {{no}} 超出範圍",
"errFilterType": "Filter {{no}} 的類型無效({{type}}",
"errFilterNumbers": "Filter {{no}} 的數值無效",
"errFilterAll": "Filter 110 必須全部提供",
"chartCopyTo": "複製到 {{mode}}",
"chartLoadToDevice": "載入至裝置",
"toastChartCopied": "已複製到 {{mode}}",
"toastChartLoadedDevice": "已載入至裝置"
}
},
"dac": {
"balance": "左右平衡",
+62 -1
View File
@@ -1,6 +1,7 @@
{
"home": {
"audioSet": "音频设置",
"systemSet": "系统设置",
"power": "电源",
"input": "输入源",
"output": "输出端口",
@@ -12,6 +13,16 @@
"powerOffToastDemo": "演示模式:已断开与设备的连接",
"powerOffToastSent": "已发送关机指令"
},
"vuPage": {
"title": "VU 表库"
},
"bottomNav": {
"community": "社区",
"hpEq": "HP-EQ",
"home": "主页",
"effects": "Effect",
"myAi": "我的AI"
},
"source": {
"label": "输入输出",
"input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" },
@@ -137,7 +148,57 @@
"gain": "GAIN",
"q": "Q值",
"loadDeviceBtn": "确定载入",
"peqNoneItem": "未配置"
"peqNoneItem": "未配置",
"eqUi": {
"batchEditTitle": "批量编辑",
"batchEditHint": "共 11 行:第 1 行为 Preamp,第 211 行为 Filter 1–10。保存后将覆盖当前耳机的参数。",
"cancel": "取消",
"save": "保存",
"tabBrands": "品牌",
"tabModels": "型号",
"tabTarget": "目标",
"searchPlaceholder": "搜索品牌或型号",
"searching": "搜索中…",
"noSearchResults": "未找到相关品牌或型号",
"loading": "加载中…",
"noBrandMatch": "无匹配品牌",
"selectBrandFirst": "请先在「品牌」页选择品牌",
"brandLabel": "品牌:",
"modelFromSearchLabel": "已选搜索型号:",
"noModelsForBrand": "该品牌暂无型号",
"targetBrand": "品牌:",
"targetModel": "型号:",
"targetForm": "Form",
"formAll": "全部",
"confirmLoading": "处理中…",
"confirmButton": "确认",
"selectModelFirst": "请先选择型号",
"noTargets": "暂无可用目标曲线",
"closeDrawer": "关闭",
"toastBatchApplied": "批量编辑已应用",
"toastBatchParseError": "批量编辑格式错误",
"toastBrandsFail": "获取品牌列表失败",
"toastModelsFail": "获取型号列表失败",
"toastCurveFail": "获取曲线失败",
"toastNewEqOk": "新耳机 EQ 已上报",
"toastTargetSelected": "已选目标:{{name}}",
"deleteConfirm": "是否删除耳机「{{name}}」?",
"toastDeleted": "已删除耳机",
"toastDeleteFail": "删除耳机失败",
"bassBoost": "低频增强:fc {{fc}}q {{q}}gain {{gain}}dB",
"errMinLines": "至少需要 11 行(Preamp + 10 个滤波器)",
"errPreampFirstLine": "第 1 行应为 Preamp:-5.96dB 格式",
"errPreampValue": "Preamp 数值无效",
"errFilterLine": "第 {{line}} 行不符合 Filter 格式",
"errFilterNoRange": "Filter 编号 {{no}} 超出范围",
"errFilterType": "Filter {{no}} 的类型无效({{type}}",
"errFilterNumbers": "Filter {{no}} 的数值无效",
"errFilterAll": "Filter 110 必须全部提供",
"chartCopyTo": "复制到 {{mode}}",
"chartLoadToDevice": "加载至设备",
"toastChartCopied": "已复制到 {{mode}}",
"toastChartLoadedDevice": "已加载至设备"
}
},
"dac": {
"balance": "左右平衡",
+115 -57
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,22 +828,24 @@ export default function EQPage() {
setIsBatchEditDialogOpen(true);
};
const parseBatchEditText = (raw: string) => {
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("格式错误:至少需要 11 行(Preamp + 10 个滤波器)");
throw new Error(eqInterp(t.errMinLines, {}));
}
const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i);
if (!preampMatch) {
throw new Error("格式错误:第 1 行应为 Preamp:-5.96dB");
throw new Error(eqInterp(t.errPreampFirstLine, {}));
}
const preamp = Number(preampMatch[1]);
if (!Number.isFinite(preamp)) {
throw new Error("格式错误:Preamp 数值无效");
throw new Error(eqInterp(t.errPreampValue, {}));
}
const parsedFilters = new Array<{
@@ -824,24 +862,24 @@ export default function EQPage() {
/^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 格式`);
throw new Error(eqInterp(t.errFilterLine, { line: String(i + 1) }));
}
const filterNo = Number(match[1]);
if (filterNo < 1 || filterNo > 10) {
throw new Error(`格式错误:Filter 编号 ${filterNo} 超出范围`);
throw new Error(eqInterp(t.errFilterNoRange, { no: String(filterNo) }));
}
const type = normalizeFilterType(match[3].toUpperCase());
if (!FILTER_TYPES.includes(type)) {
throw new Error(`格式错误:Filter ${filterNo} 的类型无效(${match[3]}`);
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(`格式错误:Filter ${filterNo} 的数值无效`);
throw new Error(eqInterp(t.errFilterNumbers, { no: String(filterNo) }));
}
parsedFilters[filterNo - 1] = {
@@ -854,14 +892,16 @@ export default function EQPage() {
}
if (parsedFilters.some((item) => !item)) {
throw new Error("格式错误:Filter 1-10 必须全部提供");
throw new Error(eqInterp(t.errFilterAll, {}));
}
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>
);
+125 -38
View File
@@ -2,12 +2,12 @@
HOME — Luxsin X9 Controller (Main Dashboard)
Design: Reference luxsin_x8_首页.png
Layout (top to bottom):
1. TopBar: device name + settings icon
1. Device area
2. Device status card (运行中/待机/充电 + 电量/模式/环保参数)
3. Device render image (hardware photo)
4. Volume slider with dB display
5. 4 quick-action circle buttons (电源/EQ/HP-EQ/Bypass)
6. iOS list rows (输入源/输出端口/Effect/音频设置/VU表)
5. 4 quick-action circle buttons (电源/HP-EQ/Effect/BypassHP-EQ/Effect 支持双击开关)
6. iOS list rows (输入源/输出端口/音频设置/系统设置/VU表)
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import ConnectScreen from "@/components/ConnectScreen";
@@ -28,6 +28,7 @@ import {
ChevronRight,
Power,
Settings,
Settings2,
Sliders,
Activity,
Gauge,
@@ -100,6 +101,10 @@ export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
const [, setLocation] = useLocation();
const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef<number | null>(null);
const hpEqNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const effectLastTapRef = useRef<number | null>(null);
const effectNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
const homeText = useMemo((): HomeLocale => {
@@ -112,6 +117,7 @@ export default function Home() {
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false);
const knobDraggingRef = useRef(false);
const knobActivePointerIdRef = useRef<number | null>(null);
const knobStartAngleRef = useRef(0);
const knobStartVolRef = useRef(0);
const knobLastVolRef = useRef<number>(localVol);
@@ -126,12 +132,6 @@ export default function Home() {
knobLastVolRef.current = localVol;
}, [localVol]);
const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setLocalVol(v);
setVolume(v);
}, [setVolume]);
const handlePowerOff = useCallback(async () => {
if (powerActionRef.current) return;
powerActionRef.current = true;
@@ -155,13 +155,66 @@ export default function Home() {
}
}, [api, disconnect, isDemoMode, homeText]);
/** HP-EQ 圆钮:单击进入 /eq;双击在 280ms 内第二次点击则切换 peqEnable(与 info.cgi?action=setting&peqEnable=0|1 一致) */
const HP_EQ_DOUBLE_CLICK_MS = 280;
const handleHpEqCircleClick = useCallback(() => {
const now = Date.now();
const last = hpEqLastTapRef.current;
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
hpEqLastTapRef.current = null;
if (hpEqNavigateTimerRef.current) {
clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = null;
}
const on = (deviceState?.peqEnable ?? 0) === 1;
void updateSetting({ peqEnable: on ? 0 : 1 });
return;
}
hpEqLastTapRef.current = now;
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = setTimeout(() => {
hpEqNavigateTimerRef.current = null;
hpEqLastTapRef.current = null;
setLocation("/eq");
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.peqEnable, updateSetting, setLocation]);
/** Effect 圆钮:单击进入 /effects;双击切换 audio_enable */
const handleEffectCircleClick = useCallback(() => {
const now = Date.now();
const last = effectLastTapRef.current;
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
effectLastTapRef.current = null;
if (effectNavigateTimerRef.current) {
clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = null;
}
const on = (deviceState?.audio_enable ?? 0) === 1;
void updateSetting({ audio_enable: on ? 0 : 1 });
return;
}
effectLastTapRef.current = now;
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = setTimeout(() => {
effectNavigateTimerRef.current = null;
effectLastTapRef.current = null;
setLocation("/effects");
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.audio_enable, updateSetting, setLocation]);
useEffect(() => {
return () => {
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
};
}, []);
if (!isConnected) return <ConnectScreen />;
const ds = deviceState!;
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
const effectOn = ds.audio_enable === 1;
const audioOn = ds.audio_enable === 1;
const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
@@ -194,14 +247,16 @@ export default function Home() {
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
knobDraggingRef.current = true;
knobActivePointerIdRef.current = e.pointerId;
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
knobStartVolRef.current = knobLastVolRef.current;
isDragging.current = true;
el.setPointerCapture(e.pointerId);
};
/** 拖动中只更新本地 UI,不调用 setVolume(避免来回旋时频繁打接口) */
const moveKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current) return;
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
const a = pointAngle(e.clientX, e.clientY, rect);
@@ -210,13 +265,15 @@ export default function Home() {
const stepsPerRad = 200 / (Math.PI * 1.5);
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
setLocalVol(next);
setVolume(next);
knobLastVolRef.current = next;
};
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current) return;
const commitKnobDragEnd = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
knobDraggingRef.current = false;
knobActivePointerIdRef.current = null;
isDragging.current = false;
setVolume(knobLastVolRef.current);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
@@ -224,17 +281,18 @@ export default function Home() {
}
};
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
commitKnobDragEnd(e);
};
/** 异常丢失 capture 时仍提交一次,避免只松手未触发 pointerup 时漏提交 */
const lostKnobPointerCapture = (e: React.PointerEvent<HTMLDivElement>) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
commitKnobDragEnd(e);
};
return (
<div className="min-h-screen bg-black flex flex-col">
{/* ── Top bar ── */}
<div className="flex items-center justify-end px-4 pt-4 pb-2">
<button onClick={() => setLocation("/system")}
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.6)" }}>
<Settings size={18} />
</button>
</div>
{/* ── Device render image ── */}
<div className="px-4 pb-4">
<div className="w-full rounded-2xl overflow-hidden flex items-center justify-center"
@@ -322,6 +380,7 @@ export default function Home() {
onPointerMove={moveKnobDrag}
onPointerUp={endKnobDrag}
onPointerCancel={endKnobDrag}
onLostPointerCapture={lostKnobPointerCapture}
>
<div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} />
</div>
@@ -357,11 +416,40 @@ export default function Home() {
<input
type="range" min={0} max={200} value={localVol}
className="cyan-slider relative z-10"
onMouseDown={() => { isDragging.current = true; }}
onMouseUp={() => { isDragging.current = false; }}
onTouchStart={() => { isDragging.current = true; }}
onTouchEnd={() => { isDragging.current = false; }}
onChange={handleVolChange}
onChange={(e) => {
const v = Number(e.target.value);
setLocalVol(v);
}}
onPointerDown={() => {
isDragging.current = true;
}}
onPointerUp={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onPointerCancel={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onBlur={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onKeyUp={(e) => {
const k = e.key;
if (
k === "ArrowLeft" ||
k === "ArrowRight" ||
k === "ArrowUp" ||
k === "ArrowDown" ||
k === "Home" ||
k === "End" ||
k === "PageUp" ||
k === "PageDown"
) {
setVolume(Number(e.currentTarget.value));
}
}}
/>
</div>
@@ -480,8 +568,9 @@ export default function Home() {
{/* EQ */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className={cn("icon-circle-btn", !bypassOn && ds.peqEnable === 1 && "active")}
onClick={() => setLocation("/eq")}
onClick={handleHpEqCircleClick}
>
<Sliders size={22} className={(!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
@@ -491,8 +580,9 @@ export default function Home() {
{/* Effect */}
<div className="flex flex-col items-center gap-2">
<button
type="button"
className={cn("icon-circle-btn", !bypassOn && effectOn && "active")}
onClick={() => setLocation("/effects")}
onClick={handleEffectCircleClick}
>
<Activity size={22} className={(!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
@@ -531,19 +621,16 @@ export default function Home() {
value={outputLabel}
onClick={() => setLocation("/io")}
/>
<ListRow
icon={<Sliders size={16} />}
label="Effect"
value={effectOn ? "已开启" : undefined}
toggle
checked={effectOn}
onToggle={(v) => updateSetting({ audio_enable: v ? 1 : 0 })}
/>
<ListRow
icon={<Settings size={16} />}
label={homeText.audioSet ?? "音频设置"}
onClick={() => setLocation("/audio")}
/>
<ListRow
icon={<Settings2 size={16} />}
label={homeText.systemSet ?? "系统设置"}
onClick={() => setLocation("/system")}
/>
<ListRow
icon={<Gauge size={16} />}
label={homeText.vu ?? "VU表"}
+35 -18
View File
@@ -1,7 +1,7 @@
/* ============================================================
VU PAGE — VU Meter Gallery
Design: Reference luxsin_x8_VU表.png
- Card-based VU meter style selector
- 3-column grid of VU style cards
- Active = cyan check circle below card
- Inactive = empty circle
============================================================ */
@@ -10,6 +10,12 @@ import { cn } from "@/lib/utils";
import BottomNav from "@/components/BottomNav";
import { Check, ChevronLeft } from "lucide-react";
import { useLocation } from "wouter";
import { useMemo } from "react";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
type VUPageLocale = NonNullable<(typeof localeZh)["vuPage"]>;
interface VUStyle {
index: number;
@@ -28,62 +34,73 @@ export default function VUPage() {
const { deviceState, updateSetting } = useDevice();
const currentVU = deviceState?.vu ?? 0;
const vuPageText = useMemo((): VUPageLocale => {
const lang = deviceState?.language;
const loc =
lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return (loc.vuPage ?? {}) as VUPageLocale;
}, [deviceState?.language]);
return (
<div className="min-h-screen bg-black">
<div className="page-header">
<button onClick={() => setLocation("/")} 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">VU </h1>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">
{vuPageText.title ?? "VU 表库"}
</h1>
<div className="w-8" />
</div>
<div className="px-4 pb-32 pt-4 space-y-5">
<div className="px-4 pb-32 pt-4">
<div className="grid grid-cols-3 gap-x-2 gap-y-4">
{VU_STYLES.map((style) => {
const isActive = currentVU === style.index;
return (
<div key={style.index} className="space-y-3">
{/* Card */}
<div key={style.index} className="flex flex-col items-center gap-1.5 min-w-0">
<div
className={cn(
"ios-list-group p-3 cursor-pointer transition-all duration-200",
"ios-list-group p-1.5 cursor-pointer transition-all duration-200 w-full",
isActive && "border-[#00FFF6]/30"
)}
style={isActive ? { borderColor: "rgba(0,255,246,0.3)", boxShadow: "0 0 20px rgba(0,255,246,0.08)" } : {}}
style={isActive ? { borderColor: "rgba(0,255,246,0.3)", boxShadow: "0 0 16px rgba(0,255,246,0.08)" } : {}}
onClick={() => updateSetting({ vu: style.index })}
>
<img
src={style.preview}
alt={style.name}
className="w-full h-28 object-contain rounded-xl bg-black/20"
className="w-full h-[4.5rem] sm:h-24 object-contain rounded-lg bg-black/20"
/>
</div>
{/* Selection indicator */}
<div className="flex justify-center">
<button
type="button"
onClick={() => updateSetting({ vu: style.index })}
className={cn(
"w-7 h-7 rounded-full flex items-center justify-center transition-all duration-200",
"w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 shrink-0",
isActive
? "bg-[#00FFF6] text-black shadow-[0_0_12px_rgba(0,255,246,0.5)]"
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
: "border-2 border-white/20 text-transparent"
)}
aria-label={isActive ? `${style.name}(已选)` : `选择${style.name}`}
>
{isActive && <Check size={14} strokeWidth={3} />}
{isActive && <Check size={12} strokeWidth={3} />}
</button>
</div>
{/* Style name */}
<div className="text-center">
<p className={cn("text-[15px] font-medium", isActive ? "text-[#00FFF6]" : "text-white/60")}>
<p
className={cn(
"text-center text-[11px] sm:text-xs font-medium truncate w-full px-0.5",
isActive ? "text-[#00FFF6]" : "text-white/60"
)}
>
{style.name}
</p>
</div>
</div>
);
})}
</div>
</div>
<BottomNav />
</div>
);