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.

This commit is contained in:
yangy
2026-04-20 17:51:23 +08:00
parent 0e7670c215
commit 22fdf39670
3 changed files with 660 additions and 4 deletions
+154
View File
@@ -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<LuxsinAudioBrand[]> {
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<LuxsinAudioModel[]> {
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<LuxsinAudioModelListItem[]> {
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<string> {
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);
}
+498 -4
View File
@@ -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<BrandDrawerTab>("brands");
const [brandSearchQuery, setBrandSearchQuery] = useState("");
const [catalogBrands, setCatalogBrands] = useState<LuxsinAudioBrand[]>([]);
const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false);
const [catalogBrandsError, setCatalogBrandsError] = useState<string | null>(null);
const [catalogSearchResults, setCatalogSearchResults] = useState<LuxsinAudioModelListItem[]>([]);
const [catalogSearchLoading, setCatalogSearchLoading] = useState(false);
const [catalogSearchError, setCatalogSearchError] = useState<string | null>(null);
const [selectedCatalogBrand, setSelectedCatalogBrand] = useState<string>("");
const [catalogModels, setCatalogModels] = useState<LuxsinAudioModel[]>([]);
const [catalogModelsLoading, setCatalogModelsLoading] = useState(false);
const [catalogModelsError, setCatalogModelsError] = useState<string | null>(null);
const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState<string>("");
const [selectedCatalogModelName, setSelectedCatalogModelName] = useState("");
const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState<string | undefined>(undefined);
const [selectedCatalogTarget, setSelectedCatalogTarget] = useState<string>("");
const [isConfirmingTarget, setIsConfirmingTarget] = useState(false);
const allowPeqRemoteSyncRef = useRef(false);
const syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<number | null>(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() {
</button>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={() => toast.info("添加耳机型号功能即将推出")}>
onClick={openAddHeadsetCatalog}>
<div className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
<Headphones size={20} style={{ color: "#00FFF6" }} />
@@ -1252,6 +1419,333 @@ export default function EQPage() {
</div>
)}
{isBrandDrawerOpen && (
<div className="fixed inset-0 z-[110] flex flex-col justify-end">
<button
type="button"
className="absolute inset-0 bg-black/55"
aria-label="关闭"
onClick={() => setIsBrandDrawerOpen(false)}
/>
<div
className="relative z-10 flex max-h-[88vh] flex-col rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
<div className="h-1 w-10 rounded-full bg-black/15" />
</div>
<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";
return (
<button
key={tab}
type="button"
className={`flex-1 py-2.5 text-[14px] font-medium transition-colors rounded-t-[10px] ${
active ? "bg-[#1c1c1e] text-white" : "text-white/55 hover:text-white/80"
}`}
onClick={() => setBrandDrawerTab(tab)}
>
{label}
</button>
);
})}
</div>
<div className="flex min-h-0 flex-1 flex-col bg-white">
{brandDrawerTab === "brands" && (
<>
<div className="shrink-0 border-b border-black/8 px-3 py-2.5">
<div className="flex items-center gap-2 rounded-[10px] border border-black/12 bg-[#f5f5f7] px-3 py-2">
<Search size={18} className="shrink-0 text-black/35" />
<input
type="search"
value={brandSearchQuery}
onChange={(e) => setBrandSearchQuery(e.target.value)}
placeholder="搜索品牌或型号"
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">
{brandSearchQuery.trim() && catalogSearchLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogSearchError}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.map((item, idx) => (
<button
key={`${item.brandName}-${item.modelName}-${idx}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(item.brandName);
setSelectedCatalogModelFromSearch(item.modelName);
setSelectedCatalogModelName(item.modelName);
setSelectedCatalogModelForm(item.form);
setSelectedCatalogTarget("");
setCatalogModels([{ id: 0, name: item.modelName, ...(item.form ? { form: item.form } : {}) }]);
setCatalogModelsLoading(false);
setCatalogModelsError(null);
setBrandDrawerTab("target");
}}
>
<div className="text-[15px] text-black/85 font-medium">{item.modelName}</div>
<div className="mt-0.5 text-[12px] text-black/50">{item.brandName}</div>
</button>
))}
{!brandSearchQuery.trim() && catalogBrandsLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></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>
)}
{!brandSearchQuery.trim() &&
!catalogBrandsLoading &&
!catalogBrandsError &&
filteredCatalogBrands.map((b) => (
<button
key={b.id}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(b.name);
setSelectedCatalogModelFromSearch("");
setSelectedCatalogModelName("");
setSelectedCatalogModelForm(undefined);
setSelectedCatalogTarget("");
setBrandDrawerTab("models");
void loadCatalogModels(b.name);
}}
>
{b.name}
</button>
))}
</div>
</>
)}
{brandDrawerTab === "models" && (
<div className="min-h-0 flex-1 overflow-y-auto">
<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>
{selectedCatalogModelFromSearch && (
<span className="ml-2 text-black/45">{selectedCatalogModelFromSearch}</span>
)}
</div>
{!selectedCatalogBrand && (
<div className="py-10 text-center text-[14px] text-black/45"> Brands </div>
)}
{!!selectedCatalogBrand && catalogModelsLoading && (
<div className="py-10 text-center text-[14px] text-black/45"></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>
)}
{!!selectedCatalogBrand &&
!catalogModelsLoading &&
!catalogModelsError &&
catalogModels.map((m) => (
<button
key={`${m.id}-${m.name}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogModelName(m.name);
setSelectedCatalogModelForm(m.form);
setSelectedCatalogTarget("");
setBrandDrawerTab("target");
}}
>
<div>{m.name}</div>
{m.form && <div className="mt-0.5 text-[12px] text-black/50">{m.form}</div>}
</button>
))}
</div>
)}
{brandDrawerTab === "target" && (
<div className="min-h-0 flex-1 overflow-y-auto">
<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>
<div className="px-5 pt-4 pb-3">
<button
type="button"
disabled={!selectedCatalogTarget || isConfirmingTarget}
className="w-full h-10 rounded-full text-[24px] leading-none transition-all disabled:cursor-not-allowed flex items-center justify-center gap-3"
style={{
background: selectedCatalogTarget && !isConfirmingTarget
? "linear-gradient(180deg, #6a6a6d 0%, #565659 100%)"
: "linear-gradient(180deg, #8b8b8f 0%, #78787c 100%)",
color: selectedCatalogTarget && !isConfirmingTarget ? "#00FFF6" : "rgba(255,255,255,0.55)",
boxShadow: selectedCatalogTarget && !isConfirmingTarget
? "inset 0 1px 0 rgba(255,255,255,0.15)"
: "inset 0 1px 0 rgba(255,255,255,0.08)",
opacity: selectedCatalogTarget && !isConfirmingTarget ? 1 : 0.9,
}}
onClick={() => {
const brand = selectedCatalogBrand;
const name = selectedCatalogModelName;
const target = selectedCatalogTarget;
const form = selectedCatalogModelForm;
void (async () => {
setIsConfirmingTarget(true);
try {
const decoded = await fetchLuxsinAudioCurve(brand, name, target);
let parsed: unknown = decoded;
try {
parsed = JSON.parse(decoded);
} catch {
// Keep raw decoded text when payload isn't JSON.
}
const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
const parametricEqRaw = parsedObj?.parametric_eq;
if (!parametricEqRaw || typeof parametricEqRaw !== "object") {
console.warn("getCurve decoded: missing parametric_eq", { brand, name, target, data: parsed });
return;
}
const parametricEq = parametricEqRaw as {
filters?: Array<{ type: string | number; fc: number; gain: number; q: number }>;
preamp?: number;
};
const filters = (Array.isArray(parametricEq.filters) ? parametricEq.filters : [])
.slice(0, 10)
.map((item) => ({
type: getFilterType(item.type),
fc: Number(Number(item.fc).toFixed(2)),
gain: Number(Number(item.gain).toFixed(2)),
q: Number(Number(item.q).toFixed(2)),
}));
const postPeq: PeqChangePayload = {
peqChange: {
name: `${brand} ${name}`,
brand,
model: name,
target,
...(form ? { form } : {}),
filters,
preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)),
autoPre: 0,
canDel: 1,
},
};
await upgradePeqChange(postPeq);
const createdName = postPeq.peqChange.name;
if (api && !isDemoMode) {
const latest = await api.getPeqState();
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
const createdIndex = latest.peq?.findIndex((item) => item.name === createdName) ?? -1;
if (createdIndex >= 0) {
setHeadphoneIdx(createdIndex);
updateSetting({ peqSelect: createdIndex });
}
} else {
let nextIndex = 0;
setPeqItems((prev) => [
...prev,
{
name: createdName,
brand,
model: name,
target,
...(form ? { form } : {}),
filters: postPeq.peqChange.filters,
preamp: postPeq.peqChange.preamp ?? 0,
autoPre: postPeq.peqChange.autoPre ?? 0,
canDel: postPeq.peqChange.canDel ?? 1,
},
]);
setHeadphoneModels((prev) => {
nextIndex = prev.length;
return [...prev, createdName];
});
setHeadphoneIdx(nextIndex);
}
setIsBrandDrawerOpen(false);
console.log("getCurve decoded + peqChange posted:", {
brand,
name,
target,
data: parsed,
postPeq,
});
toast.success("新耳机 EQ 已上报");
} catch (error) {
console.error("getCurve failed:", error);
toast.error("获取曲线失败");
} finally {
setIsConfirmingTarget(false);
}
})();
}}
>
{isConfirmingTarget && (
<span
className="inline-block w-4 h-4 rounded-full border-2 border-white/40 border-t-[#00FFF6] animate-spin"
aria-hidden="true"
/>
)}
{isConfirmingTarget ? "loading..." : "confirm"}
</button>
</div>
{!selectedCatalogModelName && (
<div className="py-10 text-center text-[14px] text-black/45"></div>
)}
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45"> Target</div>
)}
{!!selectedCatalogModelName &&
availableCatalogTargets.map((target) => {
const active = selectedCatalogTarget === target.name;
return (
<button
key={target.name}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3 text-left active:bg-black/[0.04] transition-colors"
style={active ? { background: "rgba(0,255,246,0.14)" } : undefined}
onClick={() => {
setSelectedCatalogTarget(target.name);
toast.success(`已选 Target${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
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
)}
<BottomNav />
</div>
);
+8
View File
@@ -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",