diff --git a/.gitignore b/.gitignore index a52a726..ac4e68d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ build/ # IDE and editor files .vscode/ +.qoder/ .idea/ *.swp *.swo diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 3fed8e0..c38fb89 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -673,6 +673,26 @@ export async function fetchLuxsinAudioCurve( // Share Code API — shareCreate / shareList / shareQuery / shareAccept // ============================================================ +const SHARE_DEVICE_MODELS = ["Luxsin-X9", "Luxsin-X8"] as const; + +export type ShareDeviceModel = (typeof SHARE_DEVICE_MODELS)[number]; + +/** Map DeviceState.device to app-api share code model enum. */ +export function resolveShareDeviceModel(device: string): ShareDeviceModel | null { + const trimmed = device.trim(); + if ((SHARE_DEVICE_MODELS as readonly string[]).includes(trimmed)) { + return trimmed as ShareDeviceModel; + } + const hyphenated = trimmed.replace(/\s+/g, "-"); + if ((SHARE_DEVICE_MODELS as readonly string[]).includes(hyphenated)) { + return hyphenated as ShareDeviceModel; + } + const lower = trimmed.toLowerCase(); + if (lower.includes("x9")) return "Luxsin-X9"; + if (lower.includes("x8")) return "Luxsin-X8"; + return null; +} + export interface ShareCodeCreateResponse { code: number; msg: string; @@ -697,6 +717,7 @@ export interface ShareCodeAcceptResponse { code: number; msg: string; eq_data?: Record; + model?: string; } export interface ShareCodeQueryResponse { @@ -704,6 +725,7 @@ export interface ShareCodeQueryResponse { msg: string; eq_data?: Record; expire_at?: string; + model?: string; } /** Unwrap stringified PEQ filters (remove JSON escape layers) for API submit. */ @@ -727,6 +749,7 @@ export function normalizePeqFiltersForSubmit(filters: unknown): PeqFilter[] { /** POST `/audio/shareCreate` — 创建 EQ 分享码,返回 share_code + expire_at + eq_data */ export async function createShareCode( mac: string, + model: ShareDeviceModel, eqData: Record, ): Promise { const payload = { @@ -738,7 +761,7 @@ export async function createShareCode( const res = await fetch(buildLuxsinAudioUrl("shareCreate"), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mac, eq_data: payload }), + body: JSON.stringify({ mac, model, eq_data: payload }), }); if (!res.ok) throw new Error(`shareCreate HTTP ${res.status}`); return (await res.json()) as ShareCodeCreateResponse; @@ -762,16 +785,36 @@ export async function queryShareCode(shareCode: string): Promise { const res = await fetch( buildLuxsinAudioUrl( - `shareAccept?mac=${encodeURIComponent(mac)}&shareCode=${encodeURIComponent(shareCode)}`, + `shareAccept?mac=${encodeURIComponent(mac)}&model=${encodeURIComponent(model)}&shareCode=${encodeURIComponent(shareCode)}`, ), ); if (!res.ok) throw new Error(`shareAccept HTTP ${res.status}`); return (await res.json()) as ShareCodeAcceptResponse; } + +export interface ShareCodeDeleteResponse { + code: number; + msg: string; +} + +/** GET `/audio/shareDelete?mac=...&shareCode=...` — 删除分享码 */ +export async function deleteShareCode( + mac: string, + shareCode: string, +): Promise { + const res = await fetch( + buildLuxsinAudioUrl( + `shareDelete?mac=${encodeURIComponent(mac)}&shareCode=${encodeURIComponent(shareCode)}`, + ), + ); + if (!res.ok) throw new Error(`shareDelete HTTP ${res.status}`); + return (await res.json()) as ShareCodeDeleteResponse; +} diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index b0bd685..d6828e8 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -375,6 +375,8 @@ "importQuerying": "Querying…", "importCodeNotFound": "Share code not found or expired", "importEqName": "EQ Name", + "importEqNameHint": "You can customize the name", + "importSourceModel": "Source device", "importButton": "Import", "importSaving": "Importing…", "importSuccess": "EQ imported successfully", @@ -384,7 +386,12 @@ "mySharesEmpty": "No shared EQ yet", "mySharesCode": "Code", "mySharesName": "EQ Name", - "mySharesLoadFail": "Failed to load share list" + "mySharesLoadFail": "Failed to load share list", + "mySharesDeleteConfirmTitle": "Delete share code", + "mySharesDeleteConfirmDesc": "Delete share code {{code}}? It will no longer be available for import.", + "mySharesDeleteConfirmOk": "Delete", + "mySharesDeleteSuccess": "Share code deleted", + "mySharesDeleteFail": "Failed to delete share code" } }, "dac": { diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 8fe7198..2931385 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -311,6 +311,8 @@ "importQuerying": "查詢中…", "importCodeNotFound": "分享碼不存在", "importEqName": "EQ 名稱", + "importEqNameHint": "可自訂名稱", + "importSourceModel": "來源設備", "importButton": "匯入", "importSaving": "匯入中…", "importSuccess": "EQ 匯入成功", @@ -321,6 +323,11 @@ "mySharesCode": "分享碼", "mySharesName": "EQ 名稱", "mySharesLoadFail": "載入分享列表失敗", + "mySharesDeleteConfirmTitle": "刪除分享碼", + "mySharesDeleteConfirmDesc": "確定刪除分享碼 {{code}}?刪除後將無法再通過該碼匯入。", + "mySharesDeleteConfirmOk": "刪除", + "mySharesDeleteSuccess": "分享碼已刪除", + "mySharesDeleteFail": "刪除分享碼失敗", "importCodeNotFound": "分享碼不存在或已過期" } }, diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 523a7b8..43472a5 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -311,6 +311,8 @@ "importQuerying": "查询中…", "importCodeNotFound": "分享码不存在", "importEqName": "EQ 名称", + "importEqNameHint": "可自定义名称", + "importSourceModel": "来源设备", "importButton": "导入", "importSaving": "导入中…", "importSuccess": "EQ 导入成功", @@ -321,6 +323,11 @@ "mySharesCode": "分享码", "mySharesName": "EQ 名称", "mySharesLoadFail": "加载分享列表失败", + "mySharesDeleteConfirmTitle": "删除分享码", + "mySharesDeleteConfirmDesc": "确定删除分享码 {{code}}?删除后将无法再通过该码导入。", + "mySharesDeleteConfirmOk": "删除", + "mySharesDeleteSuccess": "分享码已删除", + "mySharesDeleteFail": "删除分享码失败", "importCodeNotFound": "分享码不存在或已过期" } }, diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index b2af808..5f5a9ba 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -1839,6 +1839,7 @@ export default function EQPage() { eqUi={eqUi} peqCardLabels={peqCardLabels} mac={deviceState?.mac ?? ""} + device={deviceState?.device ?? ""} isDemoMode={isDemoMode} onImportEq={handleImportSharedEq} /> diff --git a/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx b/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx index f24b2a7..5a74c3e 100644 --- a/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx +++ b/client/src/pages/eq/components/PeqOverwriteConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { AlertDialog, @@ -32,11 +33,16 @@ export function PeqOverwriteConfirmDialog({ onConfirm: () => void; onCancel: () => void; }) { + const actionTakenRef = useRef(false); + return ( { - if (!next) onCancel(); + if (!next) { + if (!actionTakenRef.current) onCancel(); + actionTakenRef.current = false; + } }} > @@ -56,13 +62,18 @@ export function PeqOverwriteConfirmDialog({ e.preventDefault()} onClick={onCancel} > {cancelLabel} e.preventDefault()} + onClick={() => { + actionTakenRef.current = true; + onConfirm(); + }} > {confirmLabel} diff --git a/client/src/pages/eq/components/ShareDialog.tsx b/client/src/pages/eq/components/ShareDialog.tsx index f96e678..fed6af7 100644 --- a/client/src/pages/eq/components/ShareDialog.tsx +++ b/client/src/pages/eq/components/ShareDialog.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; -import { X, Copy, Loader2 } from "lucide-react"; +import { X, Copy, Loader2, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { acceptShareCode, createShareCode, listShareCodes, queryShareCode } from "@/lib/luxsinApi"; +import { acceptShareCode, createShareCode, deleteShareCode, listShareCodes, queryShareCode, resolveShareDeviceModel } from "@/lib/luxsinApi"; import type { PeqEqUi } from "../constants"; import { resolveShareApiMessage } from "../shareMessages"; +import { eqInterp } from "../utils"; +import { PeqOverwriteConfirmDialog } from "./PeqOverwriteConfirmDialog"; type PeqItem = { name: string; @@ -40,6 +42,7 @@ export function ShareDialog({ eqUi, peqCardLabels, mac, + device, isDemoMode, onImportEq, }: { @@ -50,6 +53,7 @@ export function ShareDialog({ eqUi: PeqEqUi; peqCardLabels: PeqCardLabels; mac: string; + device: string; isDemoMode: boolean; onImportEq: (data: ImportedEqData, shareCode: string) => void | Promise; }) { @@ -63,9 +67,34 @@ export function ShareDialog({ const [importPresetName, setImportPresetName] = useState(""); const [queriedShareCode, setQueriedShareCode] = useState(""); const [importedEqData, setImportedEqData] = useState(null); + const [importedSourceModel, setImportedSourceModel] = useState(null); const [shareCodeExpireAt, setShareCodeExpireAt] = useState(null); const [mySharesList, setMySharesList] = useState }>>([]); const [mySharesLoading, setMySharesLoading] = useState(false); + const [deleteConfirmCode, setDeleteConfirmCode] = useState(null); + const [deletingShareCode, setDeletingShareCode] = useState(null); + + const handleDeleteConfirm = () => { + const code = deleteConfirmCode; + if (!code || !mac || deletingShareCode) return; + setDeleteConfirmCode(null); + setDeletingShareCode(code); + void (async () => { + try { + const res = await deleteShareCode(mac, code); + if (res.code !== 200) { + toast.error(resolveShareApiMessage("delete", res, eqUi)); + return; + } + setMySharesList((prev) => prev.filter((item) => item.share_code !== code)); + toast.success(eqUi.mySharesDeleteSuccess); + } catch { + toast.error(resolveShareApiMessage("delete", { code: 500 }, eqUi)); + } finally { + setDeletingShareCode(null); + } + })(); + }; const handleImportClick = async () => { if (importSaving || !importedEqData || !queriedShareCode) return; @@ -75,7 +104,12 @@ export function ShareDialog({ setImportSaving(true); try { if (!isDemoMode && mac) { - const acceptRes = await acceptShareCode(mac, queriedShareCode); + const shareDeviceModel = resolveShareDeviceModel(device); + if (!shareDeviceModel) { + toast.error(eqUi.shareInvalidParams); + return; + } + const acceptRes = await acceptShareCode(mac, shareDeviceModel, queriedShareCode); if (acceptRes.code !== 200) { toast.error(resolveShareApiMessage("accept", acceptRes, eqUi)); return; @@ -108,10 +142,13 @@ export function ShareDialog({ }; return ( + <>
{ - if (!importSaving) onClose(); + onClick={(e) => { + if (e.target !== e.currentTarget) return; + if (importSaving || deleteConfirmCode !== null || deletingShareCode) return; + onClose(); }} >
+ {importedSourceModel && ( +

+ {eqUi.importSourceModel}: {importedSourceModel} +

+ )}
-

{eqUi.importEqName}

+

+ {eqUi.importEqName} + ({eqUi.importEqNameHint}) +

setImportPresetName(e.target.value)} @@ -543,15 +598,33 @@ export function ShareDialog({

)}
- {/* copy code button */} + {/* copy + delete */} +
))}
@@ -560,5 +633,20 @@ export function ShareDialog({ )} + + setDeleteConfirmCode(null)} + /> + ); } diff --git a/client/src/pages/eq/shareMessages.ts b/client/src/pages/eq/shareMessages.ts index c9485ac..9b46b8a 100644 --- a/client/src/pages/eq/shareMessages.ts +++ b/client/src/pages/eq/shareMessages.ts @@ -1,6 +1,6 @@ import type { PeqEqUi } from "./constants"; -export type ShareApiAction = "create" | "query" | "accept" | "list"; +export type ShareApiAction = "create" | "query" | "accept" | "list" | "delete"; export function resolveShareApiMessage( action: ShareApiAction, @@ -13,6 +13,19 @@ export function resolveShareApiMessage( return eqUi.mySharesLoadFail ?? "Failed to load share list"; } + if (action === "delete") { + if (res.code === 0) { + return eqUi.mySharesDeleteFail ?? "Failed to delete share code"; + } + if (res.code === 400) { + return eqUi.shareInvalidParams ?? eqUi.mySharesDeleteFail ?? "Invalid request"; + } + if (res.code === 500) { + return eqUi.shareSystemError ?? eqUi.mySharesDeleteFail ?? "System error"; + } + return eqUi.mySharesDeleteFail ?? "Failed to delete share code"; + } + if (res.code === 0) { if (action === "create") { return eqUi.shareActiveExists ?? eqUi.shareCreateFail ?? "Failed to generate share code"; diff --git a/package.json b/package.json index f78e046..5de858c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "build": "pnpm build:legacy && pnpm build:modern", "build:modern": "vite build", "build:legacy": "vite build --config vite.legacy.config.ts", + "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", diff --git a/vite.config.ts b/vite.config.ts index 2325ee6..245158d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,9 +49,16 @@ const plugins = [ vitePluginCopyRouterEntry(), ]; +/* ── Deploy base path ── */ +const DEPLOY_ENV = process.env.DEPLOY_ENV || "production"; +const isProd = process.env.NODE_ENV === "production"; +const deployBase = isProd + ? (DEPLOY_ENV === "test" ? "/x9/test/modern/" : "/x9/v2/modern/") + : "/"; + export default defineConfig({ plugins, - base: process.env.NODE_ENV === "production" ? "/x9/v2/modern/" : "/", + base: deployBase, resolve: { alias: { "@": path.resolve(import.meta.dirname, "client", "src"), diff --git a/vite.legacy.config.ts b/vite.legacy.config.ts index 2e76f85..de2968e 100644 --- a/vite.legacy.config.ts +++ b/vite.legacy.config.ts @@ -43,12 +43,19 @@ function legacySkipModernCssPlugin(): Plugin { }; } +/* ── Deploy base path ── */ +const DEPLOY_ENV = process.env.DEPLOY_ENV || "production"; +const isProd = process.env.NODE_ENV === "production"; +const deployBase = isProd + ? (DEPLOY_ENV === "test" ? "/x9/test/legacy/" : "/x9/v2/legacy/") + : "/"; + export default defineConfig({ plugins: [legacySkipModernCssPlugin(), legacyIndexRewritePlugin(), react(), jsxLocPlugin()], define: { "import.meta.env.VITE_X9_LEGACY": JSON.stringify("1"), }, - base: process.env.NODE_ENV === "production" ? "/x9/v2/legacy/" : "/", + base: deployBase, resolve: { alias: { "@": path.resolve(PROJECT_ROOT, "client", "src"),