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:
@@ -3,6 +3,7 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
deploy.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# ESS — machine-local root pointer(仓库内用 scripts 包装器)
|
# ESS — machine-local root pointer(仓库内用 scripts 包装器)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell" :class="{ 'mp-embed': mpEmbed }">
|
<div class="app-shell" :class="shellClass">
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
<AppHeader />
|
<AppHeader v-if="showHeader" />
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
<IcpFooter class="app-footer" />
|
<IcpFooter class="app-footer" />
|
||||||
@@ -10,10 +10,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import AppHeader from './components/AppHeader.vue'
|
import AppHeader from './components/AppHeader.vue'
|
||||||
import IcpFooter from './components/IcpFooter.vue'
|
import IcpFooter from './components/IcpFooter.vue'
|
||||||
import TabBar from './components/TabBar.vue'
|
import TabBar from './components/TabBar.vue'
|
||||||
import { isMpEmbed } from './lib/mpEmbed'
|
import { isMpEmbed, isMpNativeHeader } from './lib/mpEmbed'
|
||||||
|
|
||||||
const mpEmbed = isMpEmbed()
|
const mpEmbed = isMpEmbed()
|
||||||
|
const mpNativeHeader = isMpNativeHeader()
|
||||||
|
const showHeader = !mpNativeHeader
|
||||||
|
const shellClass = computed(() => ({
|
||||||
|
'mp-embed': mpEmbed,
|
||||||
|
'mp-native-header': mpNativeHeader,
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { applyMpEmbedDom, initMpEmbed, isMpEmbed } from './mpEmbed'
|
import { applyMpEmbedDom, initMpEmbed, isMpEmbed, isMpNativeHeader } from './mpEmbed'
|
||||||
|
|
||||||
describe('mpEmbed', () => {
|
describe('mpEmbed', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,6 +28,15 @@ describe('mpEmbed', () => {
|
|||||||
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('47px')
|
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', () => {
|
it('returns false without mp query', () => {
|
||||||
initMpEmbed()
|
initMpEmbed()
|
||||||
expect(isMpEmbed()).toBe(false)
|
expect(isMpEmbed()).toBe(false)
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
const MP_EMBED_KEY = 'yxg_mp_embed'
|
const MP_EMBED_KEY = 'yxg_mp_embed'
|
||||||
|
const MP_NATIVE_HEADER_KEY = 'yxg_mp_native_header'
|
||||||
const MP_QUERY = 'mp'
|
const MP_QUERY = 'mp'
|
||||||
|
const NH_QUERY = 'nh'
|
||||||
const SBH_QUERY = 'sbh'
|
const SBH_QUERY = 'sbh'
|
||||||
const SBH_KEY = 'yxg_sbh'
|
const SBH_KEY = 'yxg_sbh'
|
||||||
const HEADER_H_PX = 50
|
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 {
|
function applySafeTopInset(px: number): void {
|
||||||
if (typeof document === 'undefined' || px <= 0) return
|
if (typeof document === 'undefined' || px <= 0) return
|
||||||
@@ -19,11 +30,22 @@ function readStoredSbh(): number {
|
|||||||
return parseInt(raw, 10)
|
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). */
|
/** Apply document-level embed chrome (safe-area wash, hide HTML title). */
|
||||||
export function applyMpEmbedDom(): void {
|
export function applyMpEmbedDom(): void {
|
||||||
if (typeof document === 'undefined' || !isMpEmbed()) return
|
if (typeof document === 'undefined' || !isMpEmbed()) return
|
||||||
document.title = '\u200b'
|
document.title = '\u200b'
|
||||||
document.documentElement.classList.add('mp-embed')
|
document.documentElement.classList.add('mp-embed')
|
||||||
|
if (isMpNativeHeader()) {
|
||||||
|
document.documentElement.classList.add('mp-native-header')
|
||||||
|
applyNativeHeaderMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
const stored = readStoredSbh()
|
const stored = readStoredSbh()
|
||||||
if (stored > 0) applySafeTopInset(stored)
|
if (stored > 0) applySafeTopInset(stored)
|
||||||
}
|
}
|
||||||
@@ -35,10 +57,16 @@ export function initMpEmbed(): void {
|
|||||||
if (params.get(MP_QUERY) === '1') {
|
if (params.get(MP_QUERY) === '1') {
|
||||||
sessionStorage.setItem(MP_EMBED_KEY, '1')
|
sessionStorage.setItem(MP_EMBED_KEY, '1')
|
||||||
}
|
}
|
||||||
const sbh = params.get(SBH_QUERY)
|
if (params.get(NH_QUERY) === '1') {
|
||||||
if (sbh && /^\d+$/.test(sbh)) {
|
sessionStorage.setItem(MP_NATIVE_HEADER_KEY, '1')
|
||||||
sessionStorage.setItem(SBH_KEY, sbh)
|
applyNativeHeaderMode()
|
||||||
applySafeTopInset(parseInt(sbh, 10))
|
}
|
||||||
|
if (!isMpNativeHeader()) {
|
||||||
|
const sbh = params.get(SBH_QUERY)
|
||||||
|
if (sbh && /^\d+$/.test(sbh)) {
|
||||||
|
sessionStorage.setItem(SBH_KEY, sbh)
|
||||||
|
applySafeTopInset(parseInt(sbh, 10))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
applyMpEmbedDom()
|
applyMpEmbedDom()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ router.beforeEach((to) => {
|
|||||||
q.mp = '1'
|
q.mp = '1'
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if (sessionStorage.getItem('yxg_mp_native_header') === '1' && q.nh !== '1') {
|
||||||
|
q.nh = '1'
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
const storedSbh = sessionStorage.getItem('yxg_sbh')
|
const storedSbh = sessionStorage.getItem('yxg_sbh')
|
||||||
if (storedSbh && q.sbh !== storedSbh) {
|
if (storedSbh && q.sbh !== storedSbh && q.nh !== '1') {
|
||||||
q.sbh = storedSbh
|
q.sbh = storedSbh
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ html.mp-embed body{
|
|||||||
.app-shell.mp-embed .home,
|
.app-shell.mp-embed .home,
|
||||||
.app-shell.mp-embed .ps{
|
.app-shell.mp-embed .ps{
|
||||||
margin-top:0;
|
margin-top:0;
|
||||||
padding-top:8px;
|
padding-top:var(--yxg-page-top);
|
||||||
min-height:100%;
|
min-height:100%;
|
||||||
}
|
}
|
||||||
.app-footer{
|
.app-footer{
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 复制为 deploy.env(勿提交 deploy.env)
|
||||||
|
# 用法: ./scripts/deploy-h5.sh 或 DEPLOY_ENV=./deploy.env ./scripts/deploy-h5.sh
|
||||||
|
|
||||||
|
H5_DEPLOY_USER=ubuntu
|
||||||
|
H5_DEPLOY_HOST=your-server.example.com
|
||||||
|
H5_DEPLOY_PATH=/var/www/h5.yuxingu.com.cn/psy
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
适用于:**微信小程序**(`yuxingu-Miniprogram-dev`)嵌入 **愈心魔方 H5**(`digital-psychology/apps/user-h5`)的展示期上线。
|
适用于:**微信小程序**(`yuxingu-Miniprogram-dev`)嵌入 **愈心魔方 H5**(`digital-psychology/apps/user-h5`)的展示期上线。
|
||||||
|
|
||||||
|
**本地联调(免每次改服务器)** → 见 [LOCAL-DEV-MINI-PROGRAM-H5.md](LOCAL-DEV-MINI-PROGRAM-H5.md) · `npm run dev:mp`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 架构
|
## 1. 架构
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 愈心魔方 H5 · 本地联调(免每次改服务器)
|
||||||
|
|
||||||
|
改 H5 时**不必**先部署线上;用本机 Vite + 微信开发者工具即可预览。
|
||||||
|
|
||||||
|
## 日常流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1:H5(会打印局域网 IP)
|
||||||
|
cd digital-psychology
|
||||||
|
npm run dev:mp
|
||||||
|
|
||||||
|
# 终端 2(需要登录/接口时)
|
||||||
|
npm run dev:api
|
||||||
|
# 或仅数据库: npm run deps:up && npm run dev:api
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
微信开发者工具
|
||||||
|
├── 打开 yuxingu-Miniprogram-dev
|
||||||
|
├── 详情 → 本地设置 → 勾选「不校验合法域名、web-view…」
|
||||||
|
├── 编译运行(开发模式,非发行)
|
||||||
|
└── 打开 Tab「愈心魔方」→ 自动加载本机 http://<你的IP>:5173/psy/
|
||||||
|
```
|
||||||
|
|
||||||
|
小程序侧规则(已配置):
|
||||||
|
|
||||||
|
- **开发编译**(`import.meta.env.DEV`)→ `util/yxgConfig.local.js` 里的局域网地址
|
||||||
|
- **发行 / 上传体验版** → 自动使用 `https://h5.yuxingu.com.cn/psy/`
|
||||||
|
|
||||||
|
只需改一次 `yuxingu-Miniprogram-dev/util/yxgConfig.local.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const LOCAL_H5_HOST = '192.168.x.x:5173' // 与 dev:mp 打印一致
|
||||||
|
```
|
||||||
|
|
||||||
|
## 浏览器自测 embed
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173/psy/?mp=1&sbh=88
|
||||||
|
```
|
||||||
|
|
||||||
|
## 确认要上线时(一条命令)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy.env.example deploy.env # 首次填写服务器
|
||||||
|
chmod +x scripts/deploy-h5.sh
|
||||||
|
./scripts/deploy-h5.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 局限
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 真机预览 | 手机与电脑同一 WiFi;开发者工具可勾选「真机调试」 |
|
||||||
|
| 业务域名 | 本地为 `http`,仅开发模式 + 关闭域名校验时可用 |
|
||||||
|
| 与线上一致 | 发行前仍建议在体验版走 HTTPS 域名验收一次 |
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:h5": "npm run dev -w @yuxingu/user-h5",
|
"dev:h5": "npm run dev -w @yuxingu/user-h5",
|
||||||
|
"dev:mp": "bash scripts/dev-mp-h5.sh",
|
||||||
"build:h5": "npm run build -w @yuxingu/user-h5",
|
"build:h5": "npm run build -w @yuxingu/user-h5",
|
||||||
|
"deploy:h5": "bash scripts/deploy-h5.sh",
|
||||||
"dev:admin": "npm run dev -w @yuxingu/admin-h5",
|
"dev:admin": "npm run dev -w @yuxingu/admin-h5",
|
||||||
"build:admin": "npm run build -w @yuxingu/admin-h5",
|
"build:admin": "npm run build -w @yuxingu/admin-h5",
|
||||||
"dev:api": "cd apps/api && go run ./cmd/server",
|
"dev:api": "cd apps/api && go run ./cmd/server",
|
||||||
|
|||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 构建并 rsync 上传 user-h5 dist(配置见 deploy.env.example)
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
ENV_FILE="${DEPLOY_ENV:-$ROOT/deploy.env}"
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "缺少 $ENV_FILE — 请复制 deploy.env.example 为 deploy.env 并填写服务器信息" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
|
||||||
|
: "${H5_DEPLOY_USER:?在 deploy.env 设置 H5_DEPLOY_USER}"
|
||||||
|
: "${H5_DEPLOY_HOST:?在 deploy.env 设置 H5_DEPLOY_HOST}"
|
||||||
|
: "${H5_DEPLOY_PATH:?在 deploy.env 设置 H5_DEPLOY_PATH}"
|
||||||
|
|
||||||
|
echo ">>> build h5"
|
||||||
|
npm run build:h5
|
||||||
|
|
||||||
|
DEST="${H5_DEPLOY_USER}@${H5_DEPLOY_HOST}:${H5_DEPLOY_PATH}/"
|
||||||
|
echo ">>> rsync → $DEST"
|
||||||
|
rsync -avz --delete "${ROOT}/apps/user-h5/dist/" "$DEST"
|
||||||
|
|
||||||
|
echo ">>> done"
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 本地小程序 + H5 联调:启动 Vite,并打印微信开发者工具所需地址
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
pick_ip() {
|
||||||
|
local addr=""
|
||||||
|
if command -v ipconfig >/dev/null 2>&1; then
|
||||||
|
addr="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$addr" ]; then
|
||||||
|
addr="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||||
|
fi
|
||||||
|
echo "$addr"
|
||||||
|
}
|
||||||
|
|
||||||
|
IP="$(pick_ip)"
|
||||||
|
IP="${IP:-127.0.0.1}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
echo " 愈心魔方 · 本地 H5 联调"
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
echo " 1. 确认小程序 util/yxgConfig.local.js 中 LOCAL_H5_HOST 为:"
|
||||||
|
echo " ${IP}:5173"
|
||||||
|
echo " 2. digital-psychology 本窗口保持运行(Vite + API 代理 /psy/api → :8080)"
|
||||||
|
echo " 需要接口时另开终端: npm run dev:api 或 npm run deps:up"
|
||||||
|
echo " 3. 微信开发者工具 → 详情 → 本地设置 → 勾选:"
|
||||||
|
echo " 「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」"
|
||||||
|
echo " 4. 编译运行小程序(开发模式),打开「愈心魔方」Tab"
|
||||||
|
echo ""
|
||||||
|
echo " 浏览器自测: http://${IP}:5173/psy/?mp=1&sbh=88"
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
npm run dev:h5
|
||||||
Reference in New Issue
Block a user