fix:提交代码

This commit is contained in:
ljy
2026-09-07 00:07:40 +08:00
parent b822d21eae
commit 11bbf6cb0c
50 changed files with 1832 additions and 510 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/assets/logo.png" />
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta
name="description"
@@ -34,7 +34,7 @@
"name": "杜康好客",
"alternateName": "酒祖杜康",
"url": "https://www.dukanghaoke.com/",
"logo": "https://www.dukanghaoke.com/assets/logo.png",
"logo": "https://www.dukanghaoke.com/assets/favicon.png",
"description": "杜康好客——你请客,我买单。酒祖杜康清香,买酒送等价好客权益。"
},
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 B

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 449 KiB

+142 -11
View File
@@ -8,6 +8,9 @@ const root = path.resolve(__dirname, '..')
const outThumb = path.join(root, 'public/assets/stage/thumb')
const outFull = path.join(root, 'public/assets/stage/full')
/** Stage UI dark bg — fringe target */
const DARK = { r: 14, g: 12, b: 16 }
/** @type {{ src: string; name: string; thumbW: number; fullW: number }[]} */
const STAGE_SOURCES = [
{ src: 'public/assets/logo.png', name: 'logo', thumbW: 48, fullW: 512 },
@@ -22,29 +25,156 @@ async function ensureDirs() {
await fs.mkdir(outFull, { recursive: true })
}
function lum(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b
}
function sat(r, g, b) {
return Math.max(r, g, b) - Math.min(r, g, b)
}
/**
* White fringe → dark fringe for dark UI:
* 1) transparent RGB → black
* 2) semi-transparent: un-white-matte + premultiply toward black
* 3) opaque edge near transparency: crush whitish fringe to dark
*/
async function defringeBottle(srcPathOrBuf) {
const { data, info } = await sharp(srcPathOrBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
const w = info.width
const h = info.height
const out = Buffer.from(data)
const idx = (x, y) => (y * w + x) * 4
// Flood-fill only transparent pixels connected to the canvas edge.
// This prevents transparent details inside labels/text from being treated as silhouette.
const exterior = new Uint8Array(w * h)
const queue = new Int32Array(w * h)
let head = 0
let tail = 0
const enqueue = (x, y) => {
const p = y * w + x
if (exterior[p] || data[p * 4 + 3] > 16) return
exterior[p] = 1
queue[tail++] = p
}
for (let x = 0; x < w; x++) {
enqueue(x, 0)
enqueue(x, h - 1)
}
for (let y = 0; y < h; y++) {
enqueue(0, y)
enqueue(w - 1, y)
}
while (head < tail) {
const p = queue[head++]
const x = p % w
const y = Math.floor(p / w)
if (x > 0) enqueue(x - 1, y)
if (x + 1 < w) enqueue(x + 1, y)
if (y > 0) enqueue(x, y - 1)
if (y + 1 < h) enqueue(x, y + 1)
}
const R = 3
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const p = y * w + x
const i = p * 4
if (exterior[p]) {
out[i] = out[i + 1] = out[i + 2] = 0
continue
}
let distance = R + 1
for (let dy = -R; dy <= R; dy++) {
for (let dx = -R; dx <= R; dx++) {
const xx = x + dx
const yy = y + dy
if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue
if (exterior[yy * w + xx]) {
distance = Math.min(distance, Math.max(Math.abs(dx), Math.abs(dy)))
}
}
}
if (distance > R) continue
const a = data[i + 3]
let r = data[i]
let g = data[i + 1]
let b = data[i + 2]
if (a < 255) {
const af = a / 255
r = Math.min(255, Math.max(0, Math.round((r - 255 * (1 - af)) / af)))
g = Math.min(255, Math.max(0, Math.round((g - 255 * (1 - af)) / af)))
b = Math.min(255, Math.max(0, Math.round((b - 255 * (1 - af)) / af)))
}
// Turn the complete exterior silhouette into a dark feather:
// strongest on the outermost pixel, fading by the third pixel.
// Because the mask is flood-filled from the canvas edge, internal labels stay untouched.
const strength = distance === 1 ? 0.96 : distance === 2 ? 0.68 : 0.34
out[i] = Math.round(r * (1 - strength) + DARK.r * strength)
out[i + 1] = Math.round(g * (1 - strength) + DARK.g * strength)
out[i + 2] = Math.round(b * (1 - strength) + DARK.b * strength)
}
}
return sharp(out, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer()
}
/** After WebP encode, force transparent RGB to black again. */
async function neutralizeTransparentRgb(webpBuf) {
const { data, info } = await sharp(webpBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] === 0) {
data[i] = data[i + 1] = data[i + 2] = 0
}
}
return sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
.webp({ quality: 86, alphaQuality: 100 })
.toBuffer()
}
async function writeThumb(srcPath, name, width) {
const dest = path.join(outThumb, `${name}.webp`)
await sharp(srcPath)
.resize(width, null, { withoutEnlargement: true })
.blur(2)
.webp({ quality: 50 })
.toFile(dest)
let pipe = sharp(srcPath).resize(width, null, { withoutEnlargement: true }).blur(2)
let buf = await pipe.webp({ quality: 50, alphaQuality: 100 }).toBuffer()
if (name === 't30-bottle') buf = await neutralizeTransparentRgb(buf)
await fs.writeFile(dest, buf)
const stat = await fs.stat(dest)
return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 }
return { dest, kb: Math.round((stat.size / 1024) * 10) / 10 }
}
async function writeFull(srcPath, name, width) {
const dest = path.join(outFull, `${name}.webp`)
await sharp(srcPath)
.resize(width, null, { withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(dest)
let pipe = sharp(srcPath).resize(width, null, {
withoutEnlargement: true,
})
let buf = await pipe.webp({ quality: 86, alphaQuality: 100 }).toBuffer()
if (name === 't30-bottle') buf = await neutralizeTransparentRgb(buf)
await fs.writeFile(dest, buf)
const stat = await fs.stat(dest)
return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 }
return { dest, kb: Math.round((stat.size / 1024) * 10) / 10 }
}
async function rewriteBottlePng(srcPath) {
const fixed = await defringeBottle(srcPath)
await fs.writeFile(srcPath, fixed)
const stat = await fs.stat(srcPath)
return Math.round((stat.size / 1024) * 10) / 10
}
async function main() {
await ensureDirs()
const bottleSrc = path.join(root, 'public/assets/t30-bottle.png')
const pngKb = await rewriteBottlePng(bottleSrc)
console.log(`t30-bottle.png rewritten (${pngKb} KB)`)
let thumbTotal = 0
let fullTotal = 0
@@ -57,6 +187,7 @@ async function main() {
continue
}
// Bottle PNG was already defringed once above; do not process it twice.
const thumb = await writeThumb(srcPath, item.name, item.thumbW)
const full = await writeFull(srcPath, item.name, item.fullW)
thumbTotal += thumb.kb
+6
View File
@@ -9,6 +9,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const outDir = path.resolve(__dirname, '../dist')
const ROUTES = [
'/h5',
'/h5/brand',
'/h5/poetry',
'/h5/qingxiang',
'/h5/haoke',
'/h5/contact',
'/brand',
'/poetry',
'/qingxiang',
+25 -21
View File
@@ -12,29 +12,33 @@ import { PartnerPage } from './pages/Partner'
import { TechDetailPage } from './pages/TechDetail'
import { ContactPage } from './pages/Contact'
import { DeepPage } from './pages/DeepPage'
import { ThemeProvider } from './hooks/useTheme'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="brand" element={<BrandPage />} />
<Route path="poetry" element={<PoetryPage />} />
<Route path="qingxiang" element={<QingxiangPage />} />
<Route path="haoke" element={<HaokePage />} />
<Route path="system" element={<SystemPage />} />
<Route path="tech" element={<TechPage />} />
<Route path="subsidy" element={<SubsidyPage />} />
<Route path="partner" element={<PartnerPage />} />
<Route path="tech-detail" element={<TechDetailPage />} />
<Route path="contact" element={<ContactPage />} />
<Route path="culture/:slug" element={<DeepPage />} />
<Route path="poetry/:slug" element={<DeepPage />} />
<Route path="qingxiang/:slug" element={<DeepPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="brand" element={<BrandPage />} />
<Route path="poetry" element={<PoetryPage />} />
<Route path="qingxiang" element={<QingxiangPage />} />
<Route path="haoke" element={<HaokePage />} />
<Route path="system" element={<SystemPage />} />
<Route path="tech" element={<TechPage />} />
<Route path="subsidy" element={<SubsidyPage />} />
<Route path="partner" element={<PartnerPage />} />
<Route path="tech-detail" element={<TechDetailPage />} />
<Route path="contact" element={<ContactPage />} />
<Route path="culture/:slug" element={<DeepPage />} />
<Route path="poetry/:slug" element={<DeepPage />} />
<Route path="qingxiang/:slug" element={<DeepPage />} />
<Route path="h5/*" element={<HomePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</ThemeProvider>
)
}
+4 -3
View File
@@ -5,7 +5,7 @@
border-top: 1px solid rgba(196, 163, 90, 0.15);
}
.theme-tech .site-footer {
[data-theme='technology'] .site-footer {
background: #0c0a0e;
border-top: 1px solid rgba(196, 163, 90, 0.28);
}
@@ -22,8 +22,9 @@
}
.footer-logo {
width: 2.6rem;
height: 2.6rem;
height: 4rem;
width: auto;
max-width: 11rem;
object-fit: contain;
filter: brightness(1.1);
}
+7 -4
View File
@@ -1,15 +1,18 @@
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import { footer, images } from '../content/site'
import './Footer.css'
export function Footer() {
const location = useLocation()
const contactPath =
location.pathname === '/h5' || location.pathname.startsWith('/h5/') ? '/h5/contact' : '/contact'
return (
<footer className="site-footer">
<div className="container footer-inner">
<div className="footer-brand">
<img src={images.logo} alt="" className="footer-logo" />
<img src={images.logo} alt="杜康好客" className="footer-logo" />
<div>
<p className="serif footer-title">{footer.brandLine}</p>
<p className="footer-slogan">{footer.slogan}</p>
</div>
</div>
@@ -20,7 +23,7 @@ export function Footer() {
<p>{footer.compliance}</p>
<p>{footer.dataNote}</p>
<p>
<Link to="/contact">联系我们</Link>
<Link to={contactPath}>联系我们</Link>
</p>
</div>
</div>
+6 -3
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useIsMobile } from '../hooks/useMobile'
import { StageProvider, useRemoteMode, useStageMode } from '../hooks/useStageMode'
import { MobileShell } from '../mobile/MobileShell'
@@ -8,9 +9,11 @@ import { StageShell } from '../stage/StageShell'
import './Layout.css'
function LayoutInner() {
const location = useLocation()
const isStage = useStageMode()
const isRemote = useRemoteMode()
const isMobile = useIsMobile(768)
const isH5 = location.pathname === '/h5' || location.pathname.startsWith('/h5/')
useEffect(() => {
const stageOn = isStage && !isMobile
@@ -30,13 +33,13 @@ function LayoutInner() {
return <RemoteShell />
}
if (isStage && !isMobile) {
if (isStage && !isMobile && !isH5) {
return <StageShell />
}
// PC 普通 + 手机:五页菜单切换,页内上下滚动(无下拉子菜单)
// PC 普通 + 手机:五页菜单切换,顶栏支持下拉子菜单锚点跳转
return (
<MobileShell>
<MobileShell forceMobile={isH5}>
<MobileViews />
</MobileShell>
)
+3 -2
View File
@@ -65,8 +65,9 @@
}
.brand-logo {
width: 2.2rem;
height: 2.2rem;
height: 2.6rem;
width: auto;
max-width: 7.5rem;
object-fit: contain;
}
+1 -2
View File
@@ -55,7 +55,7 @@ export function Nav() {
<div className="topnav-inner container">
<Link to={withStage('/', true)} className="brand">
<img src={images.logo} alt="杜康好客" className="brand-logo" />
<span className="brand-text serif">杜康好客 · 展控</span>
<span className="brand-text serif">展控</span>
</Link>
<nav className="nav-links stage-links" aria-label="展控导航">
{stageNavItems.map((item) => (
@@ -79,7 +79,6 @@ export function Nav() {
<div className="topnav-inner container">
<Link to="/" className="brand" onClick={() => setDrawerOpen(false)}>
<img src={images.logo} alt="杜康好客" className="brand-logo" />
<span className="brand-text serif">杜康好客</span>
</Link>
<nav className="nav-groups desktop-only" aria-label="主导航">
+2 -1
View File
@@ -8,10 +8,11 @@ export const deepImages = {
assetUrl('/assets/brand/emb_p33_1.png'),
assetUrl('/assets/brand/photo_p11_1.jpg'),
assetUrl('/assets/brand/photo_p8_4.jpg'),
assetUrl('/assets/brand/emb_p58_2.png'),
],
poetry: [
assetUrl('/assets/poetry/emb_p33_4.png'),
assetUrl('/assets/poetry/emb_p49_4.png'),
assetUrl('/assets/poetry/emb_p35_4.png'),
assetUrl('/assets/poetry/emb_p59_4.png'),
assetUrl('/assets/poetry/emb_p67_4.png'),
assetUrl('/assets/poetry/emb_p25_4.png'),
+1 -1
View File
@@ -13,7 +13,7 @@ export const culturePages: DeepPageContent[] = [
title: '杜康文化在中国酒文化中的地位',
lead: '杜康,是中国秫酒的「酒宗」「酒祖」。杜康造酒,不仅肇始了华夏酒业的辉煌诞生,铸就了酒行业顶礼膜拜的精神图腾,而且创造了行业特征与地域色彩交相辉映的杜康文化。',
layout: 'essay',
images: [B[0]],
images: [B[6]],
blocks: [
{
type: 'paragraph',
+3 -2
View File
@@ -272,10 +272,10 @@ export const contact = {
eyebrow: '联系我们',
title: '扫码咨询 · 对接项目负责人',
lead: '现场如需进一步了解酒祖杜康清香、好客模式或城市合作,请扫码添加对接人,或致电联系。',
role: '杜康好客 · 项目负责人',
role: '杜康好客CEO',
name: '吕丹',
phone: '13073729990',
wechatHint: '扫一扫上方二维码,添加项目负责人微信。',
wechatHint: '扫一扫上方二维码,添加企业微信。',
}
export const footer = {
@@ -307,6 +307,7 @@ import { assetUrl } from '../lib/assetUrl'
/** Pure visuals only (no PDF page screenshots with baked-in text). Paths resolve via VITE_OSS_BASE. */
export const images = {
logo: assetUrl('/assets/logo.png'),
brandMarkH: assetUrl('/assets/brand-mark-h.png'),
qr: assetUrl('/assets/qr-contact.png'),
t30: assetUrl('/assets/t30-bottle.png'),
brand: {
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
export type SiteTheme = 'nostalgic' | 'technology'
type ThemeContextValue = {
theme: SiteTheme
setTheme: (theme: SiteTheme) => void
toggleTheme: () => void
}
const STORAGE_KEY = 'dukang-site-theme'
const ThemeContext = createContext<ThemeContextValue | null>(null)
function readTheme(): SiteTheme {
if (typeof window === 'undefined') return 'nostalgic'
return window.localStorage.getItem(STORAGE_KEY) === 'technology' ? 'technology' : 'nostalgic'
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<SiteTheme>(readTheme)
useEffect(() => {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme === 'technology' ? 'dark' : 'light'
window.localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
const value = useMemo(
() => ({
theme,
setTheme,
toggleTheme: () => setTheme((current) => (current === 'nostalgic' ? 'technology' : 'nostalgic')),
}),
[theme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
export function ThemeToggle({ className = '' }: { className?: string }) {
const { theme, toggleTheme } = useTheme()
const isTechnology = theme === 'technology'
return (
<button
type="button"
className={`theme-toggle ${className}`.trim()}
onClick={toggleTheme}
aria-label={`切换到${isTechnology ? '怀古风' : '科技风'}`}
title={`当前:${isTechnology ? '科技风' : '怀古风'}`}
>
<span aria-hidden>{isTechnology ? '科' : '古'}</span>
<span className="theme-toggle-label">{isTechnology ? '科技风' : '怀古风'}</span>
</button>
)
}
+200 -23
View File
@@ -24,9 +24,9 @@
padding-bottom: 0.35rem;
box-sizing: border-box;
z-index: 1000;
background: rgba(247, 242, 234, 0.96);
background: var(--surface-header);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(166, 28, 32, 0.12);
border-bottom: 1px solid var(--line);
transition: background 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
@@ -39,6 +39,19 @@
box-sizing: border-box;
}
.m-theme-toggle {
position: absolute;
top: calc(0.65rem + env(safe-area-inset-top, 0px));
right: 0.7rem;
min-height: 1.8rem;
padding: 0.18rem;
z-index: 2;
}
.m-theme-toggle .theme-toggle-label {
display: none;
}
.m-brand {
display: flex;
align-items: center;
@@ -48,8 +61,9 @@
}
.m-logo {
width: 1.75rem;
height: 1.75rem;
height: 2.2rem;
width: auto;
max-width: 6.5rem;
object-fit: contain;
}
@@ -85,12 +99,82 @@
border-radius: 0.2rem;
}
.mobile-shell.is-h5 .m-nav-link {
font-size: 0.82rem;
}
.m-nav-link.is-active {
color: var(--brand, #a61c20);
font-weight: 600;
background: rgba(166, 28, 32, 0.08);
}
.m-nav-trigger {
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
gap: 0.15rem;
}
.m-nav-chevron {
width: 0.35rem;
height: 0.35rem;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg) translateY(-0.05rem);
opacity: 0.55;
flex-shrink: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.m-nav-group.is-open .m-nav-chevron,
.m-nav-group.is-mobile-open .m-nav-chevron {
transform: rotate(225deg) translateY(0.05rem);
opacity: 1;
}
.m-nav-group {
position: relative;
flex: 1;
display: flex;
justify-content: center;
}
.m-nav-dropdown {
display: none;
}
.m-mobile-subnav {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.35rem 0 0.15rem;
border-top: 1px solid rgba(166, 28, 32, 0.1);
margin-top: 0.15rem;
}
.m-header.has-mobile-subnav {
padding-bottom: 0.5rem;
}
.m-mobile-subnav-item {
flex: 0 0 auto;
padding: 0.35rem 0.65rem;
border: 1px solid rgba(166, 28, 32, 0.15);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.6);
font: inherit;
font-size: 0.72rem;
color: var(--ink-soft, #5c534c);
cursor: pointer;
}
.m-mobile-subnav-item:active {
background: rgba(166, 28, 32, 0.08);
color: var(--brand, #a61c20);
}
.m-main {
flex: 1;
min-height: 0;
@@ -105,23 +189,17 @@
.is-desktop-longpage .m-header {
min-height: 4.5rem;
padding: 0;
background: rgba(247, 242, 234, 0.88);
background: var(--surface-header);
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);
background: var(--surface-header);
border-bottom-color: var(--line);
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);
[data-theme='technology'] .is-desktop-longpage .m-header.is-scrolled {
box-shadow: 0 0.65rem 1.75rem rgba(0, 0, 0, 0.28);
}
@@ -130,7 +208,7 @@
align-items: center;
justify-content: space-between;
gap: 2rem;
width: min(100% - 3rem, 77.5rem);
width: var(--max);
margin: 0 auto;
min-height: 4.5rem;
padding: 0.65rem 0;
@@ -141,8 +219,9 @@
}
.is-desktop-longpage .m-logo {
width: 2.35rem;
height: 2.35rem;
height: 2.6rem;
width: auto;
max-width: 7.5rem;
}
.is-desktop-longpage .m-title {
@@ -151,7 +230,7 @@
font-weight: 700;
}
.is-desktop-longpage .m-header.is-tech .m-title {
[data-theme='technology'] .is-desktop-longpage .m-title {
color: #fff4df;
}
@@ -163,6 +242,18 @@
flex-wrap: wrap;
}
.is-desktop-longpage .m-theme-toggle {
position: static;
flex-shrink: 0;
min-height: 2.15rem;
padding: 0.3rem 0.65rem;
}
.is-desktop-longpage .m-theme-toggle .theme-toggle-label {
display: inline;
font-size: 0.78rem;
}
.is-desktop-longpage .m-nav-link {
position: relative;
flex: 0 0 auto;
@@ -172,7 +263,7 @@
background: transparent !important;
font-size: 0.92rem;
letter-spacing: 0.14em;
color: #6b5344;
color: var(--ink-soft);
transition: color 0.2s ease;
}
@@ -195,20 +286,106 @@
background: var(--brand, #a61c20);
}
.is-desktop-longpage .m-header.is-tech .m-nav-link {
[data-theme='technology'] .is-desktop-longpage .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 {
[data-theme='technology'] .is-desktop-longpage .m-nav-link:hover,
[data-theme='technology'] .is-desktop-longpage .m-nav-link.is-active {
color: var(--gold-light, #f1d99a);
}
.is-desktop-longpage .m-header.is-tech .m-nav-link.is-active::after {
[data-theme='technology'] .is-desktop-longpage .m-nav-link.is-active::after {
background: var(--gold-light, #f1d99a);
}
.is-desktop-longpage .m-main {
padding-top: 0;
}
.is-desktop-longpage .m-nav-group {
flex: 0 0 auto;
}
.is-desktop-longpage .m-nav-dropdown {
display: block;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(0.35rem) scale(0.98);
min-width: 11rem;
max-height: 70vh;
overflow-y: auto;
padding: 0.65rem 0.5rem;
background: var(--surface-header);
border: 1px solid var(--line);
border-radius: 0.25rem;
box-shadow: 0 0.85rem 2rem rgba(26, 21, 18, 0.12);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease, visibility 0.22s;
z-index: 1100;
}
.is-desktop-longpage .m-nav-dropdown.is-wide {
min-width: 18rem;
}
.is-desktop-longpage .m-nav-group.is-open .m-nav-dropdown {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateX(-50%) translateY(0) scale(1);
}
.is-desktop-longpage .m-nav-drop-list {
list-style: none;
margin: 0;
padding: 0;
}
.is-desktop-longpage .m-nav-drop-list.is-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.05rem 0.25rem;
}
.is-desktop-longpage .m-nav-drop-item {
display: flex;
align-items: center;
width: 100%;
min-height: 2.1rem;
padding: 0 0.65rem;
border: none;
border-radius: 0.15rem;
background: transparent;
font: inherit;
font-size: 0.84rem;
color: var(--ink-soft, #5c534c);
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.is-desktop-longpage .m-nav-drop-item:hover {
background: rgba(166, 28, 32, 0.06);
color: var(--brand, #a61c20);
}
[data-theme='technology'] .is-desktop-longpage .m-nav-dropdown {
background: rgba(18, 16, 20, 0.98);
border-color: rgba(201, 154, 69, 0.22);
box-shadow: 0 0.85rem 2rem rgba(0, 0, 0, 0.35);
}
[data-theme='technology'] .is-desktop-longpage .m-nav-drop-item {
color: rgba(255, 244, 223, 0.72);
}
[data-theme='technology'] .is-desktop-longpage .m-nav-drop-item:hover {
background: rgba(201, 154, 69, 0.12);
color: var(--gold-light, #f1d99a);
}
}
+219 -25
View File
@@ -4,12 +4,16 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react'
import { Link, Outlet } from 'react-router-dom'
import { Link, Outlet, useLocation } from 'react-router-dom'
import { images } from '../content/site'
import { useIsMobile } from '../hooks/useMobile'
import { ThemeToggle } from '../hooks/useTheme'
import { getSubnavForPanel, ordinarySubnav } from './ordinaryNav'
import './MobileShell.css'
export const longNav = [
@@ -22,9 +26,14 @@ export const longNav = [
export type NavId = (typeof longNav)[number]['id']
type GoSectionOpts = {
force?: boolean
anchor?: string
}
type OrdinaryNavContextValue = {
activeId: NavId
goSection: (id: NavId, opts?: { force?: boolean }) => void
goSection: (id: NavId, opts?: GoSectionOpts) => void
}
const OrdinaryNavContext = createContext<OrdinaryNavContextValue | null>(null)
@@ -41,29 +50,69 @@ function isNavId(id: string): id is NavId {
return longNav.some((n) => n.id === id)
}
function scrollToAnchor(anchor: string) {
const scrollEl = document.querySelector<HTMLElement>('.m-panel.is-active .m-panel-scroll')
if (!scrollEl) return
const target = scrollEl.querySelector<HTMLElement>(`#${CSS.escape(anchor)}`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
scrollEl.scrollTop = 0
}
}
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 isMobile = useIsMobile(768)
export function MobileShell({ children, forceMobile = false }: { children?: ReactNode; forceMobile?: boolean }) {
const responsiveMobile = useIsMobile(768)
const isMobile = forceMobile || responsiveMobile
const location = useLocation()
const routeBase = location.pathname === '/h5' || location.pathname.startsWith('/h5/') ? '/h5' : ''
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 [openGroup, setOpenGroup] = useState<NavId | null>(null)
const [mobileSubnav, setMobileSubnav] = useState<NavId | null>(null)
const pendingScrollRef = useRef<'top' | string | null>(null)
const headerRef = useRef<HTMLElement>(null)
const closeTimerRef = useRef<number | null>(null)
const goSection = useCallback((id: NavId, opts?: { force?: boolean }) => {
const cancelClose = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}, [])
const scheduleClose = useCallback(() => {
cancelClose()
closeTimerRef.current = window.setTimeout(() => setOpenGroup(null), 180)
}, [cancelClose])
const goSection = useCallback((id: NavId, opts?: GoSectionOpts) => {
setActiveId((prev) => {
if (prev !== id || opts?.force) {
queueMicrotask(() => {
setScrolled(false)
resetActivePanelScroll()
})
if (prev === id && opts?.anchor) {
queueMicrotask(() => scrollToAnchor(opts.anchor!))
return prev
}
if (opts?.anchor) {
pendingScrollRef.current = opts.anchor
} else if (prev !== id || opts?.force) {
pendingScrollRef.current = 'top'
}
return id
})
setOpenGroup(null)
setMobileSubnav(null)
const nextHash = `#${id}`
if (window.location.hash !== nextHash) {
window.history.replaceState(
@@ -74,6 +123,21 @@ export function MobileShell({ children }: { children?: ReactNode }) {
}
}, [])
useEffect(() => {
const pending = pendingScrollRef.current
if (pending === null) return
pendingScrollRef.current = null
requestAnimationFrame(() => {
if (pending === 'top') {
resetActivePanelScroll()
setScrolled(false)
} else {
scrollToAnchor(pending)
}
})
}, [activeId])
useEffect(() => {
const scrollEl = document.querySelector<HTMLElement>('.m-panel.is-active .m-panel-scroll')
if (!scrollEl) return
@@ -84,38 +148,168 @@ export function MobileShell({ children }: { children?: ReactNode }) {
return () => scrollEl.removeEventListener('scroll', onScroll)
}, [activeId])
const techTheme = activeId === 'm-haoke'
useEffect(() => {
if (!mobileSubnav && !openGroup) return
const onPointerDown = (e: PointerEvent) => {
if (!headerRef.current?.contains(e.target as Node)) {
setMobileSubnav(null)
setOpenGroup(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setMobileSubnav(null)
setOpenGroup(null)
}
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [mobileSubnav, openGroup])
useEffect(() => () => cancelClose(), [cancelClose])
const navValue = useMemo(() => ({ activeId, goSection }), [activeId, goSection])
const subnavMap = useMemo(
() => Object.fromEntries(ordinarySubnav.map((m) => [m.id, m.children])) as Record<NavId, typeof ordinarySubnav[number]['children']>,
[],
)
const handleNavTitleClick = (id: NavId, hasChildren: boolean) => {
if (isMobile && hasChildren) {
setMobileSubnav((prev) => (prev === id ? null : id))
if (activeId !== id) {
goSection(id, { force: true })
}
return
}
if (!isMobile && hasChildren) {
cancelClose()
if (activeId !== id) {
goSection(id, { force: true })
setOpenGroup(id)
} else {
setOpenGroup((prev) => (prev === id ? null : id))
}
return
}
goSection(id, { force: true })
}
return (
<OrdinaryNavContext.Provider value={navValue}>
<div className={`mobile-shell ${!isMobile ? 'is-desktop-longpage' : ''}`}>
<div className={`mobile-shell ${!isMobile ? 'is-desktop-longpage' : ''} ${forceMobile ? 'is-h5' : ''}`}>
<header
className={`m-header ${scrolled ? 'is-scrolled' : ''} ${techTheme && !isMobile ? 'is-tech' : ''}`}
ref={headerRef}
className={`m-header ${scrolled ? 'is-scrolled' : ''} ${mobileSubnav ? 'has-mobile-subnav' : ''}`}
>
<div className="m-header-inner">
<Link
to="/#m-home"
to={`${routeBase}/#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={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>
))}
{longNav.map((item) => {
const children = subnavMap[item.id] ?? []
const hasChildren = children.length > 0
const isWide = item.id === 'm-culture' || item.id === 'm-qingxiang'
if (!hasChildren) {
return (
<Link
key={item.id}
to={`${routeBase}/#${item.id}`}
className={`m-nav-link ${activeId === item.id ? 'is-active' : ''}`}
onClick={(e) => {
e.preventDefault()
goSection(item.id, { force: true })
}}
>
{item.label}
</Link>
)
}
return (
<div
key={item.id}
className={`m-nav-group ${activeId === item.id ? 'is-current' : ''} ${openGroup === item.id ? 'is-open' : ''} ${mobileSubnav === item.id ? 'is-mobile-open' : ''}`}
onMouseEnter={() => {
if (!isMobile) {
cancelClose()
setOpenGroup(item.id)
}
}}
onMouseLeave={() => !isMobile && scheduleClose()}
onFocusCapture={() => {
if (!isMobile) {
cancelClose()
setOpenGroup(item.id)
}
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) scheduleClose()
}}
>
<button
type="button"
className={`m-nav-link m-nav-trigger ${activeId === item.id ? 'is-active' : ''}`}
aria-expanded={openGroup === item.id || mobileSubnav === item.id}
onClick={() => handleNavTitleClick(item.id, hasChildren)}
>
<span>{item.label}</span>
<span className="m-nav-chevron" aria-hidden />
</button>
<div className={`m-nav-dropdown ${isWide ? 'is-wide' : ''}`} role="menu">
<ul className={`m-nav-drop-list ${isWide ? 'is-cols' : ''}`}>
{children.map((child, idx) => (
<li key={`${item.id}-${child.anchor ?? child.label}`}>
<button
type="button"
className="m-nav-drop-item"
role="menuitem"
style={{ '--i': idx } as CSSProperties}
onClick={() =>
goSection(item.id, { anchor: child.anchor, force: true })
}
>
{child.label}
</button>
</li>
))}
</ul>
</div>
</div>
)
})}
</nav>
<ThemeToggle className="m-theme-toggle" />
</div>
{isMobile && mobileSubnav ? (
<div className="m-mobile-subnav" role="menu">
{getSubnavForPanel(mobileSubnav).map((child) => (
<button
key={`mobile-${child.anchor ?? child.label}`}
type="button"
className="m-mobile-subnav-item"
role="menuitem"
onClick={() => goSection(mobileSubnav, { anchor: child.anchor, force: true })}
>
{child.label}
</button>
))}
</div>
) : null}
</header>
<main className="m-main">{children || <Outlet />}</main>
+349 -1
View File
@@ -38,6 +38,20 @@
padding-bottom: calc(2.25rem + env(safe-area-inset-bottom, 0px));
}
#m-home .m-panel-scroll {
display: flex;
flex-direction: column;
}
#m-home .m-panel-scroll > .m-view-container {
flex: 1 0 auto;
}
#m-home .m-panel-scroll > .site-footer {
margin-top: auto;
flex-shrink: 0;
}
.m-panel-tech {
background: var(--tech-bg, #121014);
color: #f3ece4;
@@ -281,6 +295,19 @@
padding: 1.5rem 0 1rem;
}
.m-hero-heading,
.m-hero-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.m-hero-visual {
display: grid;
place-items: center;
}
.m-hero-sub {
font-size: 0.88rem;
color: var(--gold, #c4a35a);
@@ -296,6 +323,18 @@
line-height: 1.25;
}
.m-hero-tagline {
margin: 0;
color: var(--gold);
font-family: 'Noto Serif SC', serif;
font-size: 1.05rem;
}
.m-hero-desc {
margin: 0;
text-align: center;
}
.m-hero-img {
width: min(12rem, 50vw);
height: auto;
@@ -303,6 +342,15 @@
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.15));
}
.m-qingxiang-scene {
width: min(100%, 42rem);
max-height: 24rem;
margin: 0 auto;
border-radius: 0.5rem;
object-fit: cover;
box-shadow: var(--shadow-soft);
}
/* Specific Cards & Rings */
.m-rings-row {
display: flex;
@@ -331,6 +379,9 @@
}
.m-poem-box {
margin: 0;
width: 100%;
box-sizing: border-box;
background: linear-gradient(135deg, rgba(166, 28, 32, 0.06), rgba(196, 163, 90, 0.08));
border: 1px solid rgba(196, 163, 90, 0.3);
padding: 1.25rem 1rem;
@@ -459,10 +510,15 @@
align-items: start;
}
.m-view-container > :not(.m-card) {
.m-view-container > * {
grid-column: 1 / -1;
}
.m-view-container > .m-card {
width: 100%;
box-sizing: border-box;
}
.m-page-title {
font-size: clamp(1.75rem, 2.5vw, 2.35rem);
}
@@ -496,3 +552,295 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* Unified theme and explicit content layouts */
.m-panel,
.m-panel-tech,
.m-section-tech {
color: var(--ink);
background: var(--surface);
}
.m-panel-tech .m-join-label,
.m-panel-tech .m-eyebrow,
.m-panel-tech .m-page-title,
.m-panel-tech .m-card h3,
.m-panel-tech .m-big-num,
.m-panel-tech .m-closing,
.m-panel-tech .m-deep-h,
.m-section-tech .m-eyebrow,
.m-section-tech .m-page-title,
.m-section-tech .m-card h3,
.m-section-tech .m-big-num,
.m-section-tech .m-closing {
color: var(--brand);
}
.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,
.m-section-tech .m-lead,
.m-section-tech .m-card p,
.m-section-tech .m-poem-source {
color: var(--ink-soft);
}
.m-card,
.m-panel-tech .m-card,
.m-section-tech .m-card {
color: var(--ink);
background: var(--surface-card);
border-color: var(--line);
}
[data-theme='technology'] .m-eyebrow,
[data-theme='technology'] .m-page-title,
[data-theme='technology'] .m-card h3,
[data-theme='technology'] .m-big-num,
[data-theme='technology'] .m-deep-h {
color: var(--gold);
}
[data-theme='technology'] .m-lead,
[data-theme='technology'] .m-card p,
[data-theme='technology'] .m-deep-p,
[data-theme='technology'] .m-deep-list {
color: var(--ink-soft);
}
.m-card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
.m-card-grid > .m-card--banner {
grid-column: 1 / -1;
width: 100%;
box-sizing: border-box;
}
.m-card--subsidy-hero {
text-align: center;
padding: 1.5rem 1.1rem;
}
.m-subsidy-label {
font-size: 0.88rem;
color: var(--gold, #c4a35a);
font-weight: 600;
}
.m-subsidy-note {
margin: 0;
font-size: 0.85rem;
line-height: 1.65;
color: var(--ink-soft, #5c534c);
}
.theme-tech .m-subsidy-note {
color: rgba(243, 236, 228, 0.78);
}
.m-tasting-row {
display: flex;
flex-wrap: nowrap;
align-items: baseline;
gap: 0.75rem 1.25rem;
margin-top: 0.3rem;
}
.m-tasting-item {
flex: 1;
min-width: 0;
font-size: 0.88rem;
}
.m-tasting-item strong {
color: var(--brand);
}
.m-tasting-item span {
color: var(--ink-soft);
}
.m-card-highlight {
background: var(--brand-soft) !important;
border-color: var(--accent) !important;
}
.m-card-role {
color: var(--ink) !important;
font-weight: 500;
}
.m-card-loop {
text-align: center;
background: var(--brand-soft) !important;
}
.m-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.m-btn-stage {
border: 1px solid var(--gold);
color: var(--ink);
background: linear-gradient(135deg, rgba(196, 163, 90, 0.2), var(--brand-soft));
}
@media (min-width: 768px) {
.m-join-label {
padding-inline: max(1.5rem, calc((100vw - var(--content-max)) / 2));
}
.m-view-container {
max-width: var(--content-max);
display: flex;
flex-direction: column;
}
.m-lead {
max-width: none;
text-wrap: pretty;
}
.m-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.m-card-grid.m-card-grid--subsidy {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.m-card-grid.m-card-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.m-card-grid.m-card-grid--four > .m-card--tasting {
grid-column: span 2;
}
.is-desktop-longpage .m-tasting-item {
white-space: nowrap;
}
.m-deep-blocks {
grid-template-columns: 1fr;
}
.m-qr-card {
display: grid;
grid-template-columns: auto minmax(18rem, 1fr);
align-items: center;
text-align: left;
width: min(100%, 48rem);
padding: 1.75rem;
}
}
@media (min-width: 1100px) {
.m-card-grid:not(.m-card-grid--brand):not(.m-card-grid--poetry):not(.m-card-grid--four) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.m-card-grid.m-card-grid--subsidy {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.m-card-grid.m-card-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 768px) {
.mobile-shell.is-h5 .m-panel-scroll {
padding-top: calc(5.6rem + env(safe-area-inset-top, 0px));
}
.mobile-shell.is-h5 .m-view-container {
max-width: 42rem;
padding: 1.25rem 1rem 2.5rem;
}
.mobile-shell.is-h5 .m-card-grid,
.mobile-shell.is-h5 .m-card-grid--brand,
.mobile-shell.is-h5 .m-card-grid--poetry,
.mobile-shell.is-h5 .m-card-grid--four,
.mobile-shell.is-h5 .m-card-grid--subsidy {
grid-template-columns: 1fr;
}
.mobile-shell.is-h5 .m-qr-card {
display: flex;
width: 100%;
text-align: center;
padding: 1.5rem 1rem;
}
}
@media (min-width: 768px) {
.is-desktop-longpage #m-home .m-view-container {
justify-content: center;
}
.is-desktop-longpage #m-home .m-hero {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(14rem, 0.92fr);
grid-template-areas:
'heading visual'
'content visual';
column-gap: clamp(2.5rem, 7vw, 7rem);
row-gap: 1.5rem;
width: 100%;
padding: clamp(1rem, 3vh, 2.5rem) clamp(1rem, 3vw, 3rem);
text-align: left;
}
.is-desktop-longpage #m-home .m-hero-heading {
grid-area: heading;
align-items: flex-start;
gap: 0.65rem;
}
.is-desktop-longpage #m-home .m-hero-content {
grid-area: content;
align-items: flex-start;
gap: 1.25rem;
}
.is-desktop-longpage #m-home .m-hero-visual {
grid-area: visual;
align-self: center;
}
.is-desktop-longpage #m-home .m-hero-title {
font-size: clamp(2.8rem, 4.6vw, 4.5rem);
}
.is-desktop-longpage #m-home .m-hero-tagline {
font-size: clamp(1.05rem, 1.35vw, 1.35rem);
}
.is-desktop-longpage #m-home .m-hero-img {
width: clamp(15rem, 22vw, 23rem);
max-width: none;
max-height: 58vh;
margin: 0;
object-fit: contain;
}
.is-desktop-longpage #m-home .m-hero-desc {
max-width: 42rem;
text-align: left;
}
.is-desktop-longpage #m-home .m-hero-actions {
justify-content: flex-start;
}
}
+113 -70
View File
@@ -18,6 +18,7 @@ import {
import { Footer } from '../components/Footer'
import { MobileDeepSection, pathToSectionId } from './MobileDeepSection'
import { useOrdinaryNav, type NavId } from './MobileShell'
import { pathToAnchorId } from './ordinaryNav'
import './MobileViews.css'
const cultureDeep = allDeepPages.filter((p) => p.group === 'culture')
@@ -52,6 +53,28 @@ function isNavId(id: string): id is NavId {
return id === 'm-home' || id === 'm-culture' || id === 'm-qingxiang' || id === 'm-haoke' || id === 'm-contact'
}
function normalizePath(pathname: string) {
if (pathname === '/h5') return '/'
return pathname.startsWith('/h5/') ? pathname.slice(3) || '/' : pathname
}
function anchorForPath(pathname: string) {
if (allDeepPages.some((page) => page.path === pathname)) return pathToAnchorId(pathname)
const anchors: Record<string, string> = {
'/brand': 'm-brand',
'/poetry': 'm-poetry',
'/qingxiang': 'm-qingxiang-main',
'/haoke': 'm-haoke-main',
'/system': 'm-system',
'/tech': 'm-tech',
'/subsidy': 'm-subsidy',
'/partner': 'm-partner',
'/tech-detail': 'm-tech-detail',
'/contact': 'm-contact-main',
}
return anchors[pathname]
}
function panelClass(id: NavId, activeId: NavId, extra = '') {
return `m-panel${extra ? ` ${extra}` : ''}${activeId === id ? ' is-active' : ''}`
}
@@ -62,8 +85,9 @@ export function MobileViews() {
useEffect(() => {
const fromHash = location.hash.replace('#', '')
const nextId = (isNavId(fromHash) ? fromHash : PATH_TO_SECTION[location.pathname]) || 'm-home'
goSection(nextId)
const pathname = normalizePath(location.pathname)
const nextId = (isNavId(fromHash) ? fromHash : PATH_TO_SECTION[pathname]) || 'm-home'
goSection(nextId, isNavId(fromHash) ? undefined : { anchor: anchorForPath(pathname) })
}, [location.pathname, location.hash, goSection])
return (
@@ -78,13 +102,15 @@ export function MobileViews() {
<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 />
<div id="m-brand" className="m-panel-block m-section">
<MobileBrandView />
</div>
{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">
<div id="m-poetry" className="m-panel-block m-section">
<MobilePoetryView />
</div>
{poetryDeep.map((page) => (
@@ -99,7 +125,9 @@ export function MobileViews() {
<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 />
<div id="m-qingxiang-main" className="m-panel-block m-section">
<MobileQingxiangView />
</div>
{qingxiangDeep.map((page) => (
<div key={page.path} id={pathToSectionId(page.path)} className="m-panel-block">
<MobileDeepSection page={page} />
@@ -116,8 +144,10 @@ export function MobileViews() {
>
<div className="m-panel-scroll">
<p className="m-join-label">好客科技</p>
<MobileHaokeView />
<div id="m-system" className="m-panel-block">
<div id="m-haoke-main" className="m-panel-block m-section">
<MobileHaokeView />
</div>
<div id="m-system" className="m-panel-block m-section">
<MobileSystemView />
</div>
<div id="m-tech" className="m-panel-block m-section-tech">
@@ -136,10 +166,12 @@ export function MobileViews() {
<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">
<div id="m-partner" className="m-panel-block m-section">
<MobilePartnerView />
</div>
<MobileContactView />
<div id="m-contact-main" className="m-panel-block m-section">
<MobileContactView />
</div>
<Footer />
</div>
</section>
@@ -149,26 +181,35 @@ export function MobileViews() {
function MobileHomeView() {
const { goSection } = useOrdinaryNav()
const location = useLocation()
const isH5 = location.pathname === '/h5' || location.pathname.startsWith('/h5/')
return (
<div className="m-view-container">
<div className="m-hero">
<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}
</p>
<img src={images.t30} alt="酒祖杜康 T30" className="m-hero-img" />
<p className="m-lead" style={{ textAlign: 'center' }}>
{hero.subtitle}
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button type="button" className="m-btn m-btn-primary" onClick={() => goSection('m-culture')}>
{hero.primaryCta.label}
</button>
<button type="button" className="m-btn m-btn-outline" onClick={() => goSection('m-contact')}>
{hero.secondaryCta.label}
</button>
<div className="m-hero-heading">
<div className="m-hero-sub">{hero.subBrand}</div>
<h1 className="m-hero-title">{hero.brand}</h1>
<p className="m-hero-tagline">{hero.title}</p>
</div>
<div className="m-hero-visual">
<img src={images.t30} alt="酒祖杜康 T30" className="m-hero-img" />
</div>
<div className="m-hero-content">
<p className="m-lead m-hero-desc">{hero.subtitle}</p>
<div className="m-hero-actions">
<button type="button" className="m-btn m-btn-primary" onClick={() => goSection('m-culture')}>
{hero.primaryCta.label}
</button>
<button type="button" className="m-btn m-btn-outline" onClick={() => goSection('m-contact')}>
{hero.secondaryCta.label}
</button>
{!isH5 ? (
<a href="/?mode=stage" className="m-btn m-btn-stage">
进入大屏
</a>
) : null}
</div>
</div>
</div>
</div>
@@ -184,7 +225,7 @@ function MobileBrandView() {
<p className="m-lead">{brand.lead}</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid m-card-grid--brand">
{brand.cards.map((card, idx) => (
<div key={card.title} className="m-card">
<div style={{ fontSize: '0.8rem', color: 'var(--gold)', fontWeight: 600 }}>0{idx + 1}</div>
@@ -247,7 +288,7 @@ function MobilePoetryView() {
<div className="m-poem-source">—— {poetry.mainPoem.source}</div>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid m-card-grid--poetry">
{poetry.poems.map((p) => (
<div key={p.source} className="m-card">
<p style={{ fontFamily: 'Noto Serif SC', fontSize: '1rem', color: 'var(--ink)' }}>“{p.quote}”</p>
@@ -271,7 +312,11 @@ function MobileQingxiangView() {
</div>
<div style={{ textAlign: 'center' }}>
<img src={images.t30} alt="酒祖杜康清香 T30" style={{ width: '9rem', margin: '0 auto' }} />
<img
src={images.qingxiang.storefront}
alt="酒祖杜康清香宴饮场景"
className="m-qingxiang-scene"
/>
<div className="m-rings-row">
{qingxiang.styleWords.map((word) => (
<div key={word} className="m-ring-item">
@@ -281,24 +326,23 @@ function MobileQingxiangView() {
</div>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid m-card-grid--four">
{qingxiang.positions.map((pos) => (
<div key={pos.title} className="m-card">
<h3>{pos.title}</h3>
<p>{pos.text}</p>
</div>
))}
</div>
<div className="m-card">
<h3>品鉴仪式 · 抿咂呵</h3>
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
{qingxiang.tasting.map((t) => (
<div key={t.title} style={{ fontSize: '0.88rem' }}>
<strong style={{ color: 'var(--brand)' }}>{t.title}:</strong>
<span style={{ color: 'var(--ink-soft)' }}>{t.text}</span>
</div>
))}
<div className="m-card m-card--tasting">
<h3>品鉴仪式 · 抿咂呵</h3>
<div className="m-tasting-row">
{qingxiang.tasting.map((t) => (
<div key={t.title} className="m-tasting-item">
<strong>{t.title}:</strong>
<span>{t.text}</span>
</div>
))}
</div>
</div>
</div>
@@ -316,14 +360,13 @@ function MobileHaokeView() {
<p className="m-lead">{haoke.lead}</p>
</div>
<div className="m-card" style={{ background: 'rgba(166, 28, 32, 0.08)', borderColor: 'var(--brand)' }}>
<h3 style={{ fontSize: '1.15rem' }}>核心商业机制</h3>
<p style={{ fontWeight: 600, color: 'var(--brand)', fontSize: '1.02rem', lineHeight: 1.5 }}>
{haoke.oneLiner}
</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid">
<div className="m-card m-card-highlight m-card--banner">
<h3 style={{ fontSize: '1.15rem' }}>核心商业机制</h3>
<p style={{ fontWeight: 600, color: 'var(--brand)', fontSize: '1.02rem', lineHeight: 1.5 }}>
{haoke.oneLiner}
</p>
</div>
{haoke.points.map((pt) => (
<div key={pt.title} className="m-card">
<h3>{pt.title}</h3>
@@ -346,11 +389,11 @@ function MobileSystemView() {
<p className="m-lead">{system.lead}</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid">
{system.ends.map((end) => (
<div key={end.title} className="m-card">
<h3>{end.title}</h3>
<p style={{ fontWeight: 500, color: 'var(--ink)' }}>{end.role}</p>
<p className="m-card-role">{end.role}</p>
<div
style={{
fontSize: '0.82rem',
@@ -363,11 +406,10 @@ function MobileSystemView() {
</div>
</div>
))}
</div>
<div className="m-card" style={{ textAlign: 'center', background: 'rgba(166, 28, 32, 0.05)' }}>
<div style={{ fontSize: '0.82rem', color: 'var(--brand)', fontWeight: 600 }}>闭环链路</div>
<p style={{ fontWeight: 500, color: 'var(--brand)' }}>{system.loop}</p>
<div className="m-card m-card-loop m-card--banner">
<div style={{ fontSize: '0.82rem', color: 'var(--brand)', fontWeight: 600 }}>闭环链路</div>
<p style={{ fontWeight: 500, color: 'var(--brand)' }}>{system.loop}</p>
</div>
</div>
<div className="m-closing">{system.closing}</div>
@@ -384,7 +426,7 @@ function MobileTechView() {
<p className="m-lead">{tech.lead}</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid">
{tech.points.map((pt) => (
<div key={pt.title} className="m-card">
<h3>{pt.title}</h3>
@@ -410,13 +452,12 @@ function MobileSubsidyView() {
<p className="m-lead">{subsidy.lead}</p>
</div>
<div className="m-card" style={{ textAlign: 'center', padding: '1.5rem 1rem' }}>
<div style={{ fontSize: '0.88rem', color: 'var(--gold)', fontWeight: 600 }}>专项补贴规模</div>
<div className="m-big-num">{subsidy.number}</div>
<p style={{ fontSize: '0.85rem' }}>{subsidy.numberNote}</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid m-card-grid--subsidy">
<div className="m-card m-card--banner m-card--subsidy-hero">
<div className="m-subsidy-label">专项补贴规模</div>
<div className="m-big-num">{subsidy.number}</div>
<p className="m-subsidy-note">{subsidy.numberNote}</p>
</div>
{subsidy.points.map((pt) => (
<div key={pt.title} className="m-card">
<h3>{pt.title}</h3>
@@ -431,6 +472,8 @@ function MobileSubsidyView() {
}
function MobilePartnerView() {
const { goSection } = useOrdinaryNav()
return (
<div className="m-view-container">
<div>
@@ -439,7 +482,7 @@ function MobilePartnerView() {
<p className="m-lead">{partner.lead}</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid">
{partner.points.map((pt) => (
<div key={pt.title} className="m-card">
<h3>{pt.title}</h3>
@@ -449,12 +492,12 @@ function MobilePartnerView() {
</div>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', marginTop: '1rem' }}>
<a href="#m-contact" className="m-btn m-btn-primary">
<button type="button" className="m-btn m-btn-primary" onClick={() => goSection('m-contact', { anchor: 'm-contact-main' })}>
{partner.primaryCta.label}
</a>
<a href="#m-tech-detail" className="m-btn m-btn-outline">
</button>
<button type="button" className="m-btn m-btn-outline" onClick={() => goSection('m-haoke', { anchor: 'm-tech-detail' })}>
{partner.secondaryCta.label}
</a>
</button>
</div>
</div>
)
@@ -469,7 +512,7 @@ function MobileTechDetailView() {
<p className="m-lead">{techDetail.lead}</p>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div className="m-card-grid">
{techDetail.ports.map((port) => (
<div key={port.title} className="m-card">
<h3>{port.title}</h3>
@@ -494,7 +537,7 @@ function MobileContactView() {
<div className="m-card m-qr-card">
<div className="m-qr-img">
<img src={images.qr} alt="微信二维码" />
<img src={images.qr} alt="企业微信二维码" />
</div>
<div>
<h3 style={{ fontSize: '1.25rem' }}>{contact.name}</h3>
+62
View File
@@ -0,0 +1,62 @@
import { allDeepPages } from '../content/nav'
export type OrdinaryPanelId = 'm-home' | 'm-culture' | 'm-qingxiang' | 'm-haoke' | 'm-contact'
export type OrdinarySubnavItem = {
label: string
anchor?: string
}
export type OrdinaryNavMenu = {
id: OrdinaryPanelId
children: OrdinarySubnavItem[]
}
const cultureDeep = allDeepPages.filter((p) => p.group === 'culture')
const poetryDeep = allDeepPages.filter((p) => p.group === 'poetry')
const qingxiangDeep = allDeepPages.filter((p) => p.group === 'qingxiang')
export function pathToAnchorId(path: string) {
return `m${path.replace(/\//g, '-')}`
}
export const ordinarySubnav: OrdinaryNavMenu[] = [
{ id: 'm-home', children: [] },
{
id: 'm-culture',
children: [
{ label: '品牌传承', anchor: 'm-brand' },
...cultureDeep.map((p) => ({ label: p.navLabel, anchor: pathToAnchorId(p.path) })),
{ label: '诗酒文化', anchor: 'm-poetry' },
...poetryDeep.map((p) => ({ label: p.navLabel, anchor: pathToAnchorId(p.path) })),
],
},
{
id: 'm-qingxiang',
children: [
{ label: '清香', anchor: 'm-qingxiang-main' },
...qingxiangDeep.map((p) => ({ label: p.navLabel, anchor: pathToAnchorId(p.path) })),
],
},
{
id: 'm-haoke',
children: [
{ label: '好客模式', anchor: 'm-haoke-main' },
{ label: '三端协同', anchor: 'm-system' },
{ label: '科技驱动', anchor: 'm-tech' },
{ label: '酒厂实力', anchor: 'm-subsidy' },
{ label: '科技板块', anchor: 'm-tech-detail' },
],
},
{
id: 'm-contact',
children: [
{ label: '城市合伙人', anchor: 'm-partner' },
{ label: '联系我们', anchor: 'm-contact-main' },
],
},
]
export function getSubnavForPanel(id: OrdinaryPanelId) {
return ordinarySubnav.find((m) => m.id === id)?.children ?? []
}
+1 -1
View File
@@ -22,7 +22,7 @@ export function ContactPage() {
</Reveal>
<Reveal variant="anim-meet-right" className="qr-block">
<img src={images.qr} alt="项目负责人微信二维码" className="qr-image" />
<img src={images.qr} alt="企业微信二维码" className="qr-image" />
<p>{contact.wechatHint}</p>
</Reveal>
</div>
+145 -82
View File
@@ -1,5 +1,10 @@
/* ===== Stage Shell Ambient Theme & HUD ===== */
.stage-shell {
--vw: 1vw;
--vh: 1vh;
--stage-content-max: 76vw;
--brand: #a61c20;
--gold: #c4a35a;
position: relative;
width: 100vw;
height: 100vh;
@@ -15,6 +20,30 @@
user-select: none;
}
[data-theme='nostalgic'] .stage-shell {
--stage-brand: #a61c20;
--stage-gold: #9f762f;
--stage-gold-light: #c4a35a;
--stage-glass-bg: rgba(255, 252, 246, 0.82);
--stage-glass-border: rgba(166, 28, 32, 0.16);
--stage-glow: rgba(166, 28, 32, 0.1);
color: #251b16;
background:
radial-gradient(circle at 15% 20%, rgba(166, 28, 32, 0.08), transparent 38%),
linear-gradient(180deg, #fbf7ef 0%, #eee4d5 100%);
}
[data-theme='nostalgic'] .stage-shell::before {
background-image:
linear-gradient(rgba(123, 83, 45, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(123, 83, 45, 0.035) 1px, transparent 1px);
}
[data-theme='nostalgic'] .stage-hud-top {
background: linear-gradient(180deg, rgba(247, 242, 234, 0.98), rgba(247, 242, 234, 0.72));
border-bottom-color: rgba(166, 28, 32, 0.13);
}
/* Subtle grid overlay for tech/exhibition feel */
.stage-shell::before {
content: '';
@@ -23,7 +52,7 @@
background-image:
linear-gradient(rgba(196, 163, 90, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(196, 163, 90, 0.03) 1px, transparent 1px);
background-size: 4rem 4rem;
background-size: 3.333vw 3.333vw;
pointer-events: none;
z-index: 0;
}
@@ -34,100 +63,100 @@
top: 0;
left: 0;
right: 0;
height: calc(4.5rem + env(safe-area-inset-top, 0px));
height: calc(6.667vh + env(safe-area-inset-top, 0px));
padding-top: env(safe-area-inset-top, 0px);
padding-left: 2.5rem;
padding-right: 2.5rem;
padding-left: 2.083vw;
padding-right: 2.083vw;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
gap: 0.75rem;
overflow: hidden;
gap: 0.625vw;
z-index: 50;
background: linear-gradient(180deg, rgba(14, 12, 16, 0.95) 0%, rgba(14, 12, 16, 0) 100%);
border-bottom: 1px solid rgba(196, 163, 90, 0.15);
backdrop-filter: blur(8px);
backdrop-filter: blur(0.417vw);
}
.stage-brand {
display: flex;
align-items: center;
gap: 0.85rem;
text-decoration: none;
flex-shrink: 1;
gap: 0.708vw;
flex-shrink: 0;
min-width: 0;
white-space: nowrap;
}
.stage-logo,
.stage-logo-prog {
width: 2.4rem;
height: 2.4rem;
height: min(3.2vw, 5.2vh);
width: auto;
max-width: min(12vw, 18vh);
flex-shrink: 0;
}
.stage-logo-prog .stage-prog-img__thumb,
.stage-logo-prog .stage-prog-img__full {
width: 2.4rem;
height: 2.4rem;
height: min(3.2vw, 5.2vh);
width: auto;
max-width: min(12vw, 18vh);
object-fit: contain;
filter: drop-shadow(0 0 8px rgba(196, 163, 90, 0.4));
filter: drop-shadow(0 0 0.417vw rgba(196, 163, 90, 0.4));
}
.stage-logo-prog .stage-prog-img__thumb {
filter: blur(4px) drop-shadow(0 0 8px rgba(196, 163, 90, 0.4));
filter: blur(0.208vw) drop-shadow(0 0 0.417vw rgba(196, 163, 90, 0.4));
}
.stage-logo {
object-fit: contain;
filter: drop-shadow(0 0 8px rgba(196, 163, 90, 0.4));
filter: drop-shadow(0 0 0.417vw rgba(196, 163, 90, 0.4));
}
.stage-title {
font-family: 'Noto Serif SC', serif;
font-size: 1.25rem;
font-size: 1.042vw;
font-weight: 600;
letter-spacing: 0.12em;
letter-spacing: 0.102vw;
color: var(--gold, #c4a35a);
background: linear-gradient(135deg, #f3ece4 0%, #c4a35a 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stage-badge {
padding: 0.2rem 0.6rem;
border-radius: 0.2rem;
font-size: 0.75rem;
letter-spacing: 0.1em;
background: rgba(166, 28, 32, 0.25);
border: 1px solid rgba(166, 28, 32, 0.5);
color: #f3ece4;
}
/* Slide Nav Pills */
.stage-nav-pills {
display: flex;
align-items: center;
gap: 0.35rem;
gap: 0.292vw;
background: rgba(28, 25, 31, 0.7);
padding: 0.3rem 0.5rem;
border-radius: 2rem;
padding: 0.444vh 0.417vw;
border-radius: 1.667vw;
border: 1px solid rgba(196, 163, 90, 0.2);
flex-shrink: 1;
flex: 0 1 auto;
width: max-content;
max-width: min(var(--stage-content-max), 100%);
min-width: 0;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.stage-nav-pills::-webkit-scrollbar {
display: none;
}
.stage-nav-pill {
padding: 0.35rem 0.85rem;
border-radius: 1.5rem;
font-size: 0.82rem;
padding: 0.519vh 0.708vw;
border-radius: 1.25vw;
font-size: 0.683vw;
color: rgba(243, 236, 228, 0.65);
text-decoration: none;
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
flex-shrink: 0;
}
.stage-nav-pill:hover {
@@ -139,7 +168,7 @@
color: #121014;
background: linear-gradient(135deg, #e8d08d 0%, #c4a35a 100%);
font-weight: 600;
box-shadow: 0 0 12px rgba(196, 163, 90, 0.35);
box-shadow: 0 0 0.625vw rgba(196, 163, 90, 0.35);
}
/* AutoPlay Button, Exit & Index Indicator */
@@ -148,23 +177,58 @@
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
gap: 0.85rem;
gap: 0.708vw;
}
.stage-autoplay-btn,
.stage-remote-toggle,
.stage-exit-btn {
.stage-exit-btn,
.stage-theme-toggle {
white-space: nowrap;
flex-shrink: 0;
}
.stage-theme-toggle {
min-height: auto;
padding: 0.519vh 0.625vw;
font-size: 0.65vw;
box-shadow: none;
}
.stage-theme-toggle > span:first-child {
width: 1.1vw;
height: 1.1vw;
font-size: 0.55vw;
}
.stage-theme-toggle .theme-toggle-label {
font-size: 0.65vw;
}
[data-theme='nostalgic'] .stage-nav-pills,
[data-theme='nostalgic'] .stage-autoplay-btn,
[data-theme='nostalgic'] .stage-remote-toggle {
color: #5c534c;
background: rgba(255, 255, 255, 0.68);
border-color: rgba(166, 28, 32, 0.14);
}
[data-theme='nostalgic'] .stage-nav-pill {
color: #6b5344;
}
[data-theme='nostalgic'] .stage-nav-pill:hover {
color: #a61c20;
background: rgba(166, 28, 32, 0.08);
}
.stage-autoplay-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: 1.5rem;
font-size: 0.78rem;
gap: 0.292vw;
padding: 0.519vh 0.625vw;
border-radius: 1.25vw;
font-size: 0.65vw;
color: rgba(243, 236, 228, 0.85);
background: rgba(28, 25, 31, 0.8);
border: 1px solid rgba(196, 163, 90, 0.3);
@@ -179,23 +243,23 @@
}
.stage-play-icon {
font-size: 0.7rem;
font-size: 0.583vw;
}
.stage-index-num {
font-family: 'Noto Serif SC', serif;
font-size: 1.1rem;
letter-spacing: 0.1em;
font-size: 0.917vw;
letter-spacing: 0.085vw;
color: rgba(196, 163, 90, 0.85);
}
.stage-exit-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 1rem;
border-radius: 0.2rem;
font-size: 0.82rem;
gap: 0.333vw;
padding: 0.593vh 0.833vw;
border-radius: 0.167vw;
font-size: 0.683vw;
color: #f3ece4;
background: rgba(166, 28, 32, 0.3);
border: 1px solid rgba(166, 28, 32, 0.6);
@@ -207,7 +271,7 @@
.stage-exit-btn:hover {
background: rgba(166, 28, 32, 0.7);
color: #ffffff;
box-shadow: 0 0 15px rgba(166, 28, 32, 0.4);
box-shadow: 0 0 0.781vw rgba(166, 28, 32, 0.4);
}
/* Main Viewport Container */
@@ -216,36 +280,38 @@
inset: 0;
z-index: 10;
display: flex;
align-items: center;
align-items: stretch;
justify-content: center;
padding: calc(4.5rem + env(safe-area-inset-top, 0px)) 2.5rem calc(2.5rem + env(safe-area-inset-bottom, 0px));
padding: calc(6.667vh + env(safe-area-inset-top, 0px)) 2.083vw calc(3.704vh + env(safe-area-inset-bottom, 0px));
min-height: 0;
box-sizing: border-box;
}
/* Bottom Progress & Hints with Safe Area Bottom */
.stage-hud-bottom {
position: absolute;
bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px));
bottom: calc(1.259vh + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
gap: 0.333vw;
pointer-events: none;
}
.stage-dots {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.417vw;
pointer-events: auto;
}
.stage-dot {
position: relative;
width: 0.5rem;
height: 0.5rem;
width: min(0.417vw, 0.741vh);
height: min(0.417vw, 0.741vh);
border-radius: 50%;
background: rgba(196, 163, 90, 0.25);
border: none;
@@ -256,8 +322,8 @@
}
.stage-dot.is-active {
width: 2rem;
border-radius: 0.25rem;
width: 1.667vw;
border-radius: 0.208vw;
background: rgba(196, 163, 90, 0.3);
}
@@ -268,7 +334,7 @@
bottom: 0;
width: 100%;
background: linear-gradient(90deg, var(--brand, #a61c20), var(--gold, #c4a35a));
border-radius: 0.25rem;
border-radius: 0.208vw;
transform-origin: left;
animation: stageProgressBar var(--stage-progress-dur, 6s) linear forwards;
}
@@ -283,40 +349,34 @@
}
.stage-keyboard-hint {
font-size: 0.72rem;
font-size: 0.6vw;
color: rgba(243, 236, 228, 0.4);
letter-spacing: 0.08em;
letter-spacing: 0.068vw;
}
/* Mobile Responsive Adjustments for Stage Shell */
@media (max-width: 72rem) {
.stage-nav-pills {
display: none;
}
}
@media (max-width: 48rem) {
@media (max-width: 768px) {
.stage-hud-top {
padding-left: 0.75rem;
padding-right: 0.75rem;
height: calc(3.6rem + env(safe-area-inset-top, 0px));
padding-left: 0.625vw;
padding-right: 0.625vw;
height: calc(5.333vh + env(safe-area-inset-top, 0px));
}
.stage-title,
.stage-badge,
.stage-index-num {
display: none;
}
.stage-hud-actions {
gap: 0.4rem;
gap: 0.333vw;
}
.stage-viewport {
padding-top: calc(3.6rem + env(safe-area-inset-top, 0px));
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
padding-top: calc(5.333vh + env(safe-area-inset-top, 0px));
padding-left: 0.833vw;
padding-right: 0.833vw;
padding-bottom: calc(4.444vh + env(safe-area-inset-bottom, 0px));
}
.stage-exit-btn span:first-child {
@@ -332,14 +392,17 @@
.stage-view-anim {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
align-items: stretch;
animation: stageFadeIn 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes stageFadeIn {
from {
opacity: 0;
transform: scale(0.98) translateY(10px);
filter: blur(4px);
transform: scale(0.98) translateY(0.926vh);
filter: blur(0.208vw);
}
to {
opacity: 1;
+15 -8
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { stageNavItems } from '../content/nav'
import { images } from '../content/site'
import { StageViews } from './StageViews'
import { StageProgressiveImage } from './StageProgressiveImage'
import { stageImages } from './stageAssets'
import { useStageImagePreload } from './useStageImagePreload'
import { StageRemotePanel } from './remote/StageRemotePanel'
import { useStageHost } from './remote/useStageHost'
@@ -14,6 +13,7 @@ import {
type RemoteToHost,
} from './remote/protocol'
import { assetUrl } from '../lib/assetUrl'
import { ThemeToggle } from '../hooks/useTheme'
import './StageShell.css'
const BGM_SRC = assetUrl('/assets/stage/bgm.wav')
@@ -30,6 +30,7 @@ export function StageShell() {
const location = useLocation()
const navigate = useNavigate()
const touchStartX = useRef<number | null>(null)
const navPillsRef = useRef<HTMLElement>(null)
const bgmRef = useRef<HTMLAudioElement | null>(null)
const audioWantedRef = useRef(true)
const [isAutoPlay, setIsAutoPlay] = useState(true)
@@ -224,6 +225,13 @@ export function StageShell() {
return () => window.removeEventListener('keydown', onKey)
}, [handleNext, handlePrev])
useEffect(() => {
const nav = navPillsRef.current
if (!nav) return
const activePill = nav.querySelector<HTMLElement>('.stage-nav-pill.is-active')
activePill?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
}, [activeIdx])
const normalUrl = location.pathname === '/' ? '/' : location.pathname
return (
@@ -234,14 +242,12 @@ export function StageShell() {
>
{/* Top HUD */}
<header className="stage-hud-top">
<Link to="/?mode=stage" className="stage-brand">
<StageProgressiveImage pair={stageImages.logo} alt="杜康好客" className="stage-logo-prog" priority />
<span className="stage-title">杜康好客</span>
<span className="stage-badge">全景展控</span>
</Link>
<div className="stage-brand">
<img src={images.logo} alt="杜康好客" className="stage-logo" />
</div>
{/* Module Navigation Pills */}
<nav className="stage-nav-pills">
<nav className="stage-nav-pills" ref={navPillsRef}>
{stageNavItems.map((item, idx) => (
<Link
key={item.path}
@@ -255,6 +261,7 @@ export function StageShell() {
{/* Index, AutoPlay Toggle & Exit */}
<div className="stage-hud-actions">
<ThemeToggle className="stage-theme-toggle" />
<button
type="button"
className={`stage-remote-toggle ${clientCount > 0 ? 'is-live' : ''}`}
+345 -183
View File
File diff suppressed because it is too large Load Diff
+16 -27
View File
@@ -4,6 +4,7 @@ import {
contact,
haoke,
hero,
images,
partner,
poetry,
qingxiang,
@@ -61,9 +62,12 @@ function StageHomeView() {
<h1 className="stage-home-brand stage-stagger" style={{ '--i': 2 } as React.CSSProperties}>
{hero.brand}
</h1>
<div className="stage-home-sub stage-stagger" style={{ '--i': 3 } as React.CSSProperties}>
杜康好客
</div>
<img
src={images.brandMarkH}
alt="杜康好客"
className="stage-home-brand-mark stage-stagger"
style={{ '--i': 3 } as React.CSSProperties}
/>
<h2 className="stage-home-title stage-stagger" style={{ '--i': 4 } as React.CSSProperties}>
{hero.title}
</h2>
@@ -71,7 +75,7 @@ function StageHomeView() {
{hero.subtitle}
</p>
<div className="stage-stagger" style={{ '--i': 6 } as React.CSSProperties}>
<Link to="/brand?mode=stage" className="stage-exit-btn" style={{ padding: '0.6rem 1.5rem', fontSize: '1rem' }}>
<Link to="/brand?mode=stage" className="stage-exit-btn stage-cta-btn">
进入品牌展厅 ›
</Link>
</div>
@@ -252,24 +256,9 @@ function StageHaokeView() {
<div className="stage-haoke-grid">
<div className="stage-card stage-stagger" style={{ '--i': 4 } as React.CSSProperties}>
<h3 style={{ fontFamily: 'Noto Serif SC', fontSize: '1.4rem', color: '#ffffff', margin: '0 0 0.8rem' }}>
核心模式机制
</h3>
<div
style={{
padding: '1.2rem',
background: 'rgba(166, 28, 32, 0.2)',
border: '1px solid var(--stage-brand)',
borderRadius: '0.4rem',
fontSize: '1.15rem',
color: 'var(--stage-gold-light)',
fontFamily: 'Noto Serif SC',
marginBottom: '1rem',
}}
>
{haoke.oneLiner}
</div>
<p style={{ fontSize: '0.95rem', color: 'rgba(243,236,228,0.8)', lineHeight: 1.6 }}>{haoke.closing}</p>
<h3 className="stage-haoke-core-title">核心模式机制</h3>
<div className="stage-haoke-oneliner">{haoke.oneLiner}</div>
<p className="stage-haoke-closing-text">{haoke.closing}</p>
</div>
<div className="stage-haoke-mechanism">
@@ -374,9 +363,9 @@ function StageSubsidyView() {
<div className="stage-subsidy-layout">
<div className="stage-card stage-subsidy-hero stage-stagger" style={{ '--i': 4 } as React.CSSProperties}>
<div style={{ fontSize: '1.1rem', color: 'var(--stage-gold)' }}>专项资源规模</div>
<div className="stage-subsidy-label">专项资源规模</div>
<div className="stage-big-num">{subsidy.number}</div>
<p style={{ fontSize: '0.9rem', color: 'rgba(243,236,228,0.75)' }}>{subsidy.numberNote}</p>
<p className="stage-subsidy-note">{subsidy.numberNote}</p>
</div>
<div className="stage-subsidy-cards">
@@ -438,8 +427,8 @@ function StagePartnerView() {
</div>
</div>
<div className="stage-stagger" style={{ '--i': 7, marginTop: '1.5rem' } as React.CSSProperties}>
<Link to="/contact?mode=stage" className="stage-exit-btn" style={{ padding: '0.6rem 1.5rem', fontSize: '1rem' }}>
<div className="stage-stagger stage-partner-cta" style={{ '--i': 7 } as React.CSSProperties}>
<Link to="/contact?mode=stage" className="stage-exit-btn stage-cta-btn">
立即联系合作 ›
</Link>
</div>
@@ -497,7 +486,7 @@ function StageContactView() {
<div className="stage-contact-box stage-stagger" style={{ '--i': 4 } as React.CSSProperties}>
<div className="stage-qr-frame">
<StageProgressiveImage pair={stageImages.qr} alt="微信二维码" className="stage-qr-img" priority />
<StageProgressiveImage pair={stageImages.qr} alt="企业微信二维码" className="stage-qr-img" priority />
</div>
<div className="stage-contact-info">
+3 -2
View File
@@ -23,8 +23,9 @@ body.remote-mode {
}
.remote-logo {
width: 2.4rem;
height: 2.4rem;
height: 2.6rem;
width: auto;
max-width: 7.5rem;
object-fit: contain;
}
+27 -27
View File
@@ -6,15 +6,15 @@
display: flex;
align-items: center;
justify-content: flex-end;
padding: calc(5.2rem + env(safe-area-inset-top, 0px)) 2.5rem calc(3rem + env(safe-area-inset-bottom, 0px));
padding: calc(7.704vh + env(safe-area-inset-top, 0px)) 2.083vw calc(4.444vh + env(safe-area-inset-bottom, 0px));
}
.stage-remote-panel {
width: min(22rem, 92vw);
padding: 1.35rem 1.4rem 1.5rem;
width: min(18.333vw, 92vw);
padding: 2vh 1.167vw 2.222vh;
background: linear-gradient(180deg, #1c191f 0%, #121014 100%);
border: 1px solid rgba(196, 163, 90, 0.35);
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.45);
box-shadow: 0 1.25vw 2.5vw rgba(0, 0, 0, 0.45);
color: #f3ece4;
}
@@ -22,26 +22,26 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.1rem;
gap: 0.833vw;
margin-bottom: 1.63vh;
}
.stage-remote-kicker {
margin: 0 0 0.2rem;
font-size: 0.72rem;
letter-spacing: 0.18em;
margin: 0 0 0.296vh;
font-size: 0.6vw;
letter-spacing: 0.153vw;
color: var(--gold, #c4a35a);
}
.stage-remote-head h2 {
margin: 0;
font-size: 1.25rem;
letter-spacing: 0.08em;
font-size: 1.042vw;
letter-spacing: 0.068vw;
}
.stage-remote-close {
width: 2rem;
height: 2rem;
width: min(1.667vw, 2.963vh);
height: min(1.667vw, 2.963vh);
color: rgba(243, 236, 228, 0.8);
border: 1px solid rgba(196, 163, 90, 0.3);
background: transparent;
@@ -51,8 +51,8 @@
display: flex;
align-items: center;
justify-content: center;
min-height: 12.5rem;
padding: 0.75rem;
min-height: 18.519vh;
padding: min(0.625vw, 1.111vh);
background: #f7f2ea;
}
@@ -62,18 +62,18 @@
}
.stage-remote-code {
margin: 1rem 0 0.35rem;
margin: 1.481vh 0 0.519vh;
text-align: center;
font-family: 'Noto Serif SC', serif;
font-size: 1.15rem;
letter-spacing: 0.16em;
font-size: 0.958vw;
letter-spacing: 0.136vw;
color: var(--gold, #c4a35a);
}
.stage-remote-status {
margin: 0;
text-align: center;
font-size: 0.85rem;
font-size: 0.708vw;
color: rgba(243, 236, 228, 0.65);
}
@@ -86,27 +86,27 @@
}
.stage-remote-hint {
margin: 0.85rem 0 0;
margin: 1.259vh 0 0;
text-align: center;
font-size: 0.78rem;
font-size: 0.65vw;
line-height: 1.6;
color: rgba(243, 236, 228, 0.5);
}
@media (max-width: 48rem) {
@media (max-width: 768px) {
.stage-remote-overlay {
justify-content: center;
padding: calc(4.2rem + env(safe-area-inset-top, 0px)) 1rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
padding: calc(6.222vh + env(safe-area-inset-top, 0px)) 0.833vw calc(2.222vh + env(safe-area-inset-bottom, 0px));
}
}
.stage-remote-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: 1.5rem;
font-size: 0.78rem;
gap: 0.292vw;
padding: 0.519vh 0.625vw;
border-radius: 1.25vw;
font-size: 0.65vw;
color: rgba(243, 236, 228, 0.85);
background: rgba(28, 25, 31, 0.8);
border: 1px solid rgba(196, 163, 90, 0.3);
+2 -1
View File
@@ -8,7 +8,8 @@ function pair(thumb: string, full: string): StageImagePair {
export const stageImages = {
logo: pair('/assets/stage/thumb/logo.webp', '/assets/stage/full/logo.webp'),
t30: pair('/assets/stage/thumb/t30-bottle.webp', '/assets/stage/full/t30-bottle.webp'),
// Use the shared OSS PNG; stage webp on OSS may lag behind.
t30: pair('/assets/t30-bottle.png', '/assets/t30-bottle.png'),
brand: {
manor: pair('/assets/stage/thumb/emb_p6_5.webp', '/assets/stage/full/emb_p6_5.webp'),
relic: pair('/assets/stage/thumb/emb_p33_1.webp', '/assets/stage/full/emb_p33_1.webp'),
+71 -8
View File
@@ -12,12 +12,39 @@
--tech-bg: #121014;
--tech-card: #1c191f;
--tech-line: rgba(196, 163, 90, 0.28);
--max: min(72rem, 92vw);
--content-max: 77.5rem;
--max: min(var(--content-max), calc(100vw - 3rem));
--surface: var(--paper);
--surface-card: rgba(255, 255, 255, 0.85);
--surface-header: rgba(247, 242, 234, 0.94);
--line: rgba(166, 28, 32, 0.12);
--accent: var(--brand);
--nav-h: 4.5rem;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--shadow-soft: 0 1.25rem 3rem rgba(26, 21, 18, 0.08);
}
html[data-theme='technology'] {
--brand: #d4aa56;
--brand-deep: #b88a35;
--brand-soft: rgba(212, 170, 86, 0.12);
--gold: #e5c477;
--gold-soft: rgba(229, 196, 119, 0.28);
--ink: #f2f6fa;
--ink-soft: #aeb9c6;
--paper: #0b1017;
--paper-deep: #111923;
--surface: #0b1017;
--surface-card: rgba(20, 30, 42, 0.88);
--surface-header: rgba(9, 14, 21, 0.94);
--line: rgba(116, 170, 210, 0.2);
--accent: #62bce8;
--tech-bg: #0b1017;
--tech-card: #141e2a;
--tech-line: rgba(98, 188, 232, 0.25);
--shadow-soft: 0 1.25rem 3rem rgba(0, 0, 0, 0.32);
}
/* rem base: 375px -> 14px, 1920px -> 18px */
html {
font-size: clamp(0.875rem, 0.75rem + 0.35vw, 1.125rem);
@@ -37,6 +64,7 @@ body {
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
transition: color 0.25s ease, background 0.25s ease;
}
img {
@@ -151,7 +179,7 @@ h3,
color: var(--ink);
}
.theme-tech {
[data-theme='technology'] {
--ink: #f3ece4;
--ink-soft: rgba(243, 236, 228, 0.72);
--paper: var(--tech-bg);
@@ -159,28 +187,63 @@ h3,
background: var(--tech-bg);
}
.theme-tech .eyebrow,
.theme-tech .closing {
[data-theme='technology'] .eyebrow,
[data-theme='technology'] .closing {
color: var(--gold);
}
.theme-tech .eyebrow::before {
[data-theme='technology'] .eyebrow::before {
background: var(--gold);
}
.theme-tech .closing {
[data-theme='technology'] .closing {
border-top-color: var(--tech-line);
}
.theme-tech .btn-ghost {
[data-theme='technology'] .btn-ghost {
color: var(--gold);
border-color: var(--gold);
}
.theme-tech .btn-ghost:hover {
[data-theme='technology'] .btn-ghost:hover {
background: rgba(196, 163, 90, 0.12);
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 2.15rem;
padding: 0.3rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-soft);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
white-space: nowrap;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.theme-toggle:hover {
color: var(--accent);
border-color: var(--accent);
transform: translateY(-1px);
}
.theme-toggle > span:first-child {
display: grid;
place-items: center;
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
color: var(--paper);
background: var(--accent);
font-family: 'Noto Serif SC', serif;
font-size: 0.72rem;
font-weight: 700;
}
.deep-read-rail {
margin-top: 3rem;
padding-top: 2rem;