feat(firmware): 缓存并异步获取固件版本名称以优化UI展示
- 新增固件版本名称本地缓存机制,缓存有效期30分钟,存储于localStorage - 实现本地读取缓存函数,确保读取失败、无效或过期时返回null - 实现缓存写入函数,容忍私密模式或配额限制导致的写入失败 - 新增异步获取云端固件版本名称函数,支持开发环境代理和生产环境自动切换 - SystemPage中首选尝试读取本地缓存,命中则直接显示版本名称避免接口请求 - 缓存未命中时异步调用云端接口获取版本名称,失败则使用本地格式兜底显示 - UI中固件版本行新增loading动画指示异步加载状态 - 调整固件版本格式显示函数,自动识别新旧版本号格式展示方式
This commit is contained in:
+102
-1
@@ -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=<device>&version=<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<string | null> {
|
||||
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<LuxsinAudioBrand[]> {
|
||||
const res = await fetch(buildLuxsinAudioUrl("getBrand"));
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
</div>
|
||||
<div className="ios-list-row">
|
||||
<span className="flex-1 text-[16px] text-white">{ui.firmwareVersion}</span>
|
||||
<span className="ios-row-value">{formatFirmwareVersionDisplay(ds?.version)}</span>
|
||||
<span className="ios-row-value inline-flex items-center">
|
||||
{verNameLoading ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
verName ?? "—"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ios-list-row">
|
||||
<span className="flex-1 text-[16px] text-white">{ui.macAddress}</span>
|
||||
|
||||
Reference in New Issue
Block a user