fix;提交
This commit is contained in:
Binary file not shown.
@@ -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,7 +30,11 @@ function LayoutInner() {
|
||||
return <RemoteShell />
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
if (isStage && !isMobile) {
|
||||
return <StageShell />
|
||||
}
|
||||
|
||||
// PC 普通 + 手机:五页菜单切换,页内上下滚动(无下拉子菜单)
|
||||
return (
|
||||
<MobileShell>
|
||||
<MobileViews />
|
||||
@@ -50,22 +42,6 @@ function LayoutInner() {
|
||||
)
|
||||
}
|
||||
|
||||
if (isStage) {
|
||||
return <StageShell />
|
||||
}
|
||||
|
||||
// 3. Desktop Normal Mode Branch
|
||||
return (
|
||||
<div className={`site-shell ${techLike ? 'theme-tech' : ''}`}>
|
||||
<Nav />
|
||||
<main className="page-enter" key={location.pathname}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<StageProvider>
|
||||
|
||||
+128
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+89
-23
@@ -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<OrdinaryNavContextValue | null>(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<HTMLElement>('.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<NavId>(() => {
|
||||
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<HTMLElement>('.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 (
|
||||
<div className="mobile-shell">
|
||||
<header className="m-header">
|
||||
<Link to="/#m-home" className="m-brand" onClick={() => goSection('m-home')}>
|
||||
<OrdinaryNavContext.Provider value={navValue}>
|
||||
<div className={`mobile-shell ${!isMobile ? 'is-desktop-longpage' : ''}`}>
|
||||
<header
|
||||
className={`m-header ${scrolled ? 'is-scrolled' : ''} ${techTheme && !isMobile ? 'is-tech' : ''}`}
|
||||
>
|
||||
<div className="m-header-inner">
|
||||
<Link
|
||||
to="/#m-home"
|
||||
className="m-brand"
|
||||
onClick={() => goSection('m-home', { force: true })}
|
||||
>
|
||||
<img src={images.logo} alt="杜康好客" className="m-logo" />
|
||||
<span className="m-title">杜康好客</span>
|
||||
</Link>
|
||||
|
||||
<nav className="m-header-nav" aria-label="手机导航">
|
||||
{mobileNav.map((item) => (
|
||||
<nav className="m-header-nav" aria-label={isMobile ? '手机导航' : '主导航'}>
|
||||
{longNav.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/#${item.id}`}
|
||||
className={`m-nav-link ${activeId === item.id ? 'is-active' : ''}`}
|
||||
onClick={() => goSection(item.id)}
|
||||
onClick={() => goSection(item.id, { force: true })}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="m-main">
|
||||
{children || <Outlet />}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<main className="m-main">{children || <Outlet />}</main>
|
||||
</div>
|
||||
</OrdinaryNavContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+83
-52
@@ -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<string, string> = {
|
||||
const PATH_TO_SECTION: Record<string, NavId> = {
|
||||
'/': '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 (
|
||||
<div className="m-longpage">
|
||||
<section id="m-home" className="m-section">
|
||||
<div className="m-longpage m-longpage--panels">
|
||||
<section id="m-home" className={panelClass('m-home', activeId)} hidden={activeId !== 'm-home'}>
|
||||
<div className="m-panel-scroll">
|
||||
<MobileHomeView />
|
||||
<Footer />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="m-culture" className="m-section">
|
||||
<section id="m-culture" className={panelClass('m-culture', activeId)} hidden={activeId !== 'm-culture'}>
|
||||
<div className="m-panel-scroll">
|
||||
<p className="m-join-label">酒祖文化</p>
|
||||
<MobileBrandView />
|
||||
</section>
|
||||
{cultureDeep.map((page) => (
|
||||
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
|
||||
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
|
||||
<MobileDeepSection page={page} />
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<section id="m-poetry" className="m-section">
|
||||
<div id="m-poetry" className="m-panel-block">
|
||||
<MobilePoetryView />
|
||||
</section>
|
||||
</div>
|
||||
{poetryDeep.map((page) => (
|
||||
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
|
||||
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
|
||||
<MobileDeepSection page={page} />
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
<Footer />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="m-qingxiang" className="m-section">
|
||||
<section id="m-qingxiang" className={panelClass('m-qingxiang', activeId)} hidden={activeId !== 'm-qingxiang'}>
|
||||
<div className="m-panel-scroll">
|
||||
<p className="m-join-label">清香臻品</p>
|
||||
<MobileQingxiangView />
|
||||
</section>
|
||||
{qingxiangDeep.map((page) => (
|
||||
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
|
||||
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
|
||||
<MobileDeepSection page={page} />
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
<Footer />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="m-haoke" className="m-section">
|
||||
<section
|
||||
id="m-haoke"
|
||||
className={panelClass('m-haoke', activeId, 'm-panel-tech')}
|
||||
hidden={activeId !== 'm-haoke'}
|
||||
>
|
||||
<div className="m-panel-scroll">
|
||||
<p className="m-join-label">好客科技</p>
|
||||
<MobileHaokeView />
|
||||
</section>
|
||||
|
||||
<section id="m-system" className="m-section">
|
||||
<div id="m-system" className="m-panel-block">
|
||||
<MobileSystemView />
|
||||
</section>
|
||||
|
||||
<section id="m-tech" className="m-section m-section-tech">
|
||||
</div>
|
||||
<div id="m-tech" className="m-panel-block m-section-tech">
|
||||
<MobileTechView />
|
||||
</section>
|
||||
|
||||
<section id="m-subsidy" className="m-section m-section-tech">
|
||||
</div>
|
||||
<div id="m-subsidy" className="m-panel-block m-section-tech">
|
||||
<MobileSubsidyView />
|
||||
</section>
|
||||
|
||||
<section id="m-tech-detail" className="m-section m-section-tech">
|
||||
</div>
|
||||
<div id="m-tech-detail" className="m-panel-block m-section-tech">
|
||||
<MobileTechDetailView />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="m-partner" className="m-section">
|
||||
<section id="m-contact" className={panelClass('m-contact', activeId)} hidden={activeId !== 'm-contact'}>
|
||||
<div className="m-panel-scroll">
|
||||
<p className="m-join-label">合作联系</p>
|
||||
<div id="m-partner" className="m-panel-block">
|
||||
<MobilePartnerView />
|
||||
</section>
|
||||
|
||||
<section id="m-contact" className="m-section">
|
||||
</div>
|
||||
<MobileContactView />
|
||||
<Footer />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileHomeView() {
|
||||
const { goSection } = useOrdinaryNav()
|
||||
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
<div className="m-hero">
|
||||
<div className="m-hero-sub">{hero.subBrand} · 移动全景</div>
|
||||
<div className="m-hero-sub">{hero.subBrand}</div>
|
||||
<h1 className="m-hero-title">{hero.brand}</h1>
|
||||
<p style={{ fontSize: '1.05rem', color: 'var(--gold)', fontFamily: 'Noto Serif SC', margin: 0 }}>
|
||||
{hero.title}
|
||||
@@ -132,12 +163,12 @@ function MobileHomeView() {
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||
<a href="#m-culture" className="m-btn m-btn-primary">
|
||||
<button type="button" className="m-btn m-btn-primary" onClick={() => goSection('m-culture')}>
|
||||
{hero.primaryCta.label}
|
||||
</a>
|
||||
<a href="#m-contact" className="m-btn m-btn-outline">
|
||||
</button>
|
||||
<button type="button" className="m-btn m-btn-outline" onClick={() => goSection('m-contact')}>
|
||||
{hero.secondaryCta.label}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const bgmRef = useRef<HTMLAudioElement | null>(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}` : ''}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`stage-autoplay-btn ${audioPlaying ? 'is-playing' : ''}`}
|
||||
onClick={() => setBgmPlaying(!audioPlaying)}
|
||||
title={audioPlaying ? '点击暂停音乐' : '点击播放音乐'}
|
||||
>
|
||||
<span className="stage-play-icon">{audioPlaying ? '♪' : '♫'}</span>
|
||||
<span>{audioPlaying ? '音乐播放中' : '音乐已暂停'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`stage-autoplay-btn ${isAutoPlay ? 'is-playing' : ''}`}
|
||||
|
||||
@@ -133,6 +133,7 @@ body.remote-mode {
|
||||
|
||||
.remote-nav button,
|
||||
.remote-autoplay,
|
||||
.remote-audio,
|
||||
.remote-join button,
|
||||
.remote-module-goto {
|
||||
min-height: 3.1rem;
|
||||
@@ -144,6 +145,7 @@ body.remote-mode {
|
||||
|
||||
.remote-nav button:disabled,
|
||||
.remote-autoplay:disabled,
|
||||
.remote-audio:disabled,
|
||||
.remote-module-goto:disabled,
|
||||
.remote-stepper button:disabled,
|
||||
.remote-presets button:disabled,
|
||||
@@ -151,12 +153,18 @@ body.remote-mode {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.remote-autoplay {
|
||||
.remote-autoplay,
|
||||
.remote-audio {
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.remote-audio {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.remote-autoplay.is-on {
|
||||
.remote-autoplay.is-on,
|
||||
.remote-audio.is-on {
|
||||
color: var(--gold, #c4a35a);
|
||||
border-color: rgba(196, 163, 90, 0.7);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function RemoteShell() {
|
||||
const total = state?.total ?? stageNavItems.length
|
||||
const label = state?.label ?? stageNavItems[index]?.label ?? '等待大屏'
|
||||
const autoplay = state?.autoplay ?? false
|
||||
const audioPlaying = state?.audioPlaying ?? false
|
||||
const intervals = state?.intervals ?? defaultIntervals(stageNavItems.length)
|
||||
const allSame = intervals.length > 0 && intervals.every((item) => item === intervals[0])
|
||||
const globalSeconds = allSame
|
||||
@@ -148,6 +149,15 @@ export function RemoteShell() {
|
||||
{autoplay ? '暂停自动演示' : '开启自动演示'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`remote-audio ${audioPlaying ? 'is-on' : ''}`}
|
||||
disabled={!connected}
|
||||
onClick={() => send({ type: 'audio', playing: !audioPlaying })}
|
||||
>
|
||||
{audioPlaying ? '暂停音乐' : '播放音乐'}
|
||||
</button>
|
||||
|
||||
<p className="remote-modules-hint">下方右侧加减只改该模块;顶部时长会统一改全部 11 页</p>
|
||||
<nav className="remote-modules" aria-label="展控模块">
|
||||
{stageNavItems.map((item, idx) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type RemoteToHost =
|
||||
| { type: 'prev' }
|
||||
| { type: 'goto'; index: number }
|
||||
| { type: 'autoplay'; on: boolean }
|
||||
| { type: 'audio'; playing: boolean }
|
||||
| { type: 'setInterval'; index: number; seconds: number }
|
||||
| { type: 'setAllIntervals'; seconds: number }
|
||||
|
||||
@@ -19,6 +20,7 @@ export type HostToRemote = {
|
||||
index: number
|
||||
total: number
|
||||
autoplay: boolean
|
||||
audioPlaying: boolean
|
||||
label: string
|
||||
intervals: number[]
|
||||
}
|
||||
@@ -59,6 +61,9 @@ export function isRemoteToHost(value: unknown): value is RemoteToHost {
|
||||
if (type === 'autoplay') {
|
||||
return typeof (value as { on?: unknown }).on === 'boolean'
|
||||
}
|
||||
if (type === 'audio') {
|
||||
return typeof (value as { playing?: unknown }).playing === 'boolean'
|
||||
}
|
||||
if (type === 'setInterval') {
|
||||
const msg = value as { index?: unknown; seconds?: unknown }
|
||||
return typeof msg.index === 'number' && typeof msg.seconds === 'number'
|
||||
@@ -77,6 +82,7 @@ export function isHostToRemote(value: unknown): value is HostToRemote {
|
||||
typeof msg.index === 'number' &&
|
||||
typeof msg.total === 'number' &&
|
||||
typeof msg.autoplay === 'boolean' &&
|
||||
typeof msg.audioPlaying === 'boolean' &&
|
||||
typeof msg.label === 'string' &&
|
||||
Array.isArray(msg.intervals) &&
|
||||
msg.intervals.every((item) => typeof item === 'number')
|
||||
|
||||
Reference in New Issue
Block a user