fix;提交

This commit is contained in:
ljy
2026-08-21 19:44:31 +08:00
parent 265c6d700f
commit 83c52614be
10 changed files with 608 additions and 148 deletions
Binary file not shown.
+5 -29
View File
@@ -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 <RemoteShell />
}
if (isMobile) {
return (
<MobileShell>
<MobileViews />
</MobileShell>
)
}
if (isStage) {
if (isStage && !isMobile) {
return <StageShell />
}
// 3. Desktop Normal Mode Branch
// PC 普通 + 手机:五页菜单切换,页内上下滚动(无下拉子菜单)
return (
<div className={`site-shell ${techLike ? 'theme-tech' : ''}`}>
<Nav />
<main className="page-enter" key={location.pathname}>
<Outlet />
</main>
<Footer />
</div>
<MobileShell>
<MobileViews />
</MobileShell>
)
}
+128 -6
View File
@@ -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;
}
}
+101 -35
View File
@@ -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')}>
<img src={images.logo} alt="杜康好客" className="m-logo" />
<span className="m-title"></span>
</Link>
<nav className="m-header-nav" aria-label="手机导航">
{mobileNav.map((item) => (
<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
key={item.id}
to={`/#${item.id}`}
className={`m-nav-link ${activeId === item.id ? 'is-active' : ''}`}
onClick={() => goSection(item.id)}
to="/#m-home"
className="m-brand"
onClick={() => goSection('m-home', { force: true })}
>
{item.label}
<img src={images.logo} alt="杜康好客" className="m-logo" />
<span className="m-title"></span>
</Link>
))}
</nav>
</header>
<main className="m-main">
{children || <Outlet />}
</main>
<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, { force: true })}
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
<Footer />
</div>
<main className="m-main">{children || <Outlet />}</main>
</div>
</OrdinaryNavContext.Provider>
)
}
+146
View File
@@ -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));
}
}
+105 -74
View File
@@ -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">
<MobileHomeView />
<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">
<p className="m-join-label"></p>
<MobileBrandView />
</section>
{cultureDeep.map((page) => (
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
<MobileDeepSection page={page} />
</section>
))}
<section id="m-poetry" className="m-section">
<MobilePoetryView />
</section>
{poetryDeep.map((page) => (
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
<MobileDeepSection page={page} />
</section>
))}
<section id="m-qingxiang" className="m-section">
<p className="m-join-label"></p>
<MobileQingxiangView />
</section>
{qingxiangDeep.map((page) => (
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
<MobileDeepSection page={page} />
</section>
))}
<section id="m-haoke" className="m-section">
<p className="m-join-label"></p>
<MobileHaokeView />
<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 />
{cultureDeep.map((page) => (
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
<MobileDeepSection page={page} />
</div>
))}
<div id="m-poetry" className="m-panel-block">
<MobilePoetryView />
</div>
{poetryDeep.map((page) => (
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
<MobileDeepSection page={page} />
</div>
))}
<Footer />
</div>
</section>
<section id="m-system" className="m-section">
<MobileSystemView />
<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 />
{qingxiangDeep.map((page) => (
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
<MobileDeepSection page={page} />
</div>
))}
<Footer />
</div>
</section>
<section id="m-tech" className="m-section m-section-tech">
<MobileTechView />
<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 />
<div id="m-system" className="m-panel-block">
<MobileSystemView />
</div>
<div id="m-tech" className="m-panel-block m-section-tech">
<MobileTechView />
</div>
<div id="m-subsidy" className="m-panel-block m-section-tech">
<MobileSubsidyView />
</div>
<div id="m-tech-detail" className="m-panel-block m-section-tech">
<MobileTechDetailView />
</div>
<Footer />
</div>
</section>
<section id="m-subsidy" className="m-section m-section-tech">
<MobileSubsidyView />
</section>
<section id="m-tech-detail" className="m-section m-section-tech">
<MobileTechDetailView />
</section>
<section id="m-partner" className="m-section">
<p className="m-join-label"></p>
<MobilePartnerView />
</section>
<section id="m-contact" className="m-section">
<MobileContactView />
<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 />
</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>
+97 -2
View File
@@ -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' : ''}`}
+10 -2
View File
@@ -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);
}
+10
View File
@@ -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) => {
+6
View File
@@ -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')