根据 mac 地址屏蔽导航栏入口

This commit is contained in:
eafonyang
2026-10-10 17:12:33 +08:00
parent 4ce89e8cc2
commit d1b033a1af
4 changed files with 184 additions and 8 deletions
+28 -7
View File
@@ -18,6 +18,7 @@ import SelectPage, { consumeSelectReturnScroll } from "./pages/SelectPage";
import CommunityPage from "./pages/CommunityPage"; import CommunityPage from "./pages/CommunityPage";
import ChangelogPage from "./pages/ChangelogPage"; import ChangelogPage from "./pages/ChangelogPage";
import { AIDrawerProvider } from "./contexts/AIDrawerContext"; import { AIDrawerProvider } from "./contexts/AIDrawerContext";
import { NavConfigProvider, useNavConfig } from "./contexts/NavConfigContext";
import DesktopAIShell from "./components/DesktopAIShell"; import DesktopAIShell from "./components/DesktopAIShell";
import { appShellColumnClass, appShellOuterClass } from "./lib/appShell"; import { appShellColumnClass, appShellOuterClass } from "./lib/appShell";
@@ -74,6 +75,23 @@ function SelectScrollRestore() {
return null; return null;
} }
/**
* NavRouteGuard — 当某个导航入口被 MAC 隐藏配置屏蔽时,
* 拦截对应路由(如直接访问 /ai),重定向到 /404。
* 首页 "/" 永不拦截,避免锁死。
*/
function NavRouteGuard() {
const [location, setLocation] = useLocation();
const { isPathHidden } = useNavConfig();
useEffect(() => {
if (location === "/") return;
if (isPathHidden(location)) {
setLocation("/404", { replace: true });
}
}, [location, isPathHidden, setLocation]);
return null;
}
function AppRouter() { function AppRouter() {
return ( return (
<Switch> <Switch>
@@ -104,14 +122,17 @@ function App() {
<Toaster /> <Toaster />
<Router hook={useHashLocation}> <Router hook={useHashLocation}>
<AIDrawerProvider> <AIDrawerProvider>
<SelectScrollRestore /> <NavConfigProvider>
<div className={appShellOuterClass}> <SelectScrollRestore />
<div className={appShellColumnClass}> <NavRouteGuard />
<DesktopAIShell> <div className={appShellOuterClass}>
<AppRouter /> <div className={appShellColumnClass}>
</DesktopAIShell> <DesktopAIShell>
<AppRouter />
</DesktopAIShell>
</div>
</div> </div>
</div> </NavConfigProvider>
</AIDrawerProvider> </AIDrawerProvider>
</Router> </Router>
</TooltipProvider> </TooltipProvider>
+9 -1
View File
@@ -7,6 +7,7 @@
- Inactive: muted icon + label - Inactive: muted icon + label
============================================================ */ ============================================================ */
import { useDevice } from "@/contexts/DeviceContext"; import { useDevice } from "@/contexts/DeviceContext";
import { useNavConfig } from "@/contexts/NavConfigContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { readAndClearAiSplitRestorePath, useAIDrawer } from "@/contexts/AIDrawerContext"; import { readAndClearAiSplitRestorePath, useAIDrawer } from "@/contexts/AIDrawerContext";
import { useIsLgUp } from "@/hooks/useIsLgUp"; import { useIsLgUp } from "@/hooks/useIsLgUp";
@@ -99,8 +100,15 @@ export default function BottomNav() {
const isLgUp = useIsLgUp(); const isLgUp = useIsLgUp();
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer(); const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
const { deviceState } = useDevice(); const { deviceState } = useDevice();
const { isNavHidden } = useNavConfig();
const festival = useFestival(); const festival = useFestival();
// 按 MAC 下发的隐藏配置过滤入口(fail-open:无配置时全部显示)
const navItems = useMemo(
() => NAV_ITEMS.filter((item) => !isNavHidden(item.labelKey as string)),
[isNavHidden],
);
const navText = useMemo((): BottomNavLocale => { const navText = useMemo((): BottomNavLocale => {
const pack = resolveLocalePack(deviceState?.language); const pack = resolveLocalePack(deviceState?.language);
return (pack.bottomNav ?? localeEn.bottomNav) as BottomNavLocale; return (pack.bottomNav ?? localeEn.bottomNav) as BottomNavLocale;
@@ -122,7 +130,7 @@ export default function BottomNav() {
}} }}
> >
<div className="flex items-stretch justify-around"> <div className="flex items-stretch justify-around">
{NAV_ITEMS.map((item) => { {navItems.map((item) => {
const isActive = const isActive =
item.path === "/ai" item.path === "/ai"
? location === "/ai" || (isLgUp && aiDrawerOpen) ? location === "/ai" || (isLgUp && aiDrawerOpen)
+128
View File
@@ -0,0 +1,128 @@
/* ============================================================
NAV CONFIG — 按设备 MAC 下发的导航入口隐藏配置
- 连接设备拿到 deviceState.mac 后请求 /audio/getNavConfig
- localStorage 缓存(30min):首屏/离线即时生效,后台再刷新
- fail-open:接口失败或无 mac 时隐藏集为空,默认全部显示,
绝不因后端异常导致导航不可用
============================================================ */
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useDevice } from "@/contexts/DeviceContext";
import { getNavConfig } from "@/lib/luxsinApi";
/** nav key ↔ 路由 path 映射;隐藏入口时据此同步拦截路由。 */
export const NAV_KEY_TO_PATH: Record<string, string> = {
community: "/community",
hpEq: "/eq",
home: "/",
effects: "/effects",
myAi: "/ai",
};
const CACHE_PREFIX = "luxsin:navConfig:";
const CACHE_TTL_MS = 30 * 60 * 1000;
type NavConfigContextValue = {
/** 需要隐藏的 nav key 集合 */
hiddenKeys: Set<string>;
/** 某个 nav key 是否被隐藏 */
isNavHidden: (key: string) => boolean;
/** 某个路由 path 是否因入口隐藏而被拦截 */
isPathHidden: (path: string) => boolean;
};
const NavConfigContext = createContext<NavConfigContextValue | null>(null);
function cacheKey(mac: string): string {
return CACHE_PREFIX + mac.toUpperCase();
}
function readCache(mac: string): string[] | null {
try {
const raw = localStorage.getItem(cacheKey(mac));
if (!raw) return null;
const parsed = JSON.parse(raw) as { at?: number; hidden?: string[] };
if (!parsed || typeof parsed.at !== "number" || !Array.isArray(parsed.hidden)) {
return null;
}
if (Date.now() - parsed.at > CACHE_TTL_MS) {
localStorage.removeItem(cacheKey(mac));
return null;
}
return parsed.hidden;
} catch {
return null;
}
}
function writeCache(mac: string, hidden: string[]): void {
try {
localStorage.setItem(cacheKey(mac), JSON.stringify({ at: Date.now(), hidden }));
} catch {
/* ignore — 隐私模式/配额不足时静默降级 */
}
}
export function NavConfigProvider({ children }: { children: ReactNode }) {
const { deviceState } = useDevice();
const mac = (deviceState?.mac ?? "").trim();
const [hidden, setHidden] = useState<string[]>([]);
useEffect(() => {
if (!mac) {
setHidden([]);
return;
}
// 先用本地缓存立即生效(避免首屏闪烁 / 离线可用),再后台刷新
const cached = readCache(mac);
if (cached) setHidden(cached);
let cancelled = false;
(async () => {
try {
const res = await getNavConfig(mac);
const list = Array.isArray(res.data?.hidden) ? res.data!.hidden! : [];
if (!cancelled) {
setHidden(list);
writeCache(mac, list);
}
} catch {
// fail-open:保留缓存或空数组,默认全部显示
}
})();
return () => {
cancelled = true;
};
}, [mac]);
const value = useMemo<NavConfigContextValue>(() => {
const keySet = new Set(hidden);
const pathSet = new Set(
hidden.map((k) => NAV_KEY_TO_PATH[k]).filter((p): p is string => !!p),
);
return {
hiddenKeys: keySet,
isNavHidden: (key: string) => keySet.has(key),
isPathHidden: (path: string) => pathSet.has(path),
};
}, [hidden]);
return (
<NavConfigContext.Provider value={value}>{children}</NavConfigContext.Provider>
);
}
export function useNavConfig(): NavConfigContextValue {
const ctx = useContext(NavConfigContext);
if (!ctx) {
throw new Error("useNavConfig must be used within NavConfigProvider");
}
return ctx;
}
+19
View File
@@ -935,3 +935,22 @@ export async function deleteShareCode(
if (!res.ok) throw new Error(`shareDelete HTTP ${res.status}`); if (!res.ok) throw new Error(`shareDelete HTTP ${res.status}`);
return (await res.json()) as ShareCodeDeleteResponse; return (await res.json()) as ShareCodeDeleteResponse;
} }
// ============================================================
// Nav Config API — getNavConfig(按 MAC 下发要隐藏的导航入口)
// ============================================================
export interface NavConfigResponse {
code: number;
msg: string;
data?: { hidden?: string[] };
}
/** GET `/audio/getNavConfig?mac=...` — 查询该 MAC 需要隐藏的导航入口(nav key)。未命中返回空数组。 */
export async function getNavConfig(mac: string): Promise<NavConfigResponse> {
const res = await fetch(
buildLuxsinAudioUrl(`getNavConfig?mac=${encodeURIComponent(mac)}`),
);
if (!res.ok) throw new Error(`getNavConfig HTTP ${res.status}`);
return (await res.json()) as NavConfigResponse;
}