Layout.tsx 13 KB
import { useState, useEffect } from "react";
import { Outlet, Link, useLocation } from "react-router";
import type { LucideIcon } from "lucide-react";
import {
  Menu,
  Home,
  Mountain,
  Star,
  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";

const BG_ROTATE_MS = 7000;

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

export function Layout() {
  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 isAdminPage = location.pathname === "/admin";
  const isViewportLockPage = isTourPage || isSearchPage || isGuidePage;
  /** 顶栏品牌标题全站统一(非首页顶栏显示) */
  const headerBrandTitle = "稻城天文台信息查询";

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

  /** 启动时从后端拉取展台 bundle,写入 localStorage 并触发 KIOSK_EVENT(后端不可用时静默使用本地/默认) */
  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: "首页", icon: Home },
    { id: "tour", path: "/tour", label: "共享天文台", icon: Share2 },
    { id: "planetarium", path: "/planetarium", label: "天文馆导览", icon: Compass },
    { id: "wiki", path: "/search", label: "天文知识库", icon: Library },
    {
      id: "web",
      href: "https://www.cas.cn/",
      label: "天文馆官网",
      icon: Globe,
      external: true,
    },
    { id: "guide", path: "/guide", label: "操作指南", icon: BookOpen },
    { id: "maintain", path: "/admin", label: "系统维护", 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>
                  <div className="flex min-w-0 items-center gap-2 sm:gap-3">
                    <div className="relative shrink-0">
                      <Mountain className="h-8 w-8 text-sky-400 sm:h-9 sm:w-9 lg:h-10 lg:w-10 landscape:h-7 landscape:w-7 landscape:sm:h-8 landscape:sm:w-8" />
                      <Star className="absolute -right-0.5 -top-0.5 h-4 w-4 text-sky-300 sm:h-5 sm:w-5" />
                    </div>
                    <div className="min-w-0">
                      <h1 className="truncate text-lg font-bold text-white sm:text-xl lg:text-2xl">
                        {headerBrandTitle}
                      </h1>
                    </div>
                  </div>
                </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>
  );
}