拆分 EQPage 页面,新增分享码功能,修复 bug,优化性能
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchLuxsinAudioBrands,
|
||||
fetchLuxsinAudioModelList,
|
||||
fetchLuxsinAudioModels,
|
||||
type LuxsinAudioBrand,
|
||||
type LuxsinAudioModelListItem,
|
||||
type LuxsinAudioModel,
|
||||
} from "@/lib/luxsinApi";
|
||||
import { CATALOG_TARGETS } from "../eqConstants";
|
||||
import type { PeqEqUi } from "../types";
|
||||
import { eqInterp } from "../eqFormatters";
|
||||
|
||||
type BrandDrawerTab = "brands" | "models" | "target";
|
||||
|
||||
export function BrandDrawer({
|
||||
open,
|
||||
onClose,
|
||||
eqUi,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
eqUi: PeqEqUi;
|
||||
onConfirm: (brand: string, name: string, target: string, form?: string) => Promise<boolean>;
|
||||
}) {
|
||||
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);
|
||||
|
||||
/* ── Derived data ── */
|
||||
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]);
|
||||
|
||||
/* ── API calls ── */
|
||||
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(eqUi.toastBrandsFail);
|
||||
} finally {
|
||||
setCatalogBrandsLoading(false);
|
||||
}
|
||||
}, [eqUi]);
|
||||
|
||||
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(eqUi.toastModelsFail);
|
||||
} finally {
|
||||
setCatalogModelsLoading(false);
|
||||
}
|
||||
}, [eqUi]);
|
||||
|
||||
/* ── Effects ── */
|
||||
// Auto-load brands when drawer opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadCatalogBrands();
|
||||
}, [open, loadCatalogBrands]);
|
||||
|
||||
// Search debounce
|
||||
useEffect(() => {
|
||||
if (!open || brandDrawerTab !== "brands") return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchCatalogByKeyword(brandSearchQuery);
|
||||
}, 260);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, brandDrawerTab, brandSearchQuery, searchCatalogByKeyword]);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Body overflow lock
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/55"
|
||||
aria-label={eqUi.closeDrawer}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden 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" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
|
||||
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 overflow-hidden bg-white">
|
||||
{brandDrawerTab === "brands" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<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={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">{eqUi.searching}</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">{eqUi.noSearchResults}</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">{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">{eqUi.noBrandMatch}</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>
|
||||
</div>
|
||||
)}
|
||||
{brandDrawerTab === "models" && (
|
||||
<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">
|
||||
{eqUi.brandLabel}<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||||
{selectedCatalogModelFromSearch && (
|
||||
<span className="ml-2 text-black/45">
|
||||
{eqUi.modelFromSearchLabel}
|
||||
{selectedCatalogModelFromSearch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!selectedCatalogBrand && (
|
||||
<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">{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">{eqUi.noModelsForBrand}</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>
|
||||
</div>
|
||||
)}
|
||||
{brandDrawerTab === "target" && (
|
||||
<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>
|
||||
{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
|
||||
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={async () => {
|
||||
if (isConfirmingTarget) return;
|
||||
setIsConfirmingTarget(true);
|
||||
try {
|
||||
const completed = await onConfirm(
|
||||
selectedCatalogBrand,
|
||||
selectedCatalogModelName,
|
||||
selectedCatalogTarget,
|
||||
selectedCatalogModelForm,
|
||||
);
|
||||
if (completed) {
|
||||
onClose();
|
||||
toast.success(eqUi.toastNewEqOk);
|
||||
} else {
|
||||
toast.info(eqUi.overwritePresetCancelled);
|
||||
}
|
||||
} catch {
|
||||
toast.error(eqUi.toastCurveFail);
|
||||
} 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 ? eqUi.confirmLoading : eqUi.confirmButton}
|
||||
</button>
|
||||
</div>
|
||||
{!selectedCatalogModelName && (
|
||||
<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">{eqUi.noTargets}</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(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">
|
||||
{eqInterp(eqUi.bassBoost, {
|
||||
fc: String(target.bassBoost.fc),
|
||||
q: String(target.bassBoost.q),
|
||||
gain: String(target.bassBoost.gain),
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user