diff --git a/client/src/App.tsx b/client/src/App.tsx index 3a41676..1d2612f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import SelectPage, { consumeSelectReturnScroll } from "./pages/SelectPage"; import CommunityPage from "./pages/CommunityPage"; import ChangelogPage from "./pages/ChangelogPage"; import { AIDrawerProvider } from "./contexts/AIDrawerContext"; +import { NavConfigProvider, useNavConfig } from "./contexts/NavConfigContext"; import DesktopAIShell from "./components/DesktopAIShell"; import { appShellColumnClass, appShellOuterClass } from "./lib/appShell"; @@ -63,6 +64,23 @@ function SelectScrollRestore() { 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() { return ( @@ -94,14 +112,17 @@ function App() { - -
-
- - - + + + +
+
+ + + +
-
+ diff --git a/client/src/components/BottomNav.tsx b/client/src/components/BottomNav.tsx index de87961..e9eccd6 100644 --- a/client/src/components/BottomNav.tsx +++ b/client/src/components/BottomNav.tsx @@ -7,6 +7,7 @@ - Inactive: muted icon + label ============================================================ */ import { useDevice } from "@/contexts/DeviceContext"; +import { useNavConfig } from "@/contexts/NavConfigContext"; import { cn } from "@/lib/utils"; import { readAndClearAiSplitRestorePath, useAIDrawer } from "@/contexts/AIDrawerContext"; import { useIsLgUp } from "@/hooks/useIsLgUp"; @@ -99,8 +100,15 @@ export default function BottomNav() { const isLgUp = useIsLgUp(); const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer(); const { deviceState } = useDevice(); + const { isNavHidden } = useNavConfig(); const festival = useFestival(); + // 按 MAC 下发的隐藏配置过滤入口(fail-open:无配置时全部显示) + const navItems = useMemo( + () => NAV_ITEMS.filter((item) => !isNavHidden(item.labelKey as string)), + [isNavHidden], + ); + const navText = useMemo((): BottomNavLocale => { const pack = resolveLocalePack(deviceState?.language); return (pack.bottomNav ?? localeEn.bottomNav) as BottomNavLocale; @@ -122,7 +130,7 @@ export default function BottomNav() { }} >
- {NAV_ITEMS.map((item) => { + {navItems.map((item) => { const isActive = item.path === "/ai" ? location === "/ai" || (isLgUp && aiDrawerOpen) diff --git a/client/src/contexts/NavConfigContext.tsx b/client/src/contexts/NavConfigContext.tsx new file mode 100644 index 0000000..01503d1 --- /dev/null +++ b/client/src/contexts/NavConfigContext.tsx @@ -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 = { + 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; + /** 某个 nav key 是否被隐藏 */ + isNavHidden: (key: string) => boolean; + /** 某个路由 path 是否因入口隐藏而被拦截 */ + isPathHidden: (path: string) => boolean; +}; + +const NavConfigContext = createContext(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([]); + + 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(() => { + 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 ( + {children} + ); +} + +export function useNavConfig(): NavConfigContextValue { + const ctx = useContext(NavConfigContext); + if (!ctx) { + throw new Error("useNavConfig must be used within NavConfigProvider"); + } + return ctx; +} diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 8e9c87c..1275e9c 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -1159,3 +1159,22 @@ export async function deleteShareCode( if (!res.ok) throw new Error(`shareDelete HTTP ${res.status}`); 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 { + 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; +}