Layout.tsx 12.4 KB
import { useState, useEffect } from "react";
import { Outlet, Link, useLocation } from "react-router";
import type { LucideIcon } from "lucide-react";
import {
  Menu,
  Home,
  Share2,
  Compass,
  Library,
  Globe,
  BookOpen,
  Wrench,
} from "lucide-react";
import { TibetanPattern } from "./TibetanPattern";
import { fetchAndApplyKioskBundle } from "../api/kioskApi";
import { DEFAULT_HOME_BG_URLS, KIOSK_EVENT, loadHomeBackgrounds } from "../kioskStorage";
import { useI18n } from "../i18n";

const BG_ROTATE_MS = 7000;

function preloadImages(urls: readonly string[]) {
  urls.forEach((src) => {
    const img = new Image();
    img.src = src;
  });
}

export function Layout() {
  const { t } = useI18n();
  const [sidebarOpen, setSidebarOpen] = useState(false);
  const [homeBgIndex, setHomeBgIndex] = useState(0);
  const [homeBackgrounds, setHomeBackgrounds] = useState(loadHomeBackgrounds);
  const location = useLocation();
  const isHome = location.pathname === "/";
  const isTourPage = location.pathname === "/tour";
  const isSearchPage = location.pathname === "/search";
  const isGuidePage = location.pathname === "/guide";
  const isWebPage = location.pathname === "/web";
  const isAdminPage = location.pathname === "/admin";
  const isViewportLockPage = isTourPage || isSearchPage || isGuidePage || isWebPage;
  /** 顶栏品牌标题全站统一(非首页顶栏显示) */
  const headerBrandTitle = t("brand.title");

  useEffect(() => {
    const sync = () => {
      setHomeBackgrounds(loadHomeBackgrounds());
    };
    window.addEventListener(KIOSK_EVENT, sync);
    return () => {
      window.removeEventListener(KIOSK_EVENT, sync);
    };
  }, []);

  /** 启动时从后端拉取 bundle 写入内存(不写 localStorage;失败时首页背景等仍用内置默认图) */
  useEffect(() => {
    let cancelled = false;
    void (async () => {
      try {
        await fetchAndApplyKioskBundle();
      } catch (e) {
        if (!cancelled) {
          console.warn("[kiosk] 无法从后端拉取 bundle", e);
        }
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  useEffect(() => {
    preloadImages(homeBackgrounds);
  }, [homeBackgrounds]);

  useEffect(() => {
    setHomeBgIndex((i) =>
      homeBackgrounds.length === 0 ? 0 : Math.min(i, homeBackgrounds.length - 1)
    );
  }, [homeBackgrounds.length]);

  useEffect(() => {
    if (!isHome) return;
    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
    if (reduceMotion.matches) return;

    const n = homeBackgrounds.length;
    if (n <= 1) return;
    const interval = window.setInterval(() => {
      setHomeBgIndex((prev) => (prev + 1) % n);
    }, BG_ROTATE_MS);
    return () => window.clearInterval(interval);
  }, [isHome, homeBackgrounds.length]);

  useEffect(() => {
    const root = document.documentElement;
    if (isViewportLockPage) {
      root.classList.add("app-viewport-lock");
    } else {
      root.classList.remove("app-viewport-lock");
    }
    return () => root.classList.remove("app-viewport-lock");
  }, [isViewportLockPage]);

  /** 与首页六块入口顺序、文案一致;首页不含「首页」项,顶栏保留首页便于返回 */
  const navItems: (
    | { id: string; path: string; label: string; icon: LucideIcon }
    | { id: string; href: string; label: string; icon: LucideIcon; external: true }
  )[] = [
    { id: "home", path: "/", label: t("nav.home"), icon: Home },
    { id: "tour", path: "/tour", label: t("nav.tour"), icon: Share2 },
    { id: "planetarium", path: "/planetarium", label: t("nav.planetarium"), icon: Compass },
    { id: "wiki", path: "/search", label: t("nav.wiki"), icon: Library },
    { id: "web", path: "/web", label: t("nav.web"), icon: Globe },
    { id: "guide", path: "/guide", label: t("nav.guide"), icon: BookOpen },
    { id: "maintain", path: "/admin", label: t("nav.maintain"), icon: Wrench },
  ];

  return (
    <div
      className={
        isViewportLockPage
          ? "relative flex h-full min-h-0 w-full max-w-[100%] flex-1 flex-col overflow-hidden"
          : isAdminPage
            ? "relative flex h-[100dvh] max-h-[100dvh] min-h-0 w-full max-w-[100%] flex-1 flex-col overflow-x-hidden overflow-y-hidden"
            : "layout-min-h-touch relative min-h-[100dvh] min-h-screen w-full min-w-0 max-w-[100%] overflow-x-hidden"
      }
    >
      {/* 动态背景层:首页多图轮播;其余页固定首页首张 + 与操作指南同款渐变 */}
      <div className="fixed inset-0 z-0" aria-hidden>
        {isHome ? (
          <>
            {(homeBackgrounds.length ? homeBackgrounds : [...DEFAULT_HOME_BG_URLS]).map((bg, index) => (
              <div
                key={`${bg}-${index}`}
                className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat transition-opacity duration-2000 ease-in-out"
                style={{
                  backgroundImage: `url(${bg})`,
                  opacity: homeBgIndex === index ? 1 : 0,
                  zIndex: homeBgIndex === index ? 1 : 0,
                }}
              />
            ))}
            <div className="pointer-events-none absolute inset-0 z-[2] bg-gradient-to-b from-slate-950/55 via-blue-950/75 to-black/92" />
          </>
        ) : (
          <>
            <div
              className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
              style={{
                backgroundImage: `url(${(homeBackgrounds[0] ?? DEFAULT_HOME_BG_URLS[0])})`,
              }}
            />
            <div className="pointer-events-none absolute inset-0 z-[2] bg-gradient-to-b from-slate-950/55 via-blue-950/75 to-black/92" />
          </>
        )}
      </div>

      {/* 藏式装饰纹理叠层(全站隐藏,与操作指南所用背景风格一致) */}
      <div className="pointer-events-none fixed inset-0 z-0 text-sky-300 opacity-0 transition-opacity duration-500">
        <TibetanPattern />
      </div>

      {/* 主容器:共享天文台为固定一屏高度;其余页至少一屏高 */}
      <div
        className={
          isViewportLockPage || isAdminPage
            ? "relative z-10 flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden"
            : "layout-min-h-touch relative z-10 flex min-h-[100dvh] min-h-screen w-full min-w-0 flex-col"
        }
      >
        {/* 顶部导航栏 */}
        <header
          className={`shrink-0 pt-[env(safe-area-inset-top,0px)] ${
            isHome
              ? "border-none bg-transparent shadow-none backdrop-blur-none"
              : "border-b border-white/10 bg-blue-950/40 backdrop-blur-md"
          }`}
        >
          {!isHome ? (
            <div className="px-4 py-3 sm:px-6 sm:py-3.5 lg:px-8 lg:py-4 landscape:py-2 landscape:lg:py-3">
              <div className="flex items-center justify-between gap-2 pl-[max(0px,env(safe-area-inset-left,0px))] pr-[max(0px,env(safe-area-inset-right,0px))] sm:gap-4">
                <div className="flex min-w-0 items-center gap-2 sm:gap-4">
                  <button
                    onClick={() => setSidebarOpen(!sidebarOpen)}
                    className="shrink-0 rounded-lg p-2 transition-colors hover:bg-white/10 lg:hidden"
                    type="button"
                    aria-label="打开菜单"
                  >
                    <Menu className="h-6 w-6 text-white" />
                  </button>
                  <h1 className="min-w-0 truncate text-lg font-bold text-white sm:text-xl lg:text-2xl">
                    {headerBrandTitle}
                  </h1>
                </div>

                <nav className="hidden shrink-0 flex-wrap items-center justify-end gap-1 lg:ml-auto lg:flex xl:gap-2">
                  {navItems.map((item) => {
                    const Icon = item.icon;
                    const isActive =
                      "path" in item && location.pathname === item.path;
                    const className = `flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm transition-all xl:gap-2 xl:px-4 xl:py-2.5 xl:text-base ${
                      isActive
                        ? "bg-sky-500/90 text-white shadow-lg shadow-sky-500/30"
                        : "bg-transparent text-white hover:bg-white/10"
                    }`;
                    if ("external" in item && item.external) {
                      return (
                        <a
                          key={item.id}
                          href={item.href}
                          target="_blank"
                          rel="noopener noreferrer"
                          className={className}
                        >
                          <Icon className="h-5 w-5 shrink-0" />
                          <span className="font-medium">{item.label}</span>
                        </a>
                      );
                    }
                    return (
                      <Link key={item.id} to={item.path} className={className}>
                        <Icon className="h-5 w-5 shrink-0" />
                        <span className="font-medium">{item.label}</span>
                      </Link>
                    );
                  })}
                </nav>
              </div>
            </div>
          ) : null}
        </header>

        {/* 移动端侧边栏 */}
        {sidebarOpen && (
          <div
            className={`fixed inset-0 z-50 ${isHome ? "" : "lg:hidden"}`}
          >
            <div
              className="absolute inset-0 bg-black/60 backdrop-blur-sm"
              onClick={() => setSidebarOpen(false)}
            />
            <div className="absolute bottom-0 left-0 top-0 flex w-[min(100%,20rem)] max-w-[85vw] flex-col border-r border-white/10 bg-blue-950/95 p-4 pt-[max(1.5rem,env(safe-area-inset-top,0px))] backdrop-blur-xl sm:w-80 sm:p-6">
              <div className="flex items-center justify-between mb-8">
                <h2 className="text-xl font-bold text-white">导航菜单</h2>
                <button
                  onClick={() => setSidebarOpen(false)}
                  className="p-2 rounded-lg hover:bg-white/10 text-white"
                >
                  ×
                </button>
              </div>
              <nav className="space-y-2">
                {navItems.map((item) => {
                  const Icon = item.icon;
                  const isActive =
                    "path" in item && location.pathname === item.path;
                  const className = `flex items-center gap-3 px-4 py-4 rounded-lg transition-all ${
                    isActive
                      ? "bg-sky-500/90 text-white"
                      : "bg-transparent text-white hover:bg-white/10"
                  }`;
                  if ("external" in item && item.external) {
                    return (
                      <a
                        key={item.id}
                        href={item.href}
                        target="_blank"
                        rel="noopener noreferrer"
                        onClick={() => setSidebarOpen(false)}
                        className={className}
                      >
                        <Icon className="h-6 w-6 shrink-0" />
                        <span className="font-medium text-lg">{item.label}</span>
                      </a>
                    );
                  }
                  return (
                    <Link
                      key={item.id}
                      to={item.path}
                      onClick={() => setSidebarOpen(false)}
                      className={className}
                    >
                      <Icon className="h-6 w-6 shrink-0" />
                      <span className="font-medium text-lg">{item.label}</span>
                    </Link>
                  );
                })}
              </nav>
            </div>
          </div>
        )}

        {/* 页面内容;共享天文台单屏布局:占满剩余高度且不出现主区域整页滚动 */}
        <main
          className={`flex min-h-0 min-w-0 w-full flex-1 flex-col ${
            isHome ? "pb-[env(safe-area-inset-bottom,0px)]" : ""
          } ${
            isViewportLockPage || isAdminPage
              ? "overflow-x-hidden overflow-y-hidden"
              : "overflow-x-hidden overflow-y-auto"
          }`}
        >
          <div
            className={`w-full min-w-0 max-w-full ${
              isViewportLockPage || isAdminPage
                ? "flex min-h-0 flex-1 flex-col"
                : "my-auto shrink-0"
            }`}
          >
            <Outlet />
          </div>
        </main>
      </div>
    </div>
  );
}