Files
app_03_pregnancy/docs/development-standards-v2.md

29 KiB
Raw Blame History

H5 小程序嵌入式应用开发模板 v2

适用于嵌入微信小程序 web-view 的 H5 应用 技术栈:FastAPI + MongoDB (Beanie) + React 18 + React Router 7 + Vite 6 认证:小程序传 openid → 后端调 CRMEB 接口获取用户信息 → 自动补齐昵称/头像

v2 变更要点(实战沉淀)

  1. 前端资源改相对路径(Vite base: './'),适配二级目录与 web-view,避免白屏。
  2. Nginx 无斜杠跳转保留 query,避免 ?openid 丢失导致登录失败。
  3. 二级目录三处一致规则细化(base / basename / API 前缀)。
  4. 新增:小程序 web-view 接入要点、MongoDB 部署注意、时区统一、后台管理 + 访问埋点。

一、项目架构

1.1 技术栈

层 技术 备注
前端 React 18 + React Router 7 + Vite 6 SPA,二级目录部署,资源相对路径
UI TailwindCSS + 自研 SVG 图标(按需 MUI/shadcn) 母婴等品牌建议自研图标,避免堆依赖
状态 React Context + localStorage 持久化 无 Redux/Zustand
后端 FastAPI + Uvicorn(PM2 守护,--interpreter none)
数据库 MongoDB(Motor + Beanie ODM) localhost 绑定 + 鉴权
认证 openid → 后端调 CRMEB 内部接口 无 JWT 签名验证

1.2 目录结构

project/
├── frontend/
│   ├── src/app/
│   │   ├── pages/            # 页面组件
│   │   ├── components/       # 通用组件(含自研 icons.tsx)
│   │   ├── context/          # React Context
│   │   ├── hooks/            # 自定义 hook(可独立测试)
│   │   ├── services/         # api / debug / analytics
│   │   ├── data/             # 静态数据表
│   │   ├── routes.tsx        # 路由 + 埋点 RootTracker
│   │   └── App.tsx
│   ├── .env / .env.production # 环境变量(生产关闭 debug)
│   └── dist/                 # 构建产物
├── backend/
│   ├── app/
│   │   ├── routers/          # auth/user/sessions/events/admin...
│   │   ├── models/           # Beanie 数据模型
│   │   ├── services/         # 业务逻辑 + jwt_util
│   │   ├── middleware/       # 认证(get_current_user / get_admin)
│   │   └── config.py
│   ├── tests/                # pytest(建议测试占比高)
│   ├── serv.sh / backup.sh
│   └── .env
├── deploy/
│   ├── nginx-<app>.conf
│   └── admin/index.html      # 独立后台(单文件,无需构建)
└── docs/

1.3 多应用同域名 / 二级目录部署

同一 Nginx 下多个 H5 应用,各自独立后端进程;前端按二级目录部署:

https://mp.example.com/
├── /app_03/            → 前端 SPA(部署在二级目录 /app_03/)
│   └── /app_03/api/    → proxy_pass 127.0.0.1:8030/api/
├── /baby_food/         → /baby_food/api/ → 8031
└── /admin/(可选)     → 独立后台静态页

二、前端路径规范(v2 核心,务必遵守)

H5 会被微信小程序 web-view 以二级目录地址打开(如 …/app_03/?openid=xxx)。 路径处理不当是 web-view 白屏最常见原因。规则如下:

2.1 资源用相对路径(关键)

// vite.config.ts
export default defineConfig({
  base: "./",   // ✅ 相对路径:资源按文档所在二级目录解析
  // ❌ 不要用绝对 base: "/app_03/"(小程序/代理环境下易解析错导致白屏)
});
  • base: './' 后,构建出的 index.html 引用为 ./assets/xxx.js,无论挂在哪个二级目录都能正确加载。
  • 单层路由(/home、/count)下,./assets/ 从 /app_03/home 解析回 /app_03/assets/,正常。
  • 如有多层路由,需改用 HashRouter 或加 <base href>,否则深层路由刷新会算错相对基准。

2.2 二级目录三处一致

配置项 位置 值(以 app_03 为例) 说明
Vite base vite.config.ts './' 相对,不写死二级目录
Router basename routes.tsx '/app_03' 路由仍按二级目录(路径里编码二级目录)
VITE_API_PREFIX .env '/app_03/api' 同源绝对路径,web-view 同源可用

资源相对、路由二级目录、API 同源绝对——三者配合:资源可移植,路由/接口仍锚定二级目录。

2.3 生产环境关闭调试

# .env.production
VITE_API_PREFIX=/app_03/api
VITE_DEBUG=false      # 生产关闭:避免无 openid 时自动 dev-login

npm run build 默认加载 .env.production。

2.4 Vite dev proxy

server: {
  proxy: {
    [apiPrefix]: {           // "/app_03/api"
      target: "http://localhost:8030",
      changeOrigin: true,
      rewrite: (p) => p.replace(/^\/app_03\/api/, "/api"),
    },
  },
}

三、小程序 web-view 接入要点(v2 新增)

  1. 业务域名白名单:小程序后台「业务域名」必须加入本 H5 域名(需 HTTPS、需下载校验文件放服务器)。否则 web-view 直接打不开。
  2. 打开地址:https://mp.example.com/app_03/?openid=<openid>,建议带尾斜杠。
  3. 跳转保留参数:无斜杠地址 …/app_03?openid=xxx 经 nginx 301 时必须保留 query,否则 openid 丢失、登录失败:
    location = /app_03 { return 301 /app_03/$is_args$args; }   # ✅ 保留 ?openid
    # ❌ return 301 /app_03/;   会丢掉 ?openid
    
  4. openid 读取:用 BrowserRouter 时 useSearchParams() 能读到 ?openid。若改 HashRouter,?openid 在 # 之前不会被 router 读到,需 new URLSearchParams(window.location.search) 直接取。
  5. 真机调试:白屏先用 vConsole / 真机调试看是不是资源 404 或域名未白名单。

四、认证规范

4.1 流程

小程序 web-view → /app_03/?openid=xxx
  → LoginPage 读取 openid
  → POST /app_03/api/auth/openid-login { openid }
  → 后端 GET CRMEB /api/internal/user-info?openid=xxx (X-Internal-Key)
  → { nickname, phone, avatar } → 建/更新 User
  → 返回 token → setTokenAndUser → fetchProfile → 跳首页/引导页

4.2 关键约定

  • 中间件 get_current_user:解 token(verify_signature=False),手动验 exp,按 openid 查/建用户。
  • CRMEB 客户端任何异常都优雅降级(返回空昵称/头像,不阻断登录),并 logger.warning 记录,便于排查。
  • DEV_MODE=1 才启用 POST /api/auth/dev-login,生产必须为 0。
  • CRMEB 匹配口径:openid 按 appid 隔离。若 CRMEB 与小程序非同一 appid,应改用 unionid 匹配(小程序传 unionid,CRMEB 内部接口按 unionid 查;openid 仍作本系统主键)。

五、后端规范

5.1 路由注册

# routers/__init__.py
router = APIRouter(prefix="/api")
router.include_router(auth.router)      # /api/auth/*
router.include_router(user.router)      # /api/user/*
router.include_router(sessions.router)  # /api/sessions/*
router.include_router(events.router)    # /api/events  (埋点上报)
router.include_router(admin.router)      # /api/admin/* (后台)

注意路由顺序:/sessions/stats 必须声明在 /sessions/{id} 之前,否则 stats 被当作 id。

5.2 配置(pydantic-settings)

# .env
MONGODB_URL=mongodb://user:pwd@127.0.0.1:27017/app_03?authSource=admin
MONGODB_DB_NAME=app_03
JWT_SECRET_KEY=...
JWT_EXPIRE_DAYS=30
CRMEB_BASE_URL=https://shop.example.com
CRMEB_API_KEY=sk_...
HOST=0.0.0.0
PORT=8030
DEV_MODE=0
ADMIN_USER=admin
ADMIN_PASS=<强密码>        # 后台登录,生产务必改默认
CORS_ORIGINS=https://mp.example.com   # 生产收紧,勿用 *

5.3 时区统一(v2 新增,重要)

  • 数据库统一存 UTC(datetime.now(timezone.utc))。
  • 一切「按天分组 / 今天 / 日期反推」统一按目标时区(国内=北京 UTC+8)换算,前后端一致:
    • 后端聚合按天:把 created_at astimezone(UTC+8) 再取 .date()。
    • 前端分组:new Date(iso).toLocaleDateString('zh-CN')(设备本地=北京)。
    • 由 UTC 存储反推业务日期时,先转北京再比较,避免凌晨跨日差一天。

5.4 数据模型

class User(Document):
    openid: str = Field(..., unique=True)
    name: str = ""
    phone: Optional[str] = None
    avatar_url: Optional[str] = None
    is_onboarded: bool = False
    created_at: datetime
    class Settings:
        name = "users"
        indexes = [IndexModel("openid", unique=True)]

业务集合自定义;为常用查询建复合索引(如 [(openid,1),(created_at,-1)])。


六、后台管理 + 访问埋点(v2 新增)

6.1 管理员鉴权

def build_admin_token():  # jwt {"admin": True, exp}
def get_admin(credentials):  # 校验 payload["admin"],否则 401/403
  • POST /api/admin/login {username,password} 校验 .env 的 ADMIN_USER/PASS → 发 admin token。
  • 后台接口统一 Depends(get_admin)。

6.2 独立后台页

  • 单文件静态页(deploy/admin/index.html,纯 HTML+CSS+JS,无需构建),nginx 独立 location 服务:
    location /admin/ { alias /opt/<app>/admin/; try_files $uri $uri/ /admin/index.html; }
    
  • 同域调 /app_03/api/admin/*,token 存 localStorage。

6.3 访问埋点

  • 模型 PageView { openid, visit_id, path, duration_ms, created_at }。
  • 上报:POST /api/events {visit_id, path, duration_ms}(带用户 token)。
  • 前端 RootTracker(包裹路由)监听 useLocation 变化与 visibilitychange,上报上一页停留时长;visit_id 用 sessionStorage(一次打开=一次访问)。仅登录后、停留 >0.2s 才上报。
  • 后台聚合:按 openid 出「访问次数/总时长/最近活跃」,按 visit 还原「访问路径 + 每页时长」。

七、Nginx 配置模板(v2)

server {
    listen 80;
    server_name mp.example.com;
    location /.well-known/acme-challenge/ { root /opt/app_03/frontend/dist; }
    location / { return 301 https://$host$request_uri; }
}
server {
    listen 443 ssl;
    server_name mp.example.com;
    ssl_certificate     /etc/letsencrypt/live/mp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mp.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # 独立后台(可选,放在 catch-all 之前)
    location /admin/ { alias /opt/app_03/admin/; try_files $uri $uri/ /admin/index.html; }

    # 无斜杠跳转:保留 query(避免丢 openid)
    location = /app_03 { return 301 /app_03/$is_args$args; }

    # 前端 SPA(相对资源 + history 回退)
    location /app_03/ {
        alias /opt/app_03/frontend/dist/;
        try_files $uri $uri/ /app_03/index.html;
    }

    # API 代理(proxy_pass 带尾 /api/,前缀被替换)
    location /app_03/api/ {
        proxy_pass http://127.0.0.1:8030/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

证书用 certbot certonly --webroot -w <dist目录> -d <域名> 现签(复用已有 ACME 账号,无需邮箱)。


八、MongoDB 部署注意(v2 新增)

国内服务器优先原生安装,比 Docker 拉镜像稳:

  • Docker Hub / 常见镜像源拉 mongo 大层常卡死/0 速(验证:docker pull 长时间无进度)。若必须 Docker,确认有可用国内 registry-mirror 且能拉 blob。
  • 推荐原生 apt 装(清华源稳):
    echo "deb [trusted=yes] https://mirrors.tuna.tsinghua.edu.cn/mongodb/apt/ubuntu <codename>/mongodb-org/8.0 multiverse" \
      > /etc/apt/sources.list.d/mongodb-org-8.0.list
    apt-get update && apt-get install -y mongodb-org   # 注意 codename 与版本匹配(如 noble→8.0)
    
  • 安全基线:bindIp: 127.0.0.1(仅本地)+ security.authorization: enabled,建 root 用户;连接串带 ?authSource=admin。
  • 每日备份:mongodump --gzip --archive + cron,保留最近 N 份。

九、部署与运维

  • 后端 PM2:pm2 start .venv/bin/uvicorn --name <app> --interpreter none --cwd <backend> -- app.main:app --host 127.0.0.1 --port <port>(--interpreter none 让 PM2 用 Python shebang,否则会当 Node 跑报错);pm2 save 持久化。
  • 后端绑 127.0.0.1,由 nginx 反代,不对公网直接暴露端口。
  • pip 用阿里源加速:pip install -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt。
  • 前端:npm run build → rsync dist/ 到 /opt/<app>/frontend/dist/(nginx 直接服务静态,无需 reload)。

十、新建应用 Checklist(v2)

  • 项目标识 snake_case;二级目录三处一致:Vite base:'./' / Router basename:'/xxx' / VITE_API_PREFIX:'/xxx/api'
  • 生产 .env.production 设 VITE_DEBUG=false
  • 后端 .env:MongoDB(带 authSource) / JWT / CRMEB / DEV_MODE=0 / ADMIN_PASS 改默认 / CORS_ORIGINS 收紧
  • 全站时区:UTC 存储 + 北京(UTC+8)分组/反推,前后端一致
  • Nginx:location = /xxx 跳转保留 query;/xxx/ 相对资源 + try_files;/xxx/api/ 代理;(可选)/admin/
  • MongoDB:原生安装 + localhost + 鉴权 + 每日备份 cron
  • PM2 --interpreter none 启动后端 + pm2 save
  • 小程序:业务域名白名单 + HTTPS + /xxx/?openid= 打开
  • 后台 /admin/ 可登录;前端埋点上报通;CRMEB openid/unionid 口径确认
  • 真机 web-view 实测打开(带/不带斜杠都不丢 openid)

十一、从零起一个新应用 · 命令级 step-by-step

下面以新应用 app_03、端口 8030、域名 mp.example.com、二级目录 /app_03/ 为例。 先定义变量(本地和服务器都可复用):

APP=app_03            # 应用标识(全小写 snake_case)
PORT=8030             # 后端端口(确认未占用)
DOMAIN=mp.example.com # 访问域名(已解析、能签 HTTPS)
DBNAME=$APP           # Mongo 库名
SERVER=root@1.2.3.4   # 目标服务器

0. 本地建仓

mkdir -p ~/work/$APP && cd ~/work/$APP
git init -b main
printf "node_modules/\ndist/\n__pycache__/\n*.pyc\n.env\n.venv/\ntsconfig.tsbuildinfo\n" > .gitignore
mkdir -p backend/app/{routers,models,services,middleware} backend/tests frontend deploy/admin docs

1. 后端骨架

cd ~/work/$APP/backend
cat > requirements.txt <<'EOF'
fastapi==0.115.*
uvicorn[standard]==0.32.*
motor==3.6.*
beanie==1.27.*
pydantic-settings==2.6.*
PyJWT==2.10.*
httpx==0.27.*
pytest==8.3.*
pytest-asyncio==0.24.*
mongomock-motor==0.0.34
EOF

python3 -m venv .venv && . .venv/bin/activate
pip install -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt
printf "[pytest]\nasyncio_mode = auto\npythonpath = .\n" > pytest.ini

# .env(按需改密钥)
cat > .env <<EOF
MONGODB_URL=mongodb://127.0.0.1:27017
MONGODB_DB_NAME=${DBNAME}_dev
JWT_SECRET_KEY=$(openssl rand -hex 32)
JWT_EXPIRE_DAYS=30
CRMEB_BASE_URL=https://shop.example.com
CRMEB_API_KEY=sk_change_me
HOST=0.0.0.0
PORT=${PORT}
DEV_MODE=1
ADMIN_USER=admin
ADMIN_PASS=admin888
CORS_ORIGINS=*
EOF

然后按第二~六节写 config.py / db.py / models / routers / main.py(可直接复制模板应用改标识)。 最小可跑:main.py 注册 router,GET /api/health 返回 {"status":"ok"}。

本地起后端(开发):

DEV_MODE=1 .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $PORT --reload
# 另开终端验证
curl -s localhost:$PORT/api/health   # {"status":"ok"}
pytest -q                            # 跑测试

2. 前端骨架

cd ~/work/$APP/frontend
npm create vite@latest . -- --template react-ts   # 或手动建
npm i react-router-dom framer-motion recharts
npm i -D tailwindcss@3 postcss autoprefixer jsdom @testing-library/react vitest
npx tailwindcss init -p

关键配置(三处一致 + 相对资源):

# vite.config.ts: base: "./",dev proxy 见第 2.4 节
# routes.tsx:    createBrowserRouter([...], { basename: "/app_03" })
# .env
printf "VITE_API_PREFIX=/app_03/api\nVITE_DEBUG=true\n" > .env
# .env.production(生产关调试)
printf "VITE_API_PREFIX=/app_03/api\nVITE_DEBUG=false\n" > .env.production

本地开发 / 构建:

npm run dev       # 代理到后端 $PORT
npm run build     # 产出 dist/(资源为 ./assets/...)
grep assets dist/index.html   # 确认是 ./assets(相对)

3. 服务器:MongoDB(原生,首次装一次)

ssh $SERVER
CODENAME=$(. /etc/os-release; echo $VERSION_CODENAME)   # 如 noble
echo "deb [trusted=yes] https://mirrors.tuna.tsinghua.edu.cn/mongodb/apt/ubuntu $CODENAME/mongodb-org/8.0 multiverse" \
  > /etc/apt/sources.list.d/mongodb-org-8.0.list
apt-get update && apt-get install -y mongodb-org
systemctl enable --now mongod

# 建用户 + 开鉴权
MPWD=$(openssl rand -hex 24); echo "MONGO_PWD=$MPWD"   # 记下来
mongosh --quiet --eval "db.getSiblingDB('admin').createUser({user:'$APP',pwd:'$MPWD',roles:[{role:'root',db:'admin'}]})"
grep -q 'authorization: enabled' /etc/mongod.conf || printf '\nsecurity:\n  authorization: enabled\n' >> /etc/mongod.conf
systemctl restart mongod

4. 部署代码到服务器

# 本地:同步代码 + 构建产物
cd ~/work/$APP
rsync -az --exclude node_modules --exclude .venv --exclude __pycache__ --exclude dist \
  backend deploy docs $SERVER:/opt/$APP/
rsync -az --delete frontend/dist/ $SERVER:/opt/$APP/frontend/dist/

# 服务器:建 venv + 写生产 .env(MONGODB_URL 用上一步的 MPWD)
ssh $SERVER bash -lc "
  cd /opt/$APP/backend && python3 -m venv .venv &&
  .venv/bin/pip install -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt &&
  sed -i 's#^MONGODB_URL=.*#MONGODB_URL=mongodb://$APP:<MPWD>@127.0.0.1:27017/$DBNAME?authSource=admin#;
          s#^MONGODB_DB_NAME=.*#MONGODB_DB_NAME=$DBNAME#;
          s#^DEV_MODE=.*#DEV_MODE=0#;
          s#^CORS_ORIGINS=.*#CORS_ORIGINS=https://$DOMAIN#' .env
"

5. 服务器:PM2 起后端

ssh $SERVER bash -lc "
  cd /opt/$APP/backend &&
  pm2 start .venv/bin/uvicorn --name $APP --interpreter none --cwd /opt/$APP/backend -- app.main:app --host 127.0.0.1 --port $PORT &&
  pm2 save &&
  sleep 3 && curl -s localhost:$PORT/api/health
"

--interpreter none 必加,否则 PM2 当 Node 跑 uvicorn 会报 ESM 错。

6. 服务器:Nginx + HTTPS

ssh $SERVER bash -lc "
  # 先放一个 HTTP server 块(含 acme 与 /$APP/)做证书校验,再签证书
  certbot certonly --webroot -w /opt/$APP/frontend/dist -d $DOMAIN --non-interactive --agree-tos --keep-until-expiring
"
# 然后写第七节的完整 vhost(含 location = /$APP 保留 query、/$APP/ 相对资源、/$APP/api/ 代理、可选 /admin/)
ssh $SERVER bash -lc "ln -sf /etc/nginx/sites-available/$APP /etc/nginx/sites-enabled/$APP && nginx -t && systemctl reload nginx"

7. 小程序接入

  • 小程序后台「业务域名」加入 https://$DOMAIN(下载校验文件放到 dist 根,nginx 可访问)。
  • web-view 打开:https://$DOMAIN/$APP/?openid=<openid>。

8. 上线验证(一键自检)

B=https://$DOMAIN
curl -s -o /dev/null -w "http跳转: %{http_code}\n"  http://$DOMAIN/$APP/
curl -s -o /dev/null -w "首页: %{http_code}\n"       $B/$APP/
curl -s $B/$APP/ | grep -o './assets/[^"]*\.js' | head -1          # 相对资源
curl -s -I "$B/$APP?openid=t1" | grep -i location                 # 应保留 ?openid=t1
curl -s $B/$APP/api/health                                         # {"status":"ok"}
# 走一遍登录
TOK=$(curl -s -X POST $B/$APP/api/auth/openid-login -H 'Content-Type: application/json' -d '{"openid":"t1"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -s $B/$APP/api/user/profile -H "Authorization: Bearer $TOK"

9. 每日备份 cron(可选但推荐)

ssh $SERVER bash -lc "
cat > /opt/$APP/backup.sh <<'EOS'
#!/usr/bin/env bash
set -e; TS=\$(date +%Y%m%d-%H%M); OUT=/opt/$APP/backups; mkdir -p \$OUT
mongodump --uri='mongodb://$APP:<MPWD>@127.0.0.1:27017/$DBNAME?authSource=admin' --gzip --archive=\"\$OUT/$APP-\$TS.gz\"
ls -1t \$OUT/$APP-*.gz | tail -n +15 | xargs -r rm -f
EOS
chmod +x /opt/$APP/backup.sh
( crontab -l 2>/dev/null | grep -v $APP/backup.sh; echo '30 3 * * * /opt/$APP/backup.sh >> /var/log/$APP-backup.log 2>&1' ) | crontab -
"

日常更新(改完代码再次发布)

# 后端改动
rsync -az --exclude __pycache__ backend/app $SERVER:/opt/$APP/backend/
ssh $SERVER "pm2 restart $APP"
# 前端改动
npm run build && rsync -az --delete frontend/dist/ $SERVER:/opt/$APP/frontend/dist/

十二、WebView Bridge:分享 & 复制链接(v2 新增)

H5 页面在微信小程序 <web-view> 中运行,无法直接调用微信原生分享 API 或系统剪贴板。 需通过 Mini Program Bridge 模式实现:H5 发 postMessage → 小程序 @message 事件监听 → 小程序调用原生 API。

12.1 架构图

H5 (React SPA)                        小程序 (uni-app web-view)         微信原生
┌─────────────────┐                   ┌────────────────────┐         ┌─────────┐
│ 用户点"分享好友"  │                   │                     │         │         │
│ bridge.share()   │──postMessage────→│  @message           │──调用──→ │ 分享面板 │
│ wx.miniProgram   │                   │  handleMessage()    │         │         │
│ .postMessage()   │                   │  uni.share()        │         └─────────┘
│                  │                   │                     │         ┌─────────┐
│ 用户点"复制链接"  │──postMessage────→│  @message           │──调用──→ │ 剪贴板  │
│ bridge.copyLink()│                   │  handleMessage()    │         │         │
│                  │                   │  uni.setClipboard() │         └─────────┘
└─────────────────┘                   └────────────────────┘

12.2 小程序侧:web-view 组件配置

在 <web-view> 上声明 @message 事件监听:

<web-view :src="url" @message="handleMessage"></web-view>

实现 handleMessage 方法(uni-app / Vue 2 语法):

handleMessage(e) {
  const data = e.detail && e.detail.data ? e.detail.data[0] : null
  if (!data) return

  switch (data.action) {
    case 'share':
      uni.share({
        provider: 'weixin',
        scene: data.scene || 'WXSceneSession',
        type: data.type || 0,
        title: data.title || '',
        href: data.href || this.url,
        imageUrl: data.imageUrl || '',
        summary: data.summary || '',
      })
      break

    case 'copyLink':
      uni.setClipboardData({
        data: data.href || this.url,
        success() { uni.showToast({ title: '链接已复制', icon: 'success' }) },
        fail(err) { console.warn('[webview] 复制失败:', err) }
      })
      break

    case 'setTitle':
      if (data.title) uni.setNavigationBarTitle({ title: data.title })
      break
  }
}

12.3 小程序侧:原生"..."菜单分享

web-view 页面的右上角"..."菜单默认显示"转发/分享"为灰色不可点。 需要实现以下两个生命周期钩子使其可用:

// 在 onLoad 中主动显示分享菜单
onLoad(option) {
  // #ifdef MP
  uni.showShareMenu({
    menus: ['shareAppMessage', 'shareTimeline']
  });
  // #endif
}

// 处理转发给朋友
onShareAppMessage() {
  return {
    title: '页面标题',
    path: '/pages/web-view-path?url=' + encodeURIComponent(this.url),
  }
}

// 处理分享到朋友圈(微信限制:web-view 不支持,此钩子不会被触发)
onShareTimeline() {
  return {
    title: '页面标题',
    query: 'url=' + encodeURIComponent(this.url)
  }
}

已知限制:onShareTimeline(分享到朋友圈)在 <web-view> 页面中不支持,微信 SDK 将其永久灰化。 替代方案:使用「生成分享图片 → 保存到相册 → 手动发朋友圈」。

12.4 H5 侧:bridge.ts 工具

在新应用的 src/app/services/ 下创建 bridge.ts:

/**
 * 小程序 WebView 桥接工具
 */
declare const wx: any

function getMiniProgram() {
  try {
    if (typeof wx !== 'undefined' && wx.miniProgram) {
      return wx.miniProgram
    }
  } catch { /* ignore */ }
  return null
}

const bridge = {
  postMessage(data: Record<string, any>) {
    const mp = getMiniProgram()
    if (mp) {
      mp.postMessage({ data })
    } else {
      console.warn('[bridge] 不在小程序 web-view 环境中')
    }
  },

  /** 分享给微信好友 / 朋友圈 */
  share(opts: {
    title: string
    href?: string
    summary?: string
    imageUrl?: string
    scene?: 'WXSceneSession' | 'WXSceneTimeline'
  }) {
    this.postMessage({
      action: 'share',
      title: opts.title || '',
      href: opts.href || window.location.href,
      summary: opts.summary || '',
      imageUrl: opts.imageUrl || '',
      scene: opts.scene || 'WXSceneSession',
    })
  },

  /** 复制链接到剪贴板(通过 bridge 兜底) */
  copyLink(link?: string) {
    this.postMessage({
      action: 'copyLink',
      href: link || window.location.href,
    })
  },

  /** 设置小程序导航栏标题 */
  setTitle(title: string) {
    if (!title) return
    this.postMessage({ action: 'setTitle', title })
  },
}

export default bridge

12.5 H5 侧:复制链接的正确方案

微信 web-view 中 navigator.clipboard.writeText() 不支持, 需用 document.execCommand('copy') 作为主方案,bridge postMessage 作为兜底:

async function handleCopyLink(url: string) {
  let copied = false
  const textArea = document.createElement('textarea')
  textArea.value = url
  textArea.style.position = 'fixed'
  textArea.style.left = '-999999px'
  textArea.style.top = '-999999px'
  textArea.style.opacity = '0'
  document.body.appendChild(textArea)
  textArea.focus()
  textArea.select()

  try {
    copied = document.execCommand('copy')
  } catch (e) {
    console.warn('[copy] execCommand failed:', e)
  }
  document.body.removeChild(textArea)

  if (copied) {
    setLinkCopied(true)
    setTimeout(() => setLinkCopied(false), 2000)
  } else {
    // 兜底:通过 bridge postMessage(消息在页面离开时才触发)
    bridge.copyLink(url)
    // 乐观提示用户
    setLinkCopied(true)
    setTimeout(() => setLinkCopied(false), 2000)
  }
}

12.6 postMessage 时序限制

问题 说明
wx.miniProgram.postMessage() 消息不会立即送达 仅在页面跳转/导航离开时才会触发小程序的 @message 事件
不适合"立即复制"场景 点完按钮希望"立即复制到剪贴板"时,postMessage 无法实时响应
最终方案 execCommand('copy') 做实时复制(主方案),postMessage 做兜底(用户离开页面时最终确认)

12.7 微信 web-view 分享限制

分享方式 支持 备注
转发给朋友("..."菜单) ✅ 需实现 onShareAppMessage + uni.showShareMenu()
分享到朋友圈("..."菜单) ❌ 微信平台限制,所有 web-view 页面均不支持
H5 内部"分享给好友"按钮 ✅ 通过 bridge.share() → postMessage → uni.share()
H5 内部复制链接 ✅ document.execCommand('copy') 主方案 + bridge 兜底
生成分享图→存相册→手动发圈 ✅ 替代方案,需 html-to-image 等截图库

十三、新建应用 Checklist 补充(v2)

在原有 Checklist(第十节)基础上增加以下项:

  • WebView Bridge: 小程序 web-view 加 @message + handleMessage 方法
  • WebView Bridge: 实现 onShareAppMessage() + uni.showShareMenu() 启用原生"..."分享
  • WebView Bridge: H5 端创建 src/app/services/bridge.ts 桥接工具
  • WebView Bridge: H5 复制链接优先用 document.execCommand('copy'),bridge 兜底
  • WebView Bridge: 确认微信 JS-SDK 已加载(jweixin-1.2.0.js),否则 wx.miniProgram 不可用
  • WebView Bridge: 生成分享图片场景使用 html-to-image 等截图库