From b421fe1ca3495c4f66ed8073bb0cf0006bf5c746 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Mon, 15 Jun 2026 18:00:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E4=BA=AB=E7=A0=81=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E7=8E=B0=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=86=E5=87=A0=E4=B8=AA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/lib/aiApi.ts | 23 +- client/src/lib/luxsinApi.ts | 140 ++++++ client/src/locales/data-en.json | 11 +- client/src/locales/data-zh-HK.json | 11 +- client/src/locales/data-zh.json | 11 +- client/src/pages/AIPage.tsx | 26 +- client/src/pages/EQPage.tsx | 419 +++++++++++++----- .../src/pages/eq/components/BrandDrawer.tsx | 17 +- client/src/pages/eq/components/FreqChart.tsx | 8 +- .../components/PeqOverwriteConfirmDialog.tsx | 74 ++++ .../src/pages/eq/components/ShareDialog.tsx | 224 ++++++---- client/src/pages/eq/utils.ts | 14 + 12 files changed, 756 insertions(+), 222 deletions(-) create mode 100644 client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx diff --git a/client/src/lib/aiApi.ts b/client/src/lib/aiApi.ts index 15e8e17..d4df4da 100644 --- a/client/src/lib/aiApi.ts +++ b/client/src/lib/aiApi.ts @@ -7,6 +7,7 @@ import { LuxsinAPI, PeqFilter, FILTER_TYPE_LABELS, + buildPeqPresetBody, dbToVolume, volumeToDb, } from "./luxsinApi"; @@ -34,6 +35,8 @@ export interface OptimizePeqPayload { name?: string; brand?: string; model?: string; + target?: string; + form?: string; preamp?: number; canDel?: number; autoPre?: number; @@ -327,13 +330,19 @@ export async function executeFrontendTool( case "set_peq": { const p = block.input ?? {}; await api.upgradePeqChange({ - peqChange: { - name: p.name, - filters: p.filters ?? [], - autoPre: p.autoPre, - preamp: p.preamp, - canDel: p.canDel, - }, + peqChange: buildPeqPresetBody( + { + name: p.name, + brand: p.brand, + model: p.model, + target: p.target, + form: p.form, + autoPre: p.autoPre, + preamp: p.preamp, + canDel: p.canDel, + }, + (p.filters ?? []) as PeqFilter[], + ), }); await api.refreshState(); return { ok: true, content: { ok: true, name: p.name } }; diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 17286e3..3fed8e0 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -145,6 +145,7 @@ export interface PeqState { canDel?: number; brand?: string; model?: string; + target?: string; form?: string; }>; } @@ -162,6 +163,38 @@ export interface PeqPresetBody { form?: string; } +/** PEQ preset metadata used when building peqChange / peqApply bodies. */ +export type PeqPresetMeta = { + name: string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; +}; + +/** Build device PEQ POST body; optional brand/model/target/form included only when non-empty. */ +export function buildPeqPresetBody(peq: PeqPresetMeta, filters: PeqFilter[]): PeqPresetBody { + const body: PeqPresetBody = { + name: peq.name, + filters, + autoPre: peq.autoPre ?? 0, + preamp: peq.preamp ?? 0, + canDel: peq.canDel ?? 1, + }; + const brand = peq.brand?.trim(); + const model = peq.model?.trim(); + const target = peq.target?.trim(); + const form = peq.form?.trim(); + if (brand) body.brand = brand; + if (model) body.model = model; + if (target) body.target = target; + if (form) body.form = form; + return body; +} + /** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */ export interface PeqChangePayload { peqChange: PeqPresetBody; @@ -635,3 +668,110 @@ export async function fetchLuxsinAudioCurve( const body = (await res.text()).trim(); return decodeCustomBase64(body); } + +// ============================================================ +// Share Code API — shareCreate / shareList / shareQuery / shareAccept +// ============================================================ + +export interface ShareCodeCreateResponse { + code: number; + msg: string; + share_code?: string; + expire_at?: string; + eq_data?: Record; +} + +export interface ShareCodeListItem { + share_code: string; + expire_at: string; + eq_data: Record; +} + +export interface ShareCodeListResponse { + code: number; + msg: string; + share_codes: ShareCodeListItem[]; +} + +export interface ShareCodeAcceptResponse { + code: number; + msg: string; + eq_data?: Record; +} + +export interface ShareCodeQueryResponse { + code: number; + msg: string; + eq_data?: Record; + expire_at?: string; +} + +/** Unwrap stringified PEQ filters (remove JSON escape layers) for API submit. */ +export function normalizePeqFiltersForSubmit(filters: unknown): PeqFilter[] { + if (Array.isArray(filters)) return filters as PeqFilter[]; + if (typeof filters !== "string" || !filters.trim()) return []; + + let current: unknown = filters.trim(); + for (let depth = 0; depth < 3; depth++) { + if (Array.isArray(current)) return current as PeqFilter[]; + if (typeof current !== "string") return []; + try { + current = JSON.parse(current); + } catch { + return []; + } + } + return Array.isArray(current) ? (current as PeqFilter[]) : []; +} + +/** POST `/audio/shareCreate` — 创建 EQ 分享码,返回 share_code + expire_at + eq_data */ +export async function createShareCode( + mac: string, + eqData: Record, +): Promise { + const payload = { + ...eqData, + ...(eqData.filters !== undefined + ? { filters: normalizePeqFiltersForSubmit(eqData.filters) } + : {}), + }; + const res = await fetch(buildLuxsinAudioUrl("shareCreate"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mac, eq_data: payload }), + }); + if (!res.ok) throw new Error(`shareCreate HTTP ${res.status}`); + return (await res.json()) as ShareCodeCreateResponse; +} + +/** GET `/audio/shareList?mac=...` — 查询某 MAC 尚未过期的分享码列表 */ +export async function listShareCodes(mac: string): Promise { + const res = await fetch( + buildLuxsinAudioUrl(`shareList?mac=${encodeURIComponent(mac)}`), + ); + if (!res.ok) throw new Error(`shareList HTTP ${res.status}`); + return (await res.json()) as ShareCodeListResponse; +} + +/** GET `/audio/shareQuery?shareCode=...` — 预览分享码 EQ 数据(不记录导入) */ +export async function queryShareCode(shareCode: string): Promise { + const res = await fetch( + buildLuxsinAudioUrl(`shareQuery?shareCode=${encodeURIComponent(shareCode)}`), + ); + if (!res.ok) throw new Error(`shareQuery HTTP ${res.status}`); + return (await res.json()) as ShareCodeQueryResponse; +} + +/** GET `/audio/shareAccept?mac=...&shareCode=...` — 确认导入分享码并记录流水 */ +export async function acceptShareCode( + mac: string, + shareCode: string, +): Promise { + const res = await fetch( + buildLuxsinAudioUrl( + `shareAccept?mac=${encodeURIComponent(mac)}&shareCode=${encodeURIComponent(shareCode)}`, + ), + ); + if (!res.ok) throw new Error(`shareAccept HTTP ${res.status}`); + return (await res.json()) as ShareCodeAcceptResponse; +} diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index a6fad27..8ebe519 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -287,6 +287,10 @@ "addPresetTitle": "New preset", "addPresetNameEmpty": "Name cannot be empty", "addPresetNameExists": "That name already exists. Choose another.", + "overwritePresetTitle": "Preset name already exists", + "overwritePresetDesc": "A preset named \"{{name}}\" already exists. Overwrite it?", + "overwritePresetConfirm": "Overwrite", + "overwritePresetCancelled": "Cancelled", "toastAddPresetOk": "Preset added", "toastAddPresetFail": "Failed to add preset", "toastPresetLimitReached": "Preset limit reached ({{max}}). Delete some presets before adding more.", @@ -360,6 +364,8 @@ "shareCodeLabel": "Share Code", "shareCopied": "Code copied", "shareGenerating": "Generating…", + "shareCreateFail": "Failed to generate share code", + "shareExpireAt": "Expires at", "importTab": "Import EQ", "importCodeHint": "Enter or paste the 5-character share code", "importQuery": "Query", @@ -367,13 +373,16 @@ "importCodeNotFound": "Share code not found", "importEqName": "EQ Name", "importButton": "Import", + "importSaving": "Importing…", "importSuccess": "EQ imported successfully", + "importFail": "Failed to import EQ", "mySharesTab": "My Shares", "mySharesLoading": "Loading…", "mySharesEmpty": "No shared EQ yet", "mySharesCode": "Code", "mySharesName": "EQ Name", - "mySharesLoadFail": "Failed to load share list" + "mySharesLoadFail": "Failed to load share list", + "importCodeNotFound": "Share code not found or expired" } }, "dac": { diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 2775358..1b5a249 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -223,6 +223,10 @@ "addPresetTitle": "新增預設", "addPresetNameEmpty": "名稱不能為空", "addPresetNameExists": "名稱已存在,請修改後再儲存", + "overwritePresetTitle": "預設名稱已存在", + "overwritePresetDesc": "預設「{{name}}」已存在,確認覆蓋原有資料嗎?", + "overwritePresetConfirm": "覆蓋", + "overwritePresetCancelled": "已取消", "toastAddPresetOk": "已新增預設", "toastAddPresetFail": "新增預設失敗", "toastPresetLimitReached": "預設已達上限({{max}} 個),請先清理後再操作", @@ -296,6 +300,8 @@ "shareCodeLabel": "分享碼", "shareCopied": "已複製分享碼", "shareGenerating": "產生中…", + "shareCreateFail": "產生分享碼失敗", + "shareExpireAt": "有效期至", "importTab": "匯入 EQ", "importCodeHint": "輸入或貼上 5 位分享碼", "importQuery": "查詢", @@ -303,13 +309,16 @@ "importCodeNotFound": "分享碼不存在", "importEqName": "EQ 名稱", "importButton": "匯入", + "importSaving": "匯入中…", "importSuccess": "EQ 匯入成功", + "importFail": "EQ 匯入失敗", "mySharesTab": "我的分享", "mySharesLoading": "載入中…", "mySharesEmpty": "暫無分享記錄", "mySharesCode": "分享碼", "mySharesName": "EQ 名稱", - "mySharesLoadFail": "載入分享列表失敗" + "mySharesLoadFail": "載入分享列表失敗", + "importCodeNotFound": "分享碼不存在或已過期" } }, "dac": { diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 0bd08df..cd1d700 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -223,6 +223,10 @@ "addPresetTitle": "新增预设", "addPresetNameEmpty": "名称不能为空", "addPresetNameExists": "名称已存在,请修改后再保存", + "overwritePresetTitle": "预设名称已存在", + "overwritePresetDesc": "预设「{{name}}」已存在,确认覆盖原有数据吗?", + "overwritePresetConfirm": "覆盖", + "overwritePresetCancelled": "已取消", "toastAddPresetOk": "已新增预设", "toastAddPresetFail": "新增预设失败", "toastPresetLimitReached": "预设已达上限({{max}} 个),请先清理后再操作", @@ -296,6 +300,8 @@ "shareCodeLabel": "分享码", "shareCopied": "已复制分享码", "shareGenerating": "生成中…", + "shareCreateFail": "生成分享码失败", + "shareExpireAt": "有效期至", "importTab": "导入 EQ", "importCodeHint": "输入或粘贴 5 位分享码", "importQuery": "查询", @@ -303,13 +309,16 @@ "importCodeNotFound": "分享码不存在", "importEqName": "EQ 名称", "importButton": "导入", + "importSaving": "导入中…", "importSuccess": "EQ 导入成功", + "importFail": "EQ 导入失败", "mySharesTab": "我的分享", "mySharesLoading": "加载中…", "mySharesEmpty": "暂无分享记录", "mySharesCode": "分享码", "mySharesName": "EQ 名称", - "mySharesLoadFail": "加载分享列表失败" + "mySharesLoadFail": "加载分享列表失败", + "importCodeNotFound": "分享码不存在或已过期" } }, "dac": { diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index ad8fdc0..edd9a38 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -30,6 +30,7 @@ import { } from "@/lib/aiApi"; import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio"; +import { buildPeqPresetBody } from "@/lib/luxsinApi"; import { cn } from "@/lib/utils"; import { ArrowUp, @@ -622,6 +623,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) autoPre: selected?.autoPre, brand: selected?.brand, model: selected?.model, + target: selected?.target, + form: selected?.form, filters: (selectedFilters ?? peqState.filters ?? []) as OptimizePeqPayload["filters"], }); } catch { @@ -641,21 +644,28 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) } const nextApplied = !msg.applied; const targetPeq = nextApplied ? msg.afterPeq : msg.beforePeq; + const metaSource = currentDevicePeq ?? targetPeq; setApplyingId(msg.id); try { await api.upgradePeqChange({ - peqChange: { - name: targetPeq.name ?? msg.name, - filters: (targetPeq.filters ?? []).map((f) => ({ + peqChange: buildPeqPresetBody( + { + name: targetPeq.name ?? msg.name ?? metaSource?.name ?? "", + brand: targetPeq.brand ?? metaSource?.brand, + model: targetPeq.model ?? metaSource?.model, + target: targetPeq.target ?? metaSource?.target, + form: targetPeq.form ?? metaSource?.form, + autoPre: targetPeq.autoPre ?? metaSource?.autoPre, + preamp: targetPeq.preamp ?? metaSource?.preamp, + canDel: targetPeq.canDel ?? metaSource?.canDel, + }, + (targetPeq.filters ?? []).map((f) => ({ type: getFilterType(f.type), fc: (f.fc ?? f.frequency ?? 1000) as number, gain: f.gain, q: f.q, })), - autoPre: targetPeq.autoPre, - preamp: targetPeq.preamp, - canDel: targetPeq.canDel, - }, + ), }); await updateMessageApplied(msg.id, nextApplied); await api.refreshState(); @@ -679,7 +689,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) setApplyingId(null); } }, - [aiText, api, refreshCurrentDevicePeq], + [aiText, api, currentDevicePeq, refreshCurrentDevicePeq], ); const openCompare = useCallback( diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index fa9baae..1156db8 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -23,8 +23,9 @@ import { type PeqFilter, type PeqApplyPayload, type PeqChangePayload, - type PeqPresetBody, type PeqState, + buildPeqPresetBody, + acceptShareCode, } from "@/lib/luxsinApi"; import { getFilterType, @@ -64,6 +65,7 @@ import { getUniquePresetName, bandToPeqFilter, fetchEqSyncPeq, + importedFiltersToPeqFilters, } from "./eq/utils"; import { IOSToggle } from "./eq/components/IOSToggle"; import { CyanSlider } from "./eq/components/CyanSlider"; @@ -74,6 +76,7 @@ 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 { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog"; import { useRawCurve } from "./eq/hooks/useRawCurve"; /* ── Default bands imported from eq/constants ── */ @@ -123,6 +126,7 @@ export default function EQPage() { name: string; brand?: string; model?: string; + target?: string; form?: string; filters?: any[] | string; autoPre?: number; @@ -154,6 +158,11 @@ export default function EQPage() { const [brandDrawerKey, setBrandDrawerKey] = useState(0); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [shareDialogKey, setShareDialogKey] = useState(0); + const [overwritePresetDialog, setOverwritePresetDialog] = useState<{ + name: string; + onConfirm: () => void | Promise; + onDismiss: () => void; + } | null>(null); const { currentRawCurve, setCurrentRawCurve, @@ -162,6 +171,7 @@ export default function EQPage() { loadRawCurveForPeq, } = useRawCurve(); const allowPeqRemoteSyncRef = useRef(false); + const peqHydrationPendingRef = useRef(true); const lastPeqCatalogSyncKeyRef = useRef(""); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); @@ -215,6 +225,29 @@ export default function EQPage() { return false; }; + const promptOverwriteIfNeeded = useCallback( + (name: string, action: () => void | Promise): Promise => { + if (!headphoneModels.includes(name)) { + return Promise.resolve(action()).then(() => true); + } + return new Promise((resolve) => { + setOverwritePresetDialog({ + name, + onConfirm: async () => { + setOverwritePresetDialog(null); + await action(); + resolve(true); + }, + onDismiss: () => { + setOverwritePresetDialog(null); + resolve(false); + }, + }); + }); + }, + [headphoneModels], + ); + const openAddHeadsetCatalog = () => { if (!guardPeqPresetCapacity()) return; setIsBrandDrawerOpen(true); @@ -510,28 +543,29 @@ export default function EQPage() { const copyBrand = currentPeq?.brand?.trim() ?? ""; const copyModel = currentPeq?.model?.trim() ?? ""; + const copyTarget = currentPeq?.target?.trim() ?? ""; const copyForm = currentPeq?.form?.trim() ?? ""; const copyPayload: PeqChangePayload = { - peqChange: { - name: nextName, - ...(copyBrand ? { brand: copyBrand } : {}), - ...(copyModel ? { model: copyModel } : {}), - ...(copyForm ? { form: copyForm } : {}), - filters: bands.map(bandToPeqFilter), - autoPre: currentPeq?.autoPre ?? 0, - preamp: currentPeq?.preamp ?? 0, - canDel: currentPeq?.canDel ?? 1, - }, + peqChange: buildPeqPresetBody( + { + name: nextName, + brand: copyBrand || undefined, + model: copyModel || undefined, + target: copyTarget || undefined, + form: copyForm || undefined, + autoPre: currentPeq?.autoPre, + preamp: currentPeq?.preamp, + canDel: currentPeq?.canDel, + }, + bands.map(bandToPeqFilter), + ), }; const flatPayload: PeqChangePayload = { - peqChange: { - name: nextName, - preamp: 0, - canDel: 1, - autoPre: 0, - filters: FLAT_PRESET_FILTERS, - }, + peqChange: buildPeqPresetBody( + { name: nextName, preamp: 0, canDel: 1, autoPre: 0 }, + FLAT_PRESET_FILTERS, + ), }; try { @@ -557,6 +591,7 @@ export default function EQPage() { name: nextName, ...(addPresetMode === "copy" && copyBrand ? { brand: copyBrand } : {}), ...(addPresetMode === "copy" && copyModel ? { model: copyModel } : {}), + ...(addPresetMode === "copy" && copyTarget ? { target: copyTarget } : {}), ...(addPresetMode === "copy" && copyForm ? { form: copyForm } : {}), filters: localFilters, autoPre: localAutoPre, @@ -587,13 +622,19 @@ export default function EQPage() { const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { - peqChange: { - name: nextName, + peqChange: buildPeqPresetBody( + { + name: nextName, + brand: currentPeq?.brand, + model: currentPeq?.model, + target: currentPeq?.target, + form: currentPeq?.form, + autoPre: currentPeq?.autoPre, + preamp: currentPeq?.preamp, + canDel: currentPeq?.canDel, + }, filters, - autoPre: currentPeq?.autoPre ?? 0, - preamp: currentPeq?.preamp ?? 0, - canDel: currentPeq?.canDel ?? 1, - }, + ), }; try { @@ -627,6 +668,125 @@ export default function EQPage() { } }; + const handleImportSharedEq = async ( + data: { + name: string; + brand?: string; + model?: string; + target?: string; + form?: string; + filters?: unknown; + preamp?: number; + autoPre?: number; + }, + shareCode: string, + ) => { + const nextName = data.name?.trim(); + if (!nextName) { + toast.error(eqUi.addPresetNameEmpty); + return; + } + if (!shareCode.trim()) { + toast.error(eqUi.importCodeNotFound); + return; + } + if (!guardPeqPresetCapacity() && !headphoneModels.includes(nextName)) return; + + const filters = importedFiltersToPeqFilters(data.filters); + if (filters.length === 0) { + toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail); + return; + } + + const submitImport = async () => { + const existingIdx = headphoneModels.indexOf(nextName); + const isOverwrite = existingIdx >= 0; + const payload: PeqChangePayload = { + peqChange: buildPeqPresetBody( + { + name: nextName, + brand: data.brand, + model: data.model, + target: data.target, + form: data.form, + preamp: data.preamp, + autoPre: data.autoPre ?? 0, + canDel: 1, + }, + filters, + ), + }; + + skipPeqAutoSyncRef.current = true; + try { + const mac = deviceState?.mac ?? ""; + if (!isDemoMode && mac) { + const acceptRes = await acceptShareCode(mac, shareCode.trim()); + if (acceptRes.code !== 200) { + toast.error(eqUi.importFail ?? eqUi.importCodeNotFound); + skipPeqAutoSyncRef.current = false; + return; + } + } + + await upgradePeqChange(payload); + if (api && !isDemoMode) { + const latest = await fetchEqSyncPeq(api, "importSharedEq"); + applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + const targetIdx = latest.peq?.findIndex((item) => item.name === nextName) ?? -1; + if (targetIdx >= 0) { + setHeadphoneIdx(targetIdx); + updateSetting({ peqSelect: targetIdx }); + } + } else if (isOverwrite) { + setPeqItems((prev) => + prev.map((item, i) => + i === existingIdx + ? { + ...item, + name: nextName, + brand: data.brand, + model: data.model, + target: data.target, + form: data.form, + filters, + preamp: data.preamp ?? 0, + autoPre: data.autoPre ?? 0, + canDel: 1, + } + : item, + ), + ); + setHeadphoneIdx(existingIdx); + } else { + setHeadphoneModels((prev) => [...prev, nextName]); + setPeqItems((prev) => [ + ...prev, + { + name: nextName, + brand: data.brand, + model: data.model, + target: data.target, + form: data.form, + filters, + preamp: data.preamp ?? 0, + autoPre: data.autoPre ?? 0, + canDel: 1, + }, + ]); + setHeadphoneIdx(headphoneModels.length); + } + toast.success(eqUi.importSuccess); + setIsShareDialogOpen(false); + } catch { + skipPeqAutoSyncRef.current = false; + toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail); + } + }; + + await promptOverwriteIfNeeded(nextName, submitImport); + }; + // msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项 useEffect(() => { if (!peqState?.peq) return; @@ -644,6 +804,7 @@ export default function EQPage() { // 加载耳机列表并初始化曲线 useEffect(() => { async function loadHeadphones() { + peqHydrationPendingRef.current = true; allowPeqRemoteSyncRef.current = false; try { if (isDemoMode || !api) { @@ -720,6 +881,10 @@ export default function EQPage() { } } finally { allowPeqRemoteSyncRef.current = true; + skipPeqAutoSyncRef.current = true; + queueMicrotask(() => { + peqHydrationPendingRef.current = false; + }); } } loadHeadphones(); @@ -739,6 +904,7 @@ export default function EQPage() { const nextBands = normalizeFiltersFromPeq(peq); if (!nextBands.length) return; syncingHeadphoneRef.current = true; + skipPeqAutoSyncRef.current = true; setBandsForBothModes(nextBands); setSelectedBandByMode({ A: 0, B: 0 }); const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim()); @@ -835,7 +1001,16 @@ export default function EQPage() { const schedulePeqSync = useCallback( ( - peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined, + peq: { + name: string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; + } | undefined, filtersSource: typeof DEFAULT_BANDS, mode: "A" | "B", delay = 320, @@ -843,6 +1018,7 @@ export default function EQPage() { catalogIdx = headphoneIdx, ) => { if (!allowPeqRemoteSyncRef.current) return; + if (peqHydrationPendingRef.current) return; if (!isDemoMode && !api) return; if (!peq?.name) return; @@ -853,13 +1029,7 @@ export default function EQPage() { const idxAtSchedule = catalogIdx; peqSyncTimerRef.current = window.setTimeout(() => { const filters = filtersSource.map(bandToPeqFilter); - const body: PeqPresetBody = { - name: peq.name, - filters, - autoPre: peq.autoPre ?? 0, - preamp: peq.preamp ?? 0, - canDel: peq.canDel ?? 1, - }; + const body = buildPeqPresetBody(peq, filters); const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); if (usePeqChange) { @@ -904,6 +1074,7 @@ export default function EQPage() { // 参数编辑防抖:A→peqChange,B→peqApply useEffect(() => { + if (peqHydrationPendingRef.current) return; if (syncingHeadphoneRef.current) return; if (skipPeqAutoSyncRef.current) { skipPeqAutoSyncRef.current = false; @@ -949,13 +1120,19 @@ export default function EQPage() { const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { - peqChange: { - name: peq.name, + peqChange: buildPeqPresetBody( + { + name: peq.name, + brand: peq.brand, + model: peq.model, + target: peq.target, + form: peq.form, + autoPre: peq.autoPre, + preamp: peq.preamp, + canDel: peq.canDel, + }, filters, - autoPre: peq.autoPre ?? 0, - preamp: peq.preamp ?? 0, - canDel: peq.canDel ?? 1, - }, + ), }; skipPeqAutoSyncRef.current = true; @@ -1525,7 +1702,7 @@ export default function EQPage() { /> setIsBrandDrawerOpen(false)} eqUi={eqUi} @@ -1540,7 +1717,9 @@ export default function EQPage() { const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record) : null; const parametricEqRaw = parsedObj?.parametric_eq; - if (!parametricEqRaw || typeof parametricEqRaw !== "object") return; + if (!parametricEqRaw || typeof parametricEqRaw !== "object") { + throw new Error("parametric_eq missing"); + } const parametricEq = parametricEqRaw as { filters?: Array<{ type: string | number; fc: number; gain: number; q: number }>; @@ -1556,86 +1735,122 @@ export default function EQPage() { q: Number(Number(item.q).toFixed(2)), })); + const createdName = `${brand} ${name}`; 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 fetchEqSyncPeq(api, "addCatalogPreset"); - 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, + peqChange: buildPeqPresetBody( { 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, + form, + preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)), + autoPre: 0, + canDel: 1, }, - ]); - setHeadphoneModels((prev) => { - nextIndex = prev.length; - return [...prev, createdName]; - }); - setHeadphoneIdx(nextIndex); + filters, + ), + }; + + const submitCatalogPreset = async () => { + const existingIdx = headphoneModels.indexOf(createdName); + const isOverwrite = existingIdx >= 0; + if (!isOverwrite && !guardPeqPresetCapacity()) return; + skipPeqAutoSyncRef.current = true; + try { + await upgradePeqChange(postPeq); + if (api && !isDemoMode) { + const latest = await fetchEqSyncPeq(api, "addCatalogPreset"); + applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number }); + const targetIdx = latest.peq?.findIndex((item) => item.name === createdName) ?? -1; + if (targetIdx >= 0) { + setHeadphoneIdx(targetIdx); + updateSetting({ peqSelect: targetIdx }); + } + } else if (isOverwrite) { + setPeqItems((prev) => + prev.map((item, i) => + i === existingIdx + ? { + ...item, + 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, + } + : item, + ), + ); + setHeadphoneIdx(existingIdx); + } 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); + } catch { + skipPeqAutoSyncRef.current = false; + toast.error(eqUi.toastAddPresetFail); + } + }; + + if (headphoneModels.includes(createdName)) { + setIsBrandDrawerOpen(false); } + return promptOverwriteIfNeeded(createdName, submitCatalogPreset); + }} + /> + + { + void overwritePresetDialog?.onConfirm(); + }} + onCancel={() => { + overwritePresetDialog?.onDismiss(); }} /> setIsShareDialogOpen(false)} peqItems={peqItems} headphoneIdx={headphoneIdx} eqUi={eqUi} peqCardLabels={peqCardLabels} - onImportEq={(data) => { - // ── TODO: 伪代码 — 导入 EQ 到预设列表 ── - // if (!guardPeqPresetCapacity()) return; - // setPeqItems((prev) => [ - // ...prev, - // { - // name: data.name, - // brand: data.brand, - // model: data.model, - // form: data.form, - // filters: data.filters, - // preamp: data.preamp, - // autoPre: 0, - // canDel: 1, - // }, - // ]); - // setHeadphoneIdx(peqItems.length); - // toast.success(eqUi.importSuccess); - - // 模拟导入 - toast.success(eqUi.importSuccess); - setIsShareDialogOpen(false); - }} + mac={deviceState?.mac ?? ""} + onImportEq={handleImportSharedEq} /> diff --git a/client/src/pages/eq/components/BrandDrawer.tsx b/client/src/pages/eq/components/BrandDrawer.tsx index 8afe73a..f5eadcd 100644 --- a/client/src/pages/eq/components/BrandDrawer.tsx +++ b/client/src/pages/eq/components/BrandDrawer.tsx @@ -24,7 +24,7 @@ export function BrandDrawer({ open: boolean; onClose: () => void; eqUi: PeqEqUi; - onConfirm: (brand: string, name: string, target: string, form?: string) => Promise; + onConfirm: (brand: string, name: string, target: string, form?: string) => Promise; }) { const [brandDrawerTab, setBrandDrawerTab] = useState("brands"); const [brandSearchQuery, setBrandSearchQuery] = useState(""); @@ -357,9 +357,18 @@ export function BrandDrawer({ if (isConfirmingTarget) return; setIsConfirmingTarget(true); try { - await onConfirm(selectedCatalogBrand, selectedCatalogModelName, selectedCatalogTarget, selectedCatalogModelForm); - onClose(); - toast.success(eqUi.toastNewEqOk); + 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 { diff --git a/client/src/pages/eq/components/FreqChart.tsx b/client/src/pages/eq/components/FreqChart.tsx index b601726..858071e 100644 --- a/client/src/pages/eq/components/FreqChart.tsx +++ b/client/src/pages/eq/components/FreqChart.tsx @@ -327,7 +327,7 @@ export function FreqChart({ > {/* dB grid */} {gainLabels.map((g) => ( - + @@ -337,7 +337,7 @@ export function FreqChart({ ))} {/* Freq grid */} {freqLabels.map((f) => ( - ))} {/* Zero line */} @@ -385,7 +385,7 @@ export function FreqChart({ {/* Band nodes with index */} {showEq && bands.map((band, i) => ( handleBandPointerDown(i, e)} > @@ -430,7 +430,7 @@ export function FreqChart({ ))} {/* Freq axis labels */} {freqLabels.map((f) => ( - {f >= 1000 ? `${f / 1000}k` : f} diff --git a/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx b/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx new file mode 100644 index 0000000..f24b2a7 --- /dev/null +++ b/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx @@ -0,0 +1,74 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; + +/** Above BrandDrawer (z-110) and ShareDialog (z-120). */ +const EQ_OVERWRITE_DIALOG_Z = "z-[130]"; + +export function PeqOverwriteConfirmDialog({ + open, + title, + description, + cancelLabel, + confirmLabel, + onConfirm, + onCancel, +}: { + open: boolean; + title: string; + description: string; + cancelLabel: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + { + if (!next) onCancel(); + }} + > + + + + + {title} + + {description} + + + + + {cancelLabel} + + + {confirmLabel} + + + + + + ); +} diff --git a/client/src/pages/eq/components/ShareDialog.tsx b/client/src/pages/eq/components/ShareDialog.tsx index 800e70e..376135c 100644 --- a/client/src/pages/eq/components/ShareDialog.tsx +++ b/client/src/pages/eq/components/ShareDialog.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { X, Copy, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { createShareCode, listShareCodes, queryShareCode } from "@/lib/luxsinApi"; import type { PeqEqUi } from "../constants"; type PeqItem = { @@ -23,9 +24,11 @@ type ImportedEqData = { name: string; brand?: string; model?: string; + target?: string; form?: string; - filters?: any[] | string; + filters?: unknown; preamp?: number; + autoPre?: number; }; export function ShareDialog({ @@ -35,6 +38,7 @@ export function ShareDialog({ headphoneIdx, eqUi, peqCardLabels, + mac, onImportEq, }: { open: boolean; @@ -43,7 +47,8 @@ export function ShareDialog({ headphoneIdx: number; eqUi: PeqEqUi; peqCardLabels: PeqCardLabels; - onImportEq: (data: ImportedEqData) => void; + mac: string; + onImportEq: (data: ImportedEqData, shareCode: string) => void | Promise; }) { const [shareDialogTab, setShareDialogTab] = useState<"share" | "import" | "myShares">("share"); const [shareSelectedIdx, setShareSelectedIdx] = useState(headphoneIdx); @@ -51,8 +56,12 @@ export function ShareDialog({ const [shareGenerating, setShareGenerating] = useState(false); const [importCodeInputs, setImportCodeInputs] = useState(["", "", "", "", ""]); const [importQuerying, setImportQuerying] = useState(false); + const [importSaving, setImportSaving] = useState(false); + const [importPresetName, setImportPresetName] = useState(""); + const [queriedShareCode, setQueriedShareCode] = useState(""); const [importedEqData, setImportedEqData] = useState(null); - const [mySharesList, setMySharesList] = useState>([]); + const [shareCodeExpireAt, setShareCodeExpireAt] = useState(null); + const [mySharesList, setMySharesList] = useState }>>([]); const [mySharesLoading, setMySharesLoading] = useState(false); if (!open) return null; @@ -131,25 +140,14 @@ export function ShareDialog({ 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" }, - ]); + const res = await listShareCodes(mac); + if (res.code !== 200) { + toast.error(eqUi.mySharesLoadFail); + return; + } + setMySharesList(res.share_codes ?? []); } catch { - // TODO: 错误处理 + toast.error(eqUi.mySharesLoadFail); } finally { setMySharesLoading(false); } @@ -220,33 +218,31 @@ export function ShareDialog({ if (shareGenerating) return; setShareGenerating(true); setShareCode(null); + setShareCodeExpireAt(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); + const selectedPeq = peqItems[shareSelectedIdx]; + if (!selectedPeq) { + toast.error(eqUi.shareSelectHint); + return; + } + const eqData: Record = { + name: selectedPeq.name, + brand: selectedPeq.brand ?? "", + model: selectedPeq.model ?? "", + form: selectedPeq.form ?? "", + filters: selectedPeq.filters ?? [], + autoPre: selectedPeq.autoPre ?? 0, + preamp: selectedPeq.preamp ?? 0, + }; + const res = await createShareCode(mac, eqData); + if (res.code !== 200) { + toast.error(res.msg || eqUi.shareCreateFail); + return; + } + setShareCode(res.share_code ?? ""); + setShareCodeExpireAt(res.expire_at ?? null); } catch { - // TODO: 错误处理 + toast.error(eqUi.shareCreateFail); } finally { setShareGenerating(false); } @@ -293,6 +289,10 @@ export function ShareDialog({ + {/* expiration time */} + {shareCodeExpireAt && ( +

{eqUi.shareExpireAt}: {shareCodeExpireAt}

+ )} )} @@ -326,6 +326,8 @@ export function ShareDialog({ next[i] = val; setImportCodeInputs(next); setImportedEqData(null); + setImportPresetName(""); + setQueriedShareCode(""); // auto-focus next box if (val && i < 4) { const el = e.target.nextElementSibling as HTMLInputElement | null; @@ -339,6 +341,8 @@ export function ShareDialog({ next[i - 1] = ""; setImportCodeInputs(next); setImportedEqData(null); + setImportPresetName(""); + setQueriedShareCode(""); const prev = (e.target as HTMLElement).previousElementSibling as HTMLInputElement | null; prev?.focus(); } @@ -353,6 +357,8 @@ export function ShareDialog({ } setImportCodeInputs(next); setImportedEqData(null); + setImportPresetName(""); + setQueriedShareCode(""); // focus last filled or last box const focusIdx = Math.min(text.length, 4); const inputs = (e.target as HTMLElement).parentElement?.querySelectorAll("input"); @@ -380,36 +386,34 @@ export function ShareDialog({ const code = importCodeInputs.join(""); setImportQuerying(true); setImportedEqData(null); + setImportPresetName(""); + setQueriedShareCode(""); 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 数据 + const res = await queryShareCode(code); + if (res.code !== 200) { + toast.error(eqUi.importCodeNotFound); + return; + } + const eq = res.eq_data; + if (!eq) { + toast.error(eqUi.importCodeNotFound); + return; + } + const fetchedName = (eq.name as string) || ""; setImportedEqData({ - name: "Sennheiser HD 600 (AutoEQ)", - brand: "Sennheiser", - model: "HD 600", - form: "over-ear", - filters: [], - preamp: -5.2, + name: fetchedName, + brand: (eq.brand as string) || undefined, + model: (eq.model as string) || undefined, + target: (eq.target as string) || undefined, + form: (eq.form as string) || undefined, + filters: eq.filters, + preamp: eq.preamp as number | undefined, + autoPre: eq.autoPre as number | undefined, }); + setImportPresetName(fetchedName); + setQueriedShareCode(code); } catch { - // TODO: 错误处理 + toast.error(eqUi.importCodeNotFound); } finally { setImportQuerying(false); } @@ -427,24 +431,47 @@ export function ShareDialog({ {/* imported EQ result */} {importedEqData && ( -
-
-
-

{eqUi.importEqName}

-

{importedEqData.name}

-
- +
+
+

{eqUi.importEqName}

+ setImportPresetName(e.target.value)} + disabled={importSaving} + className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40 disabled:opacity-60" + placeholder={eqUi.importEqName} + />
+
)} @@ -471,7 +498,7 @@ export function ShareDialog({ > {mySharesList.map((item, idx) => (
{/* share code */} @@ -479,7 +506,7 @@ export function ShareDialog({ className="shrink-0 flex gap-0.5" title={eqUi.mySharesCode} > - {item.code.split("").map((ch, ci) => ( + {item.share_code.split("").map((ch: string, ci: number) => ( ))}
- {/* EQ name */} -
{item.name}
+ {/* EQ name + expiration */} +
+
+ {(item.eq_data?.name as string) ?? "—"} +
+ {item.expire_at && ( +

+ {eqUi.shareExpireAt}: {item.expire_at} +

+ )} +
{/* copy code button */} diff --git a/client/src/pages/eq/utils.ts b/client/src/pages/eq/utils.ts index 45f0ed8..f33e04f 100644 --- a/client/src/pages/eq/utils.ts +++ b/client/src/pages/eq/utils.ts @@ -1,4 +1,5 @@ import type { PeqFilter, PeqState } from "@/lib/luxsinApi"; +import { normalizePeqFiltersForSubmit } from "@/lib/luxsinApi"; import { getFilterShortName, getFilterType } from "@/lib/peqAudio"; import { BAND_FREQ_MIN, @@ -197,6 +198,19 @@ export function getUniquePresetName( return `${base}_${index}`; } +/** Normalize shared/imported EQ filters into device PeqFilter[]. */ +export function importedFiltersToPeqFilters(filters: unknown): PeqFilter[] { + return normalizePeqFiltersForSubmit(filters).map((f) => { + const item = f as Record; + return { + fc: Number(item.fc ?? item.freq ?? item.frequency ?? 1000), + gain: Number(item.gain ?? 0), + q: Number(item.q ?? 1), + type: getFilterType(item.type as string | number), + }; + }); +} + /* ── Convert UI band → device PeqFilter ── */ export function bandToPeqFilter(b: { freq: number;