From 22fdf39670a264d0380f95db5d2b5e3ce30dc98f Mon Sep 17 00:00:00 2001 From: yangy Date: Mon, 20 Apr 2026 17:51:23 +0800 Subject: [PATCH] Implement Luxsin audio API integration: Add proxy configuration in Vite for CORS handling, enhance luxsinApi with brand and model fetching functions, and update EQPage to support audio catalog browsing and searching. Introduce new state management for catalog brands and models. --- client/src/lib/luxsinApi.ts | 154 +++++++++++ client/src/pages/EQPage.tsx | 502 +++++++++++++++++++++++++++++++++++- vite.config.ts | 8 + 3 files changed, 660 insertions(+), 4 deletions(-) diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 5704052..c24ddc3 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -143,6 +143,10 @@ export interface PeqChangePayload { autoPre?: number; preamp?: number; canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; }; } @@ -330,3 +334,153 @@ export const MOCK_PEQ_STATE: PeqState = { { fc: 12000, gain: 1.5, q: 0.7, type: 6 }, ], }; + +// ============================================================ +// Luxsin cloud audio catalog (brands / models) +// Dev: proxied via Vite (`/luxsin-audio-api` → `/audio`) to avoid Origin-based 403. +// ============================================================ + +export interface LuxsinAudioBrand { + id: number; + name: string; +} + +export interface LuxsinAudioModel { + id: number; + name: string; + form?: string; +} + +export interface LuxsinAudioModelListItem { + brandName: string; + modelName: string; + form?: string; + source?: string; +} + +export function buildLuxsinAudioUrl(resourcePath: string): string { + const path = resourcePath.replace(/^\//, ""); + if (import.meta.env.DEV) { + return `/luxsin-audio-api/${path}`; + } + return `https://api.luxsin.com.cn/audio/${path}`; +} + +/** Fetches `getBrand`; body is custom Base64 text, decodes to JSON array of `{ id, name }`. */ +export async function fetchLuxsinAudioBrands(): Promise { + const res = await fetch(buildLuxsinAudioUrl("getBrand")); + if (!res.ok) { + throw new Error(`getBrand HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + const json = decodeCustomBase64(body); + const data = JSON.parse(json) as unknown; + if (!Array.isArray(data)) { + throw new Error("getBrand: response is not an array"); + } + return data + .map((row) => { + if (!row || typeof row !== "object") return null; + const r = row as { id?: unknown; name?: unknown }; + const name = typeof r.name === "string" ? r.name : ""; + if (!name) return null; + const id = typeof r.id === "number" ? r.id : Number(r.id); + return { id: Number.isFinite(id) ? id : 0, name }; + }) + .filter((b): b is LuxsinAudioBrand => b !== null); +} + +/** Fetches `getModel?brandName=...`; body is custom Base64 text, decodes to JSON array of model rows. */ +export async function fetchLuxsinAudioModels(brandName: string): Promise { + const brand = brandName.trim(); + if (!brand) return []; + + const res = await fetch(buildLuxsinAudioUrl(`getModel?brandName=${encodeURIComponent(brand)}`)); + if (!res.ok) { + throw new Error(`getModel HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + const json = decodeCustomBase64(body); + const data = JSON.parse(json) as unknown; + if (!Array.isArray(data)) { + throw new Error("getModel: response is not an array"); + } + return data + .map((row) => { + if (!row || typeof row !== "object") return null; + const r = row as { id?: unknown; name?: unknown; form?: unknown }; + const name = typeof r.name === "string" ? r.name : ""; + if (!name) return null; + const id = typeof r.id === "number" ? r.id : Number(r.id); + const form = typeof r.form === "string" ? r.form : undefined; + return { + id: Number.isFinite(id) ? id : 0, + name, + ...(form ? { form } : {}), + }; + }) + .filter((m): m is LuxsinAudioModel => m !== null); +} + +/** Fetches `modelList?key=...&count=...`; body is custom Base64 text, decodes to brand+model rows. */ +export async function fetchLuxsinAudioModelList( + key: string, + count = 1000, +): Promise { + const q = key.trim(); + if (!q) return []; + const safeCount = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1000; + const res = await fetch( + buildLuxsinAudioUrl(`modelList?key=${encodeURIComponent(q)}&count=${safeCount}`), + ); + if (!res.ok) { + throw new Error(`modelList HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + const json = decodeCustomBase64(body); + const data = JSON.parse(json) as unknown; + if (!Array.isArray(data)) { + throw new Error("modelList: response is not an array"); + } + return data + .map((row): LuxsinAudioModelListItem | null => { + if (!row || typeof row !== "object") return null; + const r = row as { + brand_name?: unknown; + name?: unknown; + form?: unknown; + source?: unknown; + }; + const brandName = typeof r.brand_name === "string" ? r.brand_name : ""; + const modelName = typeof r.name === "string" ? r.name : ""; + if (!brandName || !modelName) return null; + const form = typeof r.form === "string" ? r.form : undefined; + const source = typeof r.source === "string" ? r.source : undefined; + return { + brandName, + modelName, + ...(form ? { form } : {}), + ...(source ? { source } : {}), + }; + }) + .filter((item): item is LuxsinAudioModelListItem => item !== null); +} + +/** Fetches `getCurve` and returns decoded text payload. */ +export async function fetchLuxsinAudioCurve( + brand: string, + name: string, + target: string, +): Promise { + const brandName = encodeURIComponent(brand.trim()); + const modelName = encodeURIComponent(name.trim()); + const targetName = encodeURIComponent(target.trim()); + const res = await fetch( + buildLuxsinAudioUrl(`getCurve?brand=${brandName}&name=${modelName}&target=${targetName}`), + ); + if (!res.ok) { + throw new Error(`getCurve HTTP ${res.status}`); + } + const body = (await res.text()).trim(); + return decodeCustomBase64(body); +} diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index e7ed90a..b9a5f67 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -11,13 +11,24 @@ 7. Total gain card: 总增益 value + AUTO toggle + slider ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; -import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, X } from "lucide-react"; +import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } from "lucide-react"; import { useLocation } from "wouter"; -import { useState, useMemo, useEffect, useRef } from "react"; +import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; import BottomNav from "@/components/BottomNav"; import { toast } from "sonner"; -import { decodeCustomBase64, type PeqFilter, type PeqChangePayload } from "@/lib/luxsinApi"; +import { + decodeCustomBase64, + fetchLuxsinAudioBrands, + fetchLuxsinAudioCurve, + fetchLuxsinAudioModelList, + fetchLuxsinAudioModels, + type LuxsinAudioBrand, + type LuxsinAudioModelListItem, + type LuxsinAudioModel, + type PeqFilter, + type PeqChangePayload, +} from "@/lib/luxsinApi"; import * as echarts from "echarts"; import { getSectionsMatrix, @@ -411,6 +422,29 @@ const FLAT_PRESET_FILTERS: PeqFilter[] = [ { type: 4, fc: 18000, gain: 0, q: 0.1 }, ]; +type CatalogTarget = { + name: string; + bassBoost: { fc: number; q: number; gain: number }; + ear: "in" | "over" | "all"; +}; + +const CATALOG_TARGETS: CatalogTarget[] = [ + { name: "Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "HMS II.3 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "crinacle EARS + 711 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, + { name: "AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" }, + { name: "HMS II.3 AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" }, + { name: "HMS II.3 Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, + { name: "Diffuse Field 5128 (-1 dB/oct)", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "over" }, + { name: "LMG 5128 0.6 without bass", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "JM-1 with Harman filters", bassBoost: { fc: 105, q: 0.7, gain: 6.5 }, ear: "all" }, + { name: "oratory1990 in-ear", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" }, + { name: "oratory1990 over-ear", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "Harman over-ear 2013", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" }, + { name: "Flat", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "all" }, +]; + /** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */ function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: string | number }): PeqFilter { return { @@ -449,6 +483,25 @@ export default function EQPage() { const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy"); const [copyPresetName, setCopyPresetName] = useState(""); const [flatPresetName, setFlatPresetName] = useState(""); + type BrandDrawerTab = "brands" | "models" | "target"; + const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false); + const [brandDrawerTab, setBrandDrawerTab] = useState("brands"); + const [brandSearchQuery, setBrandSearchQuery] = useState(""); + const [catalogBrands, setCatalogBrands] = useState([]); + const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false); + const [catalogBrandsError, setCatalogBrandsError] = useState(null); + const [catalogSearchResults, setCatalogSearchResults] = useState([]); + const [catalogSearchLoading, setCatalogSearchLoading] = useState(false); + const [catalogSearchError, setCatalogSearchError] = useState(null); + const [selectedCatalogBrand, setSelectedCatalogBrand] = useState(""); + const [catalogModels, setCatalogModels] = useState([]); + const [catalogModelsLoading, setCatalogModelsLoading] = useState(false); + const [catalogModelsError, setCatalogModelsError] = useState(null); + const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState(""); + const [selectedCatalogModelName, setSelectedCatalogModelName] = useState(""); + const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState(undefined); + const [selectedCatalogTarget, setSelectedCatalogTarget] = useState(""); + const [isConfirmingTarget, setIsConfirmingTarget] = useState(false); const allowPeqRemoteSyncRef = useRef(false); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); @@ -458,6 +511,120 @@ export default function EQPage() { const preampValue = Number(currentPeq?.preamp ?? 0); const autoPreOn = (currentPeq?.autoPre ?? 0) === 1; + const filteredCatalogBrands = useMemo(() => { + const q = brandSearchQuery.trim().toLowerCase(); + if (!q) return catalogBrands; + return catalogBrands.filter((b) => b.name.toLowerCase().includes(q)); + }, [catalogBrands, brandSearchQuery]); + + const availableCatalogTargets = useMemo(() => { + if (selectedCatalogModelForm === "in-ear") { + return CATALOG_TARGETS.filter((item) => item.ear === "in" || item.ear === "all"); + } + if (selectedCatalogModelForm === "over-ear") { + return CATALOG_TARGETS.filter((item) => item.ear === "over" || item.ear === "all"); + } + return CATALOG_TARGETS; + }, [selectedCatalogModelForm]); + + const loadCatalogBrands = useCallback(async () => { + setCatalogBrandsLoading(true); + setCatalogBrandsError(null); + try { + const list = await fetchLuxsinAudioBrands(); + const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + setCatalogBrands(sorted); + } catch (e) { + const msg = e instanceof Error ? e.message : "加载失败"; + setCatalogBrandsError(msg); + toast.error("获取品牌列表失败"); + } finally { + setCatalogBrandsLoading(false); + } + }, []); + + const searchCatalogByKeyword = useCallback(async (keyword: string) => { + const q = keyword.trim(); + if (!q) { + setCatalogSearchResults([]); + setCatalogSearchError(null); + setCatalogSearchLoading(false); + return; + } + setCatalogSearchLoading(true); + setCatalogSearchError(null); + try { + const list = await fetchLuxsinAudioModelList(q, 1000); + setCatalogSearchResults(list); + } catch (e) { + const msg = e instanceof Error ? e.message : "搜索失败"; + setCatalogSearchError(msg); + setCatalogSearchResults([]); + } finally { + setCatalogSearchLoading(false); + } + }, []); + + const loadCatalogModels = useCallback(async (brandName: string) => { + setSelectedCatalogModelFromSearch(""); + setCatalogModelsLoading(true); + setCatalogModelsError(null); + try { + const list = await fetchLuxsinAudioModels(brandName); + const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + setCatalogModels(sorted); + } catch (e) { + const msg = e instanceof Error ? e.message : "加载失败"; + setCatalogModelsError(msg); + toast.error("获取型号列表失败"); + } finally { + setCatalogModelsLoading(false); + } + }, []); + + const openAddHeadsetCatalog = () => { + setIsBrandDrawerOpen(true); + setBrandDrawerTab("brands"); + setBrandSearchQuery(""); + setCatalogSearchResults([]); + setCatalogSearchError(null); + setCatalogSearchLoading(false); + setSelectedCatalogBrand(""); + setSelectedCatalogModelFromSearch(""); + setSelectedCatalogModelName(""); + setSelectedCatalogModelForm(undefined); + setSelectedCatalogTarget(""); + setCatalogModels([]); + setCatalogModelsError(null); + void loadCatalogBrands(); + }; + + useEffect(() => { + if (!isBrandDrawerOpen || brandDrawerTab !== "brands") return; + const timer = window.setTimeout(() => { + void searchCatalogByKeyword(brandSearchQuery); + }, 260); + return () => window.clearTimeout(timer); + }, [isBrandDrawerOpen, brandDrawerTab, brandSearchQuery, searchCatalogByKeyword]); + + useEffect(() => { + if (!isBrandDrawerOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsBrandDrawerOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isBrandDrawerOpen]); + + useEffect(() => { + if (!isBrandDrawerOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [isBrandDrawerOpen]); + const normalizeFiltersFromPeq = (peq: { filters?: any[] | string } | undefined) => { if (!peq) return [] as typeof bands; let filters: any[] = []; @@ -936,7 +1103,7 @@ export default function EQPage() { + ); + })} + + +
+ {brandDrawerTab === "brands" && ( + <> +
+
+ + setBrandSearchQuery(e.target.value)} + placeholder="搜索品牌或型号" + className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35" + /> +
+
+
+ {brandSearchQuery.trim() && catalogSearchLoading && ( +
搜索中…
+ )} + {!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && ( +
{catalogSearchError}
+ )} + {!!brandSearchQuery.trim() && + !catalogSearchLoading && + !catalogSearchError && + catalogSearchResults.length === 0 && ( +
未找到相关品牌或型号
+ )} + {!!brandSearchQuery.trim() && + !catalogSearchLoading && + !catalogSearchError && + catalogSearchResults.map((item, idx) => ( + + ))} + {!brandSearchQuery.trim() && catalogBrandsLoading && ( +
加载中…
+ )} + {!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && ( +
{catalogBrandsError}
+ )} + {!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && ( +
无匹配品牌
+ )} + {!brandSearchQuery.trim() && + !catalogBrandsLoading && + !catalogBrandsError && + filteredCatalogBrands.map((b) => ( + + ))} +
+ + )} + {brandDrawerTab === "models" && ( +
+
+ 品牌:{selectedCatalogBrand || "—"} + {selectedCatalogModelFromSearch && ( + 已选搜索型号:{selectedCatalogModelFromSearch} + )} +
+ {!selectedCatalogBrand && ( +
请先在 Brands 中选择品牌
+ )} + {!!selectedCatalogBrand && catalogModelsLoading && ( +
加载中…
+ )} + {!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && ( +
{catalogModelsError}
+ )} + {!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && ( +
该品牌暂无型号
+ )} + {!!selectedCatalogBrand && + !catalogModelsLoading && + !catalogModelsError && + catalogModels.map((m) => ( + + ))} +
+ )} + {brandDrawerTab === "target" && ( +
+
+
品牌:{selectedCatalogBrand || "—"}
+
型号:{selectedCatalogModelName || "—"}
+
Form:{selectedCatalogModelForm || "全部"}
+
+
+ +
+ {!selectedCatalogModelName && ( +
请先选择型号
+ )} + {!!selectedCatalogModelName && availableCatalogTargets.length === 0 && ( +
暂无可用 Target
+ )} + {!!selectedCatalogModelName && + availableCatalogTargets.map((target) => { + const active = selectedCatalogTarget === target.name; + return ( + + ); + })} +
+ )} +
+ + + )} + ); diff --git a/vite.config.ts b/vite.config.ts index 51a0c17..a4e58ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -231,6 +231,14 @@ export default defineConfig({ port: 3000, strictPort: false, // Will find next available port if 3000 is busy host: true, + // Browser requests with Origin are rejected (403) by the cloud API; proxy in dev avoids CORS/WAF. + proxy: { + "/luxsin-audio-api": { + target: "https://api.luxsin.com.cn", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/luxsin-audio-api/, "/audio"), + }, + }, allowedHosts: [ ".manuspre.computer", ".manus.computer",