新增氛围灯功能,对 EQ 名称长度做限制
This commit is contained in:
@@ -122,6 +122,10 @@ export interface DeviceState {
|
||||
bt_title: string;
|
||||
bt_artist: string;
|
||||
msgCount: number;
|
||||
led_enable: number;
|
||||
led_red: number;
|
||||
led_green: number;
|
||||
led_blue: number;
|
||||
}
|
||||
|
||||
export interface PeqFilter {
|
||||
@@ -390,6 +394,19 @@ export function getBootSoundMaxIndex(firmwareVersion: number): number {
|
||||
return firmwareVersion >= 26 ? 10 : 6;
|
||||
}
|
||||
|
||||
/** Ambient LED block on System page (firmware build 28+). */
|
||||
export const AMBIENT_LED_MIN_FIRMWARE_VERSION = 28;
|
||||
|
||||
export function supportsAmbientLed(firmwareVersion: number): boolean {
|
||||
return firmwareVersion >= AMBIENT_LED_MIN_FIRMWARE_VERSION;
|
||||
}
|
||||
|
||||
export function clampLedChannel(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(255, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
|
||||
export function normalizeBootSound(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
@@ -408,7 +425,7 @@ export function readBootSoundFromState(state: DeviceState | null | undefined): n
|
||||
// ============================================================
|
||||
export const MOCK_DEVICE_STATE: DeviceState = {
|
||||
device: "Luxsin-X8",
|
||||
version: "1.2.3",
|
||||
version: 28,
|
||||
mac: "AA:BB:CC:DD:EE:FF",
|
||||
language: 0,
|
||||
volume: 75,
|
||||
@@ -463,6 +480,10 @@ export const MOCK_DEVICE_STATE: DeviceState = {
|
||||
bt_title: "Bohemian Rhapsody",
|
||||
bt_artist: "Queen",
|
||||
msgCount: 42,
|
||||
led_enable: 1,
|
||||
led_red: 128,
|
||||
led_green: 64,
|
||||
led_blue: 200,
|
||||
};
|
||||
|
||||
export const MOCK_PEQ_STATE: PeqState = {
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"addPresetTitle": "Neue Voreinstellung",
|
||||
"addPresetNameEmpty": "Name darf nicht leer sein",
|
||||
"addPresetNameExists": "Dieser Name existiert bereits. Wählen Sie einen anderen.",
|
||||
"addPresetNameTooLong": "Name darf maximal 80 Zeichen lang sein",
|
||||
"toastAddPresetOk": "Voreinstellung hinzugefügt",
|
||||
"toastAddPresetFail": "Hinzufügen der Voreinstellung fehlgeschlagen",
|
||||
"toastPresetLimitReached": "Voreinstellungslimit erreicht ({{max}}). Löschen Sie einige Voreinstellungen, bevor Sie weitere hinzufügen.",
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"addPresetTitle": "New preset",
|
||||
"addPresetNameEmpty": "Name cannot be empty",
|
||||
"addPresetNameExists": "That name already exists. Choose another.",
|
||||
"addPresetNameTooLong": "Name cannot exceed 80 characters",
|
||||
"toastAddPresetOk": "Preset added",
|
||||
"toastAddPresetFail": "Failed to add preset",
|
||||
"toastPresetLimitReached": "Preset limit reached ({{max}}). Delete some presets before adding more.",
|
||||
@@ -796,7 +797,12 @@
|
||||
"deviceName": "Device name",
|
||||
"firmwareVersion": "Firmware version",
|
||||
"macAddress": "MAC address",
|
||||
"deviceIp": "Device IP"
|
||||
"deviceIp": "Device IP",
|
||||
"sectionAmbientLight": "Ambient light",
|
||||
"ambientLight": "Master switch",
|
||||
"ledRed": "Red",
|
||||
"ledGreen": "Green",
|
||||
"ledBlue": "Blue"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"addPresetTitle": "Nuevo ajuste",
|
||||
"addPresetNameEmpty": "El nombre no puede estar vacío",
|
||||
"addPresetNameExists": "Ese nombre ya existe. Elija otro.",
|
||||
"addPresetNameTooLong": "El nombre no puede superar los 80 caracteres",
|
||||
"toastAddPresetOk": "Ajuste agregado",
|
||||
"toastAddPresetFail": "Error al agregar el ajuste",
|
||||
"toastPresetLimitReached": "Límite de ajustes alcanzado ({{max}}). Elimine algunos ajustes antes de agregar más.",
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"addPresetTitle": "Nouveau préréglage",
|
||||
"addPresetNameEmpty": "Le nom ne peut pas être vide",
|
||||
"addPresetNameExists": "Ce nom existe déjà. Choisissez-en un autre.",
|
||||
"addPresetNameTooLong": "Le nom ne peut pas dépasser 80 caractères",
|
||||
"toastAddPresetOk": "Préréglage ajouté",
|
||||
"toastAddPresetFail": "Échec de l'ajout du préréglage",
|
||||
"toastPresetLimitReached": "Limite de préréglages atteinte ({{max}}). Supprimez des préréglages avant d'en ajouter.",
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"addPresetTitle": "新增預設",
|
||||
"addPresetNameEmpty": "名稱不能為空",
|
||||
"addPresetNameExists": "名稱已存在,請修改後再儲存",
|
||||
"addPresetNameTooLong": "名稱不能超過 80 個字元",
|
||||
"toastAddPresetOk": "已新增預設",
|
||||
"toastAddPresetFail": "新增預設失敗",
|
||||
"toastPresetLimitReached": "預設已達上限({{max}} 個),請先清理後再操作",
|
||||
@@ -796,7 +797,12 @@
|
||||
"deviceName": "裝置名稱",
|
||||
"firmwareVersion": "韌體版本",
|
||||
"macAddress": "MAC 位址",
|
||||
"deviceIp": "裝置 IP"
|
||||
"deviceIp": "裝置 IP",
|
||||
"sectionAmbientLight": "氛圍燈",
|
||||
"ambientLight": "總開關",
|
||||
"ledRed": "紅",
|
||||
"ledGreen": "綠",
|
||||
"ledBlue": "藍"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"addPresetTitle": "新增预设",
|
||||
"addPresetNameEmpty": "名称不能为空",
|
||||
"addPresetNameExists": "名称已存在,请修改后再保存",
|
||||
"addPresetNameTooLong": "名称不能超过 80 个字符",
|
||||
"toastAddPresetOk": "已新增预设",
|
||||
"toastAddPresetFail": "新增预设失败",
|
||||
"toastPresetLimitReached": "预设已达上限({{max}} 个),请先清理后再操作",
|
||||
@@ -796,7 +797,12 @@
|
||||
"deviceName": "设备名称",
|
||||
"firmwareVersion": "固件版本",
|
||||
"macAddress": "MAC 地址",
|
||||
"deviceIp": "设备 IP"
|
||||
"deviceIp": "设备 IP",
|
||||
"sectionAmbientLight": "氛围灯",
|
||||
"ambientLight": "总开关",
|
||||
"ledRed": "红",
|
||||
"ledGreen": "绿",
|
||||
"ledBlue": "蓝"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
|
||||
@@ -42,6 +42,7 @@ 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 { isPeqPresetNameTooLong } from "./eq/peqPresetName";
|
||||
import {
|
||||
BAND_FREQ_MAX,
|
||||
BAND_FREQ_MIN,
|
||||
@@ -597,6 +598,10 @@ export default function EQPage() {
|
||||
toast.error(eqUi.addPresetNameEmpty);
|
||||
return;
|
||||
}
|
||||
if (isPeqPresetNameTooLong(nextName)) {
|
||||
toast.error(eqUi.addPresetNameTooLong);
|
||||
return;
|
||||
}
|
||||
if (headphoneModels.includes(nextName)) {
|
||||
toast.error(eqUi.addPresetNameExists);
|
||||
return;
|
||||
@@ -675,6 +680,10 @@ export default function EQPage() {
|
||||
toast.error(eqUi.addPresetNameEmpty);
|
||||
return;
|
||||
}
|
||||
if (isPeqPresetNameTooLong(nextName)) {
|
||||
toast.error(eqUi.addPresetNameTooLong);
|
||||
return;
|
||||
}
|
||||
if (headphoneModels.includes(nextName)) {
|
||||
toast.error(eqUi.addPresetNameExists);
|
||||
return;
|
||||
@@ -747,6 +756,10 @@ export default function EQPage() {
|
||||
toast.error(eqUi.addPresetNameEmpty);
|
||||
return;
|
||||
}
|
||||
if (isPeqPresetNameTooLong(nextName)) {
|
||||
toast.error(eqUi.addPresetNameTooLong);
|
||||
return;
|
||||
}
|
||||
if (!shareCode.trim()) {
|
||||
toast.error(eqUi.importCodeNotFound);
|
||||
return;
|
||||
|
||||
@@ -11,10 +11,15 @@
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { navigateToSelect } from "./SelectPage";
|
||||
import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
|
||||
import {
|
||||
clampLedChannel,
|
||||
formatFirmwareVersionDisplay,
|
||||
parseFirmwareVersion,
|
||||
supportsAmbientLed,
|
||||
} from "@/lib/luxsinApi";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
@@ -36,6 +41,60 @@ function SectionHeader({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CyanSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
onRelease,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (v: number) => void;
|
||||
onRelease?: (v: number) => void;
|
||||
}) {
|
||||
const fillPct = ((value - min) / (max - min)) * 100;
|
||||
const emitRelease = (el: EventTarget | null) => {
|
||||
if (!(el instanceof HTMLInputElement)) return;
|
||||
onRelease?.(Number(el.value));
|
||||
};
|
||||
return (
|
||||
<div className="relative flex items-center w-full mt-2">
|
||||
<div
|
||||
className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: `${fillPct}%`,
|
||||
background: "#00FFF6",
|
||||
boxShadow: "0 0 6px rgba(0,255,246,0.5)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
className="cyan-slider relative z-10"
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerUp={(e) => emitRelease(e.currentTarget)}
|
||||
onPointerCancel={(e) => emitRelease(e.currentTarget)}
|
||||
onKeyUp={(e) => {
|
||||
if (
|
||||
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(
|
||||
e.key,
|
||||
)
|
||||
) {
|
||||
emitRelease(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListRow({ label, description, value, onClick, toggle, checked, onToggle }: {
|
||||
label: string; description?: string; value?: string;
|
||||
onClick?: () => void; toggle?: boolean; checked?: boolean; onToggle?: (v: boolean) => void;
|
||||
@@ -92,6 +151,11 @@ type LocaleSettings = {
|
||||
firmwareVersion: string;
|
||||
macAddress: string;
|
||||
deviceIp: string;
|
||||
sectionAmbientLight?: string;
|
||||
ambientLight?: string;
|
||||
ledRed?: string;
|
||||
ledGreen?: string;
|
||||
ledBlue?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -140,6 +204,29 @@ export default function SystemPage() {
|
||||
const rawLangIdx = ds?.language ?? 2;
|
||||
const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : 2;
|
||||
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
|
||||
const firmwareVersion = parseFirmwareVersion(ds?.version);
|
||||
const showAmbientLed = supportsAmbientLed(firmwareVersion);
|
||||
const ledOn = (ds?.led_enable ?? 0) === 1;
|
||||
|
||||
const [localLedRed, setLocalLedRed] = useState(() => clampLedChannel(ds?.led_red));
|
||||
const [localLedGreen, setLocalLedGreen] = useState(() => clampLedChannel(ds?.led_green));
|
||||
const [localLedBlue, setLocalLedBlue] = useState(() => clampLedChannel(ds?.led_blue));
|
||||
const draggingLedRed = useRef(false);
|
||||
const draggingLedGreen = useRef(false);
|
||||
const draggingLedBlue = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingLedRed.current) setLocalLedRed(clampLedChannel(ds?.led_red));
|
||||
}, [ds?.led_red]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingLedGreen.current) setLocalLedGreen(clampLedChannel(ds?.led_green));
|
||||
}, [ds?.led_green]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingLedBlue.current) setLocalLedBlue(clampLedChannel(ds?.led_blue));
|
||||
}, [ds?.led_blue]);
|
||||
|
||||
const localeData = LOCALE_BY_LANG[langIdx] ?? localeZh;
|
||||
const settingsText = localeData.settings;
|
||||
const ui = settingsText.systemPage;
|
||||
@@ -193,6 +280,91 @@ export default function SystemPage() {
|
||||
onToggle={(v) => updateSetting({ knob_breathlight: v ? 0 : 1 })} />
|
||||
</div>
|
||||
|
||||
{showAmbientLed && (
|
||||
<>
|
||||
<SectionHeader title={ui.sectionAmbientLight ?? "氛围灯"} />
|
||||
<div className="ios-list-group">
|
||||
<div className="ios-list-row">
|
||||
<div className="flex-1" />
|
||||
<IOSToggle
|
||||
checked={ledOn}
|
||||
onChange={(v) => updateSetting({ led_enable: v ? 1 : 0 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ledOn && (
|
||||
<div className="p-4 pt-0 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] text-white/60">{ui.ledRed ?? "红"}</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
{localLedRed}
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localLedRed}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => {
|
||||
draggingLedRed.current = true;
|
||||
setLocalLedRed(v);
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
draggingLedRed.current = false;
|
||||
updateSetting({ led_red: clampLedChannel(v) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] text-white/60">{ui.ledGreen ?? "绿"}</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
{localLedGreen}
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localLedGreen}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => {
|
||||
draggingLedGreen.current = true;
|
||||
setLocalLedGreen(v);
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
draggingLedGreen.current = false;
|
||||
updateSetting({ led_green: clampLedChannel(v) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] text-white/60">{ui.ledBlue ?? "蓝"}</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
{localLedBlue}
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localLedBlue}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => {
|
||||
draggingLedBlue.current = true;
|
||||
setLocalLedBlue(v);
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
draggingLedBlue.current = false;
|
||||
updateSetting({ led_blue: clampLedChannel(v) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 通用 ── */}
|
||||
<SectionHeader title={ui.sectionGeneral} />
|
||||
<div className="ios-list-group">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PeqEqUi } from "../types";
|
||||
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
|
||||
import { clampPeqPresetName } from "../peqPresetName";
|
||||
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
|
||||
|
||||
export function AddPresetDialog({
|
||||
open,
|
||||
@@ -63,12 +66,16 @@ export function AddPresetDialog({
|
||||
onChange={() => onModeChange("copy")}
|
||||
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
|
||||
/>
|
||||
<input
|
||||
value={copyName}
|
||||
onChange={(e) => onCopyNameChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={copyName}
|
||||
onChange={(e) => onCopyNameChange(clampPeqPresetName(e.target.value))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
|
||||
className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
<PeqPresetNameLengthHint value={copyName} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
@@ -84,12 +91,16 @@ export function AddPresetDialog({
|
||||
onChange={() => onModeChange("flat")}
|
||||
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
|
||||
/>
|
||||
<input
|
||||
value={flatName}
|
||||
onChange={(e) => onFlatNameChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={flatName}
|
||||
onChange={(e) => onFlatNameChange(clampPeqPresetName(e.target.value))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
|
||||
className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
<PeqPresetNameLengthHint value={flatName} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { PeqEqUi } from "../types";
|
||||
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
|
||||
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
|
||||
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
|
||||
|
||||
/* ── Custom checkbox ── */
|
||||
function Checkbox({
|
||||
@@ -133,6 +136,10 @@ export function PeqPresetManageDialog({
|
||||
toast.error(eqUi.renameEmptyName);
|
||||
return;
|
||||
}
|
||||
if (isPeqPresetNameTooLong(nextName)) {
|
||||
toast.error(eqUi.addPresetNameTooLong);
|
||||
return;
|
||||
}
|
||||
const oldItem = items[editingIdx];
|
||||
if (!oldItem) return;
|
||||
if (nextName === oldItem.name) {
|
||||
@@ -266,12 +273,14 @@ export function PeqPresetManageDialog({
|
||||
|
||||
<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"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(clampPeqPresetName(e.target.value))}
|
||||
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
|
||||
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}
|
||||
@@ -300,6 +309,8 @@ export function PeqPresetManageDialog({
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<PeqPresetNameLengthHint value={editingName} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { peqPresetNameLengthText } from "../peqPresetName";
|
||||
|
||||
export function PeqPresetNameLengthHint({ value }: { value: string }) {
|
||||
return (
|
||||
<p className="mt-1 text-right text-[11px] text-white/35 tabular-nums">
|
||||
{peqPresetNameLengthText(value)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { X } from "lucide-react";
|
||||
import type { PeqEqUi } from "../types";
|
||||
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
|
||||
import { clampPeqPresetName } from "../peqPresetName";
|
||||
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
|
||||
|
||||
export function SaveBDialog({
|
||||
open,
|
||||
@@ -40,12 +43,16 @@ export function SaveBDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={presetName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
|
||||
autoFocus
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
value={presetName}
|
||||
onChange={(e) => onNameChange(clampPeqPresetName(e.target.value))}
|
||||
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
|
||||
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
|
||||
autoFocus
|
||||
/>
|
||||
<PeqPresetNameLengthHint value={presetName} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { PeqEqUi } from "../types";
|
||||
import { resolveShareApiMessage } from "../shareMessages";
|
||||
import { eqInterp } from "../eqFormatters";
|
||||
import { PeqOverwriteConfirmDialog } from "./PeqOverwriteConfirmDialog";
|
||||
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
|
||||
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
|
||||
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
|
||||
|
||||
type PeqItem = {
|
||||
name: string;
|
||||
@@ -100,6 +103,10 @@ export function ShareDialog({
|
||||
if (importSaving || !importedEqData || !queriedShareCode) return;
|
||||
const nextName = importPresetName.trim();
|
||||
if (!nextName) return;
|
||||
if (isPeqPresetNameTooLong(nextName)) {
|
||||
toast.error(eqUi.addPresetNameTooLong);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportSaving(true);
|
||||
try {
|
||||
@@ -144,7 +151,7 @@ export function ShareDialog({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"
|
||||
className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto bg-black/65 px-4 pb-8 pt-[22vh]"
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (importSaving || deleteConfirmCode !== null || deletingShareCode) return;
|
||||
@@ -152,7 +159,7 @@ export function ShareDialog({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-[420px] rounded-[14px] p-5"
|
||||
className="flex w-full max-w-[420px] flex-col 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)",
|
||||
@@ -161,13 +168,16 @@ export function ShareDialog({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4 flex shrink-0 items-center gap-2">
|
||||
{/* tab bar */}
|
||||
<div className="flex gap-1 rounded-[10px] p-1" style={{ background: "rgba(44,44,46,0.8)" }}>
|
||||
<div
|
||||
className="grid min-w-0 flex-1 grid-cols-3 gap-1 rounded-[10px] p-1"
|
||||
style={{ background: "rgba(44,44,46,0.8)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-[8px] text-[13px] font-medium transition-colors",
|
||||
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
|
||||
shareDialogTab === "share"
|
||||
? "bg-[#00FFF6] text-black font-semibold"
|
||||
: "text-white/60 hover:text-white/80",
|
||||
@@ -179,7 +189,7 @@ export function ShareDialog({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-[8px] text-[13px] font-medium transition-colors",
|
||||
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
|
||||
shareDialogTab === "import"
|
||||
? "bg-[#00FFF6] text-black font-semibold"
|
||||
: "text-white/60 hover:text-white/80",
|
||||
@@ -191,7 +201,7 @@ export function ShareDialog({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-[8px] text-[13px] font-medium transition-colors",
|
||||
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
|
||||
shareDialogTab === "myShares"
|
||||
? "bg-[#00FFF6] text-black font-semibold"
|
||||
: "text-white/60 hover:text-white/80",
|
||||
@@ -222,7 +232,7 @@ export function ShareDialog({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80 disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="shrink-0 rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80 disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={importSaving}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -238,7 +248,7 @@ export function ShareDialog({
|
||||
|
||||
{/* EQ preset list */}
|
||||
<div
|
||||
className="rounded-[10px] overflow-hidden max-h-48 overflow-y-auto mb-4"
|
||||
className="mb-4 max-h-48 overflow-hidden overflow-y-auto rounded-[10px]"
|
||||
style={{
|
||||
background: "rgba(10,12,16,0.98)",
|
||||
border: "1px solid rgba(0,255,246,0.35)",
|
||||
@@ -482,7 +492,7 @@ export function ShareDialog({
|
||||
preamp: eq.preamp as number | undefined,
|
||||
autoPre: eq.autoPre as number | undefined,
|
||||
});
|
||||
setImportPresetName(fetchedName);
|
||||
setImportPresetName(clampPeqPresetName(fetchedName));
|
||||
setImportedSourceModel(res.model ?? null);
|
||||
setQueriedShareCode(code);
|
||||
} catch {
|
||||
@@ -517,11 +527,13 @@ export function ShareDialog({
|
||||
</p>
|
||||
<input
|
||||
value={importPresetName}
|
||||
onChange={(e) => setImportPresetName(e.target.value)}
|
||||
onChange={(e) => setImportPresetName(clampPeqPresetName(e.target.value))}
|
||||
disabled={importSaving}
|
||||
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
|
||||
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}
|
||||
/>
|
||||
<PeqPresetNameLengthHint value={importPresetName} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -552,7 +564,7 @@ export function ShareDialog({
|
||||
{shareDialogTab === "myShares" && (
|
||||
<>
|
||||
{mySharesLoading && mySharesList.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 gap-2 text-white/50">
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-white/50">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-[13px]">{eqUi.mySharesLoading}</span>
|
||||
</div>
|
||||
@@ -560,7 +572,7 @@ export function ShareDialog({
|
||||
<div className="py-10 text-center text-[13px] text-white/30">{eqUi.mySharesEmpty}</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-[10px] overflow-hidden max-h-64 overflow-y-auto"
|
||||
className="max-h-64 overflow-hidden overflow-y-auto rounded-[10px]"
|
||||
style={{
|
||||
background: "rgba(10,12,16,0.98)",
|
||||
border: "1px solid rgba(0,255,246,0.35)",
|
||||
|
||||
@@ -14,6 +14,9 @@ export const BAND_Q_MAX = 10;
|
||||
/** Maximum headphone PEQ presets stored on device. */
|
||||
export const PEQ_PRESET_MAX = 50;
|
||||
|
||||
/** Maximum characters for a custom PEQ preset name (letter, CJK, symbol, or space = 1). */
|
||||
export const PEQ_PRESET_NAME_MAX_LENGTH = 80;
|
||||
|
||||
export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = {
|
||||
background: "rgba(44,44,46,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getFilterType } from "@/lib/peqAudio";
|
||||
import { normalizePeqFiltersForSubmit, type PeqFilter, type PeqState } from "@/lib/luxsinApi";
|
||||
import type { EqBand } from "./types";
|
||||
import { clampPeqPresetName } from "./peqPresetName";
|
||||
|
||||
export function cloneBands(source: EqBand[]) {
|
||||
return source.map((band) => ({ ...band }));
|
||||
@@ -32,12 +33,13 @@ export function buildPeqCatalogSyncKey(
|
||||
}
|
||||
|
||||
export function getUniquePresetName(base: string, existingNames: string[]) {
|
||||
if (!existingNames.includes(base)) return base;
|
||||
const clippedBase = clampPeqPresetName(base);
|
||||
if (!existingNames.includes(clippedBase)) return clippedBase;
|
||||
let index = 1;
|
||||
while (existingNames.includes(`${base}_${index}`)) {
|
||||
while (existingNames.includes(clampPeqPresetName(`${clippedBase}_${index}`))) {
|
||||
index += 1;
|
||||
}
|
||||
return `${base}_${index}`;
|
||||
return clampPeqPresetName(`${clippedBase}_${index}`);
|
||||
}
|
||||
|
||||
/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PEQ_PRESET_NAME_MAX_LENGTH } from "./eqConstants";
|
||||
|
||||
export function clampPeqPresetName(value: string): string {
|
||||
return value.slice(0, PEQ_PRESET_NAME_MAX_LENGTH);
|
||||
}
|
||||
|
||||
export function isPeqPresetNameTooLong(value: string): boolean {
|
||||
return value.trim().length > PEQ_PRESET_NAME_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function peqPresetNameLengthText(value: string): string {
|
||||
return `${value.length}/${PEQ_PRESET_NAME_MAX_LENGTH}`;
|
||||
}
|
||||
+66
-20
@@ -1,20 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================
|
||||
# 部署脚本 - 支持 test 环境与 images 目录排除
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/deploy.sh 上传至正式环境 (v2)
|
||||
# ./scripts/deploy.sh -test 上传至测试环境 (test)
|
||||
# ./scripts/deploy.sh -eximg 排除 images 目录,上传至正式环境
|
||||
# ./scripts/deploy.sh -eximg -test 排除 images 目录,上传至测试环境
|
||||
# -reload 参数:上传完成后刷新 CloudFront 缓存
|
||||
# ./scripts/deploy.sh -reload 上传正式环境并刷新缓存
|
||||
# ./scripts/deploy.sh -eximg -test -reload 排除 images,上传测试环境并刷新缓存
|
||||
# 部署脚本 - 支持 test/prod 环境与 images 目录排除
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
用法: ./scripts/deploy.sh [环境] [选项]
|
||||
|
||||
环境(可选,默认 prod):
|
||||
prod 上传至正式环境 (s3://.../v2/)
|
||||
test 上传至测试环境 (s3://.../test/)
|
||||
|
||||
选项(可合并,如 -ir):
|
||||
-i 排除 images 目录 (*images/*)
|
||||
-r 上传完成后刷新 CloudFront 缓存
|
||||
-h 显示此帮助
|
||||
|
||||
示例:
|
||||
./scripts/deploy.sh # prod,不排除 images,不刷新
|
||||
./scripts/deploy.sh test # test 环境
|
||||
./scripts/deploy.sh test -ir # test,排除 images,并刷新缓存
|
||||
./scripts/deploy.sh prod -i # prod,排除 images
|
||||
./scripts/deploy.sh -r # prod,刷新缓存
|
||||
EOF
|
||||
}
|
||||
|
||||
# 自动识别项目名称 (x8 / x9),依据脚本所在的项目根目录名
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(basename "$(dirname "$SCRIPT_DIR")")"
|
||||
@@ -26,24 +39,49 @@ if [ -z "$PROJECT_NAME" ]; then
|
||||
fi
|
||||
|
||||
# 解析参数
|
||||
TARGET_ENV="prod" # 默认正式环境
|
||||
EXCLUDE_IMAGES=false # 默认不排除 images
|
||||
RELOAD=false # 默认不刷新 CloudFront 缓存
|
||||
TARGET_ENV="prod"
|
||||
EXCLUDE_IMAGES=false
|
||||
RELOAD=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-test)
|
||||
TARGET_ENV="test"
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-eximg)
|
||||
EXCLUDE_IMAGES=true
|
||||
test|prod)
|
||||
TARGET_ENV="$arg"
|
||||
;;
|
||||
-reload)
|
||||
RELOAD=true
|
||||
-*)
|
||||
flags="${arg#-}"
|
||||
if [ -z "$flags" ]; then
|
||||
echo "未知参数: $arg"
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
for ((i = 0; i < ${#flags}; i++)); do
|
||||
c="${flags:i:1}"
|
||||
case "$c" in
|
||||
i)
|
||||
EXCLUDE_IMAGES=true
|
||||
;;
|
||||
r)
|
||||
RELOAD=true
|
||||
;;
|
||||
*)
|
||||
echo "未知选项: -$c"
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $arg"
|
||||
echo "用法: ./scripts/deploy.sh [-test] [-eximg] [-reload]"
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -79,6 +117,14 @@ echo "命令: ${CMD_ARGS[*]}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
read -r -p "确认执行部署? 输入 y 继续: " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "已取消部署。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 执行同步
|
||||
"${CMD_ARGS[@]}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user