清理项目没用的代码,新增 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>
);
}
-4
View File
@@ -12,7 +12,6 @@
"build:test": "pnpm build:test:legacy && pnpm build:test:modern",
"build:test:modern": "DEPLOY_ENV=test vite build",
"build:test:legacy": "DEPLOY_ENV=test vite build --config vite.legacy.config.ts",
"start": "NODE_ENV=production tsx server/index.ts",
"preview": "vite preview --host",
"check": "tsc --noEmit",
"format": "prettier --write ."
@@ -51,7 +50,6 @@
"cmdk": "^1.1.1",
"echarts": "^6.0.0",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"framer-motion": "^12.23.22",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
@@ -75,7 +73,6 @@
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",
"@types/react": "^19.2.1",
@@ -90,7 +87,6 @@
"tailwindcss": "^4.1.14",
"tailwindcss3": "npm:tailwindcss@^3.4.17",
"terser": "^5.46.1",
"tsx": "^4.19.1",
"tw-animate-css": "^1.4.0",
"typescript": "5.6.3",
"vite": "^7.1.7",
-28
View File
@@ -1,28 +0,0 @@
diff --git a/esm/index.js b/esm/index.js
index c83bc63a2c10431fb62e25b7d490656a3796f301..bcae513cc20a4be6c38dc116e0b8d9bacda62b5b 100644
--- a/esm/index.js
+++ b/esm/index.js
@@ -338,6 +338,23 @@ const Switch = ({ children, location }) => {
const router = useRouter();
const [originalLocation] = useLocationFromRouter(router);
+ // Collect all route paths to window object
+ if (typeof window !== 'undefined') {
+ if (!window.__WOUTER_ROUTES__) {
+ window.__WOUTER_ROUTES__ = [];
+ }
+
+ const allChildren = flattenChildren(children);
+ allChildren.forEach((element) => {
+ if (isValidElement(element) && element.props.path) {
+ const path = element.props.path;
+ if (!window.__WOUTER_ROUTES__.includes(path)) {
+ window.__WOUTER_ROUTES__.push(path);
+ }
+ }
+ });
+ }
+
for (const element of flattenChildren(children)) {
let match = 0;
-74
View File
@@ -1,74 +0,0 @@
import express from "express";
import { createServer } from "http";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function startServer() {
const app = express();
const server = createServer(app);
const modernPath = path.resolve(__dirname, "..", "dist-modern");
const legacyPath = path.resolve(__dirname, "..", "dist-legacy");
app.use("/modern", express.static(modernPath));
app.use("/legacy", express.static(legacyPath));
function shouldUseLegacy(req: express.Request) {
// Prefer capability detection on client, but keep a UA escape hatch for very old WebViews.
const ua = req.headers["user-agent"] || "";
// Chrome 91 WebView frequently reports like: Chrome/91.0.4472.114
const m = String(ua).match(/Chrome\/(\d+)\./);
if (m) return Number(m[1]) <= 91;
return false;
}
// Bootstrap entry: redirect to /legacy or /modern while preserving hash routes.
app.get(["/", "/index.html", "/i.html"], (req, res) => {
const defaultToLegacy = shouldUseLegacy(req) ? "true" : "false";
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Luxsin X8 Controller</title>
<script>
(function () {
function supportsModernCss() {
try {
return (
typeof CSS !== "undefined" &&
CSS.supports("color", "color-mix(in srgb, red 50%, blue)") &&
CSS.supports("height", "100dvh")
);
} catch (e) {
return false;
}
}
var useLegacy = ${defaultToLegacy} || !supportsModernCss();
var prefix = useLegacy ? "/x8/v2/legacy/" : "/x8/v2/modern/";
// Hash router: keep location.hash
location.replace(prefix + (location.hash || "#/"));
})();
</script>
</head>
<body></body>
</html>`);
});
// Hash routing: any other path should serve the bootstrap as well.
app.get("*", (_req, res) => {
res.redirect(302, "/x8/v2/");
});
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
}
startServer().catch(console.error);
-2
View File
@@ -1,2 +0,0 @@
export const COOKIE_NAME = "app_session_id";
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
+2 -3
View File
@@ -1,5 +1,5 @@
{
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"include": ["client/src/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
@@ -16,8 +16,7 @@
"baseUrl": ".",
"types": ["node", "vite/client"],
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
"@/*": ["./client/src/*"]
}
}
}
-1
View File
@@ -62,7 +62,6 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "client", "src"),
"@shared": path.resolve(import.meta.dirname, "shared"),
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
},
dedupe: ["react", "react-dom"],
-1
View File
@@ -59,7 +59,6 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(PROJECT_ROOT, "client", "src"),
"@shared": path.resolve(PROJECT_ROOT, "shared"),
"@assets": path.resolve(PROJECT_ROOT, "attached_assets"),
},
dedupe: ["react", "react-dom"],