feat(h5): 小程序原生顶栏 + 本地联调与一键部署

nh=1 隐藏 H5 顶栏并由 cover 承载 logo;H5 仅 4px 顶距避免重复留白。新增 dev:mp、deploy:h5 与联调文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 01:38:32 +08:00
co-authored by Cursor
parent f4bd57ce1b
commit 57687de360
12 changed files with 188 additions and 10 deletions
+10 -3
View File
@@ -1,7 +1,7 @@
<template>
<div class="app-shell" :class="{ 'mp-embed': mpEmbed }">
<div class="app-shell" :class="shellClass">
<div class="app-body">
<AppHeader />
<AppHeader v-if="showHeader" />
<router-view />
</div>
<IcpFooter class="app-footer" />
@@ -10,10 +10,17 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AppHeader from './components/AppHeader.vue'
import IcpFooter from './components/IcpFooter.vue'
import TabBar from './components/TabBar.vue'
import { isMpEmbed } from './lib/mpEmbed'
import { isMpEmbed, isMpNativeHeader } from './lib/mpEmbed'
const mpEmbed = isMpEmbed()
const mpNativeHeader = isMpNativeHeader()
const showHeader = !mpNativeHeader
const shellClass = computed(() => ({
'mp-embed': mpEmbed,
'mp-native-header': mpNativeHeader,
}))
</script>
+10 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { applyMpEmbedDom, initMpEmbed, isMpEmbed } from './mpEmbed'
import { applyMpEmbedDom, initMpEmbed, isMpEmbed, isMpNativeHeader } from './mpEmbed'
describe('mpEmbed', () => {
beforeEach(() => {
@@ -28,6 +28,15 @@ describe('mpEmbed', () => {
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('47px')
})
it('uses native mini-program header mode with ?nh=1', () => {
window.history.replaceState({}, '', '/psy/?mp=1&nh=1')
initMpEmbed()
expect(isMpNativeHeader()).toBe(true)
expect(document.documentElement.classList.contains('mp-native-header')).toBe(true)
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('0px')
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('4px')
})
it('returns false without mp query', () => {
initMpEmbed()
expect(isMpEmbed()).toBe(false)
+32 -4
View File
@@ -1,8 +1,19 @@
const MP_EMBED_KEY = 'yxg_mp_embed'
const MP_NATIVE_HEADER_KEY = 'yxg_mp_native_header'
const MP_QUERY = 'mp'
const NH_QUERY = 'nh'
const SBH_QUERY = 'sbh'
const SBH_KEY = 'yxg_sbh'
const HEADER_H_PX = 50
const MP_CONTENT_TOP_PX = 4
/** Native cover-view handles logo; H5 only needs a tiny gap below web-view top. */
function applyNativeHeaderMode(): void {
if (typeof document === 'undefined') return
const root = document.documentElement
root.style.setProperty('--yxg-safe-top', '0px')
root.style.setProperty('--yxg-page-top', `${MP_CONTENT_TOP_PX}px`)
}
function applySafeTopInset(px: number): void {
if (typeof document === 'undefined' || px <= 0) return
@@ -19,11 +30,22 @@ function readStoredSbh(): number {
return parseInt(raw, 10)
}
/** True when mini-program renders the brand header above web-view. */
export function isMpNativeHeader(): boolean {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(MP_NATIVE_HEADER_KEY) === '1'
}
/** Apply document-level embed chrome (safe-area wash, hide HTML title). */
export function applyMpEmbedDom(): void {
if (typeof document === 'undefined' || !isMpEmbed()) return
document.title = '\u200b'
document.documentElement.classList.add('mp-embed')
if (isMpNativeHeader()) {
document.documentElement.classList.add('mp-native-header')
applyNativeHeaderMode()
return
}
const stored = readStoredSbh()
if (stored > 0) applySafeTopInset(stored)
}
@@ -35,10 +57,16 @@ export function initMpEmbed(): void {
if (params.get(MP_QUERY) === '1') {
sessionStorage.setItem(MP_EMBED_KEY, '1')
}
const sbh = params.get(SBH_QUERY)
if (sbh && /^\d+$/.test(sbh)) {
sessionStorage.setItem(SBH_KEY, sbh)
applySafeTopInset(parseInt(sbh, 10))
if (params.get(NH_QUERY) === '1') {
sessionStorage.setItem(MP_NATIVE_HEADER_KEY, '1')
applyNativeHeaderMode()
}
if (!isMpNativeHeader()) {
const sbh = params.get(SBH_QUERY)
if (sbh && /^\d+$/.test(sbh)) {
sessionStorage.setItem(SBH_KEY, sbh)
applySafeTopInset(parseInt(sbh, 10))
}
}
applyMpEmbedDom()
}
+5 -1
View File
@@ -57,8 +57,12 @@ router.beforeEach((to) => {
q.mp = '1'
changed = true
}
if (sessionStorage.getItem('yxg_mp_native_header') === '1' && q.nh !== '1') {
q.nh = '1'
changed = true
}
const storedSbh = sessionStorage.getItem('yxg_sbh')
if (storedSbh && q.sbh !== storedSbh) {
if (storedSbh && q.sbh !== storedSbh && q.nh !== '1') {
q.sbh = storedSbh
changed = true
}
+1 -1
View File
@@ -61,7 +61,7 @@ html.mp-embed body{
.app-shell.mp-embed .home,
.app-shell.mp-embed .ps{
margin-top:0;
padding-top:8px;
padding-top:var(--yxg-page-top);
min-height:100%;
}
.app-footer{