diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx
index 95e47b2..fa9baae 100644
--- a/client/src/pages/EQPage.tsx
+++ b/client/src/pages/EQPage.tsx
@@ -11,38 +11,24 @@
7. Total gain card: 总增益 value + AUTO toggle + slider
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
-import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X, Share2, Copy, Loader2 } from "lucide-react";
+import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Share2 } from "lucide-react";
import { useLocation } from "wouter";
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
-import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
import BottomNav from "@/components/BottomNav";
import { FeatureGate } from "@/components/FeatureGate";
import { toast } from "sonner";
import {
- decodeCustomBase64,
- fetchLuxsinAudioBrands,
fetchLuxsinAudioCurve,
- fetchLuxsinAudioModelList,
- fetchLuxsinAudioModels,
- type LuxsinAudioBrand,
- type LuxsinAudioModelListItem,
- type LuxsinAudioModel,
type PeqFilter,
type PeqApplyPayload,
type PeqChangePayload,
type PeqPresetBody,
type PeqState,
} from "@/lib/luxsinApi";
-import * as echarts from "echarts";
import {
- getSectionsMatrix,
- visualizeResponse,
- getChartOps,
getFilterType,
getFilterShortName,
- buildPeqSvgCurveData,
- sampleCombinedPeqMagnitudeDb,
} from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
@@ -59,12 +45,10 @@ import {
DEFAULT_BANDS,
FLAT_PRESET_FILTERS,
PEQ_PRESET_MAX,
- CATALOG_TARGETS,
type BandParamKind,
type PeqEqUi,
type PeqCatalogItem,
type PeqPresetLocalCache,
- type CatalogTarget,
} from "./eq/constants";
import {
eqInterp,
@@ -83,447 +67,18 @@ import {
} from "./eq/utils";
import { IOSToggle } from "./eq/components/IOSToggle";
import { CyanSlider } from "./eq/components/CyanSlider";
-
-
-
-function LegendCurveIndicator({
- loading,
- color,
-}: {
- loading: boolean;
- color: string;
-}) {
- if (loading) {
- return (
-
- );
- }
- return
;
-}
-
-/* ── Frequency Response Chart ── */
-function FreqChart({
- bands,
- rawCurve,
- rawCurveLoading = false,
- selectedBand,
- abMode,
- onAbToggle,
- onCopyAndSwitchTo,
- onApplyB,
- onSaveB,
- onBandDrag,
- onBandSelect,
- eqUi,
-}: {
- bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
- rawCurve: number[] | null;
- rawCurveLoading?: boolean;
- selectedBand: number;
- abMode: "A" | "B";
- onAbToggle: (m: "A" | "B") => void;
- onCopyAndSwitchTo: (to: "A" | "B") => void;
- onApplyB: () => void;
- onSaveB: () => void;
- onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
- onBandSelect: (idx: number) => void;
- eqUi: PeqEqUi;
-}) {
- /** 频响图 SVG 高度(viewBox 与 CSS 一致) */
- const H = 400;
- const chartContainerRef = useRef(null);
- const svgRef = useRef(null);
- const draggingBandRef = useRef(null);
- const curvePathRef = useRef(null);
- const rawPathRef = useRef(null);
- const equalizedPathRef = useRef(null);
- const [chartWidth, setChartWidth] = useState(340);
- const W = chartWidth;
- const [isBandDragging, setIsBandDragging] = useState(false);
-
- useEffect(() => {
- const node = chartContainerRef.current;
- if (!node) return;
-
- const updateWidth = () => {
- const nextWidth = Math.round(node.clientWidth);
- if (nextWidth > 0) setChartWidth(nextWidth);
- };
-
- updateWidth();
- const observer = new ResizeObserver(updateWidth);
- observer.observe(node);
- return () => observer.disconnect();
- }, []);
-
- const Y_DB_MAX = 20;
-
- const freqToX = (f: number) => {
- const logMin = Math.log10(20), logMax = Math.log10(20000);
- return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W;
- };
- const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10);
- const xToFreq = (x: number) => {
- const logMin = Math.log10(20), logMax = Math.log10(20000);
- const clampedX = Math.max(0, Math.min(W, x));
- return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin));
- };
- const yToGain = (y: number) => {
- const clampedY = Math.max(10, Math.min(H - 10, y));
- return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX;
- };
-
- const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => {
- const svg = svgRef.current;
- if (!svg || W <= 0) return;
- const rect = svg.getBoundingClientRect();
- if (!rect.width || !rect.height) return;
-
- const x = ((clientX - rect.left) / rect.width) * W;
- const y = ((clientY - rect.top) / rect.height) * H;
- const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x))));
- const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1));
- onBandDrag(idx, { freq, gain });
- };
-
- const handleBandPointerDown = (idx: number, e: React.PointerEvent) => {
- e.preventDefault();
- e.stopPropagation();
- draggingBandRef.current = idx;
- setIsBandDragging(true);
- onBandSelect(idx);
- updateBandFromPointer(idx, e.clientX, e.clientY);
- };
-
- const handleSvgPointerMove = (e: React.PointerEvent) => {
- const idx = draggingBandRef.current;
- if (idx === null) return;
- e.preventDefault();
- updateBandFromPointer(idx, e.clientX, e.clientY);
- };
-
- const stopDragging = () => {
- draggingBandRef.current = null;
- setIsBandDragging(false);
- };
-
- const curveData = useMemo(
- () =>
- buildPeqSvgCurveData({
- bands,
- width: W,
- height: H,
- fs: 48000,
- yDbMax: Y_DB_MAX,
- minFreq: 20,
- maxFreq: 20000,
- paddingY: 10,
- }),
- [bands, W, H],
- );
- const pathD = curveData.pathD;
- const fillD = curveData.fillD;
-
- /** 各带中心频率处级联响应 dB,与黄线同源 — 手柄纵坐标须用此值才能落在曲线上 */
- const combinedMagDbAtHandles = useMemo(
- () => bands.map((b) => sampleCombinedPeqMagnitudeDb(bands, b.freq, 48000)),
- [bands],
- );
-
- const [curveVisibilityByMode, setCurveVisibilityByMode] = useState<
- Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }>
- >({
- A: { eq: true, raw: true, equalized: true },
- B: { eq: true, raw: false, equalized: false },
- });
- const showEq = curveVisibilityByMode[abMode].eq;
- const showRaw = curveVisibilityByMode[abMode].raw;
- const showEqualized = curveVisibilityByMode[abMode].equalized;
-
- const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => {
- setCurveVisibilityByMode((prev) => ({
- ...prev,
- [abMode]: {
- ...prev[abMode],
- [key]: !prev[abMode][key],
- },
- }));
- };
-
- const rawPathD = useMemo(() => {
- if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
- return curveData.points
- .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`)
- .join(" ");
- }, [rawCurve, curveData.points]);
- const equalizedPathD = useMemo(() => {
- if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
- return curveData.points
- .map((p, i) => {
- const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0));
- return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`;
- })
- .join(" ");
- }, [rawCurve, curveData.points]);
- const hasRawCurve = !!rawPathD;
- const hasEqualizedCurve = !!equalizedPathD;
-
- // Intro draw once; EQ param edits update path in place without replaying the stroke animation.
- const strokeAnimOpts = { durationMs: 1350, replayOnPathChange: false as const };
- useStrokeDrawAnimation(curvePathRef, pathD, {
- ...strokeAnimOpts,
- enabled: !isBandDragging,
- });
- useStrokeDrawAnimation(rawPathRef, rawPathD, {
- ...strokeAnimOpts,
- enabled: !isBandDragging && !!rawPathD,
- });
- useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, {
- ...strokeAnimOpts,
- enabled: !isBandDragging && !!equalizedPathD,
- });
-
- const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
- const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
-
- const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
- const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
-
- return (
-
- {/* Legend row */}
-
-
-
-
-
-
- {/* A/B + DIFF controls */}
-
-
- {/* A/B toggle pill */}
-
- {(["A", "B"] as const).map((m) => (
-
- ))}
-
-
-
-
-
-
-
-
-
- {/* SVG chart */}
-
-
-
-
- );
-}
+import { FreqChart } from "./eq/components/FreqChart";
+import { BandParamDialog } from "./eq/components/BandParamDialog";
+import { SaveBDialog } from "./eq/components/SaveBDialog";
+import { AddPresetDialog } from "./eq/components/AddPresetDialog";
+import { BatchEditDialog } from "./eq/components/BatchEditDialog";
+import { ShareDialog } from "./eq/components/ShareDialog";
+import { BrandDrawer } from "./eq/components/BrandDrawer";
+import { useRawCurve } from "./eq/hooks/useRawCurve";
/* ── Default bands imported from eq/constants ── */
-
/* ── Main Component ── */
-const rawCurveCache = new Map();
-
export default function EQPage() {
const [, setLocation] = useLocation();
const {
@@ -595,38 +150,17 @@ export default function EQPage() {
const bandSliderDragRef = useRef(null);
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
const [batchEditText, setBatchEditText] = useState("");
- type BrandDrawerTab = "brands" | "models" | "target";
const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false);
- const [brandDrawerTab, setBrandDrawerTab] = useState("brands");
+ const [brandDrawerKey, setBrandDrawerKey] = useState(0);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
- const [shareDialogTab, setShareDialogTab] = useState<"share" | "import" | "myShares">("share");
- const [shareSelectedIdx, setShareSelectedIdx] = useState(headphoneIdx);
- const [shareCode, setShareCode] = useState(null);
- const [shareGenerating, setShareGenerating] = useState(false);
- const [importCodeInputs, setImportCodeInputs] = useState(["", "", "", "", ""]);
- const [importQuerying, setImportQuerying] = useState(false);
- const [importedEqData, setImportedEqData] = useState<{ name: string; brand?: string; model?: string; form?: string; filters?: any[] | string; preamp?: number } | null>(null);
- const [mySharesList, setMySharesList] = useState>([]);
- const [mySharesLoading, setMySharesLoading] = useState(false);
- 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 [currentRawCurve, setCurrentRawCurve] = useState(null);
- const [rawCurveLoading, setRawCurveLoading] = useState(false);
- const rawCurveCacheRef = useRef(rawCurveCache);
+ const [shareDialogKey, setShareDialogKey] = useState(0);
+ const {
+ currentRawCurve,
+ setCurrentRawCurve,
+ rawCurveLoading,
+ setRawCurveLoading,
+ loadRawCurveForPeq,
+ } = useRawCurve();
const allowPeqRemoteSyncRef = useRef(false);
const lastPeqCatalogSyncKeyRef = useRef("");
const syncingHeadphoneRef = useRef(false);
@@ -672,77 +206,6 @@ 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(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]);
-
const guardPeqPresetCapacity = () => {
if (peqItems.length < PEQ_PRESET_MAX) return true;
toast.error(
@@ -755,47 +218,9 @@ export default function EQPage() {
const openAddHeadsetCatalog = () => {
if (!guardPeqPresetCapacity()) return;
setIsBrandDrawerOpen(true);
- setBrandDrawerTab("brands");
- setBrandSearchQuery("");
- setCatalogSearchResults([]);
- setCatalogSearchError(null);
- setCatalogSearchLoading(false);
- setSelectedCatalogBrand("");
- setSelectedCatalogModelFromSearch("");
- setSelectedCatalogModelName("");
- setSelectedCatalogModelForm(undefined);
- setSelectedCatalogTarget("");
- setCatalogModels([]);
- setCatalogModelsError(null);
- void loadCatalogBrands();
+ setBrandDrawerKey((k) => k + 1);
};
- 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[] = [];
@@ -1216,90 +641,6 @@ export default function EQPage() {
setAbMode("A");
}, [headphoneIdx]);
- // 获取耳机原始曲线
- const getModelCurve = async (brand: string, name: string) => {
- try {
- console.log("[modelCurve] request", { brand, name });
- const resp = await fetch(
- `//api.luxsin.com.cn/audio/modelCurve?brand=${encodeURIComponent(brand)}&name=${encodeURIComponent(name)}`
- );
- const data = await resp.text();
- // 使用自定义 Base64 解码
- const decoded = decodeCustomBase64(data);
- const parsed = JSON.parse(decoded);
- console.log("[modelCurve] decoded", {
- hasFrRaw:
- !!(parsed &&
- typeof parsed === "object" &&
- "fr" in (parsed as Record) &&
- (parsed as { fr?: unknown }).fr &&
- typeof (parsed as { fr?: unknown }).fr === "object" &&
- "raw" in ((parsed as { fr?: Record }).fr ?? {})),
- });
- console.log("[modelCurve] fr.raw", (parsed as { fr?: { raw?: unknown } }).fr?.raw);
- return parsed;
- } catch (error) {
- console.log("[modelCurve] request failed", error);
- return null;
- }
- };
-
- const loadRawCurveForPeq = useCallback(
- async (peq: { brand?: string; model?: string; name?: string } | undefined): Promise => {
- const brand = peq?.brand?.trim() ?? "";
- const model = peq?.model?.trim() ?? "";
- if (!brand || !model) return null;
-
- const cacheKey = `${brand}|${model}`;
- if (rawCurveCacheRef.current.has(cacheKey)) {
- const cached = rawCurveCacheRef.current.get(cacheKey);
- if (cached) {
- console.log("[modelCurve] cache hit", { brand, model, rawLength: cached.length });
- } else {
- console.log("[modelCurve] cache hit (no raw)", { brand, model });
- }
- return cached ?? null;
- }
-
- const modelCurve = await getModelCurve(brand, model);
- if (modelCurve && typeof modelCurve === "object") {
- const payload = modelCurve as {
- fr?: { raw?: unknown } | unknown;
- };
- const rawCandidate =
- payload.fr && typeof payload.fr === "object" && Array.isArray((payload.fr as { raw?: unknown }).raw)
- ? (payload.fr as { raw?: unknown }).raw
- : null;
- const raw = Array.isArray(rawCandidate)
- ? rawCandidate
- .map((point) => {
- if (typeof point === "number" && Number.isFinite(point)) return point;
- if (Array.isArray(point) && point.length >= 2 && typeof point[1] === "number") return point[1];
- if (point && typeof point === "object") {
- const y = (point as Record).y
- ?? (point as Record).value
- ?? (point as Record).db
- ?? (point as Record).gain;
- if (typeof y === "number" && Number.isFinite(y)) return y;
- }
- return NaN;
- })
- : null;
- const cleanedRaw = raw && raw.every((v) => Number.isFinite(v)) ? (raw as number[]) : null;
- rawCurveCacheRef.current.set(cacheKey, cleanedRaw);
- console.log("[modelCurve] raw check", {
- brand,
- model,
- hasRaw: !!cleanedRaw,
- rawLength: cleanedRaw?.length ?? 0,
- });
- return cleanedRaw;
- }
- return null;
- },
- [],
- );
-
// 加载耳机列表并初始化曲线
useEffect(() => {
async function loadHeadphones() {
@@ -1414,7 +755,6 @@ export default function EQPage() {
if (cancelled) return;
setCurrentRawCurve(raw);
setRawCurveLoading(false);
- renderCharts(nextBands, raw, false);
})();
requestAnimationFrame(() => {
syncingHeadphoneRef.current = false;
@@ -1597,86 +937,8 @@ export default function EQPage() {
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
- const chartRef = useRef(null);
const band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
- // Render frequency response chart using ECharts
- const renderCharts = (peqFilters: typeof bands, raw: number[] | null, changeParam: boolean) => {
- const list: any[] = [];
- const fs = 48000;
-
- // Calculate coefficient matrix for each filter
- peqFilters.forEach((item) => {
- const filterType = getFilterType(item.type);
-
- const coeff = getSectionsMatrix(
- item.gain,
- item.freq,
- item.q,
- filterType,
- false,
- fs
- );
- if (coeff) {
- list.push(coeff);
- }
- });
-
- // Get frequency response data
- const dataSet = visualizeResponse(list, fs);
- const ops = getChartOps(dataSet, 20, -20, '#FFED00') as any;
-
- // Get or create chart instance
- const chartDom = document.getElementById('freq-chart');
- if (!chartDom) return;
-
- if (!chartRef.current) {
- chartRef.current = echarts.init(chartDom);
- }
-
- const myChart = chartRef.current;
-
- // Handle changeParam (get raw data from existing chart)
- if (changeParam && myChart) {
- const option = myChart.getOption() as { series?: Array<{ data?: number[] }> };
- if (option.series && option.series.length > 1) {
- raw = (option.series[1] as any).data;
- }
- }
-
- // Clear and rebuild chart
- myChart.clear();
-
- // Add Raw and Equalized curves if raw data is available (expects same 349 points as EQ curve)
- if (Array.isArray(raw) && raw.length === dataSet[1].length) {
- ops.series.push({
- name: 'Raw',
- data: raw,
- type: 'line',
- showSymbol: false,
- lineStyle: {
- color: '#ffffff',
- },
- });
-
- // Calculate equalized curve
- const equalizedRaw = dataSet[1].map((value, index) => value + raw[index]);
- ops.series.push({
- name: 'Equalized',
- data: equalizedRaw,
- type: 'line',
- showSymbol: false,
- lineStyle: {
- color: '#23d2fe',
- width: 6,
- opacity: 0.7,
- },
- });
- }
-
- myChart.setOption(ops, true);
- };
-
const handleApplyB = useCallback(async () => {
const peq = peqItems[headphoneIdx];
if (!peq?.name) {
@@ -1716,9 +978,6 @@ export default function EQPage() {
try {
await upgradePeqChange(payload);
- if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
- renderCharts(bBands, currentRawCurve, false);
- }
toast.success(eqUi.toastApplyBSuccess);
} catch {
skipPeqAutoSyncRef.current = false;
@@ -1726,7 +985,6 @@ export default function EQPage() {
}
}, [
bandsByMode.B,
- currentRawCurve,
eqUi.toastApplyBFail,
eqUi.toastApplyBSuccess,
headphoneIdx,
@@ -1929,15 +1187,7 @@ export default function EQPage() {
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={() => {
setIsShareDialogOpen(true);
- setShareDialogTab("share");
- setShareSelectedIdx(headphoneIdx);
- setShareCode(null);
- setShareGenerating(false);
- setImportCodeInputs(["", "", "", "", ""]);
- setImportQuerying(false);
- setImportedEqData(null);
- setMySharesList([]);
- setMySharesLoading(false);
+ setShareDialogKey((k) => k + 1);
}}>
@@ -2233,1075 +1483,160 @@ export default function EQPage() {
- {bandParamDialog && bandParamDialogMeta && (
-
-
e.stopPropagation()}
- >
-
-
{bandParamDialogMeta.title}
-
-
-
{bandParamDialogMeta.hint}
-
setBandParamInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- applyBandParamDialog();
- }
- }}
- inputMode={bandParamDialogMeta.inputMode}
- placeholder={bandParamDialogMeta.placeholder}
- className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
- autoFocus
- />
-
-
-
-
-
-
- )}
+
- {isSaveBDialogOpen && (
-
-
e.stopPropagation()}
- >
-
-
{eqUi.saveBTitle}
-
-
+
void handleSaveBAsPreset()}
+ onClose={() => setIsSaveBDialogOpen(false)}
+ eqUi={eqUi}
+ />
- setSaveBPresetName(e.target.value)}
- className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
- autoFocus
- />
+ setIsAddPresetDialogOpen(false)}
+ eqUi={eqUi}
+ />
-
-
-
-
-
-
- )}
+ setIsBatchEditDialogOpen(false)}
+ eqUi={eqUi}
+ />
- {isAddPresetDialogOpen && (
-
- )}
+ 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)),
+ }));
- {isBatchEditDialogOpen && (
-
-
e.stopPropagation()}
- >
-
-
{eqUi.batchEditTitle}
-
-
-
- {eqUi.batchEditHint}
-
-
-
- )}
+ 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,
+ },
+ };
- {isBrandDrawerOpen && (
-
-
- )}
-
- {/* ── Share / Import EQ Dialog ── */}
- {isShareDialogOpen && (
- setIsShareDialogOpen(false)}
- >
-
e.stopPropagation()}
- >
- {/* header */}
-
- {/* tab bar */}
-
- setShareDialogTab("share")}
- >
- {peqCardLabels.share}
-
- setShareDialogTab("import")}
- >
- {eqUi.importTab}
-
- {
- setShareDialogTab("myShares");
- // 进入选项卡时调用查询分享列表接口
- if (mySharesList.length === 0 && !mySharesLoading) {
- void (async () => {
- setMySharesLoading(true);
- try {
- // ── TODO: 伪代码 — 调用查询我的分享列表接口 ──
- // const res = await fetch("/api/eq/share/list");
- // if (!res.ok) {
- // toast.error(eqUi.mySharesLoadFail);
- // return;
- // }
- // const data = await res.json();
- // setMySharesList(data.list);
-
- // 模拟接口延迟
- await new Promise((r) => setTimeout(r, 600));
- // 模拟返回分享列表
- setMySharesList([
- { code: "AK7NR", name: "Sennheiser HD 600 (AutoEQ)" },
- { code: "P3WQM", name: "Beyerdynamic DT 880" },
- { code: "Z5XKL", name: "Hifiman HE400i" },
- ]);
- } catch {
- // TODO: 错误处理
- } finally {
- setMySharesLoading(false);
- }
- })();
- }
- }}
- >
- {eqUi.mySharesTab}
-
-
-
setIsShareDialogOpen(false)}
- >
-
-
-
-
- {/* ── Tab: Share EQ ── */}
- {shareDialogTab === "share" && (
- <>
- {/* hint */}
-
{eqUi.shareSelectHint}
-
- {/* EQ preset list */}
-
- {peqItems.length === 0 && (
-
—
- )}
- {peqItems.map((item, idx) => {
- const active = idx === shareSelectedIdx;
- return (
-
setShareSelectedIdx(idx)}
- >
- {item.name}
-
- );
- })}
-
-
- {/* confirm button */}
-
{
- if (shareGenerating) return;
- setShareGenerating(true);
- setShareCode(null);
- try {
- // ── TODO: 伪代码 — 调用分享接口 ──
- // const selectedPeq = peqItems[shareSelectedIdx];
- // const res = await fetch("/api/eq/share", {
- // method: "POST",
- // headers: { "Content-Type": "application/json" },
- // body: JSON.stringify({
- // brand: selectedPeq.brand,
- // model: selectedPeq.model,
- // target: selectedPeq.form,
- // filters: selectedPeq.filters,
- // preamp: selectedPeq.preamp,
- // }),
- // });
- // const data = await res.json();
- // setShareCode(data.shareCode);
-
- // 模拟接口延迟
- await new Promise((r) => setTimeout(r, 800));
- // 模拟返回 5 位随机分享码
- const fakeCode = Array.from(
- { length: 5 },
- () => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[Math.floor(Math.random() * 32)],
- ).join("");
- setShareCode(fakeCode);
- } catch {
- // TODO: 错误处理
- } finally {
- setShareGenerating(false);
- }
- }}
- >
- {shareGenerating ? (
-
-
- {eqUi.shareGenerating}
-
- ) : (
- eqUi.shareConfirm
- )}
-
-
- {/* share code display */}
- {shareCode && (
-
-
{eqUi.shareCodeLabel}
-
-
- {shareCode.split("").map((ch, i) => (
-
- {ch}
-
- ))}
-
-
{
- try {
- await navigator.clipboard.writeText(shareCode);
- toast.success(eqUi.shareCopied);
- } catch {
- const ta = document.createElement("textarea");
- ta.value = shareCode;
- ta.style.position = "fixed";
- ta.style.opacity = "0";
- document.body.appendChild(ta);
- ta.select();
- document.execCommand("copy");
- document.body.removeChild(ta);
- toast.success(eqUi.shareCopied);
- }
- }}
- >
-
-
-
-
- )}
- >
- )}
-
- {/* ── Tab: Import EQ ── */}
- {shareDialogTab === "import" && (
- <>
- {/* hint */}
-
{eqUi.importCodeHint}
-
- {/* 5 input boxes */}
-
- {importCodeInputs.map((ch, i) => (
- {
- const val = e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(-1).toUpperCase();
- const next = [...importCodeInputs];
- next[i] = val;
- setImportCodeInputs(next);
- setImportedEqData(null);
- // auto-focus next box
- if (val && i < 4) {
- const el = e.target.nextElementSibling as HTMLInputElement | null;
- el?.focus();
- }
- }}
- onKeyDown={(e) => {
- // backspace: clear current & focus previous
- if (e.key === "Backspace" && !importCodeInputs[i] && i > 0) {
- const next = [...importCodeInputs];
- next[i - 1] = "";
- setImportCodeInputs(next);
- setImportedEqData(null);
- const prev = (e.target as HTMLElement).previousElementSibling as HTMLInputElement | null;
- prev?.focus();
- }
- }}
- onPaste={(e) => {
- e.preventDefault();
- const text = (e.clipboardData.getData("text") || "").replace(/[^a-zA-Z0-9]/g, "").toUpperCase().slice(0, 5);
- if (!text) return;
- const next = [...importCodeInputs];
- for (let j = 0; j < 5; j++) {
- next[j] = text[j] ?? "";
- }
- setImportCodeInputs(next);
- setImportedEqData(null);
- // focus last filled or last box
- const focusIdx = Math.min(text.length, 4);
- const inputs = (e.target as HTMLElement).parentElement?.querySelectorAll("input");
- (inputs?.[focusIdx] as HTMLInputElement | undefined)?.focus();
- }}
- ref={(el) => {
- // stash ref for focus management if needed later
- }}
- />
- ))}
-
-
- {/* query button */}
-
!c)}
- className={cn(
- "w-full rounded-full py-2.5 text-[15px] font-semibold text-black transition-all active:scale-[0.98]",
- importQuerying || importCodeInputs.some((c) => !c)
- ? "bg-[#00FFF6]/40 cursor-not-allowed"
- : "bg-[#00FFF6] hover:brightness-95",
- )}
- onClick={async () => {
- if (importQuerying) return;
- const code = importCodeInputs.join("");
- setImportQuerying(true);
- setImportedEqData(null);
- try {
- // ── TODO: 伪代码 — 调用导入查询接口 ──
- // const res = await fetch(`/api/eq/share/${code}`);
- // if (!res.ok) {
- // toast.error(eqUi.importCodeNotFound);
- // return;
- // }
- // const data = await res.json();
- // setImportedEqData({
- // name: data.name,
- // brand: data.brand,
- // model: data.model,
- // form: data.target,
- // filters: data.filters,
- // preamp: data.preamp,
- // });
-
- // 模拟接口延迟
- await new Promise((r) => setTimeout(r, 600));
- // 模拟返回 EQ 数据
- setImportedEqData({
- name: "Sennheiser HD 600 (AutoEQ)",
- brand: "Sennheiser",
- model: "HD 600",
- form: "over-ear",
- filters: [],
- preamp: -5.2,
- });
- } catch {
- // TODO: 错误处理
- } finally {
- setImportQuerying(false);
- }
- }}
- >
- {importQuerying ? (
-
-
- {eqUi.importQuerying}
-
- ) : (
- eqUi.importQuery
- )}
-
-
- {/* imported EQ result */}
- {importedEqData && (
-
-
-
-
{eqUi.importEqName}
-
{importedEqData.name}
-
-
{
- // ── TODO: 伪代码 — 导入 EQ 到预设列表 ──
- // if (!guardPeqPresetCapacity()) return;
- // setPeqItems((prev) => [
- // ...prev,
- // {
- // name: importedEqData.name,
- // brand: importedEqData.brand,
- // model: importedEqData.model,
- // form: importedEqData.form,
- // filters: importedEqData.filters,
- // preamp: importedEqData.preamp,
- // autoPre: 0,
- // canDel: 1,
- // },
- // ]);
- // setHeadphoneIdx(peqItems.length);
- // setIsShareDialogOpen(false);
- // toast.success(eqUi.importSuccess);
-
- // 模拟导入
- toast.success(eqUi.importSuccess);
- setIsShareDialogOpen(false);
- }}
- >
- {eqUi.importButton}
-
-
-
- )}
- >
- )}
-
- {/* ── Tab: My Shares ── */}
- {shareDialogTab === "myShares" && (
- <>
- {mySharesLoading && mySharesList.length === 0 ? (
-
-
- {eqUi.mySharesLoading}
-
- ) : mySharesList.length === 0 ? (
-
{eqUi.mySharesEmpty}
- ) : (
-
- {mySharesList.map((item, idx) => (
-
- {/* share code */}
-
- {item.code.split("").map((ch, ci) => (
-
- {ch}
-
- ))}
-
- {/* EQ name */}
-
{item.name}
- {/* copy code button */}
-
{
- try {
- await navigator.clipboard.writeText(item.code);
- toast.success(eqUi.shareCopied);
- } catch {
- const ta = document.createElement("textarea");
- ta.value = item.code;
- ta.style.position = "fixed";
- ta.style.opacity = "0";
- document.body.appendChild(ta);
- ta.select();
- document.execCommand("copy");
- document.body.removeChild(ta);
- toast.success(eqUi.shareCopied);
- }
- }}
- >
-
-
-
- ))}
-
- )}
- >
- )}
-
-
- )}
+ // 模拟导入
+ toast.success(eqUi.importSuccess);
+ setIsShareDialogOpen(false);
+ }}
+ />
diff --git a/client/src/pages/eq/components/AddPresetDialog.tsx b/client/src/pages/eq/components/AddPresetDialog.tsx
new file mode 100644
index 0000000..cf12390
--- /dev/null
+++ b/client/src/pages/eq/components/AddPresetDialog.tsx
@@ -0,0 +1,115 @@
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { PeqEqUi } from "../constants";
+
+export function AddPresetDialog({
+ open,
+ mode,
+ copyName,
+ flatName,
+ onModeChange,
+ onCopyNameChange,
+ onFlatNameChange,
+ onSave,
+ onClose,
+ eqUi,
+}: {
+ open: boolean;
+ mode: "copy" | "flat";
+ copyName: string;
+ flatName: string;
+ onModeChange: (m: "copy" | "flat") => void;
+ onCopyNameChange: (v: string) => void;
+ onFlatNameChange: (v: string) => void;
+ onSave: () => void;
+ onClose: () => void;
+ eqUi: PeqEqUi;
+}) {
+ if (!open) return null;
+ return (
+
+ );
+}
diff --git a/client/src/pages/eq/components/BandParamDialog.tsx b/client/src/pages/eq/components/BandParamDialog.tsx
new file mode 100644
index 0000000..5e31c63
--- /dev/null
+++ b/client/src/pages/eq/components/BandParamDialog.tsx
@@ -0,0 +1,89 @@
+import { X } from "lucide-react";
+import type { PeqEqUi } from "../constants";
+
+type BandParamDialogMeta = {
+ title: string;
+ hint: string;
+ placeholder: string;
+ inputMode: "decimal" | "numeric" | "text";
+ rangeMessages: { invalid: string; outOfRange: string };
+};
+
+export function BandParamDialog({
+ open,
+ meta,
+ input,
+ onInputChange,
+ onConfirm,
+ onClose,
+ eqUi,
+}: {
+ open: boolean;
+ meta: BandParamDialogMeta | null;
+ input: string;
+ onInputChange: (v: string) => void;
+ onConfirm: () => void;
+ onClose: () => void;
+ eqUi: PeqEqUi;
+}) {
+ if (!open || !meta) return null;
+ return (
+
+
e.stopPropagation()}
+ >
+
+
{meta.title}
+
+
+
+
+
{meta.hint}
+
onInputChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm();
+ }
+ }}
+ inputMode={meta.inputMode}
+ placeholder={meta.placeholder}
+ className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
+ autoFocus
+ />
+
+
+ {eqUi.cancel}
+
+
+ {eqUi.confirmButton ?? eqUi.save}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/eq/components/BatchEditDialog.tsx b/client/src/pages/eq/components/BatchEditDialog.tsx
new file mode 100644
index 0000000..8bd18af
--- /dev/null
+++ b/client/src/pages/eq/components/BatchEditDialog.tsx
@@ -0,0 +1,69 @@
+import { X } from "lucide-react";
+import type { PeqEqUi } from "../constants";
+
+export function BatchEditDialog({
+ open,
+ text,
+ onChangeText,
+ onSave,
+ onClose,
+ eqUi,
+}: {
+ open: boolean;
+ text: string;
+ onChangeText: (v: string) => void;
+ onSave: () => void;
+ onClose: () => void;
+ eqUi: PeqEqUi;
+}) {
+ if (!open) return null;
+ return (
+
+
e.stopPropagation()}
+ >
+
+
{eqUi.batchEditTitle}
+
+
+
+
+
+ {eqUi.batchEditHint}
+
+
+
+ );
+}
diff --git a/client/src/pages/eq/components/BrandDrawer.tsx b/client/src/pages/eq/components/BrandDrawer.tsx
new file mode 100644
index 0000000..8afe73a
--- /dev/null
+++ b/client/src/pages/eq/components/BrandDrawer.tsx
@@ -0,0 +1,417 @@
+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 "../constants";
+import type { PeqEqUi } from "../constants";
+import { eqInterp } from "../utils";
+
+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;
+}) {
+ 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);
+
+ /* ── 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 (
+
+
+
e.stopPropagation()}
+ >
+
+
+ {(["brands", "models", "target"] as const).map((tab) => {
+ const active = brandDrawerTab === tab;
+ const label =
+ tab === "brands" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
+ return (
+ setBrandDrawerTab(tab)}
+ >
+ {label}
+
+ );
+ })}
+
+
+
+ {brandDrawerTab === "brands" && (
+
+
+
+
+ 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"
+ />
+
+
+
+ {brandSearchQuery.trim() && catalogSearchLoading && (
+
{eqUi.searching}
+ )}
+ {!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
+
{catalogSearchError}
+ )}
+ {!!brandSearchQuery.trim() &&
+ !catalogSearchLoading &&
+ !catalogSearchError &&
+ catalogSearchResults.length === 0 && (
+
{eqUi.noSearchResults}
+ )}
+ {!!brandSearchQuery.trim() &&
+ !catalogSearchLoading &&
+ !catalogSearchError &&
+ catalogSearchResults.map((item, idx) => (
+
{
+ 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");
+ }}
+ >
+ {item.modelName}
+ {item.brandName}
+
+ ))}
+ {!brandSearchQuery.trim() && catalogBrandsLoading && (
+
{eqUi.loading}
+ )}
+ {!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && (
+
{catalogBrandsError}
+ )}
+ {!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && (
+
{eqUi.noBrandMatch}
+ )}
+ {!brandSearchQuery.trim() &&
+ !catalogBrandsLoading &&
+ !catalogBrandsError &&
+ filteredCatalogBrands.map((b) => (
+
{
+ setSelectedCatalogBrand(b.name);
+ setSelectedCatalogModelFromSearch("");
+ setSelectedCatalogModelName("");
+ setSelectedCatalogModelForm(undefined);
+ setSelectedCatalogTarget("");
+ setBrandDrawerTab("models");
+ void loadCatalogModels(b.name);
+ }}
+ >
+ {b.name}
+
+ ))}
+
+
+ )}
+ {brandDrawerTab === "models" && (
+
+
+
+ {eqUi.brandLabel}{selectedCatalogBrand || "—"}
+ {selectedCatalogModelFromSearch && (
+
+ {eqUi.modelFromSearchLabel}
+ {selectedCatalogModelFromSearch}
+
+ )}
+
+ {!selectedCatalogBrand && (
+
{eqUi.selectBrandFirst}
+ )}
+ {!!selectedCatalogBrand && catalogModelsLoading && (
+
{eqUi.loading}
+ )}
+ {!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && (
+
{catalogModelsError}
+ )}
+ {!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && (
+
{eqUi.noModelsForBrand}
+ )}
+ {!!selectedCatalogBrand &&
+ !catalogModelsLoading &&
+ !catalogModelsError &&
+ catalogModels.map((m) => (
+
{
+ setSelectedCatalogModelName(m.name);
+ setSelectedCatalogModelForm(m.form);
+ setSelectedCatalogTarget("");
+ setBrandDrawerTab("target");
+ }}
+ >
+ {m.name}
+ {m.form && {m.form}
}
+
+ ))}
+
+
+ )}
+ {brandDrawerTab === "target" && (
+
+
+
+
+ {eqUi.targetBrand}
+ {selectedCatalogBrand || "—"}
+
+
+ {eqUi.targetModel}
+ {selectedCatalogModelName || "—"}
+
+
+ {eqUi.targetForm}
+ {selectedCatalogModelForm || eqUi.formAll}
+
+
+
+ {
+ if (isConfirmingTarget) return;
+ setIsConfirmingTarget(true);
+ try {
+ await onConfirm(selectedCatalogBrand, selectedCatalogModelName, selectedCatalogTarget, selectedCatalogModelForm);
+ onClose();
+ toast.success(eqUi.toastNewEqOk);
+ } catch {
+ toast.error(eqUi.toastCurveFail);
+ } finally {
+ setIsConfirmingTarget(false);
+ }
+ }}
+ >
+ {isConfirmingTarget && (
+
+ )}
+ {isConfirmingTarget ? eqUi.confirmLoading : eqUi.confirmButton}
+
+
+ {!selectedCatalogModelName && (
+
{eqUi.selectModelFirst}
+ )}
+ {!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
+
{eqUi.noTargets}
+ )}
+ {!!selectedCatalogModelName &&
+ availableCatalogTargets.map((target) => {
+ const active = selectedCatalogTarget === target.name;
+ return (
+
{
+ setSelectedCatalogTarget(target.name);
+ toast.success(eqInterp(eqUi.toastTargetSelected, { name: target.name }));
+ }}
+ >
+ {target.name}
+
+ {eqInterp(eqUi.bassBoost, {
+ fc: String(target.bassBoost.fc),
+ q: String(target.bassBoost.q),
+ gain: String(target.bassBoost.gain),
+ })}
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/pages/eq/components/FreqChart.tsx b/client/src/pages/eq/components/FreqChart.tsx
new file mode 100644
index 0000000..b601726
--- /dev/null
+++ b/client/src/pages/eq/components/FreqChart.tsx
@@ -0,0 +1,442 @@
+import { useState, useEffect, useRef, useMemo } from "react";
+import { toast } from "sonner";
+import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
+import {
+ buildPeqSvgCurveData,
+ sampleCombinedPeqMagnitudeDb,
+} from "@/lib/peqAudio";
+import type { PeqEqUi } from "../constants";
+import { eqInterp } from "../utils";
+
+/* ── Legend indicator ── */
+function LegendCurveIndicator({
+ loading,
+ color,
+}: {
+ loading: boolean;
+ color: string;
+}) {
+ if (loading) {
+ return (
+
+ );
+ }
+ return ;
+}
+
+/* ── Frequency Response Chart ── */
+export function FreqChart({
+ bands,
+ rawCurve,
+ rawCurveLoading = false,
+ selectedBand,
+ abMode,
+ onAbToggle,
+ onCopyAndSwitchTo,
+ onApplyB,
+ onSaveB,
+ onBandDrag,
+ onBandSelect,
+ eqUi,
+}: {
+ bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
+ rawCurve: number[] | null;
+ rawCurveLoading?: boolean;
+ selectedBand: number;
+ abMode: "A" | "B";
+ onAbToggle: (m: "A" | "B") => void;
+ onCopyAndSwitchTo: (to: "A" | "B") => void;
+ onApplyB: () => void;
+ onSaveB: () => void;
+ onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
+ onBandSelect: (idx: number) => void;
+ eqUi: PeqEqUi;
+}) {
+ /** 频响图 SVG 高度(viewBox 与 CSS 一致) */
+ const H = 400;
+ const chartContainerRef = useRef(null);
+ const svgRef = useRef(null);
+ const draggingBandRef = useRef(null);
+ const curvePathRef = useRef(null);
+ const rawPathRef = useRef(null);
+ const equalizedPathRef = useRef(null);
+ const [chartWidth, setChartWidth] = useState(340);
+ const W = chartWidth;
+ const [isBandDragging, setIsBandDragging] = useState(false);
+
+ useEffect(() => {
+ const node = chartContainerRef.current;
+ if (!node) return;
+
+ const updateWidth = () => {
+ const nextWidth = Math.round(node.clientWidth);
+ if (nextWidth > 0) setChartWidth(nextWidth);
+ };
+
+ updateWidth();
+ const observer = new ResizeObserver(updateWidth);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
+
+ const Y_DB_MAX = 20;
+
+ const freqToX = (f: number) => {
+ const logMin = Math.log10(20), logMax = Math.log10(20000);
+ return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W;
+ };
+ const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10);
+ const xToFreq = (x: number) => {
+ const logMin = Math.log10(20), logMax = Math.log10(20000);
+ const clampedX = Math.max(0, Math.min(W, x));
+ return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin));
+ };
+ const yToGain = (y: number) => {
+ const clampedY = Math.max(10, Math.min(H - 10, y));
+ return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX;
+ };
+
+ const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => {
+ const svg = svgRef.current;
+ if (!svg || W <= 0) return;
+ const rect = svg.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+
+ const x = ((clientX - rect.left) / rect.width) * W;
+ const y = ((clientY - rect.top) / rect.height) * H;
+ const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x))));
+ const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1));
+ onBandDrag(idx, { freq, gain });
+ };
+
+ const handleBandPointerDown = (idx: number, e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ draggingBandRef.current = idx;
+ setIsBandDragging(true);
+ onBandSelect(idx);
+ updateBandFromPointer(idx, e.clientX, e.clientY);
+ };
+
+ const handleSvgPointerMove = (e: React.PointerEvent) => {
+ const idx = draggingBandRef.current;
+ if (idx === null) return;
+ e.preventDefault();
+ updateBandFromPointer(idx, e.clientX, e.clientY);
+ };
+
+ const stopDragging = () => {
+ draggingBandRef.current = null;
+ setIsBandDragging(false);
+ };
+
+ const curveData = useMemo(
+ () =>
+ buildPeqSvgCurveData({
+ bands,
+ width: W,
+ height: H,
+ fs: 48000,
+ yDbMax: Y_DB_MAX,
+ minFreq: 20,
+ maxFreq: 20000,
+ paddingY: 10,
+ }),
+ [bands, W, H],
+ );
+ const pathD = curveData.pathD;
+ const fillD = curveData.fillD;
+
+ /** 各带中心频率处级联响应 dB,与黄线同源 — 手柄纵坐标须用此值才能落在曲线上 */
+ const combinedMagDbAtHandles = useMemo(
+ () => bands.map((b) => sampleCombinedPeqMagnitudeDb(bands, b.freq, 48000)),
+ [bands],
+ );
+
+ const [curveVisibilityByMode, setCurveVisibilityByMode] = useState<
+ Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }>
+ >({
+ A: { eq: true, raw: true, equalized: true },
+ B: { eq: true, raw: false, equalized: false },
+ });
+ const showEq = curveVisibilityByMode[abMode].eq;
+ const showRaw = curveVisibilityByMode[abMode].raw;
+ const showEqualized = curveVisibilityByMode[abMode].equalized;
+
+ const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => {
+ setCurveVisibilityByMode((prev) => ({
+ ...prev,
+ [abMode]: {
+ ...prev[abMode],
+ [key]: !prev[abMode][key],
+ },
+ }));
+ };
+
+ const rawPathD = useMemo(() => {
+ if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
+ return curveData.points
+ .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`)
+ .join(" ");
+ }, [rawCurve, curveData.points]);
+ const equalizedPathD = useMemo(() => {
+ if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
+ return curveData.points
+ .map((p, i) => {
+ const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0));
+ return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`;
+ })
+ .join(" ");
+ }, [rawCurve, curveData.points]);
+ const hasRawCurve = !!rawPathD;
+ const hasEqualizedCurve = !!equalizedPathD;
+
+ // Intro draw once; EQ param edits update path in place without replaying the stroke animation.
+ const strokeAnimOpts = { durationMs: 1350, replayOnPathChange: false as const };
+ useStrokeDrawAnimation(curvePathRef, pathD, {
+ ...strokeAnimOpts,
+ enabled: !isBandDragging,
+ });
+ useStrokeDrawAnimation(rawPathRef, rawPathD, {
+ ...strokeAnimOpts,
+ enabled: !isBandDragging && !!rawPathD,
+ });
+ useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, {
+ ...strokeAnimOpts,
+ enabled: !isBandDragging && !!equalizedPathD,
+ });
+
+ const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
+ const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
+
+ const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
+ const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
+
+ return (
+
+ {/* Legend row */}
+
+
toggleCurveVisibility("eq")}
+ >
+
+ Equalizer
+
+
toggleCurveVisibility("raw")}
+ disabled={rawCurveLoading || !hasRawCurve}
+ >
+
+
+ Raw
+
+
+
toggleCurveVisibility("equalized")}
+ disabled={rawCurveLoading || !hasEqualizedCurve}
+ >
+
+
+ Equalized
+
+
+
+
+ {/* A/B + DIFF controls */}
+
+
+ {/* A/B toggle pill */}
+
+ {(["A", "B"] as const).map((m) => (
+ onAbToggle(m)}>
+ {m}
+
+ ))}
+
+
{
+ onCopyAndSwitchTo(targetMode);
+ setCurveVisibilityByMode((prev) => ({
+ ...prev,
+ [targetMode]: { ...prev[abMode] },
+ }));
+ toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
+ }}>
+ {copyButtonText}
+
+
+
+ onApplyB()}
+ >
+ {eqUi.chartApplyB}
+
+ onSaveB()}
+ >
+ {eqUi.chartSaveB}
+
+
+
+
+ {/* SVG chart */}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/eq/components/SaveBDialog.tsx b/client/src/pages/eq/components/SaveBDialog.tsx
new file mode 100644
index 0000000..e2964e8
--- /dev/null
+++ b/client/src/pages/eq/components/SaveBDialog.tsx
@@ -0,0 +1,69 @@
+import { X } from "lucide-react";
+import type { PeqEqUi } from "../constants";
+
+export function SaveBDialog({
+ open,
+ presetName,
+ onNameChange,
+ onSave,
+ onClose,
+ eqUi,
+}: {
+ open: boolean;
+ presetName: string;
+ onNameChange: (v: string) => void;
+ onSave: () => void;
+ onClose: () => void;
+ eqUi: PeqEqUi;
+}) {
+ if (!open) return null;
+ return (
+
+
e.stopPropagation()}
+ >
+
+
{eqUi.saveBTitle}
+
+
+
+
+
+
onNameChange(e.target.value)}
+ className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
+ autoFocus
+ />
+
+
+
+ {eqUi.cancel}
+
+
+ {eqUi.save}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/eq/components/ShareDialog.tsx b/client/src/pages/eq/components/ShareDialog.tsx
new file mode 100644
index 0000000..800e70e
--- /dev/null
+++ b/client/src/pages/eq/components/ShareDialog.tsx
@@ -0,0 +1,512 @@
+import { useState } from "react";
+import { X, Copy, Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import type { PeqEqUi } from "../constants";
+
+type PeqItem = {
+ name: string;
+ brand?: string;
+ model?: string;
+ form?: string;
+ filters?: any[] | string;
+ autoPre?: number;
+ preamp?: number;
+ canDel?: number;
+};
+
+type PeqCardLabels = {
+ share: string;
+};
+
+type ImportedEqData = {
+ name: string;
+ brand?: string;
+ model?: string;
+ form?: string;
+ filters?: any[] | string;
+ preamp?: number;
+};
+
+export function ShareDialog({
+ open,
+ onClose,
+ peqItems,
+ headphoneIdx,
+ eqUi,
+ peqCardLabels,
+ onImportEq,
+}: {
+ open: boolean;
+ onClose: () => void;
+ peqItems: PeqItem[];
+ headphoneIdx: number;
+ eqUi: PeqEqUi;
+ peqCardLabels: PeqCardLabels;
+ onImportEq: (data: ImportedEqData) => void;
+}) {
+ const [shareDialogTab, setShareDialogTab] = useState<"share" | "import" | "myShares">("share");
+ const [shareSelectedIdx, setShareSelectedIdx] = useState(headphoneIdx);
+ const [shareCode, setShareCode] = useState(null);
+ const [shareGenerating, setShareGenerating] = useState(false);
+ const [importCodeInputs, setImportCodeInputs] = useState(["", "", "", "", ""]);
+ const [importQuerying, setImportQuerying] = useState(false);
+ const [importedEqData, setImportedEqData] = useState(null);
+ const [mySharesList, setMySharesList] = useState>([]);
+ const [mySharesLoading, setMySharesLoading] = useState(false);
+
+ if (!open) return null;
+
+ /* ── clipboard helper ── */
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(eqUi.shareCopied);
+ } catch {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.position = "fixed";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ document.body.removeChild(ta);
+ toast.success(eqUi.shareCopied);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* header */}
+
+ {/* tab bar */}
+
+ setShareDialogTab("share")}
+ >
+ {peqCardLabels.share}
+
+ setShareDialogTab("import")}
+ >
+ {eqUi.importTab}
+
+ {
+ setShareDialogTab("myShares");
+ if (mySharesList.length === 0 && !mySharesLoading) {
+ void (async () => {
+ setMySharesLoading(true);
+ try {
+ // ── TODO: 伪代码 — 调用查询我的分享列表接口 ──
+ // const res = await fetch("/api/eq/share/list");
+ // if (!res.ok) {
+ // toast.error(eqUi.mySharesLoadFail);
+ // return;
+ // }
+ // const data = await res.json();
+ // setMySharesList(data.list);
+
+ // 模拟接口延迟
+ await new Promise((r) => setTimeout(r, 600));
+ // 模拟返回分享列表
+ setMySharesList([
+ { code: "AK7NR", name: "Sennheiser HD 600 (AutoEQ)" },
+ { code: "P3WQM", name: "Beyerdynamic DT 880" },
+ { code: "Z5XKL", name: "Hifiman HE400i" },
+ ]);
+ } catch {
+ // TODO: 错误处理
+ } finally {
+ setMySharesLoading(false);
+ }
+ })();
+ }
+ }}
+ >
+ {eqUi.mySharesTab}
+
+
+
+
+
+
+
+ {/* ── Tab: Share EQ ── */}
+ {shareDialogTab === "share" && (
+ <>
+ {/* hint */}
+
{eqUi.shareSelectHint}
+
+ {/* EQ preset list */}
+
+ {peqItems.length === 0 && (
+
—
+ )}
+ {peqItems.map((item, idx) => {
+ const active = idx === shareSelectedIdx;
+ return (
+
setShareSelectedIdx(idx)}
+ >
+ {item.name}
+
+ );
+ })}
+
+
+ {/* confirm button */}
+
{
+ if (shareGenerating) return;
+ setShareGenerating(true);
+ setShareCode(null);
+ try {
+ // ── TODO: 伪代码 — 调用分享接口 ──
+ // const selectedPeq = peqItems[shareSelectedIdx];
+ // const res = await fetch("/api/eq/share", {
+ // method: "POST",
+ // headers: { "Content-Type": "application/json" },
+ // body: JSON.stringify({
+ // brand: selectedPeq.brand,
+ // model: selectedPeq.model,
+ // target: selectedPeq.form,
+ // filters: selectedPeq.filters,
+ // preamp: selectedPeq.preamp,
+ // }),
+ // });
+ // const data = await res.json();
+ // setShareCode(data.shareCode);
+
+ // 模拟接口延迟
+ await new Promise((r) => setTimeout(r, 800));
+ // 模拟返回 5 位随机分享码
+ const fakeCode = Array.from(
+ { length: 5 },
+ () => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[Math.floor(Math.random() * 32)],
+ ).join("");
+ setShareCode(fakeCode);
+ } catch {
+ // TODO: 错误处理
+ } finally {
+ setShareGenerating(false);
+ }
+ }}
+ >
+ {shareGenerating ? (
+
+
+ {eqUi.shareGenerating}
+
+ ) : (
+ eqUi.shareConfirm
+ )}
+
+
+ {/* share code display */}
+ {shareCode && (
+
+
{eqUi.shareCodeLabel}
+
+
+ {shareCode.split("").map((ch, i) => (
+
+ {ch}
+
+ ))}
+
+
void copyToClipboard(shareCode)}
+ >
+
+
+
+
+ )}
+ >
+ )}
+
+ {/* ── Tab: Import EQ ── */}
+ {shareDialogTab === "import" && (
+ <>
+ {/* hint */}
+
{eqUi.importCodeHint}
+
+ {/* 5 input boxes */}
+
+ {importCodeInputs.map((ch, i) => (
+ {
+ const val = e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(-1).toUpperCase();
+ const next = [...importCodeInputs];
+ next[i] = val;
+ setImportCodeInputs(next);
+ setImportedEqData(null);
+ // auto-focus next box
+ if (val && i < 4) {
+ const el = e.target.nextElementSibling as HTMLInputElement | null;
+ el?.focus();
+ }
+ }}
+ onKeyDown={(e) => {
+ // backspace: clear current & focus previous
+ if (e.key === "Backspace" && !importCodeInputs[i] && i > 0) {
+ const next = [...importCodeInputs];
+ next[i - 1] = "";
+ setImportCodeInputs(next);
+ setImportedEqData(null);
+ const prev = (e.target as HTMLElement).previousElementSibling as HTMLInputElement | null;
+ prev?.focus();
+ }
+ }}
+ onPaste={(e) => {
+ e.preventDefault();
+ const text = (e.clipboardData.getData("text") || "").replace(/[^a-zA-Z0-9]/g, "").toUpperCase().slice(0, 5);
+ if (!text) return;
+ const next = [...importCodeInputs];
+ for (let j = 0; j < 5; j++) {
+ next[j] = text[j] ?? "";
+ }
+ setImportCodeInputs(next);
+ setImportedEqData(null);
+ // focus last filled or last box
+ const focusIdx = Math.min(text.length, 4);
+ const inputs = (e.target as HTMLElement).parentElement?.querySelectorAll("input");
+ (inputs?.[focusIdx] as HTMLInputElement | undefined)?.focus();
+ }}
+ ref={() => {
+ // stash ref for focus management if needed later
+ }}
+ />
+ ))}
+
+
+ {/* query button */}
+
!c)}
+ className={cn(
+ "w-full rounded-full py-2.5 text-[15px] font-semibold text-black transition-all active:scale-[0.98]",
+ importQuerying || importCodeInputs.some((c) => !c)
+ ? "bg-[#00FFF6]/40 cursor-not-allowed"
+ : "bg-[#00FFF6] hover:brightness-95",
+ )}
+ onClick={async () => {
+ if (importQuerying) return;
+ const code = importCodeInputs.join("");
+ setImportQuerying(true);
+ setImportedEqData(null);
+ try {
+ // ── TODO: 伪代码 — 调用导入查询接口 ──
+ // const res = await fetch(`/api/eq/share/${code}`);
+ // if (!res.ok) {
+ // toast.error(eqUi.importCodeNotFound);
+ // return;
+ // }
+ // const data = await res.json();
+ // setImportedEqData({
+ // name: data.name,
+ // brand: data.brand,
+ // model: data.model,
+ // form: data.target,
+ // filters: data.filters,
+ // preamp: data.preamp,
+ // });
+
+ // 模拟接口延迟
+ await new Promise((r) => setTimeout(r, 600));
+ // 模拟返回 EQ 数据
+ setImportedEqData({
+ name: "Sennheiser HD 600 (AutoEQ)",
+ brand: "Sennheiser",
+ model: "HD 600",
+ form: "over-ear",
+ filters: [],
+ preamp: -5.2,
+ });
+ } catch {
+ // TODO: 错误处理
+ } finally {
+ setImportQuerying(false);
+ }
+ }}
+ >
+ {importQuerying ? (
+
+
+ {eqUi.importQuerying}
+
+ ) : (
+ eqUi.importQuery
+ )}
+
+
+ {/* imported EQ result */}
+ {importedEqData && (
+
+
+
+
{eqUi.importEqName}
+
{importedEqData.name}
+
+
{
+ // ── TODO: 伪代码 — 导入 EQ 到预设列表 ──
+ // onImportEq will handle the parent-level state changes
+ onImportEq(importedEqData);
+ }}
+ >
+ {eqUi.importButton}
+
+
+
+ )}
+ >
+ )}
+
+ {/* ── Tab: My Shares ── */}
+ {shareDialogTab === "myShares" && (
+ <>
+ {mySharesLoading && mySharesList.length === 0 ? (
+
+
+ {eqUi.mySharesLoading}
+
+ ) : mySharesList.length === 0 ? (
+
{eqUi.mySharesEmpty}
+ ) : (
+
+ {mySharesList.map((item, idx) => (
+
+ {/* share code */}
+
+ {item.code.split("").map((ch, ci) => (
+
+ {ch}
+
+ ))}
+
+ {/* EQ name */}
+
{item.name}
+ {/* copy code button */}
+
void copyToClipboard(item.code)}
+ >
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/client/src/pages/eq/hooks/useRawCurve.ts b/client/src/pages/eq/hooks/useRawCurve.ts
new file mode 100644
index 0000000..aa321a8
--- /dev/null
+++ b/client/src/pages/eq/hooks/useRawCurve.ts
@@ -0,0 +1,102 @@
+import { useState, useRef, useCallback } from "react";
+import { decodeCustomBase64 } from "@/lib/luxsinApi";
+
+/* ── Module-level cache: survives route switches ── */
+const rawCurveCache = new Map();
+
+export function useRawCurve() {
+ const [currentRawCurve, setCurrentRawCurve] = useState(null);
+ const [rawCurveLoading, setRawCurveLoading] = useState(false);
+ const rawCurveCacheRef = useRef(rawCurveCache);
+
+ const getModelCurve = async (brand: string, name: string) => {
+ try {
+ console.log("[modelCurve] request", { brand, name });
+ const resp = await fetch(
+ `//api.luxsin.com.cn/audio/modelCurve?brand=${encodeURIComponent(brand)}&name=${encodeURIComponent(name)}`
+ );
+ const data = await resp.text();
+ // 使用自定义 Base64 解码
+ const decoded = decodeCustomBase64(data);
+ const parsed = JSON.parse(decoded);
+ console.log("[modelCurve] decoded", {
+ hasFrRaw:
+ !!(parsed &&
+ typeof parsed === "object" &&
+ "fr" in (parsed as Record) &&
+ (parsed as { fr?: unknown }).fr &&
+ typeof (parsed as { fr?: unknown }).fr === "object" &&
+ "raw" in ((parsed as { fr?: Record }).fr ?? {})),
+ });
+ console.log("[modelCurve] fr.raw", (parsed as { fr?: { raw?: unknown } }).fr?.raw);
+ return parsed;
+ } catch (error) {
+ console.log("[modelCurve] request failed", error);
+ return null;
+ }
+ };
+
+ const loadRawCurveForPeq = useCallback(
+ async (peq: { brand?: string; model?: string; name?: string } | undefined): Promise => {
+ const brand = peq?.brand?.trim() ?? "";
+ const model = peq?.model?.trim() ?? "";
+ if (!brand || !model) return null;
+
+ const cacheKey = `${brand}|${model}`;
+ if (rawCurveCacheRef.current.has(cacheKey)) {
+ const cached = rawCurveCacheRef.current.get(cacheKey);
+ if (cached) {
+ console.log("[modelCurve] cache hit", { brand, model, rawLength: cached.length });
+ } else {
+ console.log("[modelCurve] cache hit (no raw)", { brand, model });
+ }
+ return cached ?? null;
+ }
+
+ const modelCurve = await getModelCurve(brand, model);
+ if (modelCurve && typeof modelCurve === "object") {
+ const payload = modelCurve as {
+ fr?: { raw?: unknown } | unknown;
+ };
+ const rawCandidate =
+ payload.fr && typeof payload.fr === "object" && Array.isArray((payload.fr as { raw?: unknown }).raw)
+ ? (payload.fr as { raw?: unknown }).raw
+ : null;
+ const raw = Array.isArray(rawCandidate)
+ ? rawCandidate
+ .map((point) => {
+ if (typeof point === "number" && Number.isFinite(point)) return point;
+ if (Array.isArray(point) && point.length >= 2 && typeof point[1] === "number") return point[1];
+ if (point && typeof point === "object") {
+ const y = (point as Record).y
+ ?? (point as Record).value
+ ?? (point as Record).db
+ ?? (point as Record).gain;
+ if (typeof y === "number" && Number.isFinite(y)) return y;
+ }
+ return NaN;
+ })
+ : null;
+ const cleanedRaw = raw && raw.every((v) => Number.isFinite(v)) ? (raw as number[]) : null;
+ rawCurveCacheRef.current.set(cacheKey, cleanedRaw);
+ console.log("[modelCurve] raw check", {
+ brand,
+ model,
+ hasRaw: !!cleanedRaw,
+ rawLength: cleanedRaw?.length ?? 0,
+ });
+ return cleanedRaw;
+ }
+ return null;
+ },
+ [],
+ );
+
+ return {
+ currentRawCurve,
+ setCurrentRawCurve,
+ rawCurveLoading,
+ setRawCurveLoading,
+ loadRawCurveForPeq,
+ };
+}