538 lines
22 KiB
TypeScript
538 lines
22 KiB
TypeScript
/* ============================================================
|
|
HOME — Luxsin X9 Controller (Main Dashboard)
|
|
Design: Reference luxsin_x8_首页.png
|
|
Layout (top to bottom):
|
|
1. TopBar: device name + settings icon
|
|
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表)
|
|
============================================================ */
|
|
import { useDevice } from "@/contexts/DeviceContext";
|
|
import ConnectScreen from "@/components/ConnectScreen";
|
|
import BottomNav from "@/components/BottomNav";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
ChevronRight,
|
|
Power,
|
|
Settings,
|
|
Sliders,
|
|
Activity,
|
|
Gauge,
|
|
ChevronDown,
|
|
Zap,
|
|
Wifi,
|
|
Bluetooth,
|
|
} from "lucide-react";
|
|
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";
|
|
|
|
type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
|
|
|
|
// ── Volume in dB (0-200 → -100dB to 0dB) ──
|
|
function volToDB(v: number) {
|
|
if (v === 0) return "-∞";
|
|
const db = (v - 200) / 2;
|
|
return `${db >= 0 ? "+" : ""}${db.toFixed(1)}dB`;
|
|
}
|
|
|
|
// ── iOS-style Toggle ──
|
|
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
<span className="ios-toggle-track">
|
|
<span className="ios-toggle-thumb" />
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
// ── List row ──
|
|
function ListRow({
|
|
icon, label, value, onClick, toggle, checked, onToggle,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value?: string;
|
|
onClick?: () => void;
|
|
toggle?: boolean;
|
|
checked?: boolean;
|
|
onToggle?: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="ios-list-row cursor-pointer" onClick={onClick}>
|
|
<div className="w-8 h-8 rounded-[8px] flex items-center justify-center mr-3 flex-shrink-0"
|
|
style={{ background: "rgba(0,255,246,0.1)", border: "1px solid rgba(0,255,246,0.15)" }}>
|
|
<span className="text-[#00FFF6]">{icon}</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-[16px] text-white font-medium">{label}</span>
|
|
</div>
|
|
{toggle ? (
|
|
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
{value && <span className="ios-row-value">{value}</span>}
|
|
<ChevronRight size={16} className="ios-chevron" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Home() {
|
|
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
|
|
const [, setLocation] = useLocation();
|
|
const powerActionRef = useRef(false);
|
|
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
|
|
|
|
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]);
|
|
|
|
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
|
const isDragging = useRef(false);
|
|
const knobDraggingRef = useRef(false);
|
|
const knobStartAngleRef = useRef(0);
|
|
const knobStartVolRef = useRef(0);
|
|
const knobLastVolRef = useRef<number>(localVol);
|
|
|
|
useEffect(() => {
|
|
if (!isDragging.current && deviceState?.volume !== undefined) {
|
|
setLocalVol(deviceState.volume);
|
|
}
|
|
}, [deviceState?.volume]);
|
|
|
|
useEffect(() => {
|
|
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;
|
|
try {
|
|
if (isDemoMode) {
|
|
toast.info(homeText.powerOffToastDemo ?? "演示模式:已断开与设备的连接");
|
|
disconnect();
|
|
return;
|
|
}
|
|
if (api) {
|
|
try {
|
|
await api.powerOff();
|
|
} catch {
|
|
// 设备可能已掉线或未返回响应,仍断开本地会话
|
|
}
|
|
}
|
|
disconnect();
|
|
toast.success(homeText.powerOffToastSent ?? "已发送关机指令");
|
|
} finally {
|
|
powerActionRef.current = false;
|
|
}
|
|
}, [api, disconnect, isDemoMode, homeText]);
|
|
|
|
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;
|
|
|
|
// Slider fill % (0-200 → 0-100%)
|
|
const fillPct = (localVol / 200) * 100;
|
|
// Knob angle (map 0..200 -> -135..+135 degrees)
|
|
const knobAngle = -135 + (localVol / 200) * 270;
|
|
|
|
const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => {
|
|
const cx = rect.left + rect.width / 2;
|
|
const cy = rect.top + rect.height / 2;
|
|
return Math.atan2(clientY - cy, clientX - cx);
|
|
};
|
|
|
|
const normalizeDelta = (delta: number) => {
|
|
// Wrap to [-PI, PI] to avoid jumps across the -PI/PI boundary.
|
|
const pi2 = Math.PI * 2;
|
|
let d = ((delta + Math.PI) % pi2) - Math.PI;
|
|
if (d < -Math.PI) d += pi2;
|
|
return d;
|
|
};
|
|
|
|
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
const el = e.currentTarget;
|
|
const rect = el.getBoundingClientRect();
|
|
knobDraggingRef.current = true;
|
|
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
|
|
knobStartVolRef.current = knobLastVolRef.current;
|
|
isDragging.current = true;
|
|
el.setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const moveKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!knobDraggingRef.current) return;
|
|
const el = e.currentTarget;
|
|
const rect = el.getBoundingClientRect();
|
|
const a = pointAngle(e.clientX, e.clientY, rect);
|
|
const delta = normalizeDelta(a - knobStartAngleRef.current);
|
|
// 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps
|
|
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);
|
|
};
|
|
|
|
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!knobDraggingRef.current) return;
|
|
knobDraggingRef.current = false;
|
|
isDragging.current = false;
|
|
try {
|
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
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"
|
|
style={{ background: "rgba(18,18,20,0.8)", minHeight: 140 }}>
|
|
{/* Stylized device illustration */}
|
|
<div className="w-full relative" style={{ height: 140 }}>
|
|
{/* Device body */}
|
|
<div className="absolute inset-x-4 inset-y-3 rounded-xl flex items-center"
|
|
style={{ background: "linear-gradient(135deg, #1a1a1c 0%, #2a2a2e 50%, #1a1a1c 100%)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
|
{/* Left ports */}
|
|
<div className="px-4">
|
|
<div className="flex items-center gap-3">
|
|
{/* Big hole (left) */}
|
|
<div
|
|
className="w-7 h-7 rounded-full border-2 bg-black/25 flex items-center justify-center"
|
|
style={{
|
|
borderColor: "rgba(245, 158, 11, 0.75)",
|
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
|
}}
|
|
>
|
|
<div
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ background: "rgba(196, 126, 9, 0.9)" }}
|
|
/>
|
|
</div>
|
|
{/* Two small holes (right, stacked) */}
|
|
<div className="flex flex-col gap-2">
|
|
<div
|
|
className="w-5 h-5 rounded-full border-2 bg-black/25"
|
|
style={{
|
|
borderColor: "rgba(245, 158, 11, 0.65)",
|
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="w-5 h-5 rounded-full border-2 bg-black/25"
|
|
style={{
|
|
borderColor: "rgba(245, 158, 11, 0.65)",
|
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* VU display area */}
|
|
<div className="flex-1 mx-3 rounded-lg overflow-hidden"
|
|
style={{ background: "#0d1208", border: "1px solid rgba(255,255,255,0.1)", height: 90 }}>
|
|
<div className="flex items-center justify-between px-2 pt-1">
|
|
<span className="text-[9px] text-white/50">{ds.audioFormat}</span>
|
|
<span className="text-[9px] text-white/50">{volToDB(localVol)}</span>
|
|
</div>
|
|
{/* Mini VU bars */}
|
|
<div className="flex gap-0.5 px-2 pt-1 pb-2 h-16 items-end">
|
|
{Array.from({ length: 20 }).map((_, i) => {
|
|
const h = Math.max(0.1, Math.sin((i / 20) * Math.PI) * 0.8 + Math.random() * 0.2);
|
|
const isHot = i > 16;
|
|
return (
|
|
<div key={i} className="flex-1 rounded-sm"
|
|
style={{
|
|
height: `${h * 100}%`,
|
|
background: isHot ? "#ef4444" : i > 13 ? "#f59e0b" : "#00FFF6",
|
|
opacity: 0.7,
|
|
}} />
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{/* Right knob */}
|
|
<div className="pr-4">
|
|
<div
|
|
className="w-12 h-12 rounded-full flex items-center justify-center touch-none select-none"
|
|
style={{
|
|
background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)",
|
|
border: "2px solid rgba(255,255,255,0.12)",
|
|
boxShadow: "inset 0 2px 4px rgba(0,0,0,0.5)",
|
|
transform: `rotate(${knobAngle}deg)`,
|
|
transition: knobDraggingRef.current ? "none" : "transform 120ms ease-out",
|
|
}}
|
|
role="slider"
|
|
aria-label="Volume"
|
|
aria-valuemin={0}
|
|
aria-valuemax={200}
|
|
aria-valuenow={localVol}
|
|
onPointerDown={beginKnobDrag}
|
|
onPointerMove={moveKnobDrag}
|
|
onPointerUp={endKnobDrag}
|
|
onPointerCancel={endKnobDrag}
|
|
>
|
|
<div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Brand label */}
|
|
<div className="absolute top-5 left-8">
|
|
<span className="text-[11px] font-bold tracking-widest text-white/30">LUXSIN</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Volume slider ── */}
|
|
<div className="px-4 mb-5">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={() => { setLocalVol(0); setVolume(0); }}
|
|
className="text-white/50 active:text-white transition-colors flex-shrink-0">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
|
<line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<div className="flex-1 relative flex items-center">
|
|
{/* Track fill - 根据按钮位置调整 */}
|
|
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
|
style={{
|
|
width: `${fillPct}%`,
|
|
background: "#00FFF6",
|
|
boxShadow: "0 0 8px rgba(0,255,246,0.5)"
|
|
}} />
|
|
<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}
|
|
/>
|
|
</div>
|
|
|
|
<button onClick={() => { setLocalVol(200); setVolume(200); }}
|
|
className="text-white/50 active:text-white transition-colors flex-shrink-0">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/* dB value */}
|
|
<div className="text-center mt-1">
|
|
<span className="text-[22px] font-bold tracking-tight" style={{ color: "#00FFF6" }}>
|
|
{volToDB(localVol)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Bluetooth info */}
|
|
{ds.bt_status !== 0 && (
|
|
<div className="mt-3 px-3 py-3 text-center">
|
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
<Bluetooth size={14} className="text-[#00FFF6]" />
|
|
<span className="text-[11px] text-white/50">{ds.bt_srcname}</span>
|
|
</div>
|
|
{ds.bt_title && (
|
|
<p className="text-[13px] text-white font-medium truncate mb-2">
|
|
{ds.bt_title}
|
|
{ds.bt_artist && <span className="text-white/50"> - {ds.bt_artist}</span>}
|
|
</p>
|
|
)}
|
|
{/* Playback controls */}
|
|
<div className="flex items-center justify-center gap-4 mt-2">
|
|
<button
|
|
onClick={() => updateSetting({ bt_next: 0 })}
|
|
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
|
|
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
|
|
<img src={`${import.meta.env.BASE_URL}player/skip-forward.png`} alt="上一首" className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => updateSetting({ bt_play: 1 })}
|
|
className="w-12 h-12 rounded-full flex items-center justify-center transition-all active:scale-95"
|
|
style={{ background: "transparent", border: "1px solid rgba(0,255,246,0.5)", boxShadow: "0 0 12px rgba(0,255,246,0.4)" }}>
|
|
<img
|
|
src={ds.bt_status === 1
|
|
? `${import.meta.env.BASE_URL}player/play.png`
|
|
: `${import.meta.env.BASE_URL}player/pause.png`}
|
|
alt={ds.bt_status === 1 ? "播放" : "暂停"}
|
|
className="w-6 h-6"
|
|
/>
|
|
</button>
|
|
<button
|
|
onClick={() => updateSetting({ bt_next: 1 })}
|
|
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
|
|
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
|
|
<img src={`${import.meta.env.BASE_URL}player/skip-back.png`} alt="下一首" className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 4 quick-action circle buttons ── */}
|
|
<div className="flex justify-around px-6 mb-6">
|
|
{/* Power */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="icon-circle-btn"
|
|
onClick={() => setPowerConfirmOpen(true)}
|
|
>
|
|
<Power size={22} className="text-white/60" />
|
|
</button>
|
|
<span className="text-[11px] text-white/35">{homeText.power ?? "电源"}</span>
|
|
</div>
|
|
|
|
{/* EQ */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<button
|
|
className={cn("icon-circle-btn", !bypassOn && ds.peqEnable === 1 && "active")}
|
|
onClick={() => setLocation("/eq")}
|
|
>
|
|
<Sliders size={22} className={(!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/60"} />
|
|
</button>
|
|
<span className={cn("text-[11px]", (!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/35")}>HP-EQ</span>
|
|
</div>
|
|
|
|
{/* Effect */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<button
|
|
className={cn("icon-circle-btn", !bypassOn && effectOn && "active")}
|
|
onClick={() => setLocation("/effects")}
|
|
>
|
|
<Activity size={22} className={(!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/60"} />
|
|
</button>
|
|
<span className={cn("text-[11px]", (!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/35")}>Effect</span>
|
|
</div>
|
|
|
|
{/* Bypass (DSP) */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<button
|
|
className={cn("icon-circle-btn", bypassOn && "active")}
|
|
onClick={() => updateSetting({ dsp_enable: bypassOn ? 1 : 0 })}
|
|
>
|
|
<Zap size={22} className={bypassOn ? "text-[#00FFF6]" : "text-white/60"} />
|
|
</button>
|
|
<span className={cn("text-[11px]", bypassOn ? "text-[#00FFF6]" : "text-white/35")}>Bypass</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── iOS list rows ── */}
|
|
<div className="px-4 pb-4">
|
|
<div className="ios-list-group">
|
|
<ListRow
|
|
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>}
|
|
label={homeText.input ?? "输入源"}
|
|
value={inputLabel}
|
|
onClick={() => setLocation("/io")}
|
|
/>
|
|
<ListRow
|
|
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M15 3H9a2 2 0 0 0-2 2v4m8-6h4a2 2 0 0 1 2 2v4M15 3v18m0 0H9a2 2 0 0 1-2-2V9m8 12h4a2 2 0 0 0 2-2V9M7 9H3"/></svg>}
|
|
label={homeText.output ?? "输出端口"}
|
|
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={<Gauge size={16} />}
|
|
label={homeText.vu ?? "VU表"}
|
|
value={vuLabel}
|
|
onClick={() => setLocation("/vu")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<AlertDialog open={powerConfirmOpen} onOpenChange={setPowerConfirmOpen}>
|
|
<AlertDialogContent className="border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-white">
|
|
{homeText.powerOffConfirmTitle ?? "确认关闭电源?"}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-white/60">
|
|
{homeText.powerOffConfirmDesc ??
|
|
"设备将关机并断开与本应用的连接,是否继续?"}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
|
|
{homeText.powerOffConfirmCancel ?? "取消"}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600"
|
|
onClick={() => void handlePowerOff()}
|
|
>
|
|
{homeText.powerOffConfirmOk ?? "关闭电源"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Bottom nav */}
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
}
|