From 8bfdf37a0725cbee87bcdc1e1595fea766e89033 Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Thu, 25 Jun 2026 12:44:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20H5=20=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=B5=8C=E5=85=A5=E5=BC=8F=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=A8=A1=E6=9D=BF=20v2=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development-standards-v2.md | 778 +++++++++++++++++++++++++++++++ 1 file changed, 778 insertions(+) create mode 100644 docs/development-standards-v2.md diff --git a/docs/development-standards-v2.md b/docs/development-standards-v2.md new file mode 100644 index 0000000..11a9948 --- /dev/null +++ b/docs/development-standards-v2.md @@ -0,0 +1,778 @@ +# 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-.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 资源用相对路径(关键) + +```ts +// 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 或加 ``,否则深层路由刷新会算错相对基准。 + +### 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 生产环境关闭调试 + +```ini +# .env.production +VITE_API_PREFIX=/app_03/api +VITE_DEBUG=false # 生产关闭:避免无 openid 时自动 dev-login +``` + +`npm run build` 默认加载 `.env.production`。 + +### 2.4 Vite dev proxy + +```ts +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=`,建议带尾斜杠。 +3. **跳转保留参数**:无斜杠地址 `…/app_03?openid=xxx` 经 nginx 301 时**必须保留 query**,否则 openid 丢失、登录失败: + ```nginx + 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 路由注册 + +```python +# 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) + +```ini +# .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 数据模型 + +```python +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 管理员鉴权 + +```python +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 服务: + ```nginx + location /admin/ { alias /opt//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) + +```nginx +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 -d <域名>` 现签(复用已有 ACME 账号,无需邮箱)。 + +--- + +## 八、MongoDB 部署注意(v2 新增) + +国内服务器优先**原生安装**,比 Docker 拉镜像稳: + +- Docker Hub / 常见镜像源拉 `mongo` 大层常**卡死/0 速**(验证:`docker pull` 长时间无进度)。若必须 Docker,确认有可用国内 registry-mirror 且能拉 blob。 +- **推荐原生 apt 装**(清华源稳): + ```bash + echo "deb [trusted=yes] https://mirrors.tuna.tsinghua.edu.cn/mongodb/apt/ubuntu /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 --interpreter none --cwd -- app.main:app --host 127.0.0.1 --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//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/` 为例。 +先定义变量(本地和服务器都可复用): + +```bash +APP=app_03 # 应用标识(全小写 snake_case) +PORT=8030 # 后端端口(确认未占用) +DOMAIN=mp.example.com # 访问域名(已解析、能签 HTTPS) +DBNAME=$APP # Mongo 库名 +SERVER=root@1.2.3.4 # 目标服务器 +``` + +### 0. 本地建仓 + +```bash +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. 后端骨架 + +```bash +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 < 然后按第二~六节写 `config.py / db.py / models / routers / main.py`(可直接复制模板应用改标识)。 +> 最小可跑:`main.py` 注册 `router`,`GET /api/health` 返回 `{"status":"ok"}`。 + +本地起后端(开发): + +```bash +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. 前端骨架 + +```bash +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 +``` + +关键配置(三处一致 + 相对资源): + +```bash +# 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 +``` + +本地开发 / 构建: + +```bash +npm run dev # 代理到后端 $PORT +npm run build # 产出 dist/(资源为 ./assets/...) +grep assets dist/index.html # 确认是 ./assets(相对) +``` + +### 3. 服务器:MongoDB(原生,首次装一次) + +```bash +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. 部署代码到服务器 + +```bash +# 本地:同步代码 + 构建产物 +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:@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 起后端 + +```bash +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 + +```bash +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=`。 + +### 8. 上线验证(一键自检) + +```bash +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(可选但推荐) + +```bash +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:@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 - +" +``` + +### 日常更新(改完代码再次发布) + +```bash +# 后端改动 +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 页面在微信小程序 `` 中运行,无法直接调用微信原生分享 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 组件配置 + +在 `` 上声明 `@message` 事件监听: + +```html + +``` + +实现 `handleMessage` 方法(uni-app / Vue 2 语法): + +```javascript +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 页面的右上角"..."菜单默认显示"转发/分享"为灰色不可点。 +需要实现以下两个生命周期钩子使其可用: + +```javascript +// 在 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`(分享到朋友圈)在 `` 页面中**不支持**,微信 SDK 将其永久灰化。 +> 替代方案:使用「生成分享图片 → 保存到相册 → 手动发朋友圈」。 + +### 12.4 H5 侧:bridge.ts 工具 + +在新应用的 `src/app/services/` 下创建 `bridge.ts`: + +```typescript +/** + * 小程序 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) { + 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` 作为兜底: + +```typescript +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` 等截图库