From ab5d85841bb80047e81e67dbfae7dfda7a3c6eaf Mon Sep 17 00:00:00 2001 From: eafonyang Date: Wed, 9 Sep 2026 14:14:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(firmware):=20=E7=BC=93=E5=AD=98=E5=B9=B6?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E8=8E=B7=E5=8F=96=E5=9B=BA=E4=BB=B6=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=90=8D=E7=A7=B0=E4=BB=A5=E4=BC=98=E5=8C=96UI?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增固件版本名称本地缓存机制,缓存有效期30分钟,存储于localStorage - 实现本地读取缓存函数,确保读取失败、无效或过期时返回null - 实现缓存写入函数,容忍私密模式或配额限制导致的写入失败 - 新增异步获取云端固件版本名称函数,支持开发环境代理和生产环境自动切换 - SystemPage中首选尝试读取本地缓存,命中则直接显示版本名称避免接口请求 - 缓存未命中时异步调用云端接口获取版本名称,失败则使用本地格式兜底显示 - UI中固件版本行新增loading动画指示异步加载状态 - 调整固件版本格式显示函数,自动识别新旧版本号格式展示方式 --- client/src/lib/luxsinApi.ts | 103 +++++++++++++++++++++++++++++++- client/src/pages/SystemPage.tsx | 47 ++++++++++++++- 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index d003fab..0a9b38e 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -423,11 +423,17 @@ export function parseFirmwareVersion(version: unknown): number { return Number(parts[parts.length - 1]); } -/** UI display: build number 26 → `1.0.0.26`. Already-prefixed values are unchanged. */ +/** + * UI display for the firmware build number. + * - Build >= 30 uses the new scheme: 30 → `1.0.30.0`, 31 → `1.0.31.0`, … + * - Build < 30 keeps the legacy scheme: 26 → `1.0.0.26` (already-prefixed unchanged). + */ export function formatFirmwareVersionDisplay(version: unknown): string { if (version === null || version === undefined) return "—"; const raw = String(version).trim(); if (!raw) return "—"; + const n = parseFirmwareVersion(raw); + if (n >= 30) return `1.0.${n}.0`; if (/^1\.0\.0\./i.test(raw)) return raw; return `1.0.0.${raw}`; } @@ -559,6 +565,101 @@ export function buildLuxsinAudioUrl(resourcePath: string): string { return `https://api.luxsin.com.cn/audio/${path}`; } +/** + * Firmware version-name cache. `getVerName` results are stable for a given + * model+version, so we cache them in `localStorage` for 30 minutes to avoid + * hitting the cloud API every time the SystemPage is opened. + */ +const VER_NAME_CACHE_PREFIX = "luxsin:verName:"; +const VER_NAME_CACHE_TTL_MS = 30 * 60 * 1000; + +function verNameCacheKey(model: string, version: string): string { + return `${VER_NAME_CACHE_PREFIX}${model}:${version}`; +} + +/** + * Synchronously read a cached firmware version name for `model`+`version`. + * Returns `null` when there is no entry, it is malformed, or it has expired + * (expired entries are dropped so the next fetch refreshes them). Storage + * access is best-effort — any failure (private mode / quota) yields `null`. + */ +export function readCachedFirmwareVersionName( + model: string, + version: string | number, +): string | null { + const m = String(model ?? "").trim(); + const v = String(version ?? "").trim(); + if (!m || !v) return null; + const key = verNameCacheKey(m, v); + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as { verName?: unknown; ts?: unknown }; + const verName = typeof parsed?.verName === "string" ? parsed.verName.trim() : ""; + const ts = typeof parsed?.ts === "number" ? parsed.ts : 0; + if (!verName || !ts) return null; + if (Date.now() - ts > VER_NAME_CACHE_TTL_MS) { + localStorage.removeItem(key); + return null; + } + return verName; + } catch { + return null; + } +} + +function writeCachedFirmwareVersionName( + model: string, + version: string, + verName: string, +): void { + try { + localStorage.setItem( + verNameCacheKey(model, version), + JSON.stringify({ verName, ts: Date.now() }), + ); + } catch { + /* storage unavailable (private mode / quota) — caching is best-effort */ + } +} + +/** + * Fetch the firmware display version name from the cloud catalog. + * `GET getVerName?model=&version=` → `{ code, msg, data: { verName } }`. + * Serves from the 30-minute `localStorage` cache when available; otherwise the + * protocol (http/https) follows the current page, matching the other + * `//api.luxsin.com.cn/audio` calls; dev goes through the Vite proxy to avoid + * Origin-based 403/CORS. Returns `null` when unavailable so callers can fall back. + */ +export async function fetchFirmwareVersionName( + model: string, + version: string | number, +): Promise { + const m = String(model ?? "").trim(); + const v = String(version ?? "").trim(); + if (!m || !v) return null; + + // Serve from the browser cache within the TTL to avoid frequent requests. + const cached = readCachedFirmwareVersionName(m, v); + if (cached) return cached; + + const query = `getVerName?model=${encodeURIComponent(m)}&version=${encodeURIComponent(v)}`; + const url = import.meta.env.DEV + ? `/luxsin-audio-api/${query}` + : `//api.luxsin.com.cn/audio/${query}`; + try { + const res = await fetch(url); + if (!res.ok) return null; + const json = (await res.json()) as { data?: { verName?: unknown } }; + const verName = json?.data?.verName; + const result = typeof verName === "string" && verName.trim() ? verName.trim() : null; + if (result) writeCachedFirmwareVersionName(m, v, result); + return result; + } catch { + return null; + } +} + /** Fetches `getBrand`; body is custom Base64 text, decodes to JSON array of `{ id, name }`. */ export async function fetchLuxsinAudioBrands(): Promise { const res = await fetch(buildLuxsinAudioUrl("getBrand")); diff --git a/client/src/pages/SystemPage.tsx b/client/src/pages/SystemPage.tsx index 25a3c7a..c0ec967 100644 --- a/client/src/pages/SystemPage.tsx +++ b/client/src/pages/SystemPage.tsx @@ -9,7 +9,7 @@ All list rows with options → navigate to /select page ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; import { useLocation } from "wouter"; import { useEffect, useRef, useState } from "react"; import BottomNav from "@/components/BottomNav"; @@ -17,8 +17,10 @@ import { navigateToSelect } from "./SelectPage"; import { supportsAmbientLed } from "@/config/firmwareFeatures"; import { clampLedChannel, + fetchFirmwareVersionName, formatFirmwareVersionDisplay, parseFirmwareVersion, + readCachedFirmwareVersionName, } from "@/lib/luxsinApi"; import localeEn from "@/locales/data-en.json"; import { DEFAULT_DEVICE_LANGUAGE, resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale"; @@ -190,6 +192,41 @@ export default function SystemPage() { } }, [isConnected, api]); + // 固件版本名称:进入系统页时优先读浏览器缓存(30 分钟内命中则直接显示, + // 不再请求接口);未命中才调用云端 getVerName 接口获取;接口异常/无数据时, + // 用本地版本名称计算兜底(本地兜底结果不写缓存)。 + const [verName, setVerName] = useState(null); + const [verNameLoading, setVerNameLoading] = useState(true); + useEffect(() => { + const model = ds?.device?.trim(); + const version = ds?.version; + if (!model || version === undefined || String(version).trim() === "") { + setVerName(null); + setVerNameLoading(false); + return; + } + // 命中缓存:直接显示,不进入 loading,也不请求接口。 + const cached = readCachedFirmwareVersionName(model, version); + if (cached) { + setVerName(cached); + setVerNameLoading(false); + return; + } + let cancelled = false; + setVerNameLoading(true); + fetchFirmwareVersionName(model, version) + .then((name) => { + // 接口异常/无数据:用本地版本名称计算兜底(该结果不缓存)。 + if (!cancelled) setVerName(name ?? formatFirmwareVersionDisplay(version)); + }) + .finally(() => { + if (!cancelled) setVerNameLoading(false); + }); + return () => { + cancelled = true; + }; + }, [ds?.device, ds?.version]); + const screenLightIdx = ds?.screenLight ?? 1; const buttonLightIdx = ds?.buttonLight ?? 1; const screenOffIdx = ds?.screenOff ?? 2; @@ -384,7 +421,13 @@ export default function SystemPage() {
{ui.firmwareVersion} - {formatFirmwareVersionDisplay(ds?.version)} + + {verNameLoading ? ( + + ) : ( + verName ?? "—" + )} +
{ui.macAddress}