清理项目没用的代码,新增 EQ 预设管理界面

This commit is contained in:
eafonyang
2026-06-18 12:01:12 +08:00
parent 0d577f939c
commit e16c3ebd9d
13 changed files with 460 additions and 130 deletions
-17
View File
@@ -1,17 +0,0 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const appId = import.meta.env.VITE_APP_ID;
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);
const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId);
url.searchParams.set("redirectUri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("type", "signIn");
return url.toString();
};
+6
View File
@@ -308,6 +308,12 @@
"toastNewEqOk": "New headphone EQ saved",
"toastTargetSelected": "Target selected: {{name}}",
"deleteConfirm": "Delete headphone \"{{name}}\"?",
"batchDeleteConfirm": "Delete {{count}} selected preset(s)?",
"deletePresetConfirm": "Delete preset \"{{name}}\"?",
"toastRenamed": "Renamed",
"toastRenameFail": "Rename failed",
"renameEmptyName": "Name cannot be empty",
"renameNameExists": "Name already exists",
"toastDeleted": "Headphone removed",
"toastDeleteFail": "Failed to delete headphone",
"bassBoost": "Bass boost: fc {{fc}}, q {{q}}, gain {{gain}} dB",
+6
View File
@@ -308,6 +308,12 @@
"toastNewEqOk": "新耳機 EQ 已上報",
"toastTargetSelected": "已選目標:{{name}}",
"deleteConfirm": "是否刪除耳機「{{name}}」?",
"batchDeleteConfirm": "是否刪除選中的 {{count}} 個預設?",
"deletePresetConfirm": "是否刪除預設「{{name}}」?",
"toastRenamed": "已重新命名",
"toastRenameFail": "重新命名失敗",
"renameEmptyName": "名稱不能為空",
"renameNameExists": "名稱已存在",
"toastDeleted": "已刪除耳機",
"toastDeleteFail": "刪除耳機失敗",
"bassBoost": "低頻增強:fc {{fc}}q {{q}}gain {{gain}}dB",
+6
View File
@@ -308,6 +308,12 @@
"toastNewEqOk": "新耳机 EQ 已上报",
"toastTargetSelected": "已选目标:{{name}}",
"deleteConfirm": "是否删除耳机「{{name}}」?",
"batchDeleteConfirm": "是否删除选中的 {{count}} 个预设?",
"deletePresetConfirm": "是否删除预设「{{name}}」?",
"toastRenamed": "已重命名",
"toastRenameFail": "重命名失败",
"renameEmptyName": "名称不能为空",
"renameNameExists": "名称已存在",
"toastDeleted": "已删除耳机",
"toastDeleteFail": "删除耳机失败",
"bassBoost": "低频增强:fc {{fc}}q {{q}}gain {{gain}}dB",
+90
View File
@@ -20,6 +20,7 @@ import { FeatureGate } from "@/components/FeatureGate";
import { toast } from "sonner";
import {
fetchLuxsinAudioCurve,
normalizePeqFiltersForSubmit,
type PeqFilter,
type PeqApplyPayload,
type PeqChangePayload,
@@ -39,6 +40,7 @@ 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 { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
import { useRawCurve } from "./eq/hooks/useRawCurve";
import {
BAND_FREQ_MAX,
@@ -151,6 +153,7 @@ export default function EQPage() {
const [brandDrawerKey, setBrandDrawerKey] = useState(0);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [shareDialogKey, setShareDialogKey] = useState(0);
const [isPresetManageDialogOpen, setIsPresetManageDialogOpen] = useState(false);
const [overwritePresetDialog, setOverwritePresetDialog] = useState<{
name: string;
onConfirm: () => void | Promise<void>;
@@ -297,6 +300,10 @@ export default function EQPage() {
setIsBatchEditDialogOpen(true);
};
const openPresetManageDialog = () => {
setIsPresetManageDialogOpen(true);
};
const parseBatchEditText = useCallback(
(raw: string) => {
const t = eqUi;
@@ -1406,6 +1413,13 @@ export default function EQPage() {
)}
</div>
<div className="flex items-center gap-2">
<button
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
onClick={openPresetManageDialog}
>
<Edit3 size={15} />
</button>
<button
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
@@ -1812,6 +1826,82 @@ export default function EQPage() {
}}
/>
<PeqPresetManageDialog
open={isPresetManageDialogOpen}
items={peqItems}
selectedIdx={headphoneIdx}
onClose={() => setIsPresetManageDialogOpen(false)}
eqUi={eqUi}
onDeletePresets={async (names) => {
try {
if (isDemoMode || !api) {
const nextItems = peqItems.filter(it => !names.includes(it.name));
names.forEach(n => delete peqPresetCacheRef.current[n]);
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.min(headphoneIdx, Math.max(0, nextItems.length - 1)) });
toast.success(eqUi.toastDeleted);
return true;
}
names.forEach(n => delete peqPresetCacheRef.current[n]);
await api.removePeq(names);
const latest = await fetchEqSyncPeq(api, "deletePresets");
applyPeqStateToUI(latest);
toast.success(eqUi.toastDeleted);
return true;
} catch {
toast.error(eqUi.toastDeleteFail);
return false;
}
}}
onRenamePreset={async (oldName, newName, item) => {
try {
const rawFilters = normalizePeqFiltersForSubmit(item.filters);
const filters = rawFilters.map(f => ({
...f,
type: getFilterType(f.type),
}));
const payload: PeqChangePayload = {
peqChange: {
name: newName,
filters,
autoPre: item.autoPre,
preamp: item.preamp,
canDel: item.canDel ?? 1,
brand: item.brand,
model: item.model,
target: item.target,
form: item.form,
},
};
if (isDemoMode || !api) {
const nextItems = peqItems.map(it =>
it.name === oldName ? { ...it, name: newName } : it
);
delete peqPresetCacheRef.current[oldName];
applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx });
toast.success(eqUi.toastRenamed);
return true;
}
// Step 1: peqChange — create new preset with new name + same EQ data
// Use api.upgradePeqChange directly (NOT the DeviceContext wrapper)
// to avoid applyPeqFiltersToState side-effect that overwrites current UI bands.
await api.upgradePeqChange(payload);
// Step 2: peqRemove — delete the old preset
delete peqPresetCacheRef.current[oldName];
await api.removePeq([oldName]);
// Step 3: sync latest state from device
const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed);
return true;
} catch {
toast.error(eqUi.toastRenameFail);
return false;
}
}}
/>
<ShareDialog
key={`share-dialog-${shareDialogKey}`}
open={isShareDialogOpen}
@@ -0,0 +1,350 @@
import { useMemo, useState } from "react";
import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { PeqEqUi } from "../types";
/* ── Custom checkbox ── */
function Checkbox({
checked,
onChange,
className,
}: {
checked: boolean;
onChange: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onChange}
className={cn(
"shrink-0 flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border transition-all duration-200 active:scale-90 outline-none focus-visible:ring-2 focus-visible:ring-[#00FFF6]/40",
checked
? "border-transparent bg-gradient-to-br from-[#00FFF6] to-[#00C8C0] shadow-[0_0_8px_rgba(0,255,246,0.35)]"
: "border-white/25 bg-white/[0.04] hover:border-white/40 hover:bg-white/[0.08]",
className
)}
>
<Check
size={12}
strokeWidth={3}
className={cn(
"text-black transition-all duration-200",
checked ? "opacity-100 scale-100" : "opacity-0 scale-50"
)}
/>
</button>
);
}
type PeqItem = {
name: string;
filters?: any[] | string;
autoPre?: number;
preamp?: number;
canDel?: number;
brand?: string;
model?: string;
target?: string;
form?: string;
};
/**
* Preset management dialog.
*
* Behavior:
* - Delete (single / batch): calls onDeletePresets callback with preset names,
* which should call info.cgi with { peqRemove: names } then sync.
* - Rename: calls onRenamePreset(oldName, newName, item) callback which
* should: 1) peqChange with new name + same EQ data 2) peqRemove old name.
*/
export function PeqPresetManageDialog({
open,
items,
selectedIdx,
onClose,
eqUi,
onDeletePresets,
onRenamePreset,
}: {
open: boolean;
items: PeqItem[];
selectedIdx: number;
onClose: () => void;
eqUi: PeqEqUi;
/**
* Called with preset names to delete. Should call api.removePeq(names) then
* fetchEqSyncPeq + applyPeqStateToUI. Return true on success, false on failure.
*/
onDeletePresets: (names: string[]) => Promise<boolean>;
/**
* Called to rename a preset. Implementation should:
* 1) upgradePeqChange({ peqChange: { name: newName, filters, ...rest } })
* 2) api.removePeq([oldName])
* 3) fetchEqSyncPeq + applyPeqStateToUI
* Return true on success, false on failure.
*/
onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise<boolean>;
}) {
const [checked, setChecked] = useState<Record<number, boolean>>({});
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [editingName, setEditingName] = useState("");
const [deleting, setDeleting] = useState(false);
const [renaming, setRenaming] = useState(false);
const checkedIdxs = useMemo(
() =>
Object.entries(checked)
.filter(([, v]) => v)
.map(([k]) => Number(k)),
[checked]
);
const allChecked = items.length > 0 && checkedIdxs.length === items.length;
if (!open) return null;
const toggleAll = () => {
if (allChecked) {
setChecked({});
return;
}
const next: Record<number, boolean> = {};
for (let i = 0; i < items.length; i++) next[i] = true;
setChecked(next);
};
const toggleOne = (idx: number) => {
setChecked(prev => ({ ...prev, [idx]: !prev[idx] }));
};
const beginRename = (idx: number) => {
const name = items[idx]?.name ?? "";
setEditingIdx(idx);
setEditingName(name);
};
const commitRename = async () => {
if (editingIdx === null) return;
const nextName = editingName.trim();
if (!nextName) {
toast.error(eqUi.renameEmptyName);
return;
}
const oldItem = items[editingIdx];
if (!oldItem) return;
if (nextName === oldItem.name) {
setEditingIdx(null);
setEditingName("");
return;
}
if (items.some((it, i) => i !== editingIdx && it.name === nextName)) {
toast.error(eqUi.renameNameExists);
return;
}
setRenaming(true);
try {
const ok = await onRenamePreset(oldItem.name, nextName, oldItem);
if (ok) {
setEditingIdx(null);
setEditingName("");
}
} finally {
setRenaming(false);
}
};
const confirmAndDelete = async (idxs: number[]) => {
const uniq = Array.from(new Set(idxs)).filter(
i => i >= 0 && i < items.length
);
if (uniq.length === 0) return;
const names = uniq.map(i => items[i].name);
// Confirmation dialog
const confirmMsg =
names.length === 1
? eqUi.deletePresetConfirm.replace("{{name}}", names[0])
: eqUi.batchDeleteConfirm.replace("{{count}}", String(names.length));
if (!window.confirm(confirmMsg)) return;
setDeleting(true);
try {
const ok = await onDeletePresets(names);
if (ok) {
setChecked({});
if (editingIdx !== null && uniq.includes(editingIdx)) {
setEditingIdx(null);
setEditingName("");
}
}
} finally {
setDeleting(false);
}
};
return (
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-black/65 px-4">
<div
className="w-full max-w-[520px] rounded-[14px] p-5"
style={{
background:
"linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
}}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[18px] font-semibold leading-tight text-white/90">
EQ
</h3>
<button
type="button"
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
onClick={onClose}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className="flex items-center justify-between gap-3 mb-3">
<label className="flex items-center gap-2.5 text-[13px] text-white/70 select-none cursor-pointer">
<Checkbox checked={allChecked} onChange={toggleAll} />
</label>
<button
type="button"
disabled={checkedIdxs.length === 0 || deleting}
className={cn(
"rounded-full px-3 py-1.5 text-[13px] font-semibold transition-all active:scale-[0.98] flex items-center gap-1.5",
checkedIdxs.length === 0 || deleting
? "bg-white/10 text-white/30 cursor-not-allowed"
: "bg-red-500/15 text-red-300 hover:bg-red-500/20"
)}
onClick={() => void confirmAndDelete(checkedIdxs)}
>
{deleting && <Loader2 size={13} className="animate-spin" />}
</button>
</div>
<div
className="rounded-[10px] overflow-hidden max-h-[46vh] overflow-y-auto"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.22)",
boxShadow: "0 8px 20px rgba(0,0,0,0.35)",
}}
>
{items.length === 0 ? (
<div className="py-10 text-center text-[13px] text-white/30">
</div>
) : (
items.map((item, idx) => {
const active = idx === selectedIdx;
const isEditing = idx === editingIdx;
return (
<div
key={`${item.name}-${idx}`}
className={cn(
"flex items-center gap-2 px-3 py-2.5 border-b border-white/[0.06] last:border-b-0",
active ? "bg-white/[0.06]" : ""
)}
>
<Checkbox
checked={!!checked[idx]}
onChange={() => toggleOne(idx)}
/>
<div className="min-w-0 flex-1">
{isEditing ? (
<div className="flex items-center gap-2">
<input
value={editingName}
onChange={e => setEditingName(e.target.value)}
className="h-9 min-w-0 flex-1 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"
/>
<button
type="button"
disabled={renaming}
className={cn(
"rounded-full px-3 py-1.5 text-[13px] font-semibold active:scale-[0.98] flex items-center gap-1.5",
renaming
? "bg-[#00FFF6]/40 text-black/50 cursor-not-allowed"
: "bg-[#00FFF6] text-black"
)}
onClick={() => void commitRename()}
>
{renaming && <Loader2 size={13} className="animate-spin" />}
</button>
<button
type="button"
disabled={renaming}
className={cn(
"rounded-full px-3 py-1.5 text-[13px] font-medium bg-white/10 text-white/70 active:scale-[0.98]",
renaming && "cursor-not-allowed opacity-50"
)}
onClick={() => {
setEditingIdx(null);
setEditingName("");
}}
>
</button>
</div>
) : (
<div
className="truncate text-[13px] text-white/85"
title={item.name}
>
{item.name}
</div>
)}
</div>
{!isEditing && (
<div className="flex items-center gap-2">
<button
type="button"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-white/70 active:scale-95 transition-transform"
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
}}
title="重命名"
onClick={() => beginRename(idx)}
>
<Pencil size={14} />
</button>
<button
type="button"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-red-300/80 active:scale-95 transition-transform"
style={{
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.2)",
}}
title="删除"
onClick={() => void confirmAndDelete([idx])}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
}