feat(h5): 小程序 embed 模式与全站 ICP 备案页脚

H5 支持 ?mp=1 嵌入微信 WebView;user-h5/admin-h5 底部展示蜀ICP备2025140386号-2 并链至工信部;补充小程序+H5 部署说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 00:41:14 +08:00
co-authored by Cursor
parent d33a25a663
commit 8d856fb56c
14 changed files with 498 additions and 7 deletions
@@ -0,0 +1,25 @@
<template>
<footer class="icp-footer">
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
</footer>
</template>
<script setup lang="ts">
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
</script>
<style scoped>
.icp-footer {
text-align: center;
padding: 1rem 0 0.5rem;
}
.icp-link {
font-size: 12px;
line-height: 1.4;
color: var(--muted, #8a7f7b);
text-decoration: none;
}
.icp-link:hover {
color: var(--accent, #e54d42);
}
</style>
+12 -2
View File
@@ -2,6 +2,7 @@
import { onMounted } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import IcpFooter from '@/components/IcpFooter.vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -55,7 +56,10 @@ async function onLogout() {
</div>
</aside>
<main class="main">
<RouterView />
<div class="main-body">
<RouterView />
</div>
<IcpFooter />
</main>
</div>
</template>
@@ -91,7 +95,13 @@ nav a.router-link-active {
}
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.role { font-size: 0.75rem; color: var(--muted); }
.main { padding: 1.5rem 1.75rem; }
.main {
padding: 1.5rem 1.75rem;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-body { flex: 1; min-width: 0; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
+15 -1
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import IcpFooter from '@/components/IcpFooter.vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -46,11 +47,24 @@ async function onSubmit() {
{{ loading ? '登录中…' : '登录' }}
</button>
</form>
<IcpFooter />
</div>
</template>
<style scoped>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.wrap :deep(.icp-footer) {
margin-top: auto;
width: 100%;
padding-bottom: 0.25rem;
}
.login { width: min(380px, 100%); text-align: center; }
.login :deep(.field) { text-align: left; }
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

+13 -3
View File
@@ -1,12 +1,22 @@
<template>
<div class="app-shell">
<AppHeader />
<div class="app-shell" :class="{ 'mp-embed': mpEmbed }">
<AppHeader v-if="!mpEmbed || showEmbedHeader" />
<router-view />
<TabBar />
<IcpFooter />
<TabBar v-if="!mpEmbed" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue'
import IcpFooter from './components/IcpFooter.vue'
import TabBar from './components/TabBar.vue'
import { isMpEmbed } from './lib/mpEmbed'
const route = useRoute()
const mpEmbed = isMpEmbed()
/** Sub-pages in web-view: show back only; home hides duplicate chrome. */
const showEmbedHeader = computed(() => mpEmbed && route.path !== '/')
</script>
+40
View File
@@ -0,0 +1,40 @@
<template>
<footer class="icp-footer" :class="{ 'mp-embed': mpEmbed }">
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
</footer>
</template>
<script setup lang="ts">
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
import { isMpEmbed } from '../lib/mpEmbed'
const mpEmbed = isMpEmbed()
</script>
<style scoped>
.icp-footer {
position: fixed;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: var(--yxg-max-w);
bottom: calc(var(--yxg-nav-h) + env(safe-area-inset-bottom, 0px));
z-index: 90;
text-align: center;
padding: 4px 12px;
background: rgba(255, 252, 250, 0.92);
border-top: 1px solid rgba(240, 230, 224, 0.6);
}
.icp-footer.mp-embed {
bottom: env(safe-area-inset-bottom, 0px);
}
.icp-link {
font-size: 11px;
line-height: 1.4;
color: #9a8f8b;
text-decoration: none;
}
.icp-link:active {
opacity: 0.75;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initMpEmbed, isMpEmbed } from './mpEmbed'
describe('mpEmbed', () => {
beforeEach(() => {
sessionStorage.clear()
window.history.replaceState({}, '', '/psy/')
})
afterEach(() => {
sessionStorage.clear()
})
it('persists embed flag from ?mp=1', () => {
window.history.replaceState({}, '', '/psy/?mp=1')
initMpEmbed()
expect(isMpEmbed()).toBe(true)
})
it('returns false without mp query', () => {
initMpEmbed()
expect(isMpEmbed()).toBe(false)
})
})
+17
View File
@@ -0,0 +1,17 @@
const MP_EMBED_KEY = 'yxg_mp_embed'
const MP_QUERY = 'mp'
/** Persist ?mp=1 from mini-program web-view entry. Call once before router mount. */
export function initMpEmbed(): void {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
if (params.get(MP_QUERY) === '1') {
sessionStorage.setItem(MP_EMBED_KEY, '1')
}
}
/** True when H5 runs inside WeChat mini-program web-view (no H5 tab bar). */
export function isMpEmbed(): boolean {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(MP_EMBED_KEY) === '1'
}
+3
View File
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { initAnalytics, trackPageView } from './lib/analytics'
import { initMpEmbed } from './lib/mpEmbed'
initMpEmbed()
import '@yuxingu/ui/tokens.css'
import './styles/app.css'
+6
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { isMpEmbed } from '../lib/mpEmbed'
const router = createRouter({
history: createWebHistory('/psy/'),
@@ -48,4 +49,9 @@ const router = createRouter({
},
})
router.beforeEach((to) => {
if (!isMpEmbed() || to.query.mp === '1') return true
return { path: to.path, query: { ...to.query, mp: '1' }, hash: to.hash, replace: true }
})
export default router
+4 -1
View File
@@ -14,9 +14,12 @@ body{
}
.app-shell{
max-width:var(--yxg-max-w);margin:0 auto;min-height:100vh;
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px) + 12px);
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px) + 36px);
position:relative;
}
.app-shell.mp-embed{
padding-bottom:calc(env(safe-area-inset-bottom,0px) + 36px);
}
a{color:inherit;text-decoration:none}
/* ── Page shell (测测式:暖色洗底 + 白 sheet) ── */