分享码功能的实现,并修复了几个 bug

This commit is contained in:
eafonyang
2026-06-15 18:00:06 +08:00
parent b620805e13
commit b421fe1ca3
12 changed files with 756 additions and 222 deletions
+18 -8
View File
@@ -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(
+317 -102
View File
@@ -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<void>;
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<number | null>(null);
@@ -215,6 +225,29 @@ export default function EQPage() {
return false;
};
const promptOverwriteIfNeeded = useCallback(
(name: string, action: () => void | Promise<void>): Promise<boolean> => {
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→peqChangeB→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() {
/>
<BrandDrawer
key={brandDrawerKey}
key={`brand-drawer-${brandDrawerKey}`}
open={isBrandDrawerOpen}
onClose={() => setIsBrandDrawerOpen(false)}
eqUi={eqUi}
@@ -1540,7 +1717,9 @@ export default function EQPage() {
const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : 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);
}}
/>
<PeqOverwriteConfirmDialog
open={!!overwritePresetDialog}
title={eqUi.overwritePresetTitle ?? "Preset name already exists"}
description={
overwritePresetDialog
? eqInterp(eqUi.overwritePresetDesc, { name: overwritePresetDialog.name })
: ""
}
cancelLabel={eqUi.cancel}
confirmLabel={eqUi.overwritePresetConfirm ?? eqUi.confirmButton ?? eqUi.save}
onConfirm={() => {
void overwritePresetDialog?.onConfirm();
}}
onCancel={() => {
overwritePresetDialog?.onDismiss();
}}
/>
<ShareDialog
key={shareDialogKey}
key={`share-dialog-${shareDialogKey}`}
open={isShareDialogOpen}
onClose={() => 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}
/>
<BottomNav />
+13 -4
View File
@@ -24,7 +24,7 @@ export function BrandDrawer({
open: boolean;
onClose: () => void;
eqUi: PeqEqUi;
onConfirm: (brand: string, name: string, target: string, form?: string) => Promise<void>;
onConfirm: (brand: string, name: string, target: string, form?: string) => Promise<boolean>;
}) {
const [brandDrawerTab, setBrandDrawerTab] = useState<BrandDrawerTab>("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 {
+4 -4
View File
@@ -327,7 +327,7 @@ export function FreqChart({
>
{/* dB grid */}
{gainLabels.map((g) => (
<g key={g}>
<g key={`gain-${g}`}>
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)}
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
@@ -337,7 +337,7 @@ export function FreqChart({
))}
{/* Freq grid */}
{freqLabels.map((f) => (
<line key={f} x1={freqToX(f)} y1="0" x2={freqToX(f)} y2={H - 12}
<line key={`freq-line-${f}`} x1={freqToX(f)} y1="0" x2={freqToX(f)} y2={H - 12}
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
))}
{/* Zero line */}
@@ -385,7 +385,7 @@ export function FreqChart({
{/* Band nodes with index */}
{showEq && bands.map((band, i) => (
<g
key={i}
key={`band-${i}`}
style={{ cursor: "grab" }}
onPointerDown={(e) => handleBandPointerDown(i, e)}
>
@@ -430,7 +430,7 @@ export function FreqChart({
))}
{/* Freq axis labels */}
{freqLabels.map((f) => (
<text key={f} x={freqToX(f)} y={H - 1} textAnchor="middle"
<text key={`freq-text-${f}`} x={freqToX(f)} y={H - 1} textAnchor="middle"
fontSize="7" fill="rgba(255,255,255,0.25)">
{f >= 1000 ? `${f / 1000}k` : f}
</text>
@@ -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 (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<AlertDialogPortal>
<AlertDialogOverlay className={cn(EQ_OVERWRITE_DIALOG_Z, "bg-black/65")} />
<AlertDialogPrimitive.Content
className={cn(
EQ_OVERWRITE_DIALOG_Z,
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[42%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-md border-white/10 bg-zinc-900 text-white",
)}
>
<AlertDialogHeader>
<AlertDialogTitle className="text-white">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className="border-white/20 bg-transparent text-white hover:bg-white/10"
onClick={onCancel}
>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:brightness-95 focus-visible:ring-[#00FFF6]"
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
);
}
+130 -94
View File
@@ -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<void>;
}) {
const [shareDialogTab, setShareDialogTab] = useState<"share" | "import" | "myShares">("share");
const [shareSelectedIdx, setShareSelectedIdx] = useState<number>(headphoneIdx);
@@ -51,8 +56,12 @@ export function ShareDialog({
const [shareGenerating, setShareGenerating] = useState(false);
const [importCodeInputs, setImportCodeInputs] = useState<string[]>(["", "", "", "", ""]);
const [importQuerying, setImportQuerying] = useState(false);
const [importSaving, setImportSaving] = useState(false);
const [importPresetName, setImportPresetName] = useState("");
const [queriedShareCode, setQueriedShareCode] = useState("");
const [importedEqData, setImportedEqData] = useState<ImportedEqData | null>(null);
const [mySharesList, setMySharesList] = useState<Array<{ code: string; name: string }>>([]);
const [shareCodeExpireAt, setShareCodeExpireAt] = useState<string | null>(null);
const [mySharesList, setMySharesList] = useState<Array<{ share_code: string; expire_at: string; eq_data: Record<string, unknown> }>>([]);
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<string, unknown> = {
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({
<Copy size={16} />
</button>
</div>
{/* expiration time */}
{shareCodeExpireAt && (
<p className="mt-2 text-[11px] text-white/30">{eqUi.shareExpireAt}: {shareCodeExpireAt}</p>
)}
</div>
)}
</>
@@ -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 && (
<div className="mt-4 rounded-[10px] p-3" style={{ background: "rgba(0,255,246,0.06)", border: "1px solid rgba(0,255,246,0.2)" }}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-[12px] text-white/40 font-medium mb-0.5">{eqUi.importEqName}</p>
<p className="text-[14px] text-white/90 font-semibold truncate">{importedEqData.name}</p>
</div>
<button
type="button"
className="shrink-0 ml-3 rounded-full px-4 py-2 text-[13px] font-semibold text-black bg-[#00FFF6] hover:brightness-95 active:scale-[0.98] transition-all"
onClick={() => {
// ── TODO: 伪代码 — 导入 EQ 到预设列表 ──
// onImportEq will handle the parent-level state changes
onImportEq(importedEqData);
}}
>
{eqUi.importButton}
</button>
<div className="mt-4 rounded-[10px] p-3 space-y-3" style={{ background: "rgba(0,255,246,0.06)", border: "1px solid rgba(0,255,246,0.2)" }}>
<div>
<p className="text-[12px] text-white/40 font-medium mb-1.5">{eqUi.importEqName}</p>
<input
value={importPresetName}
onChange={(e) => 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}
/>
</div>
<button
type="button"
disabled={importSaving || !importPresetName.trim()}
className={cn(
"w-full rounded-full py-2.5 text-[13px] font-semibold text-black transition-all active:scale-[0.98]",
importSaving || !importPresetName.trim()
? "bg-[#00FFF6]/40 cursor-not-allowed"
: "bg-[#00FFF6] hover:brightness-95",
)}
onClick={() => {
if (importSaving || !importedEqData || !queriedShareCode) return;
const nextName = importPresetName.trim();
if (!nextName) return;
setImportSaving(true);
void Promise.resolve(
onImportEq({ ...importedEqData, name: nextName }, queriedShareCode),
).finally(() => {
setImportSaving(false);
});
}}
>
{importSaving ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
{eqUi.importSaving}
</span>
) : (
eqUi.importButton
)}
</button>
</div>
)}
</>
@@ -471,7 +498,7 @@ export function ShareDialog({
>
{mySharesList.map((item, idx) => (
<div
key={`${item.code}-${idx}`}
key={`${item.share_code}-${idx}`}
className="flex items-center gap-3 px-3 py-2.5 border-b border-white/[0.06] last:border-b-0"
>
{/* 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) => (
<span
key={ci}
className="w-[18px] h-[22px] rounded-[4px] flex items-center justify-center text-[11px] font-bold text-[#00FFF6]"
@@ -489,14 +516,23 @@ export function ShareDialog({
</span>
))}
</div>
{/* EQ name */}
<div className="min-w-0 flex-1 truncate text-[13px] text-white/85">{item.name}</div>
{/* EQ name + expiration */}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white/85">
{(item.eq_data?.name as string) ?? "—"}
</div>
{item.expire_at && (
<p className="mt-0.5 truncate text-[11px] text-white/35">
{eqUi.shareExpireAt}: {item.expire_at}
</p>
)}
</div>
{/* copy code button */}
<button
type="button"
className="shrink-0 w-7 h-7 rounded-[6px] flex items-center justify-center text-[#00FFF6]/70 active:scale-95 transition-transform"
style={{ background: "rgba(0,255,246,0.08)", border: "1px solid rgba(0,255,246,0.15)" }}
onClick={() => void copyToClipboard(item.code)}
onClick={() => void copyToClipboard(item.share_code)}
>
<Copy size={13} />
</button>
+14
View File
@@ -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<string, unknown>;
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;