diff --git a/public/assets/stage/bgm.wav b/public/assets/stage/bgm.wav new file mode 100644 index 0000000..5c99cf6 Binary files /dev/null and b/public/assets/stage/bgm.wav differ diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a6c87cc..3febb7a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,28 +1,16 @@ import { useEffect } from 'react' -import { Outlet, useLocation } from 'react-router-dom' import { useIsMobile } from '../hooks/useMobile' import { StageProvider, useRemoteMode, useStageMode } from '../hooks/useStageMode' import { MobileShell } from '../mobile/MobileShell' import { MobileViews } from '../mobile/MobileViews' import { RemoteShell } from '../stage/remote/RemoteShell' import { StageShell } from '../stage/StageShell' -import { Footer } from './Footer' -import { Nav } from './Nav' import './Layout.css' -const techRoutes = new Set(['/tech', '/subsidy', '/partner', '/tech-detail', '/contact']) - function LayoutInner() { - const location = useLocation() const isStage = useStageMode() const isRemote = useRemoteMode() const isMobile = useIsMobile(768) - const techLike = techRoutes.has(location.pathname) - - useEffect(() => { - if (isMobile) return - window.scrollTo({ top: 0, behavior: 'auto' }) - }, [location.pathname, isMobile]) useEffect(() => { const stageOn = isStage && !isMobile @@ -42,27 +30,15 @@ function LayoutInner() { return } - if (isMobile) { - return ( - - - - ) - } - - if (isStage) { + if (isStage && !isMobile) { return } - // 3. Desktop Normal Mode Branch + // PC 普通 + 手机:五页菜单切换,页内上下滚动(无下拉子菜单) return ( -
-
+ + + ) } diff --git a/src/mobile/MobileShell.css b/src/mobile/MobileShell.css index 4297e42..a8981df 100644 --- a/src/mobile/MobileShell.css +++ b/src/mobile/MobileShell.css @@ -1,14 +1,15 @@ -/* ===== Mobile Shell Dedicated Styling ===== */ +/* ===== Ordinary shell: one page + menu switch (mobile + desktop) ===== */ .mobile-shell { - min-height: 100vh; - min-height: 100dvh; + height: 100vh; + height: 100dvh; + max-height: 100vh; + max-height: 100dvh; display: flex; flex-direction: column; background: var(--paper, #f7f2ea); color: var(--ink, #1a1512); - padding-bottom: env(safe-area-inset-bottom, 0px); position: relative; - overflow-x: hidden; + overflow: hidden; } .m-header { @@ -26,10 +27,16 @@ background: rgba(247, 242, 234, 0.96); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(166, 28, 32, 0.12); + transition: background 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; +} + +.m-header-inner { display: flex; flex-direction: column; justify-content: center; gap: 0.25rem; + width: 100%; + box-sizing: border-box; } .m-brand { @@ -86,7 +93,122 @@ .m-main { flex: 1; + min-height: 0; width: 100%; - padding-top: calc(5.6rem + env(safe-area-inset-top, 0px)); + padding-top: 0; box-sizing: border-box; + overflow: hidden; +} + +/* ===== Desktop PC nav: classic top bar, not mobile chips ===== */ +@media (min-width: 768px) { + .is-desktop-longpage .m-header { + min-height: 4.5rem; + padding: 0; + background: rgba(247, 242, 234, 0.88); + border-bottom-color: transparent; + } + + .is-desktop-longpage .m-header.is-scrolled { + background: rgba(247, 242, 234, 0.96); + border-bottom-color: rgba(166, 28, 32, 0.14); + box-shadow: 0 0.65rem 1.75rem rgba(83, 42, 18, 0.06); + } + + .is-desktop-longpage .m-header.is-tech { + background: rgba(12, 11, 16, 0.9); + border-bottom-color: rgba(201, 154, 69, 0.22); + } + + .is-desktop-longpage .m-header.is-tech.is-scrolled { + background: rgba(12, 11, 16, 0.96); + box-shadow: 0 0.65rem 1.75rem rgba(0, 0, 0, 0.28); + } + + .is-desktop-longpage .m-header-inner { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 2rem; + width: min(100% - 3rem, 77.5rem); + margin: 0 auto; + min-height: 4.5rem; + padding: 0.65rem 0; + } + + .is-desktop-longpage .m-brand { + gap: 0.7rem; + } + + .is-desktop-longpage .m-logo { + width: 2.35rem; + height: 2.35rem; + } + + .is-desktop-longpage .m-title { + font-size: 1.2rem; + letter-spacing: 0.16em; + font-weight: 700; + } + + .is-desktop-longpage .m-header.is-tech .m-title { + color: #fff4df; + } + + .is-desktop-longpage .m-header-nav { + width: auto; + flex: 1; + justify-content: flex-end; + gap: 0.15rem 1.35rem; + flex-wrap: wrap; + } + + .is-desktop-longpage .m-nav-link { + position: relative; + flex: 0 0 auto; + min-height: auto; + padding: 0.45rem 0.1rem; + border-radius: 0; + background: transparent !important; + font-size: 0.92rem; + letter-spacing: 0.14em; + color: #6b5344; + transition: color 0.2s ease; + } + + .is-desktop-longpage .m-nav-link:hover { + color: var(--brand, #a61c20); + } + + .is-desktop-longpage .m-nav-link.is-active { + color: var(--brand, #a61c20); + font-weight: 700; + } + + .is-desktop-longpage .m-nav-link.is-active::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0.15rem; + height: 1px; + background: var(--brand, #a61c20); + } + + .is-desktop-longpage .m-header.is-tech .m-nav-link { + color: rgba(255, 244, 223, 0.7); + } + + .is-desktop-longpage .m-header.is-tech .m-nav-link:hover, + .is-desktop-longpage .m-header.is-tech .m-nav-link.is-active { + color: var(--gold-light, #f1d99a); + } + + .is-desktop-longpage .m-header.is-tech .m-nav-link.is-active::after { + background: var(--gold-light, #f1d99a); + } + + .is-desktop-longpage .m-main { + padding-top: 0; + } } diff --git a/src/mobile/MobileShell.tsx b/src/mobile/MobileShell.tsx index 4f19999..d42c3f9 100644 --- a/src/mobile/MobileShell.tsx +++ b/src/mobile/MobileShell.tsx @@ -1,10 +1,18 @@ -import { type ReactNode } from 'react' -import { Link, Outlet, useLocation } from 'react-router-dom' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import { Link, Outlet } from 'react-router-dom' import { images } from '../content/site' -import { Footer } from '../components/Footer' +import { useIsMobile } from '../hooks/useMobile' import './MobileShell.css' -const mobileNav = [ +export const longNav = [ { id: 'm-home', label: '首页' }, { id: 'm-culture', label: '酒祖文化' }, { id: 'm-qingxiang', label: '清香臻品' }, @@ -12,48 +20,106 @@ const mobileNav = [ { id: 'm-contact', label: '合作联系' }, ] as const -function scrollToSection(id: string) { - const el = document.getElementById(id) - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }) +export type NavId = (typeof longNav)[number]['id'] + +type OrdinaryNavContextValue = { + activeId: NavId + goSection: (id: NavId, opts?: { force?: boolean }) => void +} + +const OrdinaryNavContext = createContext(null) + +export function useOrdinaryNav() { + const ctx = useContext(OrdinaryNavContext) + if (!ctx) { + throw new Error('useOrdinaryNav must be used within MobileShell') } + return ctx +} + +function isNavId(id: string): id is NavId { + return longNav.some((n) => n.id === id) +} + +function resetActivePanelScroll() { + const scrollEl = document.querySelector('.m-panel.is-active .m-panel-scroll') + if (scrollEl) scrollEl.scrollTop = 0 } export function MobileShell({ children }: { children?: ReactNode }) { - const location = useLocation() - const activeId = location.hash.replace('#', '') || 'm-home' + const isMobile = useIsMobile(768) + const [activeId, setActiveId] = useState(() => { + const hash = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : '' + return hash && isNavId(hash) ? hash : 'm-home' + }) + const [scrolled, setScrolled] = useState(false) - const goSection = (id: string) => { - requestAnimationFrame(() => scrollToSection(id)) - } + const goSection = useCallback((id: NavId, opts?: { force?: boolean }) => { + setActiveId((prev) => { + if (prev !== id || opts?.force) { + queueMicrotask(() => { + setScrolled(false) + resetActivePanelScroll() + }) + } + return id + }) + const nextHash = `#${id}` + if (window.location.hash !== nextHash) { + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}${nextHash}`, + ) + } + }, []) + + useEffect(() => { + const scrollEl = document.querySelector('.m-panel.is-active .m-panel-scroll') + if (!scrollEl) return + + const onScroll = () => setScrolled(scrollEl.scrollTop > 16) + onScroll() + scrollEl.addEventListener('scroll', onScroll, { passive: true }) + return () => scrollEl.removeEventListener('scroll', onScroll) + }, [activeId]) + + const techTheme = activeId === 'm-haoke' + const navValue = useMemo(() => ({ activeId, goSection }), [activeId, goSection]) return ( -
-
- goSection('m-home')}> - 杜康好客 - 杜康好客 - - - -
-
- {children || } -
+ +
+ -