Refactor build and start scripts in package.json; add plugin to exclude debug files from production build in vite.config.ts; remove ConnectScreen component and replace with ConnectionPlaceholder in multiple pages; update DeviceContext to auto-connect using device IP from URL; enhance locale files with new device IP labels.

This commit is contained in:
yangy
2026-05-22 15:54:11 +08:00
parent 747331c850
commit 32f15217ad
13 changed files with 199 additions and 173 deletions
-102
View File
@@ -1,102 +0,0 @@
/* ============================================================
CONNECT SCREEN — Luxsin X9 Controller
Design: Pure black iOS App style, glass card, #00FFF6 accent
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import { Loader2, Wifi } from "lucide-react";
import { useState } from "react";
export default function ConnectScreen() {
const { ip, setIp, connect, isConnecting, error, setDemoMode, isDemoMode } = useDevice();
const [inputIp, setInputIp] = useState(ip);
const handleConnect = async () => {
const targetIp = inputIp.trim();
if (!targetIp) return;
setIp(targetIp);
setDemoMode(false);
await connect(false, targetIp);
};
const handleDemo = async () => {
setDemoMode(true);
await connect(true);
};
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center px-6">
{/* Logo / Brand */}
<div className="mb-10 flex flex-col items-center">
<div
className="mb-4 flex size-[5.5rem] shrink-0 items-center justify-center rounded-[17px] border border-white/15 bg-white/[0.02]"
>
<img
src={`${import.meta.env.BASE_URL}logo.png`}
alt="Luxsin"
className="h-auto max-h-[3.75rem] w-auto max-w-[3.75rem] object-contain"
/>
</div>
<h1 className="text-[16px] font-semibold tracking-tight text-white/70">
Luxsin X8 Controller
</h1>
</div>
{/* Connection card */}
<div className="w-full max-w-sm rounded-[20px] p-6 space-y-4"
style={{
background: "rgba(28,28,30,0.95)",
border: "1px solid rgba(255,255,255,0.07)",
backdropFilter: "blur(20px)",
}}>
<div>
<label className="text-[13px] text-white/40 mb-2 block tracking-wide uppercase" style={{ letterSpacing: "0.06em" }}>
IP
</label>
<div className="relative">
<Wifi size={14} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/25" />
<input
type="text"
value={inputIp}
onChange={(e) => setInputIp(e.target.value)}
placeholder="192.168.2.30"
className="w-full pl-9 pr-4 py-3.5 rounded-[12px] text-[15px] text-white outline-none transition-all"
style={{
background: "rgba(44,44,46,0.8)",
border: "1px solid rgba(255,255,255,0.08)",
fontFamily: "inherit",
}}
onFocus={(e) => { e.target.style.borderColor = "rgba(0,255,246,0.4)"; e.target.style.boxShadow = "0 0 0 3px rgba(0,255,246,0.06)"; }}
onBlur={(e) => { e.target.style.borderColor = "rgba(255,255,255,0.08)"; e.target.style.boxShadow = "none"; }}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
/>
</div>
<p className="text-[12px] text-white/25 mt-1.5"></p>
</div>
{error && (
<div className="px-3.5 py-2.5 rounded-[10px] text-[13px]"
style={{ background: "rgba(239,68,68,0.08)", color: "#f87171", border: "1px solid rgba(239,68,68,0.15)" }}>
{error}
</div>
)}
<button
onClick={handleConnect}
disabled={isConnecting || !inputIp}
className="w-full py-3.5 rounded-[12px] text-[16px] font-semibold text-black transition-all duration-150 active:scale-[0.98] disabled:opacity-40 flex items-center justify-center gap-2"
style={{
background: "#00FFF6",
boxShadow: isConnecting ? "none" : "0 0 24px rgba(0,255,246,0.25)",
}}>
{isConnecting && !isDemoMode ? (
<><Loader2 size={17} className="animate-spin" /> ...</>
) : (
<><Wifi size={17} /> </>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useDevice } from "@/contexts/DeviceContext";
import { Loader2, Wifi } from "lucide-react";
import BottomNav from "@/components/BottomNav";
/** 自动连接设备时的占位(替代原 ConnectScreen */
export default function ConnectionPlaceholder() {
const { ip, isConnecting, error } = useDevice();
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center px-6 pb-24">
<div className="mb-6 flex size-[4.5rem] items-center justify-center rounded-[16px] border border-white/15">
<Wifi size={28} className="text-[#00FFF6]/80" />
</div>
{isConnecting ? (
<>
<Loader2 size={28} className="animate-spin text-[#00FFF6] mb-3" />
<p className="text-[15px] text-white/70">Connecting to {ip}</p>
</>
) : (
<>
<p className="text-[15px] text-white/80 mb-2">Connection failed</p>
<p className="text-[13px] text-white/45 text-center max-w-xs">
{error ?? "Check the device IP and your network connection."}
</p>
<p className="text-[12px] text-white/30 mt-3">
Specify the device in the URL, e.g.{" "}
<span className="text-white/50">?ip=10.0.0.119</span>
</p>
</>
)}
<BottomNav />
</div>
);
}
+14 -1
View File
@@ -9,6 +9,7 @@ import {
PeqFilter,
PeqState,
} from "@/lib/luxsinApi";
import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp";
interface DeviceContextType {
ip: string;
@@ -39,7 +40,7 @@ interface DeviceContextType {
const DeviceContext = createContext<DeviceContextType | null>(null);
export function DeviceProvider({ children }: { children: React.ReactNode }) {
const [ip, setIp] = useState(() => localStorage.getItem("luxsin_ip") || "192.168.2.30");
const [ip, setIp] = useState(resolveInitialDeviceIp);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isDemoMode, setDemoMode] = useState(false);
@@ -51,6 +52,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
const [api, setApi] = useState<LuxsinAPI | null>(null);
const lastMsgCountRef = useRef<number>(-1);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const autoConnectStartedRef = useRef(false);
const saveIp = useCallback((newIp: string) => {
setIp(newIp);
@@ -202,6 +204,17 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
if (!isDemoMode && api) api.setBalance(v).catch(console.error);
}, [api, isDemoMode]);
// 启动时根据 ?ip= 或已存 IP 自动连接(默认进入首页,无需连接页)
useEffect(() => {
if (autoConnectStartedRef.current) return;
autoConnectStartedRef.current = true;
const urlIp = getDeviceIpFromUrl();
const targetIp = urlIp ?? ip;
if (urlIp) saveIp(urlIp);
void connect(false, targetIp);
}, [connect, ip, saveIp]);
// Polling for changes
useEffect(() => {
if (!isConnected || isDemoMode || !api) return;
+11
View File
@@ -0,0 +1,11 @@
/** 从地址栏 ?ip= 读取设备 IPhash 路由下 query 仍在 search 上) */
export function getDeviceIpFromUrl(): string | null {
if (typeof window === "undefined") return null;
const ip = new URLSearchParams(window.location.search).get("ip");
const trimmed = ip?.trim();
return trimmed || null;
}
export function resolveInitialDeviceIp(): string {
return getDeviceIpFromUrl() ?? localStorage.getItem("luxsin_ip") ?? "192.168.2.30";
}
+3 -2
View File
@@ -12,7 +12,8 @@
"powerOffConfirmCancel": "Cancel",
"powerOffToastDemo": "Demo mode: disconnected from the device.",
"powerOffToastSent": "Shutdown command sent.",
"doubleClickHint": "Double-click to enter"
"doubleClickHint": "Double-click to enter",
"backToLegacy": "Legacy version"
},
"vuPage": {
"title": "VU meter gallery"
@@ -587,7 +588,7 @@
"deviceName": "Device name",
"firmwareVersion": "Firmware version",
"macAddress": "MAC address",
"disconnect": "Disconnect"
"deviceIp": "Device IP"
}
},
"prompt": {
+3 -2
View File
@@ -12,7 +12,8 @@
"powerOffConfirmCancel": "取消",
"powerOffToastDemo": "演示模式:已中斷與裝置的連線",
"powerOffToastSent": "已傳送關機指令",
"doubleClickHint": "按兩下進入"
"doubleClickHint": "按兩下進入",
"backToLegacy": "返回舊版"
},
"vuPage": {
"title": "VU 表庫"
@@ -403,7 +404,7 @@
"deviceName": "裝置名稱",
"firmwareVersion": "韌體版本",
"macAddress": "MAC 位址",
"disconnect": "中斷連線"
"deviceIp": "裝置 IP"
}
},
"prompt": {
+3 -2
View File
@@ -12,7 +12,8 @@
"powerOffConfirmCancel": "取消",
"powerOffToastDemo": "演示模式:已断开与设备的连接",
"powerOffToastSent": "已发送关机指令",
"doubleClickHint": "双击进入"
"doubleClickHint": "双击进入",
"backToLegacy": "返回旧版"
},
"vuPage": {
"title": "VU 表库"
@@ -404,7 +405,7 @@
"deviceName": "设备名称",
"firmwareVersion": "固件版本",
"macAddress": "MAC 地址",
"disconnect": "断开连接"
"deviceIp": "设备 IP"
}
},
"prompt": {
+3 -3
View File
@@ -7,7 +7,7 @@
Backend: /sse/question (SSE), /sse/messages, /sse/chats, /sse/clear
============================================================ */
import BottomNav from "@/components/BottomNav";
import ConnectScreen from "@/components/ConnectScreen";
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
import { useDevice } from "@/contexts/DeviceContext";
import {
AI_SPLIT_RESTORE_PATH_KEY,
@@ -337,7 +337,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
})();
}, [mac, loadChat]);
// 没有 mac 时(设备未识别完毕)不应该停在 initializingConnectScreen 会拦截
// 没有 mac 时(设备未识别完毕)不应该停在 initializing未连接时 ConnectionPlaceholder 会拦截
useEffect(() => {
if (!mac && initializing) {
// 给 mac 一段缓冲期,否则即便最终没有 mac 也要释放欢迎页
@@ -692,7 +692,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
const headerTitle = aiText.title;
if (!isConnected) return <ConnectScreen />;
if (!isConnected) return <ConnectionPlaceholder />;
return (
<div
+92 -37
View File
@@ -10,7 +10,7 @@
6. iOS list rows (输入源/输出端口/音频设置/系统设置/VU表)
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import ConnectScreen from "@/components/ConnectScreen";
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
import BottomNav from "@/components/BottomNav";
import {
AlertDialog,
@@ -32,6 +32,7 @@ import {
Sliders,
Activity,
Gauge,
Undo2,
ChevronDown,
Wifi,
Bluetooth,
@@ -57,14 +58,17 @@ const JACK_EGG_SEQUENCE = ["lg", "smTop", "smBot"] as const;
type JackId = (typeof JACK_EGG_SEQUENCE)[number];
const VU_BAR_COUNT = 20;
const EGG_DURATION_MS = 4200;
const EGG_BEAT_MS = 130;
const EGG_DANCE_DURATION_MS = 5000;
const EGG_WAVE_DURATION_MS = 5000;
const EGG_WAVE_HALF_MS = EGG_WAVE_DURATION_MS / 2;
const EGG_BEAT_MS = 400;
const JACK_SEQUENCE_TIMEOUT_MS = 4000;
/** 旋律主音在 20 段 VU 上的位置(约 4.2s */
/** 旋律主音在 20 段 VU 上的位置(约 5s */
const EGG_MELODY_LEAD = [
0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 19, 17, 15, 13, 11, 9, 7, 5, 3, 1,
0, 4, 8, 12, 16, 19, 16, 12, 8, 4, 0, 2, 5, 9, 13, 17, 19, 14, 10, 6, 2,
0, 3, 7, 11, 15, 18, 15, 11, 7, 3,
];
function idleVuBarHeights(): number[] {
@@ -73,15 +77,44 @@ function idleVuBarHeights(): number[] {
);
}
function vuHeightsForMelodyBeat(lead: number, pulse: number): number[] {
/** 彩蛋律动:主峰随节拍移动,每拍起落,全柱叠加波纹 */
function vuHeightsForDance(elapsedMs: number): number[] {
const t = elapsedMs / 1000;
const beatIdx = Math.floor(elapsedMs / EGG_BEAT_MS) % EGG_MELODY_LEAD.length;
const lead = EGG_MELODY_LEAD[beatIdx];
const beatPhase = (elapsedMs % EGG_BEAT_MS) / EGG_BEAT_MS;
const kick = Math.sin(beatPhase * Math.PI);
return Array.from({ length: VU_BAR_COUNT }, (_, i) => {
const dist = Math.abs(i - lead);
let amp = 0.14;
if (dist === 0) amp = 0.96;
else if (dist === 1) amp = 0.74;
else if (dist === 2) amp = 0.5;
else if (dist === 3) amp = 0.3;
return Math.min(1, amp * (0.82 + pulse * 0.18));
let peak = 0.06;
if (dist === 0) peak = 1;
else if (dist === 1) peak = 0.82;
else if (dist === 2) peak = 0.62;
else if (dist === 3) peak = 0.42;
else if (dist === 4) peak = 0.26;
const ripple = 0.35 * Math.max(0, Math.sin(t * 2.75 + i * 0.62));
const sway = 0.1 * Math.sin(t * 1.4 + i * 0.35);
const h = 0.05 + peak * kick + ripple * (0.55 + kick * 0.45) + sway;
return Math.min(1, Math.max(0.05, h));
});
}
/** 彩蛋律动二:海浪形波从左→右,再右→左 */
function vuHeightsForWave(phaseElapsedMs: number): number[] {
const half = EGG_WAVE_HALF_MS;
const ltr = phaseElapsedMs < half;
const localMs = ltr ? phaseElapsedMs : phaseElapsedMs - half;
const t = localMs / 1000;
const direction = ltr ? 1 : -1;
const speed = 0.75;
return Array.from({ length: VU_BAR_COUNT }, (_, i) => {
const phase = t * speed * Math.PI * 2 - direction * i * 0.72;
const wave = (Math.sin(phase) + 1) / 2;
const foam = 0.06 + 0.05 * Math.sin(i * 0.28 + t * 0.5);
return Math.min(1, Math.max(0.06, foam + wave * 0.9));
});
}
@@ -131,7 +164,7 @@ function ListRow({
}
export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode, ip } = useDevice();
const [, setLocation] = useLocation();
const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef<number | null>(null);
@@ -144,6 +177,8 @@ export default function Home() {
const jackProgressRef = useRef(0);
const jackResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const eggRafRef = useRef<number | null>(null);
/** 下次三连击成功时播放的律动:1=旋律,2=海浪(各需单独三连击触发) */
const jackEggNextRef = useRef<1 | 2>(1);
const homeText = useMemo((): HomeLocale => {
if (!deviceState) return {} as HomeLocale;
@@ -257,28 +292,33 @@ export default function Home() {
};
}, []);
const triggerJackEasterEgg = useCallback(() => {
setEasterEggActive(true);
jackProgressRef.current = 0;
const start = performance.now();
const runJackEggAnimation = useCallback(
(mode: "dance" | "wave") => {
setEasterEggActive(true);
setVuHeights(mode === "dance" ? vuHeightsForDance(0) : vuHeightsForWave(0));
jackProgressRef.current = 0;
const duration = mode === "dance" ? EGG_DANCE_DURATION_MS : EGG_WAVE_DURATION_MS;
const start = performance.now();
const tick = (now: number) => {
const elapsed = now - start;
if (elapsed >= EGG_DURATION_MS) {
setEasterEggActive(false);
setVuHeights(idleVuBarHeights());
eggRafRef.current = null;
return;
}
const beat = Math.floor(elapsed / EGG_BEAT_MS) % EGG_MELODY_LEAD.length;
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 0.02);
setVuHeights(vuHeightsForMelodyBeat(EGG_MELODY_LEAD[beat], pulse));
const tick = (now: number) => {
const elapsed = now - start;
if (elapsed >= duration) {
setEasterEggActive(false);
setVuHeights(idleVuBarHeights());
eggRafRef.current = null;
return;
}
setVuHeights(
mode === "dance" ? vuHeightsForDance(elapsed) : vuHeightsForWave(elapsed),
);
eggRafRef.current = requestAnimationFrame(tick);
};
if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current);
eggRafRef.current = requestAnimationFrame(tick);
};
if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current);
eggRafRef.current = requestAnimationFrame(tick);
}, []);
},
[],
);
const onJackClick = useCallback(
(jack: JackId) => {
@@ -288,7 +328,13 @@ export default function Home() {
if (jack === expected) {
jackProgressRef.current += 1;
if (jackProgressRef.current >= JACK_EGG_SEQUENCE.length) {
triggerJackEasterEgg();
if (jackEggNextRef.current === 1) {
runJackEggAnimation("dance");
jackEggNextRef.current = 2;
} else {
runJackEggAnimation("wave");
jackEggNextRef.current = 1;
}
}
} else {
jackProgressRef.current = jack === JACK_EGG_SEQUENCE[0] ? 1 : 0;
@@ -299,7 +345,7 @@ export default function Home() {
jackProgressRef.current = 0;
}, JACK_SEQUENCE_TIMEOUT_MS);
},
[easterEggActive, triggerJackEasterEgg],
[easterEggActive, runJackEggAnimation],
);
useEffect(() => {
@@ -309,7 +355,7 @@ export default function Home() {
};
}, []);
if (!isConnected) return <ConnectScreen />;
if (!isConnected) return <ConnectionPlaceholder />;
const ds = deviceState!;
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
@@ -471,8 +517,8 @@ export default function Home() {
background: isHot ? "#ef4444" : i > 13 ? "#f59e0b" : "#00FFF6",
opacity: easterEggActive ? 0.95 : 0.7,
transition: easterEggActive
? "height 90ms ease-out, opacity 120ms ease-out"
: "height 200ms ease-out",
? "none"
: "height 200ms ease-out, opacity 200ms ease-out",
}}
/>
);
@@ -766,6 +812,15 @@ export default function Home() {
value={vuLabel}
onClick={() => setLocation("/vu")}
/>
<ListRow
icon={<Undo2 size={16} />}
label={homeText.backToLegacy ?? "返回旧版"}
onClick={() => {
const target = ip.trim();
if (!target) return;
window.location.href = `https://am.luxsinaudio.com/x8/i.html?ip=${encodeURIComponent(target)}#/home`;
}}
/>
</div>
</div>
+6 -12
View File
@@ -90,7 +90,7 @@ type LocaleSettings = {
deviceName: string;
firmwareVersion: string;
macAddress: string;
disconnect: string;
deviceIp: string;
};
};
};
@@ -109,7 +109,7 @@ function optionLabels(items: Array<{ index: number; label: string }> | undefined
export default function SystemPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting, disconnect, refresh, isConnected, api } = useDevice();
const { deviceState, updateSetting, refresh, isConnected, api, ip } = useDevice();
const ds = deviceState;
// 页面加载且已连接时刷新数据
@@ -204,16 +204,10 @@ export default function SystemPage() {
<span className="flex-1 text-[16px] text-white">{ui.macAddress}</span>
<span className="ios-row-value text-[12px]">{ds?.mac ?? "—"}</span>
</div>
</div>
{/* ── 断开连接 ── */}
<div className="mt-6 mb-4">
<button
className="w-full py-4 rounded-[14px] text-[16px] font-medium transition-all duration-150 active:scale-[0.98]"
style={{ background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.2)", color: "#ef4444" }}
onClick={() => { disconnect(); setLocation("/"); }}>
{ui.disconnect}
</button>
<div className="ios-list-row">
<span className="flex-1 text-[16px] text-white">{ui.deviceIp ?? "设备 IP"}</span>
<span className="ios-row-value text-[12px]">{ip || "—"}</span>
</div>
</div>
</div>
+2 -2
View File
@@ -5,8 +5,8 @@
"license": "MIT",
"scripts": {
"dev": "vite --host",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"build": "vite build",
"start": "NODE_ENV=production tsx server/index.ts",
"preview": "vite preview --host",
"check": "tsc --noEmit",
"format": "prettier --write ."
+3 -7
View File
@@ -10,17 +10,13 @@ async function startServer() {
const app = express();
const server = createServer(app);
// Serve static files from dist/public in production
const staticPath =
process.env.NODE_ENV === "production"
? path.resolve(__dirname, "public")
: path.resolve(__dirname, "..", "dist", "public");
const staticPath = path.resolve(__dirname, "..", "dist");
app.use(express.static(staticPath));
// Handle client-side routing - serve index.html for all routes
// Handle client-side routing — entry is i.html (see vite rollup input)
app.get("*", (_req, res) => {
res.sendFile(path.join(staticPath, "index.html"));
res.sendFile(path.join(staticPath, "i.html"));
});
const port = process.env.PORT || 3000;
+25 -3
View File
@@ -150,11 +150,33 @@ function vitePluginManusDebugCollector(): Plugin {
};
}
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()];
/** 生产构建时剔除 public 中的 __manus__(仅开发调试使用) */
function vitePluginExcludeManusFromDist(): Plugin {
const distDir = path.resolve(import.meta.dirname, "dist");
return {
name: "exclude-manus-from-dist",
closeBundle() {
if (process.env.NODE_ENV !== "production") return;
const manusDir = path.join(distDir, "__manus__");
if (fs.existsSync(manusDir)) {
fs.rmSync(manusDir, { recursive: true, force: true });
}
},
};
}
const plugins = [
react(),
tailwindcss(),
jsxLocPlugin(),
vitePluginManusRuntime(),
vitePluginManusDebugCollector(),
vitePluginExcludeManusFromDist(),
];
export default defineConfig({
plugins,
base: process.env.NODE_ENV === 'production' ? '/x8/v2/public/' : '/',
base: process.env.NODE_ENV === "production" ? "/x8/v2/" : "/",
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "client", "src"),
@@ -169,7 +191,7 @@ export default defineConfig({
build: {
target: 'esnext',
cssTarget: 'esnext',
outDir: path.resolve(import.meta.dirname, "dist/public"),
outDir: path.resolve(import.meta.dirname, "dist"),
emptyOutDir: true,
minify: 'terser',
terserOptions: {