feat(system): 添加固件版本名称缓存及云端获取功能

- 在 luxsinApi.ts 中实现固件版本名称的本地缓存机制,缓存时长为30分钟
- 新增读取和写入 localStorage 缓存的函数,避免频繁调用云端API
- 实现 fetchFirmwareVersionName 函数,从云端获取固件版本名称并结合本地缓存使用
- 在 SystemPage.tsx 中优先从缓存读取固件版本名称,未命中时异步调用云端接口
- 增加加载状态展示,接口异常或无数据时用本地格式化版本名称兜底显示
- 避免因缓存无效或接口异常导致版本名称缺失,提升系统页固件版本显示的稳定性和体验
This commit is contained in:
eafonyang
2026-09-09 14:14:27 +08:00
parent cf7a1ba664
commit 8d13847668
2 changed files with 144 additions and 4 deletions
+95
View File
@@ -645,6 +645,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"));
+49 -4
View File
@@ -9,11 +9,15 @@
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 } from "react";
import { useEffect, useState } from "react";
import BottomNav from "@/components/BottomNav";
import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
import {
fetchFirmwareVersionName,
formatFirmwareVersionDisplay,
readCachedFirmwareVersionName,
} from "@/lib/luxsinApi";
import { navigateToSelect } from "./SelectPage";
import localeEn from "@/locales/data-en.json";
import { DEFAULT_DEVICE_LANGUAGE, resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
@@ -126,6 +130,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;
@@ -212,7 +251,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>