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')}>
-
- 杜康好客
-
-
-
+
)
}
diff --git a/src/mobile/MobileViews.css b/src/mobile/MobileViews.css
index d9c2c03..5992dee 100644
--- a/src/mobile/MobileViews.css
+++ b/src/mobile/MobileViews.css
@@ -2,6 +2,86 @@
.m-longpage {
width: 100%;
+ height: 100%;
+}
+
+/* One active panel at a time; page switch via menu only */
+.m-longpage--panels {
+ height: 100%;
+}
+
+.m-panel {
+ display: none;
+ height: 100%;
+ overflow: hidden;
+ flex-direction: column;
+ box-sizing: border-box;
+}
+
+.m-panel.is-active {
+ display: flex;
+}
+
+.m-panel-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding-top: calc(5.6rem + env(safe-area-inset-top, 0px));
+ padding-bottom: 0;
+ overscroll-behavior: contain;
+}
+
+.m-panel-scroll .site-footer {
+ margin-top: 1.5rem;
+ padding-bottom: calc(2.25rem + env(safe-area-inset-bottom, 0px));
+}
+
+.m-panel-tech {
+ background: var(--tech-bg, #121014);
+ color: #f3ece4;
+ border-top-color: rgba(196, 163, 90, 0.18);
+}
+
+.m-panel-tech .m-join-label,
+.m-panel-tech .m-eyebrow,
+.m-panel-tech .m-page-title {
+ color: var(--gold, #c4a35a);
+}
+
+.m-panel-tech .m-lead,
+.m-panel-tech .m-card p,
+.m-panel-tech .m-poem-source,
+.m-panel-tech .m-deep-p,
+.m-panel-tech .m-deep-list {
+ color: rgba(243, 236, 228, 0.75);
+}
+
+.m-panel-tech .m-card {
+ background: rgba(28, 25, 31, 0.85);
+ border-color: rgba(196, 163, 90, 0.22);
+ color: #f3ece4;
+}
+
+.m-panel-tech .m-card h3,
+.m-panel-tech .m-big-num,
+.m-panel-tech .m-closing,
+.m-panel-tech .m-deep-h {
+ color: var(--gold, #c4a35a);
+}
+
+.m-panel-block + .m-panel-block,
+.m-panel-scroll > .m-view-container + .m-panel-block,
+.m-panel-block + .m-view-container {
+ border-top: 1px solid rgba(166, 28, 32, 0.1);
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+}
+
+.m-panel-tech .m-panel-block + .m-panel-block,
+.m-panel-tech .m-panel-scroll > .m-view-container + .m-panel-block {
+ border-top-color: rgba(196, 163, 90, 0.16);
}
.m-section {
@@ -164,10 +244,13 @@
justify-content: center;
min-height: 2.75rem;
padding: 0.6rem 1.25rem;
+ border: none;
border-radius: 0.3rem;
+ font: inherit;
font-size: 0.92rem;
font-weight: 500;
text-decoration: none;
+ cursor: pointer;
transition: all 0.25s ease;
}
@@ -350,3 +433,66 @@
border-radius: 0.35rem;
object-fit: contain;
}
+
+/* Desktop long-page: wider content + multi-column cards */
+@media (min-width: 768px) {
+ .m-panel-scroll {
+ padding-top: 4.5rem;
+ }
+
+ .m-section {
+ scroll-margin-top: 5rem;
+ }
+
+ .m-join-label {
+ padding: 1.5rem max(1.5rem, calc((100vw - 77.5rem) / 2)) 0;
+ font-size: 0.85rem;
+ }
+
+ .m-view-container {
+ max-width: 77.5rem;
+ margin-inline: auto;
+ padding: 2rem 2rem 3.5rem;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1.25rem 1.5rem;
+ align-items: start;
+ }
+
+ .m-view-container > :not(.m-card) {
+ grid-column: 1 / -1;
+ }
+
+ .m-page-title {
+ font-size: clamp(1.75rem, 2.5vw, 2.35rem);
+ }
+
+ .m-lead {
+ font-size: 1.05rem;
+ max-width: 46rem;
+ }
+
+ .m-hero-title {
+ font-size: clamp(2.5rem, 4vw, 3.5rem);
+ }
+
+ .m-hero-img {
+ max-width: 22rem;
+ margin-inline: auto;
+ }
+
+ .m-qr-img {
+ width: 12rem;
+ height: 12rem;
+ }
+
+ .m-deep-blocks {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1100px) {
+ .m-view-container {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
diff --git a/src/mobile/MobileViews.tsx b/src/mobile/MobileViews.tsx
index f8a9541..62faa5c 100644
--- a/src/mobile/MobileViews.tsx
+++ b/src/mobile/MobileViews.tsx
@@ -15,114 +15,145 @@ import {
tech,
techDetail,
} from '../content/site'
+import { Footer } from '../components/Footer'
import { MobileDeepSection, pathToSectionId } from './MobileDeepSection'
+import { useOrdinaryNav, type NavId } from './MobileShell'
import './MobileViews.css'
const cultureDeep = allDeepPages.filter((p) => p.group === 'culture')
const poetryDeep = allDeepPages.filter((p) => p.group === 'poetry')
const qingxiangDeep = allDeepPages.filter((p) => p.group === 'qingxiang')
-const PATH_TO_SECTION: Record = {
+const PATH_TO_SECTION: Record = {
'/': 'm-home',
'/brand': 'm-culture',
- '/poetry': 'm-poetry',
+ '/poetry': 'm-culture',
'/qingxiang': 'm-qingxiang',
'/haoke': 'm-haoke',
- '/system': 'm-system',
- '/tech': 'm-tech',
- '/subsidy': 'm-subsidy',
- '/partner': 'm-partner',
- '/tech-detail': 'm-tech-detail',
+ '/system': 'm-haoke',
+ '/tech': 'm-haoke',
+ '/subsidy': 'm-haoke',
+ '/partner': 'm-contact',
+ '/tech-detail': 'm-haoke',
'/contact': 'm-contact',
- ...Object.fromEntries(allDeepPages.map((p) => [p.path, pathToSectionId(p.path)])),
+ ...Object.fromEntries(
+ allDeepPages.map((p) => [
+ p.path,
+ (p.group === 'qingxiang'
+ ? 'm-qingxiang'
+ : p.group === 'culture' || p.group === 'poetry'
+ ? 'm-culture'
+ : 'm-home') as NavId,
+ ]),
+ ),
+}
+
+function isNavId(id: string): id is NavId {
+ return id === 'm-home' || id === 'm-culture' || id === 'm-qingxiang' || id === 'm-haoke' || id === 'm-contact'
+}
+
+function panelClass(id: NavId, activeId: NavId, extra = '') {
+ return `m-panel${extra ? ` ${extra}` : ''}${activeId === id ? ' is-active' : ''}`
}
export function MobileViews() {
const location = useLocation()
+ const { activeId, goSection } = useOrdinaryNav()
useEffect(() => {
const fromHash = location.hash.replace('#', '')
- const id = fromHash || PATH_TO_SECTION[location.pathname] || 'm-home'
- const timer = window.setTimeout(() => {
- const el = document.getElementById(id)
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
- }, 40)
- return () => window.clearTimeout(timer)
- }, [location.pathname, location.hash])
+ const nextId = (isNavId(fromHash) ? fromHash : PATH_TO_SECTION[location.pathname]) || 'm-home'
+ goSection(nextId)
+ }, [location.pathname, location.hash, goSection])
return (
-
-
-
+
+
-
- {cultureDeep.map((page) => (
-
- ))}
-
-
- {poetryDeep.map((page) => (
-
- ))}
-
-
- {qingxiangDeep.map((page) => (
-
- ))}
-
-
- 好客科技
-
+
+
+
酒祖文化
+
+ {cultureDeep.map((page) => (
+
+
+
+ ))}
+
+
+
+ {poetryDeep.map((page) => (
+
+
+
+ ))}
+
+
-
-
+
+
+
清香臻品
+
+ {qingxiangDeep.map((page) => (
+
+
+
+ ))}
+
+
-
-
+
+
+
好客科技
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
)
}
function MobileHomeView() {
+ const { goSection } = useOrdinaryNav()
+
return (
-
{hero.subBrand} · 移动全景
+
{hero.subBrand}
{hero.brand}
{hero.title}
@@ -132,12 +163,12 @@ function MobileHomeView() {
{hero.subtitle}
diff --git a/src/stage/StageShell.tsx b/src/stage/StageShell.tsx
index 60dbc11..d208d25 100644
--- a/src/stage/StageShell.tsx
+++ b/src/stage/StageShell.tsx
@@ -15,11 +15,24 @@ import {
} from './remote/protocol'
import './StageShell.css'
+const BGM_SRC = '/assets/stage/bgm.wav'
+const BGM_START = 4
+const BGM_END = 35
+
+function clampBgmTime(audio: HTMLAudioElement) {
+ if (audio.currentTime < BGM_START || audio.currentTime >= BGM_END) {
+ audio.currentTime = BGM_START
+ }
+}
+
export function StageShell() {
const location = useLocation()
const navigate = useNavigate()
const touchStartX = useRef(null)
+ const bgmRef = useRef(null)
+ const audioWantedRef = useRef(true)
const [isAutoPlay, setIsAutoPlay] = useState(true)
+ const [audioPlaying, setAudioPlaying] = useState(true)
const [remoteOpen, setRemoteOpen] = useState(false)
const [intervals, setIntervals] = useState(() => defaultIntervals(stageNavItems.length))
@@ -28,6 +41,73 @@ export function StageShell() {
useStageImagePreload(activeIdx)
+ const ensureBgm = useCallback(() => {
+ if (bgmRef.current) return bgmRef.current
+ const audio = new Audio(BGM_SRC)
+ audio.preload = 'auto'
+ bgmRef.current = audio
+ return audio
+ }, [])
+
+ const playBgm = useCallback(async () => {
+ audioWantedRef.current = true
+ const audio = ensureBgm()
+ clampBgmTime(audio)
+ try {
+ await audio.play()
+ setAudioPlaying(true)
+ } catch {
+ setAudioPlaying(false)
+ }
+ }, [ensureBgm])
+
+ const pauseBgm = useCallback(() => {
+ audioWantedRef.current = false
+ const audio = bgmRef.current
+ if (audio) audio.pause()
+ setAudioPlaying(false)
+ }, [])
+
+ const setBgmPlaying = useCallback(
+ (playing: boolean) => {
+ if (playing) void playBgm()
+ else pauseBgm()
+ },
+ [pauseBgm, playBgm],
+ )
+
+ useEffect(() => {
+ const audio = ensureBgm()
+ const onTimeUpdate = () => {
+ if (audio.currentTime >= BGM_END) {
+ audio.currentTime = BGM_START
+ if (!audio.paused) void audio.play().catch(() => setAudioPlaying(false))
+ }
+ }
+ const onEnded = () => {
+ if (!audioWantedRef.current) return
+ audio.currentTime = BGM_START
+ void audio.play().catch(() => setAudioPlaying(false))
+ }
+ // Retry after user gesture when autoplay policy blocked start (not after intentional pause)
+ const onGesture = () => {
+ if (audioWantedRef.current && audio.paused) void playBgm()
+ }
+ audio.addEventListener('timeupdate', onTimeUpdate)
+ audio.addEventListener('ended', onEnded)
+ window.addEventListener('pointerdown', onGesture)
+ window.addEventListener('keydown', onGesture)
+ void playBgm()
+ return () => {
+ audio.removeEventListener('timeupdate', onTimeUpdate)
+ audio.removeEventListener('ended', onEnded)
+ window.removeEventListener('pointerdown', onGesture)
+ window.removeEventListener('keydown', onGesture)
+ audio.pause()
+ bgmRef.current = null
+ }
+ }, [ensureBgm, playBgm])
+
const goToSlide = useCallback(
(idx: number) => {
const target = stageNavItems[idx]
@@ -55,6 +135,10 @@ export function StageShell() {
setIsAutoPlay(msg.on)
return
}
+ if (msg.type === 'audio') {
+ setBgmPlaying(msg.playing)
+ return
+ }
if (msg.type === 'setInterval') {
const seconds = clampIntervalSeconds(msg.seconds)
setIntervals((prev) => {
@@ -79,7 +163,7 @@ export function StageShell() {
if (msg.type === 'prev') handlePrev()
if (msg.type === 'goto') goToSlide(msg.index)
},
- [goToSlide, handleNext, handlePrev],
+ [goToSlide, handleNext, handlePrev, setBgmPlaying],
)
const { roomId, status, clientCount, broadcastState } = useStageHost(handleRemoteCommand)
@@ -90,10 +174,11 @@ export function StageShell() {
index: activeIdx,
total: stageNavItems.length,
autoplay: isAutoPlay,
+ audioPlaying,
label: stageNavItems[activeIdx].label,
intervals,
})
- }, [activeIdx, isAutoPlay, intervals, clientCount, broadcastState])
+ }, [activeIdx, isAutoPlay, audioPlaying, intervals, clientCount, broadcastState])
const currentIntervalSec = intervals[activeIdx] ?? DEFAULT_INTERVAL_SECONDS
@@ -186,6 +271,16 @@ export function StageShell() {
遥控{clientCount > 0 ? ` · ${clientCount}` : ''}
+
+
+
+
下方右侧加减只改该模块;顶部时长会统一改全部 11 页