14 Commits

Author SHA1 Message Date
53a43ea18c chore: .gitignore 忽略本地备份文件(*.bak* / *.origin-bak) 2026-11-11 11:30:17 +08:00
6df7640272 feat(link_view): 链接/福利系统小程序码落地页(2026-11-04) 2026-11-11 10:55:06 +08:00
2356f33b47 chore: 忽略 App 签名证书 keystore/(私钥不入库) 2026-11-11 10:54:54 +08:00
f3648d5cef docs(AGENTS): 补远程仓库地址与 App 分支约定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 17:09:25 +08:00
b45a190a59 chore: 入库生产基线(2026-10-26 工作树快照)
- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动
- 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用)
- 排除本地备份文件(*.bak* / *.origin-bak)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 17:07:49 +08:00
91f1ee849c feat(分享图): 反查数据源换 /api/mp/public/share-images(含下架应用)—— mama-box/laixin 可配自定义转发图
- APPS_API 换 box_app 专用反查接口(不再被 isPublished=1 过滤卡住)
- 缓存 key bump share_apps_index → v2(旧缓存只有上架应用,残留 1h)
- shareCard.js 首次入库(此前为未跟踪文件);文档同步数据流与缓存说明
2026-09-23 16:59:55 +08:00
dcc84dd7b7 docs(AGENTS): 补 §4.3 —— 回到首页要清 last_app(首页壳 + mama-box 两侧互补)
- 表格列出三条返回路径分别由谁清、判定依据是什么
- 明确禁止「mama-box 每次加载都清」(会把恢复落点加载误清,毁掉记录)
- 首页壳只在重载目标命中 /mama-box 时清
2026-09-22 11:58:12 +08:00
da18d9685e docs(AGENTS): 补 §4.2 —— 显式落地后点「小房子」回首页不能被恢复逻辑再送回去
- 记录用户实测现象:扫码进应用 → 点小房子 → 被 last_app 恢复送回应用 → 左上角变成返回箭头
- 修法两层缺一不可:① App.onLaunch 按 option.path 置 restoreArmed(首页壳一次性消费);
  ② 判定「不恢复」的那一轮加载(含显式落地落点、onShow 重载、retryLoad)都要带 #norestore=1
- §6 自测补「扫码入口」用例,并说明 Console 过滤 [enter-app] 可看完整判定链
2026-09-22 11:49:06 +08:00
b06a617f13 docs(AGENTS): 新增 AGENTS.md —— 小程序(宿主)侧开发约定与踩坑(跨仓约定指向 box_app/AGENTS.md)
- 两个宿主页 / mixin / App.vue / 授权页 / 引导页 的职责地图,发布方式(HBuilderX→微信审核)
- 硬约定:宿主页共用 mixin、身份走 query 授权结果走 hash、fail-closed、分享深链脱敏、
  分享图按 webViewUrl 当场解析、tabBar 全局状态、恢复/落地走容器页、只有 options.url 算显式落地
- 踩坑:热启动(点胶囊关闭后再进)不触发 onLoad —— 恢复逻辑不能只写在 onLoad;
  onShow 无条件重载会把用户踢回首页(用 App.onShow 时间戳区分热启动与切 tab)
- 提醒:mixins/shareCard.js 未被 git 跟踪,提交时别漏
2026-09-22 11:08:41 +08:00
e99b4a475c fix(身份): 宿主页「拿到 openid 才加载 H5」+ 复用 App.vue 并行登录(不重复换 code)
背景(2026-09-20 线上排查):删掉小程序缓存重进时 H5 以访客态加载(孕周 `--`、档案空、
跳应用不带 openid,实测跳授权示例是裸 URL)。服务端证据:该设备只打了 `/api/mp/apps`,
没有任何带 openid 的 profile/pregnancy 调用 —— 宿主那次没注入身份,不是数据丢了。

根因:
1) App.vue 静默授权只处理 token 分支:v2/wechat/silence_auth 对新用户 / 强制绑定手机号返回
   {auth_login:1, key, openid, unionid}(无 token),openid 被丢掉;且 res.data.userInfo 缺失时
   取 .uid 会抛异常把后续 commit 一起带走。
2) 两个宿主页只从 store.openid / 缓存 OPENID / JWT 读身份,读不到就匿名加载(首页壳不等不重试,
   容器页 _finalizeUrl / _fetchOpenidAndLoad 兜底)。

改法:
- App.vue:补「无 token 但有 openid」分支(SET_OPENID + 缓存,unionid 同理);userInfo 判空;
  **并把这次静默登录的 promise 挂到 globalData._silenceAuthPromise**(供宿主页复用,见下)。
- mixins/webviewHost.js:新增
  · _silenceAuthOpenid()(wx.login → v2/wechat/silence_auth,兼容 auth_login/userInfo/JWT 三种
    openid 来源,拿到即落 store + 缓存,带 8s 超时保险丝);
  · _awaitHostLogin(timeout)(等 App.vue 那次登录结束,**不重复换 code**)。
- 两个宿主页统一 fail-closed「拿到 openid 才加载」,并且**第 1 次先 await App.vue 的并行登录**、
  命中缓存就直接加载;之后才自己取。**网络尝试上限 3 → 2,退避 600×n → 固定 500ms**
  (实测:code2session 约 200–300ms,重复换一次就是白花这个钱;最坏等待从 ~3.5s 降到 ~1–2.5s)。
- 首页壳:读不到身份就不设 webUrl(停在小程序自己的加载屏,文案分「取身份中/失败/页面加载失败」三态);
  2 次仍失败 → 「未获取到微信身份」+「重新获取身份」。
- 容器页:三条失败分支(有 token 无 openid 的重试失败、无 token 无 openid、onShow 仍无 openid)
  全部改走 _ensureIdentityThenLoad();删除本页重复实现的 _fetchOpenidAndLoad;新增兜底屏
  (以前 url 为空是白屏)。非可信域(商城自身域名等)不注入身份、照常直接加载。
- utils/util.js:注释里的方法名同步(_fetchOpenidAndLoad → _ensureIdentityThenLoad)。

⚠️ 提交同时包含这两处**此前已存在的未提交 WIP**(同文件、改动交织):pages/annex/web_view/index.vue
   的 mixin 重构(handleMessage→handleHostMessage 等 4 处 handler 改名)与 pages/index/index.vue
   的两行 tabBar 调整。
⚠️ 刻意 fail-closed:CRMEB silence_auth 整体不可用时会停在失败屏而不是降级加载。
⚠️ 未动 manifest.json 版本号(见另一次改动:已统一为 0.3.6 / 36)。
2026-09-20 23:50:31 +08:00
c216865852 feat(user-center): 授权页 submitAuth 带 unionid(用户中心身份键,预留功能)
- 新增 getUnionid()(store/Cache 'UNIONID')
- POST /auth/profile 增加 unionid 参数:box_app 授权成功自动推送用户中心并回写 openid→unionid 映射
- 没拿到 unionid 也不影响授权(后端映射兜底)
- 配套:box_app 1269058(已部署);需重新发版生效
2026-09-19 17:41:02 +08:00
6f033db7d4 fix(auth): 链路B原生面板回传基址 —— requestProfile 消息带 href 优先用作面板基址
- 面板弹出=requestProfile 消息送达;同一条消息带 href(H5 发起面板时的当前地址)
  → 提交后按它回传,不赌 shareHref 的 postMessage 派发时机(宫格换应用后可能旧地址)
- 不带 href 时回退调用处基址(shareHref || webUrl || MAMA_HOME),向后兼容
- 配套协议:h5-auth-standard.md §4.2 requestProfile 增加 href(推荐带)

需重新发版生效
2026-09-19 17:14:12 +08:00
3df3e08252 fix(auth): 授权回传落点改确定性通道 —— backUrl 经 navigateTo query 传递
- 授权页 onLoad 读 options.backUrl(H5 发起授权时带上的当前地址),成功时写进
  __AUTH_RESULT__.backUrl
- consumeAuthResult 优先用 res.backUrl 做回传基址(不再赌 postMessage 派发时机:
  微信只在后退/销毁/分享时批量派发,宫格 location.href 换应用后 shareHref 可能
  还是上一个应用的地址 → 授权后被踢回 mama-box 首页)
- 配套:mama-box wx-auth.js requestProfile / goAuth 已带 backUrl(mama-box d39e52c)

需重新发版生效
2026-09-19 16:56:00 +08:00
67eaae0ceb fix(auth): 授权回传落点写死 MAMA_HOME + 假成功判据 → 宫格应用授权死循环
- 首页壳 onShow consumeAuthResult:回传基址改为 shareHref(H5 发起授权前
  reportLocation 上报的当前地址)|| webUrl || MAMA_HOME —— 此前写死 MAMA_HOME,
  宫格 location.href 换应用后(webUrl 数据未变),授权结果被送回 mama-box,
  目标应用(孕检日历等)永远收不到 phone,弹层复现
- 首页壳 else 分支的 ?t= 重载同样以 shareHref 为基址(不再把宫格应用用户踢回 mama-box)
- 首页壳 submitAuth(原生面板)同步改用当前 H5 地址
- 授权页成功判据:提交了 code 时必须核对 result.phone 非空才写 __AUTH_RESULT__/
  提示成功 —— 配合 box_app 后端 400 如实报错(box_app commit e6b98e7)

需重新发版生效
2026-09-18 19:36:39 +08:00
17 changed files with 5380 additions and 2728 deletions

7
.gitignore vendored
View File

@@ -25,3 +25,10 @@ unpackage
project.config.json
.hbuilderx
/config/app.js
# Android 签名证书(私钥,禁止入库;见 manifest.json app-plus.distribute.android.keystore)
keystore/
# 本地备份文件(历史页面/配置快照),不入库
*.bak*
*.origin-bak

171
AGENTS.md Normal file
View File

@@ -0,0 +1,171 @@
# AGENTS.md · CRMEB 小程序(web-view 宿主)
> 在本仓库工作前先读。**跨仓库的权威约定在 `box_app/AGENTS.md`**(WSL:`/home/jone/workspace/box_app/AGENTS.md`)——
> 三仓布局、身份契约、接口命名、部署流程都在那里。本文件只写小程序侧特有的部分。
---
## 1. 这是什么
CRMEB 商城小程序(uniapp,manifest `name=DoubaShop`),同时是**整个 H5 生态的 web-view 宿主**:
- **首页壳** `pages/index/index.vue` —— tabBar 首页,硬编码加载 `https://mp.yizhezhe.com/mama-box/`;
H5 应用宫格在 mama-box 内用 `location.href` 换应用,**本页不会重新 onLoad**。
- **容器页** `pages/annex/web_view/index.vue` —— 太阳码 / 分享落地 / 商城宫格 / JumpPath 的通用宿主。
- H5 应用(30+)只在 box_app 后台登记,本仓库没有它们的源码。
| 项 | 值 |
| --- | --- |
| 本地路径 | `/mnt/c/Users/Jone/Documents/HBuilderProjects/crmeb`(Windows:`C:\Users\Jone\Documents\HBuilderProjects\crmeb`) |
| 发布 | HBuilderX 发行 → 微信小程序上传 → 微信审核 → 发布 |
| 远程仓库 | `git@gitea.nexjs.com:jone/crmeb-mp.git`(2026-10-26 补)—— `main` = 可发布小程序基线;App 开发在 `feat/app-android` 分支 |
| 版本 | `manifest.json` 的 `versionName` / `versionCode` |
> ⚠️ HBuilderX 打包按**当前工作树**,不看分支。发小程序版前先切回 `main`;
> App 专属代码一律包 `#ifdef APP-PLUS`,否则会被打进小程序包。
## 2. 关键文件
| 文件 | 作用 |
| --- | --- |
| `pages/index/index.vue` | 首页壳:身份 fail-closed、加载 mama-box、**冷启动恢复 last_app**、分享卡片、tabBar 显隐 |
| `pages/annex/web_view/index.vue` | 容器页:太阳码 scene 解析、身份注入(query)、`_reportLastApp` 上报 |
| `pages/annex/link_view/index.vue` | **链接型壳页**(2026-11-04):链接系统 / 福利系统的小程序码落地。scene=`l=<slug>` / `f=<slug>` 或转发 `?t=&k=` → 按硬编码的 `TARGETS` 拼 `wenda.douba.top/s/`、`fuli.douba.top/a/` 用 web-view 打开;**不注入身份、不引 mixin**;解析不出来走兜底屏(见 §3.9) |
| `mixins/webviewHost.js` | 宿主公共能力:postMessage 分发(location/share/copyLink/setTitle/requestProfile)、原生授权面板、授权回传、静默登录复用 |
| `mixins/shareCard.js` | 分享图:按当前 H5 URL 反查 box_app App 表;朋友圈本地图转换 |
| `App.vue` | 冷启动静默登录、`globalData`(`spid` / `appShowAt`)、`onShow`(前台化时间戳) |
| `pages/annex/auth/index.vue` | 独立授权页(`navigateTo` 进入,**先手机号 → 再问头像昵称**,结束写 `__AUTH_RESULT__` 后 `navigateBack`,见 §4.4) |
| `pages/guide/index.vue` | `pages.json` 第一个页面(冷启动入口,无广告时 `switchTab` 到首页壳) |
## 3. 硬约定
1. **两个宿主页必须共用 mixin**:`mixins/webviewHost.js` + `mixins/shareCard.js`。历史上两页各写一份导致能力分叉(首页壳缺 copyLink / 授权回传 / unionid),别再分叉。
2. **身份注入走 query、授权结果走 hash**:容器页 `_applyIdentity()` 负责按当前 openid 重写 URL(含 `_v=` 缓存击穿);授权回传只走 hash(`#openid=&phone=&nickname=&avatar=`)——微信 web-view **首次加载丢 query、不丢 hash**。
3. **拿不到 openid 就不加载 H5**(fail-closed):重试 → 兜底屏 → 手动重试;**不要匿名加载**(H5 会进访客态,反而更难排查)。
4. **分享深链必须 `stripAuthParams()`**:别把发送者的 `openid/unionid/t` 泄露给接收者;接收者由容器页按自己身份重新注入。
5. **分享图/标题按 `onShareAppMessage(options).webViewUrl` 当场解析**:postMessage 是延迟投递,首页壳又不随 H5 换应用 onLoad,沿用上次结果会「B 应用带 A 应用的图」。
6. **tabBar 是全局状态**:首页壳必须 `uni.hideTabBar()`(web-view 盖不住原生条,H5 自带底栏会双条);**严禁在商城页主动 `showTabBar` 配对** —— 会与 DIY 底栏同显成双 tabBar。
7. **恢复/落地统一 `navigateTo` 容器页**,并保留首页壳作为返回落点;不要直接把首页壳的 web-view src 指向第三方应用(首页壳的 `buildWebUrl` 是 **hash** 注入,会破坏自带 hash 路由的应用)。
8. **只用 `options.url` 当显式落地判据**:`options.scene` **每次冷启动都有值**(1001 最近使用 / 1007 分享 / 1011 扫码…),拿它判落地会导致恢复永远不执行。
9. **链接型壳页 `pages/annex/link_view` 的三条同步契约**(2026-11-04,方案见 box_app `docs/lianjie-wxacode-plan.md`):
① 页内 `TARGETS`(短键 / 地址前缀 / 标识正则)必须与 box_app `backend/app/services/link_wxacode_serv.py` 的同名表**逐字一致**,改表 = 两端一起改 + 小程序发版;
② 页面路径必须等于 box_app 生产 `.env` 的 `LINK_WXACODE_PAGE`(默认 `pages/annex/link_view/index`),否则已生成的码全部扫不进页面;
③ 该页**故意不注入 openid/unionid**(两家 H5 走自己的公众号网页授权),所以不要往里加 `webviewHost` / `shareCard`,也不要往 URL 上带身份参数。
`wenda.douba.top`、`fuli.douba.top`、`dbh5.ywsz.com.cn` 三个域名必须在微信后台「业务域名」里,**体验版真机同样校验**(漏一个 = 部分活动白屏)。
## 4. 踩坑:宿主生命周期(2026-09-22,都付过线上代价)
### 4.1 热启动 vs 冷启动
- 用户点右上角胶囊圆点「关闭」**不是销毁**,只是切后台。马上再进是**热启动**:页面栈与 web-view 都在,
**只触发 `onShow`,不触发 `onLoad`**;被回收后才是冷启动。
- 所以「退出小程序再进要回到上次的 H5 应用」这类需求,**只写在 `onLoad` 里会失败** —— 用户测的正是「关闭后马上再进」。
- 反过来,`onShow` 里**无条件重载 web-view** 会把用户从 H5 应用踢回首页。首页壳曾有一段「切回首页 tab 就重载」的老逻辑,
热启动也走它,于是把恢复好的应用冲掉(`shareHref` 因 postMessage 延迟还停在 mama-box)。
- **判据**:切 tab **不触发** `App.onShow`,热启动**会**。做法:`App.vue onShow` 记 `globalData.appShowAt`,
页面 `onShow` 里 `Date.now() - appShowAt < 2000` 视为热启动恢复 → **跳过重载**。
- 改任何 `onShow` 逻辑前,先想清楚触发来源(App 前后台切换 / 页内跳转 / 切 tab)。
### 4.2 显式落地后点「小房子」回首页,不能被恢复逻辑再送回去
- 扫码(太阳码)/ 分享落地时,用户直接落在**容器页**,页面栈只有它 → 左上角是**「小房子」**,点它回小程序首页。
- 如果这时「上次应用恢复」还生效,就会把用户又送回刚扫码的那个应用:表现为**点了小房子回不了家**,
而且因为多push了一层容器页,左上角变成**返回箭头**,得再点一次返回才真回首页(用户实测)。
- **修法两层,缺一不可**:
1. **入口判定**:`App.vue onLaunch` 用 `option.path` 置 `globalData.restoreArmed` ——
只有 `pages/index/index` / `pages/guide/index` 且无 `query.url` 才为 true;首页壳 `onLoad` 一次性消费。
(用 path 不用时间窗:引导页可能停很久看开屏广告。)
2. **抑制 H5 兜底**:这类「明确不恢复」的首页加载,以及显式落地/恢复后的返回落点,
`loadMamaBox(true)` / `_loadWithCachedIdentity` / `onShow` 重载 / `retryLoad` **都要带 `#norestore=1`**,
否则 mama-box 的兜底恢复会绕开小程序判定又把用户送走。
- 反向:小程序**尝试恢复但取数失败/超时**时**不带** `norestore`,让 mama-box 兜底重试一次。
### 4.3 回到首页要清 last_app(两侧互补,2026-09-22)
现象:进入 H5 应用 → **返回**回到 mama-box 首页 → 再冷启动进小程序,又被送回刚退出的应用。
记录的是「最后进入的 H5 应用」,人已经回到首页了 → 必须清掉。清除动作分两侧,互补覆盖:
| 路径 | 谁清 | 判定 |
| --- | --- | --- |
| H5 应用自己的返回(`history.back()` / 后退重载) | **mama-box** `clearLastAppOnBackHome()` | `pageshow.persisted` / `navigation.type === 'back_forward'` |
| H5 应用以 `location.href` 回 mama-box | **mama-box**(同上) | `document.referrer` 是 `https://mp.yizhezhe.com/<app>/` |
| 容器页**返回箭头**回首页(host 重载,不带 referrer) | **首页壳** `_clearLastApp()` | `onShow` 决定重载回 mama-box 时(`reloadBase` 命中 `/mama-box`) |
- 接口:`DELETE /api/mp/public/last-app?openid=`(幂等、fail-soft)。
- ⚠️ **绝不能改成「mama-box 每次加载都清」**:恢复流程会把 mama-box 当**返回落点**加载,
误清会毁掉刚用上的记录(实测:普通加载与 `#norestore=1` 落点加载都不清)。
- ⚠️ 首页壳只在**重载目标是 mama-box** 时清;重载目标是某个应用(`shareHref` 指向它)时不能清。
### 4.4 独立授权页:只采集手机号,再按库里资料决定是否问复用(2026-09-22 改版)
页面交互按 `box_app/design/授权流程演示/index.html` 重做,链路与契约(`backUrl` / `__AUTH_RESULT__` 形状 /
宿主 `consumeAuthResult`)**都没变**,只改了页内流程:
```
S0 半弹窗问手机号 ─「否」→ 直接 navigateBack(不写结果)
└「是,授权手机号」= <button open-type="getPhoneNumber"> 本身 → 拿 code
→ POST /api/mp/public/auth/profile?code=… 落库手机号
→ GET /api/mp/public/auth/profile?openid=… 查库(判分支、取值展示)
├ 库里有昵称/头像 → 问「是否复用」→ 是/否都收尾返回
└ 库里没有 → **不追问、不给表单**,直接收尾返回
收尾 = 写 __AUTH_RESULT__(手机号 + 库里真实值) → navigateBack
```
五个必须守住的点(前三条照抄演示页 README 的告诫):
1. **「是,授权手机号」按钮本身必须是 `open-type="getPhoneNumber"`** —— 手机号没有可编程调起的 API,
不能「先点按钮 → 关弹窗 → 代码调起」。
2. **手机号落库的成功判据是 `code===200 && result.phone` 非空**(后端解密失败会如实返回 400)。不满足就
**留在手机号弹窗**报错,绝不写 `__AUTH_RESULT__`、绝不提示成功 —— 假成功会让 H5 闸门拦截 → 用户反复授权
反复被拦(2026-09-18 死循环事故)。
3. **判「库里是否已有资料」用 `!!(userName || avatar)`,不能用「记录是否存在」**:`GET/POST auth/profile`
对任何 openid 都返回 200 并现场建一条空记录,用存在性判断会 100% 命中「老用户」分支。
4. **查库判分支必须走 `GET`,不能拿手机号那次 `POST` 的返回凑**(2026-09-22 修):后端的
「先取后授权」兜底回填(本地 → 上游用户中心 → `CrmebService.user_info_by_openid` 同步 CRMEB 商城)
**只在 `get_user_auth`(GET)里做,`apply_auth`(POST)只 find_one/建空记录**。用户只在商城
有昵称/头像时,POST 返回仍是空 → 会误判成新用户,白丢「是否复用」那一屏。
⚠️ 顺带明确:这一屏的昵称/头像**不是**当场从微信取的(`getUserProfile` 已废弃,昵称/头像只能由
用户手势触发 `input type=nickname` / `chooseAvatar`),展示的永远是**库里已存过的值**。
5. **本页只采集手机号**(2026-09-22 产品定案):库里没有昵称/头像时**不追问、不给表单**,
直接收尾返回 —— 所以 `chooseAvatar` / `input type=nickname` / `/api/upload` 这条采集链
已从本页移除,昵称/头像改由小程序其它流程 / 上游用户中心 / 商城同步维护。
⚠️ 由此:`get_user_auth`(GET) 里读 CRMEB 商城用户表回填 `nickname`/`avatar` 的兜底
(`CrmebService.user_info_by_openid`,命中标 `fromCRMEB=true`)**必须保留**(2026-09-22 确认)——
它是头像目前**唯一稳定来源**(上游用户中心只有 `nickname`、没有 avatar,本页又不再采集)。
去掉它 = 头像恒空、「是否复用」那一屏基本不再出现、H5 侧展示一起变空。
口径:H5 侧仍只读 box_app(不直连商城);box_app 对 H5 是上游、对商城是下游。
其它:手机号落库与随后的昵称头像**解耦**(所以「不同步直接返回」不会丢手机号);「复用」是普通 button
(不调微信接口),会再 POST 一次把 `authorized` 置位;工具宫格数据来自 `GET /api/mp/apps`
(**仅展示、不可点击**,未授权时右上角显示小锁),拉取失败只是宫格留空、不阻塞授权。
⚠️ **CSS 陷阱(2026-09-22 真机截图返工)**:页面顶部有条 `button` 全局 reset
(`.auth-page button { padding:0; border:none; … }`,特异性 (0,1,1),scoped 编译后约 (0,2,1)),
它**高于**单类选择器 `.bsec` / `.bmain` / `.cta`((0,1,0))。所以按钮自己的 `padding` / `border`
规则必须写成 `.auth-page .bsec { … }` 这样带前缀提权,否则会被 reset 清成 0 ——
表现是「否」没有边框只剩文字、所有按钮高度塌成一行字高。同理 `.bwx` 这种只改底色的变体
要写成 `.auth-page .bmain.bwx`,否则会被同级的 `.auth-page .bmain` 底色盖掉。
### 4.5 授权页宫格的内容风险(未决)
宫格按演示页照搬了「已有 36.5万+ 位妈妈在用」+ 三个内联 SVG 插画头像(`SOCIAL_COUNT` 常量),
以及「全部免费 / 换手机不丢」等承诺 —— **演示页 README 明确标注这些是占位数据**,
上线前必须核实真实数字并取得产品/合规确认,否则涉嫌虚假宣传。
## 5. 本仓库状态提醒
- ⚠️ `mixins/shareCard.js` **未被 git 跟踪**(untracked),但两个宿主页都 import 它 —— 提交时别漏,否则一旦丢工作区这份文件就没了。
- `pages/index/index.vue.origin-bak`(及若干 `.bak_*`)是原装修首页/历史备份,**别当改动目标**,也别删(回退时要用)。
- 仓库里有多次会话遗留的未提交改动(`manifest.json` / `pages.json` / `pages/index/mall.vue` 等),动手前先 `git status` 分清哪些是本次的。
## 6. 自测
微信开发者工具(HBuilderX 运行到小程序)。**Console 过滤 `[enter-app]`** 可看到完整判定链
(入口路径 / restoreArmed / last-app 请求与响应 / 是否命中 / 热启动判定 / 容器页加载与上报):
1. **冷启动**:点「编译」→ 有 last_app 记录时应直接进上次的 H5 应用(不经过首页)。
2. **热启动**:点工具栏「切后台」再「切前台」→ 应停在原 H5,**不应重载回首页**。
3. **扫码入口**:编译模式选「通过二维码编译」(或扫太阳码)→ 进应用后点左上角「小房子」→ **应停在首页**,
不应被送回应用、也不应出现返回箭头。
4. 无记录 / 断网冷启动 → 最多约 1.5s 后照常进首页。
5. 授权页返回、分享卡片、商城宫格进应用各走一遍。
接口契约与后端验证清单见 `box_app/AGENTS.md` §5、§6。

70
App.vue
View File

@@ -19,6 +19,8 @@
isLogin: false,
userInfo: {},
globalData: false,
appShowAt: 0, // 本次前台化时刻(首页壳据此区分热启动恢复 / 切 tab)
restoreArmed: false, // 冷启动是否允许首页壳恢复「上次进入的 H5 应用」(见 onLaunch)
windowHeight: uni.getWindowInfo().windowHeight + 'px',
sysHeight:uni.getWindowInfo().statusBarHeight,
},
@@ -39,6 +41,18 @@
}
},
onShow(options) {
/* 记录本次「小程序回到前台」的时刻(2026-09-22):首页壳 pages/index/index
靠它区分两种 onShow ——
· 热启动恢复(点胶囊关闭后再进):页面栈与 web-view 都还在,
用户就停在离开时那个 H5 应用里 → **绝不能重载 web-view**;
· 从其它 tab 切回首页:App.onShow **不会**触发 → 维持原行为(重载回兜爸心选首页)。
判据是时间窗:页面 onShow 紧跟 App.onShow 触发(毫秒级),切 tab 不在窗内。 */
try { this.globalData.appShowAt = Date.now(); } catch (e) {}
/* 诊断日志(过滤关键字 enter-app):
· onShow 触发 = 小程序回到前台(热启动恢复或冷启动都会走),
首页壳据此判断要不要重载 web-view;切 tab 不会打这条。 */
console.log('[enter-app] App.onShow(前台化)appShowAt=' + this.globalData.appShowAt +
'(首页壳 onShow 将据此识别热启动)');
const queryData = uni.getEnterOptionsSync(); // uni-app版本 3.5.1+ 支持
if (queryData.query.spid) {
this.$Cache.set('spread', queryData.query.spid);
@@ -70,6 +84,24 @@
// #endif
},
onLaunch(option) {
/* 冷启动入口判定(2026-09-22):**只有「落在首页/引导页且没有显式落地 url」才允许
首页壳恢复上次应用**。否则(扫码进容器页 / 分享落地 / 朋友圈 / 商品页…)用户是冲着
某个页面来的;若此时在小程序内点左上角「小房子」回首页,恢复逻辑又会把他送回刚才那个
应用 —— 形成「点了小房子却回不了首页」的循环(用户实测:扫码→小房子→被送回应用→
多了个返回箭头→再点返回才到家)。
⚠️ 只在 onLaunch 置位(热启动/切 tab 不置),首页壳 onLoad 时一次性消费。
⚠️ 用 path 判据而不是时间窗:引导页可能停很久(看开屏广告),时间窗会误判。 */
try {
const _opt = option || {};
const _path = _opt.path || '';
const _q = _opt.query || {};
const _plainHome = (!_path || _path === 'pages/index/index' || _path === 'pages/guide/index') && !_q.url;
this.globalData.restoreArmed = _plainHome;
console.log('[enter-app] App.onLaunch(冷启动)path=' + _path +
' query=' + JSON.stringify(_q) +
' scene=' + (_opt.scene || '') +
' → restoreArmed=' + _plainHome + '(false=显式落地,首页壳不会恢复上次应用)');
} catch (e) {}
uni.hideTabBar()
//#ifdef APP
plus.screen.lockOrientation("portrait-primary");
@@ -227,7 +259,7 @@
let that = this;
let spid = that.globalData.spid ? that.globalData.spid : '';
console.log('[auth-flow] silenceAuth 发起, code=' + (code ? code.substring(0, 10) + '...' : '(空)'));
silenceAuth({
const authPromise = silenceAuth({
code: code,
spread_spid: spid,
spread_code: that.globalData.code
@@ -241,11 +273,30 @@
token: res.data.token,
time: time
});
that.$store.commit('SETUID', res.data.userInfo.uid);
that.$store.commit('UPDATE_USERINFO', res.data.userInfo);
/* ⚠️ 2026-09-20:`userInfo` 不是每个响应都有(v2/wechat/silence_auth
只回 token+expires_time),直接取 `.uid` 会抛异常,把后面的
commit 一起带走。加一层判空,缺了也不影响身份(openid 由 LOGIN 从 JWT 解出)。 */
if (res.data.userInfo) {
that.$store.commit('SETUID', res.data.userInfo.uid);
that.$store.commit('UPDATE_USERINFO', res.data.userInfo);
}
console.log('[auth-flow] ✅ silenceAuth 成功, token 已存 store (含 openid)');
} else if (res.data.openid) {
/* ⚠️⚠️ 2026-09-20 修:新用户 / 后台开启了「强制绑定手机号」时,
该接口返回的是 {auth_login:1, key, openid, unionid} —— **有 openid、没有 token**。
以前这段落到下面的 else,只打一条警告就把 openid 丢了;于是
store.openid / 缓存 OPENID / JWT 三处全空,首页壳 _hostOpenid() 拿不到身份
→ **匿名加载 mama-box**(H5 进访客态:孕周 `--`、档案空、跳应用跳裸 URL)。
这里必须把它落 store + 缓存。 */
that.$store.commit('SET_OPENID', res.data.openid);
try { uni.setStorageSync('OPENID', res.data.openid); } catch (e) {}
if (res.data.unionid) {
that.$store.commit('SET_UNIONID', res.data.unionid);
try { uni.setStorageSync('UNIONID', res.data.unionid); } catch (e) {}
}
console.log('[auth-flow] ✅ 无 token 但拿到 openid,已缓存(首页壳据此注入身份): ' + String(res.data.openid).substring(0, 8) + '...');
} else {
console.warn('[auth-flow] ⚠️ silenceAuth 返回无 token:', JSON.stringify(res.data || res).substring(0, 200));
console.warn('[auth-flow] ⚠️ silenceAuth 返回无 token 也无 openid:', JSON.stringify(res.data || res).substring(0, 200));
}
}).catch(err => {
console.warn('[auth-flow] ❌ silenceAuth 失败:', JSON.stringify(err).substring(0, 300));
@@ -253,6 +304,17 @@
title: err
})
});
/* ⚠️ 2026-09-20:把这次静默登录的 promise 挂到 globalData,
让宿主页(首页壳 / 容器页)在缓存未命中时**先 await 它**、而不是自己再换一次 code
(见 mixins/webviewHost.js 的 _awaitHostLogin)。
动机:微信 code 一次性,两边各换一次要多花 200–300ms(实测 code2session 就这么贵),
而 openid 本来就该由这一条链路落缓存(本文件上面的 SET_OPENID 分支 / LOGIN 从 JWT 提取)。
settle 后清掉,避免下次误等一个已经结束的 promise。 */
try { that.globalData._silenceAuthPromise = authPromise; } catch (e) {}
authPromise.then(() => {
try { if (that.globalData._silenceAuthPromise === authPromise) that.globalData._silenceAuthPromise = null; } catch (e) {}
});
return authPromise;
},
/**
* 检测当前的小程序

View File

@@ -9,40 +9,72 @@
```
[box_app 管理后台]
新建/编辑应用 → 上传"分享图片"
新建/编辑应用 → 上传"分享图片" + 填"分享标题"
→ 存入 App.shareImageUrl = https://mp.yizhezhe.com/upload/picture/xxx.png
→ 存入 App.shareTitle(留空 = 用应用名 App.name)
[box_app API]
GET /api/mp/apps?identifier=xxx&isPublished=1
→ 返回 { identifier, url, shareImageUrl, ... }
GET /api/mp/apps?identifier=xxx&isPublished=1 (单个应用,太阳码场景)
GET /api/mp/public/share-images (分享卡片反查表:所有有 url 的应用,
**不过滤上架状态**,2026-09-22 起;
2026-10-13 起每行多回 shareTitle)
→ 返回 { url, shareImageUrl, shareTitle } 精简结构
(shareImageUrl 为空串 = 没配图;shareTitle 是服务端算好的生效值,
后台留空已回退成应用名 —— 客户端不再实现回退链)
[小程序 web_view(pages/annex/web_view/index.vue)]
① 扫码/点击应用 → 获取 shareImageUrl → 本地缓存
② 用户点 ··· 转发给好友 → 卡片图 = shareImageUrl(网络图,微信支持)
[小程序宿主页(两个,见第三节)]
pages/annex/web_view/index.vue 容器页:扫码 / JumpPath / 分享落地
pages/index/index.vue 首页壳:兜爸心选应用宫格 location.href 进入的 H5
① 获取 shareImageUrl(网络图)+ shareTitle → 内存 + 本地缓存(mixins/shareCard.js)
② 用户点 ··· 转发给好友 → 卡片图 = shareImageUrl(网络图,微信支持);标题 = shareTitle
③ 用户点 ··· 分享到朋友圈 → getImageInfo 转本地路径 → 卡片图 = 本地路径
```
### 分享标题的优先级(2026-10-13)
| 优先级 | 来源 | 落点 |
| --- | --- | --- |
| ① | H5 上报(`location`/`share`/`setTitle` 消息的 `title`) | `webviewHost.handleHostMessage` → `shareTitle` + `shareTitleSource='h5'` |
| ② | 后台该应用「分享标题」= `App.shareTitle` | `shareCard.resolveShareCard` → `_matchAppShareTitle` |
| ③ | 应用名 `App.name`(后台留空时的服务端回退) | 同上(服务端已回退,客户端拿到的就是生效标题) |
| ④ | 首页壳 `'兜爸心选 · 孕育陪伴'` / 容器页 `'分享'` | 两个宿主页的 `onShareAppMessage` 兜底 |
⚠️ 三条纪律(与分享图同款,别拆开改):
1. **`shareTitleSource==='h5'` 不被后台标题覆盖**,否则 H5 上报的页级标题会被应用级标题吃掉;
2. **换应用要清标题**(`syncShareCardTarget` → `_clearShareCard`),否则 B 应用沿用 A 的标题;
3. 反查数据的本地缓存 key 是 `share_apps_index_v3`(v2 的 items 没有 `shareTitle`)——
**加字段必须 bump key**,否则老客户端要等 1h TTL 才生效。
## 二、box_app 侧配置(已实现)
### 后端(backend/app/models/app.py + services/app_serv.py)
- `App` 模型新增字段:`shareImageUrl: str | None`(微信分享卡片图,公网 https URL)
- `AppCreate` / `AppUpdate` / `AppOut` 均含 `shareImageUrl`,查询/写入透传(find / find_all / find_one / create / update 共 5 处)
- `App` 模型字段:`shareImageUrl: str | None`(微信分享卡片图,公网 https URL)、
`shareTitle: str | None`(分享卡片标题,**留空 = 用应用名**,2026-10-13 加)
- `AppCreate` / `AppUpdate` / `AppOut` 均含两字段,查询/写入透传(find / find_all / find_one / create / update 共 5 处)
- 上传接口:`POST /api/upload`(返回公网 URL)
### 管理后台前端(frontend/src/pages/ProductManagement.jsx)
- 新建/编辑应用表单新增 **"分享图片 (Share Image)"** 上传项
- 新建/编辑应用表单:**"分享图片 (Share Image)"** 上传项 + **"分享标题 (Share Title)"** 输入项(maxLength 40)
- 支持上传 `/api/upload`、编辑回填、单图(maxCount=1)
- extra 提示:建议 5:4 比例,公网可访问
- 分享图 extra 提示:建议 5:4 比例,公网可访问;分享标题 extra 提示:留空 = 用应用名称
## 三、小程序端获取逻辑(web_view/index.vue)
## 三、小程序端获取逻辑(两个宿主页共用 `mixins/shareCard.js`)
### 三种获取路径(按优先级)
> 宿主页有两个,**分享卡片由谁产出取决于用户是从哪进来的**:
> - `pages/annex/web_view/index.vue` —— 容器页:太阳码扫码 / 分享卡片落地 / JumpPath / 客服 / 登录跳转;
> - `pages/index/index.vue` —— 首页壳:兜爸心选首页的应用宫格用 `location.href` 换 H5(见 `mama-box/js/app.js`
> `jumpApp`),**小程序页面栈不变**,所以从这里进的应用(数胎动、孕检日历…)分享永远发生在首页壳。
>
> 反查 / 缓存 / 朋友圈本地图转换统一抽到 `mixins/shareCard.js`,两页行为一致。
### 四种获取路径(按优先级)
| 优先级 | 场景 | 方法 | 说明 |
|--------|------|------|------|
| ① 首选 | 太阳码扫码 | `_resolveAppScene` | 调 `mp/apps?identifier=xxx` 时**一次请求同时拿** H5 url + shareImageUrl,identifier 严格匹配 |
| ② 兜底 | 点击应用(JumpPath 直传 URL) | `_fetchShareImage` | 按 URL 前缀反查 `mp/apps` 列表,命中写入本地缓存 |
| ② 兜底 | 容器页点击应用(JumpPath 直传 URL) | `_fetchShareImage` | 按 URL 前缀反查 `mp/apps` 列表,命中写入本地缓存 |
| ③ 覆盖 | H5 postMessage 自定义 | `handleMessage` 'share' | H5 `bridge.share({imageUrl})` 传了图才覆盖,为空保留 box_app 预取值 |
| ④ 同步 | 首页壳(应用宫格进入任意 H5) | `_loadShareApps` + `resolveShareCard` | 首页壳 onLoad 预热**全量应用表**(内存 + 1h 本地缓存),`onShareAppMessage(options)` 里用 `options.webViewUrl` 同步匹配出图(首次转发就有图);**每次分享都重解析并检测应用切换**,避免沿用上一个应用的图 |
### ① 太阳码扫码(`_resolveAppScene`)
@@ -58,46 +90,137 @@
```
点应用 → JumpPath 传完整 H5 URL(如 https://mp.yizhezhe.com/app_03/)
→ 1. 查本地缓存 web_view_share_img:{originPath} → 命中直接复用
→ 2. 未命中 → GET /api/mp/apps?isPublished=1&pageSize=100(拉全量已上架应用)
→ 3. URL 前缀匹配:originPath === appBase || originPath.startsWith(appBase + '/')
→ 4. 命中 → this._applyShareImage(shareImageUrl) + 写入缓存
→ 1. 先同步查:内存应用表(若已预热)→ 单应用缓存 web_view_share_img:{urlBase}(24h TTL)
→ 2. 未命中 → GET /api/mp/public/share-images(拉分享图反查表,**含未上架应用**,1h 本地缓存)
→ 3. URL 前缀匹配:urlBase === appBase || urlBase.startsWith(appBase + '/')
→ 4. 命中 → this._applyShareImage(shareImageUrl) + 写入单应用缓存
```
页面加载时执行,用户点转发时图已就绪。`_shareFetched` 标志保证页面内只请求一次。
页面加载时执行,用户点转发时图已就绪。`_shareFetched` 标志保证页面内只做一次异步兜底。
### ③ H5 postMessage 覆盖(`handleMessage`)
### ③ H5 postMessage 覆盖(`webviewHost.handleHostMessage`)
```
H5 bridge.share({ title, imageUrl }) → postMessage → handleMessage 'share'
→ if (data.imageUrl) this.shareImageUrl = data.imageUrl // 有值才覆盖
H5 wxAuth.reportLocation({ title, imageUrl }) / bridge.share({ title, imageUrl })
→ postMessage → handleHostMessage 'location' / 'share'
→ if (data.title) { this.shareTitle = data.title; this.shareTitleSource = 'h5'; } // 有值才覆盖
→ if (data.imageUrl) this.shareImageUrl = data.imageUrl // 有值才覆盖
→ 为空则保留 box_app 预取的分享图片
```
## 四、好友转发 vs 朋友圈(微信平台差异)
### ④ 首页壳同步匹配(`pages/index/index.vue`,2026-09 补)
> 症状:从兜爸心选首页的应用宫格进入的 H5(如数胎动),点右上角 ··· 转发**没有**后台配置的分享图。
原因:宫格用 `location.href` 换 H5,小程序页面栈仍是首页壳 `pages/index/index`;而反查逻辑此前
只写在容器页,首页壳的 `shareImageUrl` 只能靠 H5 postMessage,数胎动这类 H5 没有 `bridge.share`
→ 卡片图为空 → 微信回退默认截图。
```
onLoad → _loadShareApps() // 预热反查表:内存 shareApps + 本地缓存 share_apps_index_v3(1h)
点 ··· → onShareAppMessage(options)
→ target = options.webViewUrl || this.shareHref || this.webUrl // 当前 web-view 真实地址
→ this.resolveShareCard(target) // 见下:先做应用切换检测,再按当前地址反查图 + 标题
→ imageUrl = this.shareImageUrl || '' ;title = this.shareTitle || info.title || '兜爸心选 · 孕育陪伴'
```
`_lookupShareImage` 是**同步**的:先内存应用表,再单应用缓存。因此首次转发就能出图,
不依赖 postMessage 的投递时机。后台改分享图后,最迟 1 小时(列表缓存 TTL)生效。
朋友圈图仍走 `_prepareTimelineImage`(`getImageInfo` 转本地路径)。
> ⚠️ **跨应用残留(2026-09 修)**:症状是「A 应用转发能出图,切到 B 应用转发还是 A 的图」。
> 根因是首页壳**不随 H5 换页重新 onLoad**(宫格用 `location.href`),而当时分享图取到后就一直
> 留在 `shareImageUrl`,并且解析被 `if (!this.shareImageUrl)` 短路掉了 —— 于是上一个应用的图钉住不换。
>
> 修法(`resolveShareCard` / `syncShareCardTarget`,两个宿主页共用):
> 1. `shareImageBase` 记录当前图属于哪个 H5 应用;分享前发现 `target` 换了应用 → **清空**
> `shareImageUrl` / `shareTimelineImage` / `shareTitle` / `shareHref`;
> 2. **每次都按当前 `target` 重新反查**,不再因为已有图就跳过;
> 3. 当前图若来自本应用 H5 的 postMessage(`shareImageSource === 'h5'`)才保留优先,
> 否则 box_app 配置图覆盖;
> 4. 当前应用没有配置图 → 宁可留空回退微信默认截图,也不沿用上一个应用的图。
>
> 局限与正解:`onShareTimeline` 拿不到 `webViewUrl`,宿主无法自己判断当前应用。
> 正解是 **H5 主动上报当前位置**:换页/路由切换时发
> `wx.miniProgram.postMessage({ data: { action: 'location', href: location.href } })`,
> 宿主 `webviewHost.handleHostMessage` 收到后更新 `shareHref` 并调 `resolveShareCard(href)`,
> 朋友圈分享即拿到正确的落地页与分享图(共享库内置 `wxAuth.reportLocation()` / `wxAuth.watchLocation()`)。
> H5 侧完整接入说明见 `box_app/docs/h5-auth-standard.md` 第四节。
> 若 H5 未接入该上报,朋友圈分享只能以最后一次好友转发解析出的图为准。
## 四、分享路径(深链)—— 2026-09 修复
> 症状:在 web-view 的 H5 里点「分享给微信好友」,接收者点开卡片却落到**小程序首页**,
> 而不是发送者当时所在的那个 H5 应用/页面。
### 根因
1. web-view 里的 H5 **无法用代码拉起分享面板**,卡片只能由承载 `<web-view>` 的**宿主小程序页**产出。
2. H5 应用是从 mama-box(首页 web-view)的应用宫格用 `location.href` 换页进入的
(`mama-box/js/app.js` → `jumpApp`),小程序页面栈**仍是首页** `pages/index/index`。
3. `app_03` 的 `bridge.share()` 曾在 postMessage 后调 `wx.miniProgram.navigateBack({delta:1})`
试图触发投递 —— 这会把 web-view 页弹掉(栈里只有 tabBar 根页时还会失败),
转发随即被**别的页面**接管;首页回调又写死 `path: '/pages/index/index'`,
于是接收者永远进首页。
4. `onShareAppMessage` 忽略了微信回调参数 `options.webViewUrl`(当前 web-view 真实地址,
含 H5 的 SPA 路由),H5 的 postMessage `href` 又因投递延迟不可靠 → 深链丢失。
### 修复
| 端 | 改动 |
|----|------|
| 小程序首页 `pages/index/index.vue` | `onShareAppMessage(options)` 用 `options.webViewUrl`(兜底 `shareHref` / `webUrl`)拼 `path: '/pages/annex/web_view/index?url=...'`;`onMessage` 支持 `action:'share'/'setTitle'`;补 `onShareTimeline` + `uni.showShareMenu`;`onLoad` 支持 `?url=` 落地(朋友圈转发经此转交容器) |
| 小程序容器 `pages/annex/web_view/index.vue` | `onShareAppMessage(options)` 同样优先 `options.webViewUrl`;`onShareTimeline` 兜底 `shareHref \|\| url` |
| 两页 `stripAuthParams` | 按 key 过滤(不再用会吃掉 `#hash` 的正则),剥 query 的 `openid/unionid/t/_v` 与 hash 的 `openid/unionid/phone/nickname/avatar` |
| H5 `app_03/frontend/src/app/services/bridge.ts` | **删除 `navigateBack`**;`share()` 只发 postMessage 并返回是否在小程序内,页内提示用户点右上角「···」 |
> **接收者身份**:分享链接一律剥掉发送者身份,接收者经 `pages/annex/web_view` 容器
> 按自己的 openid 重新注入(容器 `_applyIdentity`,query 传参)。
## 五、好友转发 vs 朋友圈(微信平台差异)
| 场景 | 图片要求 | 实现 |
|------|---------|------|
| **转发给好友** `onShareAppMessage` | 支持 https 网络图 | `imageUrl: this.shareImageUrl`(box_app 网络 URL 直接用) |
| **分享到朋友圈** `onShareTimeline` | 仅本地路径/代码包路径,网络图静默忽略 | `_prepareTimelineImage` 用 `uni.getImageInfo` 转本地临时路径 `shareTimelineImage` |
## 五、关键前置条件(易踩坑)
> ⚠️ `onShareTimeline` **没有回调参数**,拿不到 `webViewUrl`,只能兜底 `shareHref || url`;
> 它也不能自定义 `path`(只重开当前页 + query),所以首页用 `?url=` 把目标转交 web_view 容器。
## 六、关键前置条件(易踩坑)
1. **box_app 已配置分享图**:管理后台 → 应用 → 上传"分享图片"
2. **图片必须公网 https 可访问**(微信服务器要抓取)
3. **mp.yizhezhe.com 必须在微信后台合法域名白名单**:
- `request` 合法域名(拉取 mp/apps 接口)
- `request` 合法域名(拉取 mp/apps 接口;首页壳预热应用表也用它)
- `downloadFile` 合法域名(朋友圈转本地图 getImageInfo)
- ⚠️ 未配置时:好友图可能正常(网络图),但**朋友圈图转换失败回退默认截图**,`_fetchShareImage` fail 分支会提示
4. **分享图有缓存,改图不是立刻生效**:反查表缓存 `share_apps_index_v2` 1h,单应用分享图缓存 `web_view_share_img:*` 24h。
真机验证新图时可先清缓存(开发者工具 → 清除数据缓存 / 重新编译)或等 TTL 过期。
## 六、相关代码位置
> **反查表与上架状态解耦(2026-09-22)**:旧数据源 `/api/mp/apps?isPublished=1` 只回上架应用,
> 导致 mama-box(未建上架记录)和 laixin(下架、经 mama-box 底 tab/来信卡的硬编码兜底 URL 进入)
> 拿不到自定义转发图。现改用专用接口 `GET /api/mp/public/share-images`(box_app `mp_router.py`):
> 返回**所有配置了分享图**的应用(含下架),只含 `url + shareImageUrl` 两字段,按 url 长度倒序
> (长前缀优先命中,防撞车)。**给「不上宫格但要可进入」的应用配图 = 后台建/编辑记录、传分享图、
> 保持下架**;宫格展示仍由 `/api/mp/apps?isPublished=1` 决定,互不影响。缓存 key 随之 bump 为
> `share_apps_index_v2`(旧缓存里只有上架应用,不 bump 会残留 1h)。
## 七、相关代码位置
| 位置 | 文件 |
|------|------|
| box_app 模型字段 | `box_app/backend/app/models/app.py` |
| box_app 服务透传 | `box_app/backend/app/services/app_serv.py` |
| box_app 表单上传 | `box_app/frontend/src/pages/ProductManagement.jsx` |
| **分享图公共逻辑(两个宿主页共用)** | `crmeb/mixins/shareCard.js` → `resolveShareCard` / `syncShareCardTarget` / `_loadShareApps` / `_lookupShareImage` / `_fetchShareImage` / `_applyShareImage` / `_prepareTimelineImage` |
| **宿主页其它公共能力**(消息分发 / 授权面板 / 授权回传 / openid+unionid) | `crmeb/mixins/webviewHost.js` → `handleHostMessage` / `consumeAuthResult` / `submitHostAuth` |
| 小程序扫码取图 | `crmeb/pages/annex/web_view/index.vue` → `_resolveAppScene` |
| 小程序兜底反查 | 同上 → `_fetchShareImage` / `_applyShareImage` / `_prepareTimelineImage` |
| 小程序分享回调 | 同上 → `onShareAppMessage` / `onShareTimeline` / `handleMessage` |
| H5 传图 | `app_03/frontend/src/app/services/bridge.ts` → `share({imageUrl})` |
| 小程序容器页(onLoad 兜底反查 / 转发回调) | 同上 → `_fetchShareImage`(mixin)/ `onShareAppMessage` / `onShareTimeline` / `@message="handleHostMessage"` |
| 小程序首页壳(应用宫格进入的 H5) | `crmeb/pages/index/index.vue` → `onLoad` 预热应用表 + `onShareAppMessage` 同步匹配(带 `spid`)/ `onShareTimeline` / `@message="handleHostMessage"` |
| 首页分享兜底(mama-box 等无 App 记录时) | 首页壳 `getShare()` → CRMEB 后台「分享设置」的 `title` / `img` |
| 分享链接净化 | 两页的 `stripAuthParams` / `dropKeyValuePairs` |
| H5 传图 / 触发分享 | `app_03/frontend/src/app/services/bridge.ts` → `share({title,href,imageUrl})` |
> 授权/消息这一侧的完整链路(两条:独立授权页 与 宿主页内嵌面板)见
> `box_app/docs/h5-miniprogram-auth.md` 第 6 节。

View File

@@ -1,9 +1,9 @@
{
"name" : "DoubaShop",
"name" : "兜爸心选",
"appid" : "__UNI__52BA861",
"description" : "兜爸商城",
"versionName" : "3.5.1",
"versionCode" : 351,
"description" : "兜爸心选",
"versionName" : "0.3.8",
"versionCode" : 38,
"transformPx" : false,
"sassImplementationName" : "node-sass",
/* 5+App特有相关 */

306
mixins/shareCard.js Normal file
View File

@@ -0,0 +1,306 @@
// +----------------------------------------------------------------------
// | 分享卡片:box_app 后台配置的分享图 + H5 postMessage 覆盖
// +----------------------------------------------------------------------
// 背景(详见 crmeb/docs/分享图片获取逻辑.md):
// · web-view 里的 H5 不能自己拉起分享面板,卡片只能由承载 <web-view> 的页面产出;
// · 分享图有两个来源,优先级:
// ① box_app App 表的 shareImageUrl(按当前 H5 的 URL 前缀反查)
// ② H5 postMessage({action:'share', imageUrl})(有值才覆盖 ①)
// · 有两个宿主页都需要这套逻辑:
// pages/annex/web_view/index.vue —— 容器页(太阳码 / 分享落地 / JumpPath 进入)
// pages/index/index.vue —— 首页壳(mama-box 应用宫格用 location.href 换 H5,
// 小程序页面栈不变,所以这里的分享永远发生在首页壳)
// 本 mixin 把「反查 + 缓存 + 朋友圈本地图转换」抽出来共用,避免两边逻辑漂移。
//
// 用法:
// ① import shareCard from '@/mixins/shareCard.js' → mixins: [shareCard]
// ② 页面 onLoad 调一次 this._loadShareApps()(预热全量应用表,分享时可同步命中)
// ③ onShareAppMessage(options) / 分享前:
// const target = (options && options.webViewUrl) || this.shareHref || this.webUrl;
// this.resolveShareCard(target); // 应用切换检测 + 每次按当前地址重解析(同步出图)
// return { ..., imageUrl: this.shareImageUrl || '' };
//
// ⚠️ 首页壳不会随 H5 应用切换重新 onLoad,所以:
// · 必须缓存「全量应用表」,在分享那一刻用 options.webViewUrl 同步匹配;
// · **不能把 shareImageUrl 当长期状态**:每次分享都要重新解析,并检测 target 是否换了应用,
// 否则 B 应用会沿用 A 应用的图(resolveShareCard / syncShareCardTarget 就是干这个的)。
const BOX_API_BASE = 'https://mp.yizhezhe.com';
// 分享图反查表:box_app 专用接口,**含未上架应用**(mama-box 没有上架记录、laixin
// 已下架但仍经底 tab/来信卡进入,它们也要有自定义转发图)。
// 与「宫格展示」用的 /api/mp/apps?isPublished=1 刻意解耦:上架状态只决定宫格显隐,
// 不决定转发图 —— 反查只看 URL 前缀(接口只回 url+shareImageUrl,长前缀优先)。
const APPS_API = BOX_API_BASE + '/api/mp/public/share-images';
const APPS_CACHE_KEY = 'share_apps_index_v3'; // 反查表本地缓存 { t, items }
// (v2:数据源从上架表换成含下架的反查表;v3:每行新增 shareTitle)
// bump key 让旧缓存立即失效,别等 1h 才生效
const APP_IMG_CACHE_PREFIX = 'web_view_share_img:'; // 单个应用分享图缓存(按 URL 基){ t, url }
const APPS_CACHE_TTL = 60 * 60 * 1000; // 应用表缓存 1 小时
const APP_IMG_CACHE_TTL = 24 * 60 * 60 * 1000; // 单应用分享图缓存 24 小时(后台改图最迟一天生效)
export default {
data() {
return {
// ---------- 分享(H5 无法自己拉起分享面板,只能由承载 web-view 的页面转发) ----------
shareTitle: '', // 分享标题(H5 postMessage 上报的优先,其次 box_app 反查)
shareTitleSource: '', // 当前标题来源:'box'(box_app 反查)| 'h5'(H5 postMessage 覆盖)
shareSummary: '', // 分享摘要(部分场景用)
shareHref: '', // H5 指定的分享链接(兜底;深链优先 webViewUrl)
shareImageUrl: '', // 好友转发卡片图(网络 https)
shareTimelineImage: '', // 朋友圈卡片图(必须本地路径,见 _prepareTimelineImage)
shareImageSource: '', // 当前分享图来源:'box'(box_app 反查)| 'h5'(H5 postMessage 覆盖)
shareImageBase: '', // 当前分享数据所属的 H5 URL 基(切换应用时用它丢弃上一应用的图/标题)
shareApps: [], // box_app 反查表(url → shareImageUrl / shareTitle),供同步匹配
shareAppsLoaded: false, // 应用表是否已就绪
shareAppsLoading: false,// 反查表请求中(防并发重入)
_shareAppsCbs: [], // 请求中的回调队列(请求结束统一回调)
_shareFetched: false, // 本页是否已做过一次异步兜底反查
_shareTargetUrl: '', // 最近一次要分享的 H5 地址(反查表就绪后按它补解析)
};
},
methods: {
/* 当前 H5 URL 去掉 query / hash / 末尾斜杠,作为应用 url 的匹配基 */
_shareUrlBase(url) {
return String(url || '').split('#')[0].split('?')[0].replace(/\/+$/, '');
},
/* 清空分享卡片状态(应用切换时调用,避免张冠李戴) */
_clearShareCard() {
this.shareImageUrl = '';
this.shareTimelineImage = '';
this.shareImageSource = '';
this.shareTitle = '';
this.shareTitleSource = '';
this.shareSummary = '';
this.shareHref = '';
},
/**
* 分享前调用:检测"当前要分享的 H5 是否换了一个应用"。
* 首页壳用 location.href 在 H5 内部换应用,小程序页面不重新 onLoad ——
* 若不检测,上一个应用的分享图/标题/href 会一直留着(本次线上问题的根因)。
* @returns {string} 当前 target 的 URL 基
*/
syncShareCardTarget(targetUrl) {
const base = this._shareUrlBase(targetUrl);
if (!base) return '';
if (this.shareImageBase && this.shareImageBase !== base) {
// 换了应用:丢弃上一应用的分享数据
this._clearShareCard();
}
this.shareImageBase = base;
return base;
},
/**
* 分享前的统一解析(两个宿主页的 onShareAppMessage / onShareTimeline 都调它):
* ① 应用切换检测 → 换应用先清空;
* ② 每次都按"当前 H5 地址"重新反查 box_app 的分享图 + 分享标题
* (不能因为已有值就跳过 —— 那可能是上一个应用的);
* ③ H5 自己上报过标题/图(source='h5')的,保留不覆盖。
*/
resolveShareCard(targetUrl) {
// 进入本次解析时的归属基:换应用时 syncShareCardTarget 会清掉标题,
// 用它区分「同一应用的标题」与「上一个应用遗留的标题」
const baseAtEntry = this.shareImageBase;
this.syncShareCardTarget(targetUrl);
// ---- 分享图 ----
const fromBox = this._lookupShareImage(targetUrl);
if (fromBox) {
if (this.shareImageSource !== 'h5') this._applyShareImage(fromBox, 'box', targetUrl);
} else if (this.shareImageSource === 'box') {
// 当前应用没有 box_app 配置图,而现有图是上一个应用反查来的 → 丢弃
this.shareImageUrl = '';
this.shareImageSource = '';
this.shareTimelineImage = '';
}
// ---- 分享标题(box_app 反查,服务端已把「后台分享标题为空」回退成应用名) ----
const boxTitle = this._matchAppShareTitle(targetUrl);
if (boxTitle) {
if (this.shareTitleSource !== 'h5') {
this.shareTitle = boxTitle;
this.shareTitleSource = 'box';
}
} else if (this.shareTitleSource === 'box' && this.shareImageBase && this.shareImageBase !== baseAtEntry) {
// 换到了反查表里查不到的应用,而现有标题是上一个应用反查来的 → 丢弃。
// ⚠️ 同一地址不清:太阳码场景的标题来自 /api/mp/apps(不在反查表里也能有标题)。
this.shareTitle = '';
this.shareTitleSource = '';
}
if (!this.shareAppsLoaded) this._loadShareApps();
},
/* 在内存应用表里按 URL 前缀匹配 shareImageUrl(同步,分享那一刻就能出图) */
_matchAppShareImage(targetUrl) {
const app = this._matchShareApp(targetUrl);
return (app && app.shareImageUrl) || '';
},
/* 在内存应用表里按 URL 前缀匹配分享标题(同步;服务端已回退应用名,空串表示没查到) */
_matchAppShareTitle(targetUrl) {
const app = this._matchShareApp(targetUrl);
return (app && app.shareTitle) || '';
},
/**
* 用「已经拿到的应用对象」直接设标题(太阳码 scene 路径:/api/mp/apps 一次就带回
* shareTitle / name,不必再等反查表)。
* 后台没配 shareTitle 时回退应用名称 —— 与反查表服务端同口径。
* @param app 应用对象(含 shareTitle / name / url)
*/
_applyShareTitleFromApp(app) {
if (!app) return;
const t = app.shareTitle || app.name || '';
if (!t) return;
this.shareTitle = t;
this.shareTitleSource = 'box';
},
/**
* 反查表里按 URL 前缀找当前应用(长前缀优先 —— 服务端已按 url 长度倒序下发,
* 防「/a 是 /a/b 的前缀」时张冠李戴)。图与标题共用同一条记录,避免两次匹配漂移。
*/
_matchShareApp(targetUrl) {
const base = this._shareUrlBase(targetUrl);
if (!base) return null;
const apps = this.shareApps || [];
for (let i = 0; i < apps.length; i++) {
const app = apps[i];
if (!app || !app.url) continue;
const appBase = this._shareUrlBase(app.url);
if (!appBase) continue;
// 如 https://mp.yizhezhe.com/taidong == https://mp.yizhezhe.com/taidong/
if (base === appBase || base.indexOf(appBase + '/') === 0) return app;
}
return null;
},
/* 同步查图:优先内存应用表(onLoad 预热,1h 缓存),再退单应用缓存(24h TTL) */
_lookupShareImage(targetUrl) {
const base = this._shareUrlBase(targetUrl);
if (!base) return '';
// 1) 内存应用表:后台改图后最迟 1h 生效
const fromList = this._matchAppShareImage(targetUrl);
if (fromList) {
// 顺手写单应用缓存:冷启动时表还没回来也能立刻出图
try { uni.setStorageSync(APP_IMG_CACHE_PREFIX + base, { t: Date.now(), url: fromList }); } catch (e) {}
return fromList;
}
// 2) 单应用缓存兜底(应用表请求失败 / 冷启动还没回来时用)
try {
const cached = uni.getStorageSync(APP_IMG_CACHE_PREFIX + base);
// 兼容旧格式(纯 URL 字符串,无 TTL)
const rec = (cached && typeof cached === 'object') ? cached : { t: 0, url: cached };
if (rec && rec.url && String(rec.url).indexOf('http') === 0 &&
(!rec.t || (Date.now() - rec.t) < APP_IMG_CACHE_TTL)) {
return rec.url;
}
} catch (e) {}
return '';
},
/* 拉 box_app 分享卡片反查表(url → 分享图/标题,带 1h 本地缓存);cb 可选,拉完(或命中缓存)后回调 */
_loadShareApps(cb) {
// 1) 本地缓存命中:冷启动也能同步出图
try {
const cached = uni.getStorageSync(APPS_CACHE_KEY);
if (cached && cached.items && cached.items.length && cached.t &&
(Date.now() - cached.t) < APPS_CACHE_TTL) {
this.shareApps = cached.items;
this.shareAppsLoaded = true;
if (cb) cb();
return;
}
} catch (e) {}
// 2) 请求中:把回调排队,请求结束统一回调(避免并发重复拉,也别丢调用方)
if (this.shareAppsLoading) {
if (cb) this._shareAppsCbs.push(cb);
return;
}
this.shareAppsLoading = true;
if (cb) this._shareAppsCbs.push(cb);
const finish = () => {
this.shareAppsLoading = false;
const cbs = this._shareAppsCbs || [];
this._shareAppsCbs = [];
cbs.forEach(fn => { try { fn(); } catch (e) {} });
};
uni.request({
url: APPS_API,
method: 'GET',
success: (res) => {
const items = (res.data && res.data.result && res.data.result.items) || [];
if (items.length) {
this.shareApps = items;
this.shareAppsLoaded = true;
try { uni.setStorageSync(APPS_CACHE_KEY, { t: Date.now(), items: items }); } catch (e) {}
}
finish();
},
fail: (err) => {
// 常见原因:mp.yizhezhe.com 未加入小程序后台 request 合法域名白名单
console.warn('[shareCard] 反查表拉取失败(请检查 mp.yizhezhe.com 是否在 request 合法域名):', err);
finish();
}
});
},
/* 异步兜底反查(容器页 onLoad 用):同步没命中就拉反查表再匹配一次 */
_fetchShareImage(targetUrl) {
if (!targetUrl) return;
this._shareTargetUrl = targetUrl; // 拉表回来后按它补解析分享图/标题
this.syncShareCardTarget(targetUrl); // 建立/校正"当前数据属于哪个应用"
// 反查表还没预热就先拉(标题/图都靠它;拉回来自己会补解析)
if (!this.shareAppsLoaded) this._loadShareApps(() => this._syncShareCard(targetUrl));
if (this.shareImageUrl) return; // H5 postMessage 已给图,不覆盖
if (this._shareFetched) return; // 本页只兜底一次
this._shareFetched = true;
const hit = this._lookupShareImage(targetUrl);
if (hit) { this._applyShareImage(hit, 'box', targetUrl); return; }
// 表已就绪但没命中(或上面那次请求刚好失败)→ 再兜底一次,覆盖冷启动时序
this._loadShareApps(() => {
this._syncShareCard(targetUrl);
if (this.shareImageUrl) console.log('[shareCard] 分享图片(兜底): ' + this.shareImageUrl);
});
},
/* 反查表就绪后补解析(onLoad 阶段表还没回来时用;已就绪则走 resolveShareCard) */
_syncShareCard(targetUrl) {
const url = targetUrl || this._shareTargetUrl;
if (!url) return;
this.resolveShareCard(url);
},
/* 统一设置分享图:好友转发用网络图;朋友圈另转本地图(见下)
@param source 'box'(box_app 反查,默认)| 'h5'(H5 postMessage 覆盖)
@param forUrl 该图所属的 H5 地址(用于切换应用时判断是否过期) */
_applyShareImage(url, source, forUrl) {
if (!url) return;
if (forUrl) this.shareImageBase = this._shareUrlBase(forUrl);
if (url === this.shareImageUrl) {
if (source) this.shareImageSource = source;
return; // 同一张图不重复处理(H5 可能重复 postMessage)
}
this.shareImageUrl = url;
this.shareImageSource = source || 'box';
this._prepareTimelineImage(url);
},
/* 朋友圈分享图必须是本地路径/代码包路径(网络图会被微信静默忽略)→ getImageInfo 转本地
注意:getImageInfo 对网络图同样受 downloadFile 合法域名限制,域名未配时朋友圈无自定义图。 */
_prepareTimelineImage(url) {
if (!url) return;
uni.getImageInfo({
src: url,
success: (res) => {
this.shareTimelineImage = res.path || '';
console.log('[shareCard] 朋友圈分享图已转本地:', this.shareTimelineImage);
},
fail: (err) => {
console.warn('[shareCard] 朋友圈分享图转本地失败(网络图/域名问题,回退默认截图):', err);
this.shareTimelineImage = '';
}
});
}
}
};

382
mixins/webviewHost.js Normal file
View File

@@ -0,0 +1,382 @@
// +----------------------------------------------------------------------
// | 宿主页公共能力:H5 postMessage / 原生授权面板 / 授权回传
// +----------------------------------------------------------------------
// 适用:承载 <web-view> 的小程序页 —— 有两个
// pages/index/index.vue 首页壳(mama-box 首页;应用宫格用 location.href 换 H5)
// pages/annex/web_view/index.vue 容器页(太阳码 / 分享落地 / JumpPath)
// 两页此前各写了一份,能力已经分叉(首页壳缺 copyLink、授权回传、unionid…),
// 本 mixin 把公共部分收口;分享图另见 mixins/shareCard.js。
//
// 页面需要提供两个 hook:
// _hostSetWebviewUrl(url) 设置本页 <web-view> 的 src(首页壳写 webUrl,容器写 url)
// _hostCurrentUrl() 可选:当前 H5 地址(copyLink 无 href 时兜底)
//
// H5 → 宿主 postMessage 协议(见 box_app/docs/h5-auth-standard.md 第四节):
// { action:'location', href, title?, summary?, imageUrl? } 换页/换应用上报,不弹 toast
// { action:'share', href?, title?, summary?, imageUrl? } 点分享按钮:同上 + toast 引导
// { action:'setTitle', title } setTitle
// { action:'copyLink', href? } 复制链接
// { action:'requestProfile' } 弹原生授权面板
//
// ⚠️ 授权结果只走 hash:微信 web-view 首次加载丢 query、不丢 hash。
import store from '@/store';
import { LOGIN_STATUS } from '@/config/cache';
// 共享授权接口(user_auth_router:全 H5 应用共用,openid 单键,不区分应用)
const SHARED_AUTH_API = 'https://mp.yizhezhe.com/api/mp/public/auth/profile';
// box_app 公开上传(不依赖 CRMEB 登录态 —— CRMEB uploadImgs 在退出登录后会 401)
const BOX_UPLOAD_API = 'https://mp.yizhezhe.com/api/upload';
function base64Decode(str) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
let output = '', i = 0;
str = String(str).replace(/[^A-Za-z0-9+/=]/g, '');
while (i < str.length) {
const b1 = chars.indexOf(str.charAt(i++));
const b2 = chars.indexOf(str.charAt(i++));
const b3 = chars.indexOf(str.charAt(i++));
const b4 = chars.indexOf(str.charAt(i++));
output += String.fromCharCode((b1 << 2) | (b2 >> 4));
if (b3 !== 64) output += String.fromCharCode(((b2 & 15) << 4) | (b3 >> 2));
if (b4 !== 64) output += String.fromCharCode(((b3 & 3) << 6) | b4);
}
return output;
}
function decodeJwt() {
try {
const token = store.state.app.token || uni.getStorageSync(LOGIN_STATUS);
if (!token) return {};
const parts = String(token).split('.');
if (parts.length !== 3) return {};
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4 !== 0) payload += '=';
return JSON.parse(base64Decode(payload)) || {};
} catch (e) { return {}; }
}
export default {
data() {
return {
showAuth: false, // 授权面板开关(H5 postMessage requestProfile 后显示)
authNickname: '',
authAvatar: '',
_authCode: '', // getPhoneNumber 的 code(提交时交 box_app 解密手机号)
_panelBaseUrl: '', // 链路B面板回传基址:requestProfile 消息带 href 时优先用它
};
},
methods: {
/* ---------- 身份:openid / unionid ---------- */
_hostOpenid() {
let oid = store.state.app.openid || '';
if (oid) return oid;
try { oid = uni.getStorageSync('OPENID'); } catch (e) {}
if (oid) {
try { store.commit('SET_OPENID', oid); } catch (e) {}
return oid;
}
oid = decodeJwt().openid || '';
if (oid) {
try { store.commit('SET_OPENID', oid); } catch (e) {}
try { uni.setStorageSync('OPENID', oid); } catch (e) {}
}
return oid;
},
_hostUnionid() {
let uid = store.state.app.unionid || '';
if (uid) return uid;
try { uid = uni.getStorageSync('UNIONID'); } catch (e) {}
if (uid) {
try { store.commit('SET_UNIONID', uid); } catch (e) {}
return uid;
}
uid = decodeJwt().unionid || '';
if (uid) {
try { store.commit('SET_UNIONID', uid); } catch (e) {}
try { uni.setStorageSync('UNIONID', uid); } catch (e) {}
}
return uid;
},
/* ---------- 复用宿主 App.vue 那次「静默登录」,不重复换 code(2026-09-20 新增) ----------
App.vue onLaunch 本来就会走一次 wx.login → v2/wechat/silence_auth,
并把 promise 挂在 globalData._silenceAuthPromise 上(见 App.vue silenceAuth())。
宿主页在缓存未命中时**先 await 它**,而不是自己再发一次:
· 微信的 code 是一次性的,两家各换一次不但多花时间(实测 code2session 约 200–300ms),
而且两边拿到的 openid 必须是同一个来源才可靠;
· 它已经结束/不存在(比如从商城宫格进来)时立即返回,不引入任何等待。
返回 Promise<{}>(只为「等它结束」,openid 由调用方重新读缓存拿)—— 这里**不缓存 openid**,
因为 App.vue 那条路自己会落 store + OPENID 缓存(两处口径见 App.vue 的注释)。 */
_awaitHostLogin(timeout) {
return new Promise((resolve) => {
let p = null;
try { const app = getApp(); p = app && app.globalData && app.globalData._silenceAuthPromise; } catch (e) {}
if (!p || typeof p.then !== 'function') return resolve({});
const timer = setTimeout(() => {
console.warn('[auth-flow] ⚠️ 等 App.vue 静默登录超时 ' + (timeout || 1500) + 'ms,改用自己取');
resolve({});
}, timeout || 1500);
p.then(() => { clearTimeout(timer); resolve({}); })
.catch(() => { clearTimeout(timer); resolve({}); });
});
},
/* ---------- 兜底:三处都读不到 openid 时,主动取一次(2026-09-20 新增) ----------
背景:宿主页的身份只来自 store.openid / 缓存 OPENID / JWT(见 _hostOpenid)。
三处全空时(新用户未走绑定手机号、存储被清、冷启动竞态),首页壳会**匿名加载**
mama-box —— H5 进访客态(孕周 `--`、档案空),此后 jumpApp 跳应用也不带身份。
容器页 (pages/annex/web_view) 早有同名兜底(原 _fetchOpenidAndLoad,现统一到本方法),首页壳没有;这里把兜底
收进 mixin,两页共用一份实现(web_view 那份暂未迁移,口径保持一致,改一处看另一处):
wx.login → CRMEB v2/wechat/silence_auth →
{auth_login:1, openid, unionid}(新用户,无 token)或 {token, ...}(老用户)
两端都算成功,只要拿到 openid 就落到 store + 缓存。
返回 Promise<{openid, unionid}>;拿不到就 resolve 空串,由调用方决定是否匿名加载。 */
_silenceAuthOpenid() {
const empty = { openid: '', unionid: '' };
return new Promise((resolve) => {
/* ⚠️ 保险丝:wx.login / 接口有可能长时间不回调(弱网、域名没配好)。
首页壳现在「拿不到身份就不加载」,没有这个超时会把用户永久停在
「正在获取微信身份」那一屏;超时后走失败分支(重试/给重试入口)。 */
let settled = false;
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
const timer = setTimeout(() => {
console.warn('[auth-flow] ⚠️ 兜底取 openid 超时 8s,按失败处理');
settle(empty);
}, 8000);
const done = (openid, unionid) => {
clearTimeout(timer);
if (!openid) return settle(empty);
try { store.commit('SET_OPENID', openid); uni.setStorageSync('OPENID', openid); } catch (e) {}
if (unionid) {
try { store.commit('SET_UNIONID', unionid); uni.setStorageSync('UNIONID', unionid); } catch (e) {}
}
settle({ openid, unionid: unionid || '' });
};
// #ifdef MP
uni.login({
provider: 'weixin',
success: (loginRes) => {
const code = (loginRes && loginRes.code) || '';
if (!code) { console.warn('[auth-flow] ⚠️ 兜底 wx.login 返回空 code'); return settle(empty); }
import('@/api/public.js').then(api => {
api.silenceAuth({ code }).then(res => {
const data = (res && res.data) || {};
let openid = data.openid || '';
let unionid = data.unionid || '';
if (!openid) {
const ui = data.userInfo || {};
openid = ui.openid || '';
unionid = unionid || ui.unionid || '';
}
if (!openid && data.token) {
try {
const parts = String(data.token).split('.');
if (parts.length === 3) {
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4 !== 0) payload += '=';
const p = JSON.parse(base64Decode(payload));
openid = p.openid || '';
unionid = unionid || p.unionid || '';
}
} catch (e) { console.warn('[auth-flow] 兜底 JWT 解码 openid 失败:', e.message); }
}
console.log('[auth-flow] 兜底 silenceAuth: has openid=' + !!openid + ', auth_login=' + data.auth_login);
done(openid, unionid);
}).catch(err => {
console.warn('[auth-flow] ⚠️ 兜底 silenceAuth 网络失败:', err);
settle(empty);
});
}).catch(err => {
console.warn('[auth-flow] ⚠️ 兜底 import api 失败:', err);
settle(empty);
});
},
fail: (err) => {
console.warn('[auth-flow] ⚠️ 兜底 wx.login 失败:', err);
settle(empty);
}
});
// #endif
// #ifndef MP
settle(empty);
// #endif
});
},
/* ---------- H5 postMessage 统一处理(4 个 action) ---------- */
handleHostMessage(e) {
const msgs = (e.detail && e.detail.data) || [];
const arr = Array.isArray(msgs) ? msgs : [msgs];
arr.forEach(m => {
if (!m || !m.action) return;
switch (m.action) {
case 'location': {
// H5 换页/换应用时上报"我现在在哪一页"(不弹 toast,可频繁调用)。
// 宿主据此刷新 shareHref 并按新地址重解析分享卡片 —— 这是
// onShareTimeline(朋友圈)拿不到 webViewUrl 时唯一的同步手段,
// 也修掉首页壳换应用后沿用上一个应用分享图的问题。
const href = m.href || '';
// 诊断日志(过滤关键字 enter-app):这条**不是实时**的 ——
// 微信把 web-view 的 postMessage 攒到「后退 / 组件销毁 / 分享 / 复制链接」才派发,
// 所以它出现的时间点通常晚于用户实际换应用;宫格换应用后宿主就是靠它才知道当前在哪。
console.log('[enter-app] 宿主收到 H5 location 上报(延迟派发)href=' + href);
// ⚠️ 顺序:先落 H5 上报的标题,再 resolveShareCard ——
// 后者只在标题来源不是 'h5' 时才用 box_app 反查标题覆盖,顺序反了会白上报。
if (m.title) { this.shareTitle = m.title; this.shareSummary = m.summary || ''; this.shareTitleSource = 'h5'; }
if (href) {
this.shareHref = href;
if (typeof this.resolveShareCard === 'function') this.resolveShareCard(href);
}
// H5 给了图就以 H5 为准(仅当前应用;换应用会被 syncShareCardTarget 清掉)
if (m.imageUrl) this._applyShareImage(m.imageUrl, 'h5', href || this.shareHref);
break;
}
case 'share':
// title / imageUrl / href 各自独立处理,单个字段缺失不影响其它
if (m.title) { this.shareTitle = m.title; this.shareSummary = m.summary || ''; this.shareTitleSource = 'h5'; }
if (m.href) this.shareHref = m.href;
if (m.imageUrl) this._applyShareImage(m.imageUrl, 'h5', m.href);
// 小程序无法用代码拉起分享面板,只能引导用户点右上角
uni.showToast({ title: '点击右上角 ··· 分享给好友', icon: 'none', duration: 2500 });
break;
case 'copyLink': {
const link = m.href || (typeof this._hostCurrentUrl === 'function' ? this._hostCurrentUrl() : '');
uni.setClipboardData({
data: link,
success() { uni.showToast({ title: '链接已复制', icon: 'success' }); },
fail(err) {
console.warn('[webviewHost] 复制失败:', err);
uni.showToast({ title: '复制失败', icon: 'none' });
}
});
break;
}
case 'requestProfile':
this.showAuth = true;
/* 面板回传基址:H5 在消息里带 href(发起面板时的当前地址)→ 提交后
按它回传。消息送达=面板弹出,href 同一条消息到达,确定性强于
赌 shareHref 的 postMessage 派发时机(宫格换应用后可能还是旧地址)。 */
if (m.href) this._panelBaseUrl = m.href;
break;
case 'setTitle':
if (m.title) {
this.shareTitle = m.title;
this.shareTitleSource = 'h5'; // H5 明确设的标题 → 后续反查不覆盖
uni.setNavigationBarTitle({ title: m.title });
}
break;
}
});
},
/* ---------- 原生授权面板 ---------- */
// 头像:chooseAvatar → box_app 公开上传 → 正式 URL
onChooseHostAvatar(e) {
const avatarUrl = e.detail && e.detail.avatarUrl;
if (!avatarUrl) return uni.showToast({ title: '未获取到头像', icon: 'none' });
uni.uploadFile({
url: BOX_UPLOAD_API,
filePath: avatarUrl,
name: 'file',
success: (res) => {
try {
const d = JSON.parse(res.data);
if (d.code === 200 && d.result && d.result.url) this.authAvatar = d.result.url;
else uni.showToast({ title: '头像上传失败', icon: 'none' });
} catch (err) { uni.showToast({ title: '头像上传失败', icon: 'none' }); }
},
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' })
});
},
onHostGetPhone(e) {
if (e.detail && e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) {
this._authCode = e.detail.code;
} else {
uni.showToast({ title: '未授权手机号', icon: 'none' });
}
},
/* 授权结果 → 带 hash 的 H5 URL(保留 base 原有 query,只追加 t= 与 #结果)
hash 同时带上当前 unionid(若有),避免从首页壳进入的应用拿不到 unionid */
hostAuthUrl(baseUrl, result) {
const base = String(baseUrl || '').split('#')[0];
const joiner = base.indexOf('?') !== -1 ? '&' : '?';
const r = result || {};
const unionid = typeof this._hostUnionid === 'function' ? this._hostUnionid() : '';
return base + joiner + 't=' + Date.now() +
'#openid=' + encodeURIComponent(r.openid || '') +
(unionid ? '&unionid=' + encodeURIComponent(unionid) : '') +
'&phone=' + encodeURIComponent(r.phone || '') +
'&nickname=' + encodeURIComponent(r.nickname || '') +
'&avatar=' + encodeURIComponent(r.avatar || '');
},
/**
* 读取独立授权页(pages/annex/auth/index)写下的 __AUTH_RESULT__。
* 该页成功后 navigateBack 回到宿主页 —— 两个宿主页都必须在 onShow 调用本方法,
* 否则授权结果不会被写回 H5(首页壳此前漏了这一步)。
* @returns {string} 带结果 hash 的新 URL;无结果时返回 ''
*/
consumeAuthResult(baseUrl) {
try {
const res = uni.getStorageSync('__AUTH_RESULT__');
if (res && res.openid) {
uni.removeStorageSync('__AUTH_RESULT__');
/* backUrl 优先:H5 发起授权时经 navigateTo query 带上的当前地址
(确定性通道,不依赖 postMessage 派发时机);旧版 H5 没有 backUrl
时回退到入参基址(shareHref || webUrl || 生效首页地址)。 */
const back = (res.backUrl && String(res.backUrl).split('#')[0]) || '';
const base = back || baseUrl;
if (!base) return '';
return this.hostAuthUrl(base, res);
}
} catch (e) {}
return '';
},
/* 授权面板提交:共享端点 → 更新 web-view src 带结果 hash 回传 H5 */
submitHostAuth(baseUrl) {
if (!this.authNickname && !this.authAvatar && !this._authCode) {
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
}
const openid = this._hostOpenid();
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
/* 面板回传基址:requestProfile 消息带的 href 优先(确定性),没有才用调用处
传入的 baseUrl(shareHref || webUrl || 生效首页地址)。清掉已用值,
避免下次面板在没有新消息 href 时复用旧地址。 */
const base = (this._panelBaseUrl && String(this._panelBaseUrl).split('#')[0]) || baseUrl;
this._panelBaseUrl = '';
const that = this;
const api = SHARED_AUTH_API + '?openid=' + encodeURIComponent(openid) +
(this._authCode ? '&code=' + encodeURIComponent(this._authCode) : '') +
(this.authNickname ? '&nickname=' + encodeURIComponent(this.authNickname) : '') +
(this.authAvatar ? '&avatar=' + encodeURIComponent(this.authAvatar) : '');
uni.request({
url: api,
method: 'POST',
timeout: 10000,
success: (r) => {
const d = r.data || {};
if (d.code === 200) {
const res = d.result || {};
that.showAuth = false;
that._authCode = '';
that._hostSetWebviewUrl(that.hostAuthUrl(base, {
openid: openid,
phone: res.phone || '',
nickname: res.userName || that.authNickname || '',
avatar: res.avatar || that.authAvatar || ''
}));
} else {
uni.showToast({ title: d.message || '授权保存失败', icon: 'none' });
}
},
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
});
},
closeAuth() { this.showAuth = false; },
noop() {},
}
};

4054
pages.json

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,142 @@
<template>
<view class="auth-page">
<!-- 顶部说明 -->
<view class="auth-head">
<text class="auth-title">授权完善资料</text>
<text class="auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</text>
<!-- ============ 可滚动内容区 ============ -->
<view class="home">
<!-- 未授权:价值主张卡(先说清"有什么好处",再要授权) -->
<view v-if="!phoneBound" class="pitch">
<view class="pbadge">全部免费 · 无需付费</view>
<view class="pttl">{{ pitchTitle }}</view>
<view class="pttl pttl2 em">授权后全部免费用</view>
<view class="psub">胎动、体重、产检、血糖、待产清单……</view>
<view class="psub">从孕期到产后,一个地方全记下来</view>
<view class="social">
<view class="dots">
<image v-for="(d, i) in SOCIAL_AVATARS" :key="i" class="dot" :src="d" mode="aspectFit" />
</view>
<text class="social-txt">已有 {{ SOCIAL_COUNT }} 位妈妈在用</text>
</view>
<button class="cta" @tap="openPhoneSheet">授权手机号,立即免费体验</button>
<view class="ctahint">微信授权 · 10 秒完成 · 仅用于创建你的孕育档案</view>
<view class="trust">
<text class="trust-i"><text class="trust-tick">✓</text>全部免费</text>
<text class="trust-i"><text class="trust-tick">✓</text>无需下载 App</text>
<text class="trust-i"><text class="trust-tick">✓</text>换手机不丢</text>
</view>
</view>
<!-- 已授权:收起成一条状态,不再占地方 -->
<view v-else class="hero">
<view class="ava">
<image v-if="heroAvatar" class="ava-img" :src="heroAvatar" mode="aspectFill" />
<text v-else class="ava-emoji">📱</text>
</view>
<view class="who">
<view class="nm">{{ heroName }}</view>
<view class="st">{{ heroSub }}</view>
</view>
<view class="ok">已授权</view>
</view>
<template v-if="apps.length">
<view class="secthead">
<text class="secttl">孕期工具</text>
<text class="sectmore">共 {{ apps.length }} 个 ›</text>
</view>
<view v-if="!phoneBound" class="freebar">
<text class="freepill">全部免费</text>
<text class="freetxt">{{ apps.length }} 款工具当前未解锁,授权手机号后立即可用</text>
</view>
<!-- 工具宫格:数据来自 box_app 后台在架应用,**仅展示不可点击**;
未授权时右上角显示小锁(授权后解锁,仍不响应点击)。 -->
<view class="grid" :class="{ locked: !phoneBound }">
<view v-for="a in apps" :key="a.id" class="tile">
<view class="ic">
<image v-if="a.logo" class="tile-logo" :src="a.logo" mode="aspectFit" />
<text v-else class="tile-fb">{{ (a.name || '·').slice(0, 1) }}</text>
</view>
<text class="tn">{{ a.name }}</text>
<view class="lock">
<image class="lock-ic" :src="LOCK_ICON" mode="aspectFit" />
</view>
</view>
</view>
</template>
</view>
<!-- 授权表单卡片 -->
<view class="auth-card">
<!-- 头像:圆形选择区,点击选择;图片填满圆并裁剪,文字放圆外 -->
<view class="auth-avatar-wrap">
<button class="auth-avatar" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image v-if="authAvatar" class="auth-ava" :src="authAvatar" mode="aspectFill" />
<view v-else class="auth-ava-plus">+</view>
<!-- 常驻底部行动条:用户滚到哪一屏都能点 -->
<view v-if="!phoneBound" class="stickycta">
<button class="cta cta2" @tap="openPhoneSheet">{{ stickyText }}</button>
</view>
<!-- 遮罩(演示里点遮罩不关闭,必须走两个按钮之一) -->
<view class="mask" :class="{ show: !!sheet }" @tap="noop"></view>
<!-- ① 手机号半弹窗:「是」按钮本身必须是 open-type="getPhoneNumber",
手机号没有可编程调起的 API;带右上角「原生」角标的才调微信接口。 -->
<view class="sheet" :class="{ show: sheet === 'phone' }">
<view class="grab"></view>
<view class="sheet-h3">是否授权手机号进行体验?</view>
<view class="sheet-p">用于创建你的孕育档案,让胎动、体重、产检记录在换设备后也能找回。</view>
<view class="btnrow">
<button class="bsec" @tap="denyPhone">否</button>
<button class="bmain bwx" open-type="getPhoneNumber" @getphonenumber="onGetPhone">
是,授权手机号
</button>
<text class="auth-ava-label">{{ authAvatar ? '点击更换头像' : '点击选择头像' }}</text>
</view>
<view class="auth-field">
<input class="auth-input" type="nickname" v-model="authNickname" placeholder="输入昵称" maxlength="16" placeholder-class="auth-ph" />
</view>
<button class="auth-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">{{ authPhone ? '✅ 已授权手机号' : '获取手机号' }}</button>
<view class="auth-actions">
<button class="auth-cancel" @tap="cancel">取消</button>
<button class="auth-ok" @tap="submitAuth">保存并返回</button>
</view>
</view>
<text class="auth-tip">微信授权仅用于在应用内展示你的资料,不会泄露给第三方。</text>
<!-- ④-A 老用户(库里已有昵称/头像):是否复用。普通按钮 —— 复用走库里资料,不调微信接口 -->
<view class="sheet" :class="{ show: sheet === 'reuse' }">
<view class="grab"></view>
<view class="sheet-h3">是否复用你的微信昵称和头像?</view>
<view class="avarow">
<view class="avarow-big">
<image v-if="libAvatar" class="avarow-img" :src="libAvatar" mode="aspectFill" />
<text v-else class="avarow-emoji">🐣</text>
</view>
<view class="avarow-txt">
<view class="t1">{{ libNickname || '微信用户' }}</view>
</view>
</view>
<view class="btnrow">
<button class="bsec" @tap="reuseNo">否,稍后再说</button>
<button class="bmain" @tap="reuseYes">是,直接用</button>
</view>
</view>
<!-- ④-B 库里没有昵称/头像:不再追问、不再给表单,直接收尾返回(2026-09-22 定案) -->
<!-- 说明:本页只负责「手机号」这一件事 + 可选复用库里已有资料。
昵称/头像不由本页采集(getUserProfile 已废弃,且产品定案不在此追问),
它们由小程序其它流程/上游用户中心/商城同步维护,见 AGENTS §4.4。 -->
<!-- 页内 toast -->
<view class="toast" :class="{ show: toast.show }">
<text v-if="toast.tick" class="toast-tick">✓</text>
<text class="toast-msg">{{ toast.msg }}</text>
</view>
</view>
</template>
<script>
import store from '@/store';
import { HTTP_REQUEST_URL } from '@/config/app';
import { LOGIN_STATUS } from '@/config/cache';
/* 共享授权接口(user_auth_router:全 H5 应用共用,openid 单键,不区分应用) */
const AUTH_API = 'https://mp.yizhezhe.com/api/mp/public/auth/profile';
/* box_app 公开应用列表:宫格按后台登记的在架应用渲染(样式照 design/授权流程演示) */
const APPS_API = 'https://mp.yizhezhe.com/api/mp/apps';
/* ⚠️ 演示页占位数据:上线前必须核实真实数字,否则涉嫌虚假宣传。
三个头像是内联 SVG 示意插画(为保持单文件离线可用),不是真实用户头像。 */
const SOCIAL_COUNT = '36.5万+';
const SOCIAL_AVATARS = [
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PGNpcmNsZSBjeD0nMTInIGN5PScxMicgcj0nMTInIGZpbGw9JyNGN0U0RUEnLz48cGF0aCBkPSdNMi42IDI0Yy43LTQuMyA0LjYtNi45IDkuNC02LjlzOC43IDIuNiA5LjQgNi45eicgZmlsbD0nI0M5N0I5MicvPjxjaXJjbGUgY3g9JzEyJyBjeT0nMTAuNicgcj0nNC4xNScgZmlsbD0nI0YyQzZBQycvPjxlbGxpcHNlIGN4PSc3LjknIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjM0IyQTI0Jy8+PGVsbGlwc2UgY3g9JzE2LjEnIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjM0IyQTI0Jy8+PHBhdGggZD0nTTcuOCAxMC42QzcuOCA2LjUgMTYuMiA2LjUgMTYuMiAxMC42Yy0uOS0yLjEtNy41LTIuMS04LjQgMHonIGZpbGw9JyMzQjJBMjQnLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PGNpcmNsZSBjeD0nMTInIGN5PScxMicgcj0nMTInIGZpbGw9JyNFRkVBRkEnLz48cGF0aCBkPSdNMi42IDI0Yy43LTQuMyA0LjYtNi45IDkuNC02LjlzOC43IDIuNiA5LjQgNi45eicgZmlsbD0nIzlCODZDOScvPjxjaXJjbGUgY3g9JzEyJyBjeT0nMTAuNicgcj0nNC4xNScgZmlsbD0nI0Y2RDNCQycvPjxlbGxpcHNlIGN4PSc3LjknIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjNUEzQjJFJy8+PGVsbGlwc2UgY3g9JzE2LjEnIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjNUEzQjJFJy8+PHBhdGggZD0nTTcuOCAxMC42QzcuOCA2LjUgMTYuMiA2LjUgMTYuMiAxMC42Yy0uOS0yLjEtNy41LTIuMS04LjQgMHonIGZpbGw9JyM1QTNCMkUnLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PGNpcmNsZSBjeD0nMTInIGN5PScxMicgcj0nMTInIGZpbGw9JyNFNEYyRUMnLz48cGF0aCBkPSdNMi42IDI0Yy43LTQuMyA0LjYtNi45IDkuNC02LjlzOC43IDIuNiA5LjQgNi45eicgZmlsbD0nIzVGQTk4QycvPjxjaXJjbGUgY3g9JzEyJyBjeT0nMTAuNicgcj0nNC4xNScgZmlsbD0nI0VGQzBBMCcvPjxlbGxpcHNlIGN4PSc3LjknIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjMjQxQzE5Jy8+PGVsbGlwc2UgY3g9JzE2LjEnIGN5PScxMC45JyByeD0nLjk1JyByeT0nMi4zJyBmaWxsPScjMjQxQzE5Jy8+PHBhdGggZD0nTTcuOCAxMC42QzcuOCA2LjUgMTYuMiA2LjUgMTYuMiAxMC42Yy0uOS0yLjEtNy41LTIuMS04LjQgMHonIGZpbGw9JyMyNDFDMTknLz48L3N2Zz4='
];
/* 宫格右上角小锁(未授权时显示;base64 保证 WXSS/小程序端可直接用) */
const LOCK_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nbm9uZScgc3Ryb2tlPScjOEM3QTZGJyBzdHJva2Utd2lkdGg9JzIuMic+PHJlY3QgeD0nNC41JyB5PScxMC41JyB3aWR0aD0nMTUnIGhlaWdodD0nMTAnIHJ4PScyLjUnLz48cGF0aCBkPSdNOCAxMC41VjcuNWE0IDQgMCAwIDEgOCAwdjMnLz48L3N2Zz4=';
function base64Decode(str) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
var output = '';
@@ -78,125 +177,377 @@
return '';
}
/* unionid:用户中心身份键(预留功能)。小程序登录态静默缓存(store/Cache 'UNIONID');
带上后 box_app 授权成功自动推送用户中心并回写 openid→unionid 映射。 */
function getUnionid() {
let uid = store.state.app.unionid;
if (uid) return uid;
try { uid = uni.getStorageSync('UNIONID'); } catch (e) {}
return uid || '';
}
export default {
data() {
return {
authNickname: '',
authAvatar: '',
authPhone: false, // 手机号是否已授权(UI 反馈)
_authCode: '',
openid: ''
openid: '',
/* 当前半弹窗:'' | 'phone' | 'reuse' */
sheet: '',
phoneBound: false, // 手机号是否已落库(= 价值卡收起 / 宫格解锁)
phone: '',
libNickname: '', // 共享库里已有的昵称(判据的取值来源)
libAvatar: '', // 共享库里已有的头像
apps: [],
busy: false,
toast: { show: false, msg: '', tick: false },
SOCIAL_COUNT: SOCIAL_COUNT,
SOCIAL_AVATARS: SOCIAL_AVATARS,
LOCK_ICON: LOCK_ICON
}
},
onLoad() {
computed: {
pitchTitle() {
return (this.apps.length ? this.apps.length + ' 款' : '') + '孕育必备工具';
},
heroName() {
return this.libNickname || this.phone || '已授权';
},
heroSub() {
return this.libNickname ? '资料已复用 · 手机号已绑定' : '手机号已绑定 · 未设置昵称';
},
heroAvatar() {
return this.libAvatar;
},
/* 应用列表拉取失败时不要显示「解锁 0 款工具」 */
stickyText() {
return this.apps.length ? '立即免费体验 · 解锁 ' + this.apps.length + ' 款工具' : '立即免费体验';
}
},
onLoad(options) {
this.openid = getOpenid();
if (!this.openid) uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
/* H5 经 navigateTo query 带来的「发起授权的当前地址」(backUrl,已 encode):
结束后写进 __AUTH_RESULT__,宿主 onShow 优先按它回传 —— 从哪个 H5 触发的
授权就回到哪个 H5(宫格 location.href 换应用后,shareHref 可能还是旧应用的)。 */
let backUrl = (options && options.backUrl) || '';
if (backUrl) {
try { backUrl = decodeURIComponent(backUrl); } catch (e) { /* 已是原文 */ }
}
this._backUrl = backUrl;
/* 宫格数据:失败不影响授权主流程(旁路能力绝不阻塞) */
this.fetchApps();
if (!this.openid) {
this.showToast('未获得 openid,请先登录', false, 1500);
setTimeout(() => { uni.navigateBack(); }, 1500);
return;
}
/* 进页即问手机号:用户是从 H5「授权完善资料」进来的,意图已经明确,少一次点击 */
this.sheet = 'phone';
},
onUnload() {
if (this._toastTimer) clearTimeout(this._toastTimer);
},
methods: {
onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl;
if (!avatarUrl) return uni.showToast({ title: '未获取到头像', icon: 'none' });
/* 上传到 box_app 公开接口(无鉴权):定制授权页设计为匿名可用,
不依赖 CRMEB 登录态 —— 用 CRMEB 的 uploadImgs 在退出登录后会 401 弹「请登录」。 */
uni.showLoading({ title: '上传头像中…' });
uni.uploadFile({
url: 'https://mp.yizhezhe.com/api/upload',
filePath: avatarUrl,
name: 'file',
/* ---------- 工具宫格:box_app 在架应用(仅展示,不响应点击) ---------- */
fetchApps() {
uni.request({
url: APPS_API,
data: { isPublished: 1, pageSize: 200 },
success: (res) => {
try {
const d = JSON.parse(res.data);
if (d.code === 200 && d.result && d.result.url) {
this.authAvatar = d.result.url;
} else {
uni.showToast({ title: '头像上传失败', icon: 'none' });
}
} catch (err) {
uni.showToast({ title: '头像上传失败', icon: 'none' });
const d = res.data || {};
const items = (d.result && d.result.items) || d.data || [];
if (Array.isArray(items)) {
this.apps = items.filter((a) => a && a.name);
}
},
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' }),
complete: () => uni.hideLoading()
fail: () => {
console.warn('[auth] 应用列表拉取失败,宫格留空(不影响授权)');
}
});
},
/* ---------- ① 手机号 ---------- */
openPhoneSheet() {
if (!this.openid) return this.showToast('未获得 openid,请先登录');
this.sheet = 'phone';
},
denyPhone() {
/* 手机号都不授权 = 本次授权放弃:直接回 H5(不写 __AUTH_RESULT__,
宿主按现有逻辑重载当前 H5,不会有假成功回流)。 */
uni.navigateBack();
},
onGetPhone(e) {
if (e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) {
this._authCode = e.detail.code;
this.authPhone = true;
const detail = (e && e.detail) || {};
if (detail.errMsg === 'getPhoneNumber:ok' && detail.code) {
this.submitPhone(detail.code);
} else {
uni.showToast({ title: '未授权手机号', icon: 'none' });
this.showToast('未授权手机号', false, 1800);
}
},
submitAuth() {
if (!this.authNickname && !this.authAvatar && !this._authCode) {
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
async submitPhone(code) {
if (this.busy) return;
this.busy = true;
uni.showLoading({ title: '授权中…', mask: true });
const r = await this.postAuth({ code: code });
uni.hideLoading();
this.busy = false;
const phone = (r.res && r.res.phone) || '';
/* ⚠️ 成功判据必须核对「手机号真的拿到了」:后端解密失败(如 access_token 被
商城后端挤掉)会如实返回 code!=200;旧版后端也可能 200 但 result.phone 为空。
两种情况都不能往下走 —— 否则 H5 收到空 phone 的回传,闸门拦截,用户会陷入
「授权成功却又要求授权」的死循环(2026-09-18 孕检日历实测)。 */
if (!r.ok || !phone) {
return this.showToast(r.message || '手机号获取失败,请重试', false, 3000);
}
const openid = getOpenid() || this.openid;
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
const api = 'https://mp.yizhezhe.com/api/mp/public/auth/profile?openid=' + encodeURIComponent(openid) +
(this._authCode ? '&code=' + encodeURIComponent(this._authCode) : '') +
(this.authNickname ? '&nickname=' + encodeURIComponent(this.authNickname) : '') +
(this.authAvatar ? '&avatar=' + encodeURIComponent(this.authAvatar) : '');
uni.request({
url: api, method: 'POST', timeout: 10000,
success: (r) => {
const d = r.data || {};
if (d.code === 200) {
const res = d.result || {};
// 把授权结果写入 storage,web_view onShow 读取后刷新 H5 URL
uni.setStorageSync('__AUTH_RESULT__', {
openid: openid,
phone: res.phone || '',
nickname: res.userName || this.authNickname || '',
avatar: res.avatar || this.authAvatar || ''
});
uni.showToast({ title: '授权成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 600);
} else {
uni.showToast({ title: d.message || '授权保存失败', icon: 'none' });
}
},
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
this.phone = phone;
this.libNickname = (r.res && r.res.userName) || '';
this.libAvatar = (r.res && r.res.avatar) || '';
this.phoneBound = true;
this.sheet = '';
this.showToast('授权成功', true, 1100);
/* ④ 查共享库(**必须走 GET,不能只看上面 POST 的返回**):
本页**不采集**昵称/头像(getUserProfile 已废弃,昵称/头像只能由用户手势触发
input type=nickname / chooseAvatar;且产品定案此页不再追问),展示的永远是
「库里已经存过的值」—— 可能来自上一次授权、上游用户中心、或 CRMEB 商城用户表同步。
而 GET /auth/profile 才带「先取后授权」兜底回填(用户中心 → CRMEB 同步),
POST(apply_auth) 不做回填 —— 只在商城有昵称/头像的用户,只有 GET 能带出来。
判据用 !!(userName || avatar),**不能**用「记录是否存在」(接口对任何 openid
都返回 200 并现场建一条空记录)。 */
const startedAt = Date.now();
const lib = await this.fetchLibraryProfile();
if (lib) {
this.libNickname = lib.userName || '';
this.libAvatar = lib.avatar || '';
}
/* 让「授权成功」toast 至少停 1.1s,查库太快也不闪 */
const wait = Math.max(0, 1100 - (Date.now() - startedAt));
setTimeout(() => {
if (this.libNickname || this.libAvatar) {
/* 库里有昵称/头像 → 提示是否复用 */
this.sheet = 'reuse';
} else {
/* 库里没有 → **不追问头像昵称**(本页不采集这两项),直接收尾返回 */
this.finish('', '', '授权成功');
}
}, wait);
},
/* ---------- ④-A 复用库里已有资料 ---------- */
async reuseYes() {
if (this.busy) return;
const extra = {};
if (this.libNickname) extra.nickname = this.libNickname;
if (this.libAvatar) extra.avatar = this.libAvatar;
/* 库里值可能来自 CRMEB 商城同步(authorized=false):这里再写一次,
把「复用」视为用户主动授权,置位 authorized 并回写用户中心。 */
if (extra.nickname || extra.avatar) {
this.busy = true;
uni.showLoading({ title: '处理中…', mask: true });
const r = await this.postAuth(extra);
uni.hideLoading();
this.busy = false;
if (!r.ok) return this.showToast(r.message, false, 3000);
this.libNickname = (r.res && r.res.userName) || this.libNickname;
this.libAvatar = (r.res && r.res.avatar) || this.libAvatar;
}
this.finish(this.libNickname, this.libAvatar, '已复用微信资料');
},
reuseNo() {
/* 不同步:库里资料原样保留,照样把真实值回传给 H5,然后直接返回 */
this.finish(this.libNickname, this.libAvatar, '已保留手机号,资料未改动');
},
/* ---------- 收尾:写 __AUTH_RESULT__ → 宿主 onShow 回传 H5 hash ---------- */
finish(nickname, avatar, msg) {
const openid = this.openid || getOpenid();
if (!openid) return this.showToast('未获得 openid,请先登录');
try {
uni.setStorageSync('__AUTH_RESULT__', {
openid: openid,
phone: this.phone || '',
nickname: nickname || '',
avatar: avatar || '',
backUrl: this._backUrl || ''
});
} catch (e) {
console.warn('[auth] 写 __AUTH_RESULT__ 失败:', e && e.message);
}
this.sheet = '';
this.showToast(msg || '授权成功', true, 1000);
setTimeout(() => { uni.navigateBack(); }, 700);
},
/* ---------- ④ 查共享库(GET)----------
唯一带「先取后授权」兜底回填的入口(本地 → 上游用户中心 → CRMEB 商城同步),
所以判「库里是否已有昵称/头像」必须走它,不能拿 POST 的返回凑。
超时/失败一律 resolve(null)(fail-soft):退回用 POST 带回的值判分支,
后续任意一次 GET 仍会把兜底数据补上,不会丢状态。 */
fetchLibraryProfile() {
return new Promise((resolve) => {
const openid = this.openid || getOpenid();
if (!openid) return resolve(null);
const unionid = getUnionid();
let url = AUTH_API + '?openid=' + encodeURIComponent(openid);
if (unionid) url += '&unionid=' + encodeURIComponent(unionid);
uni.request({
url: url,
method: 'GET',
timeout: 2500,
success: (r) => {
const d = r.data || {};
resolve(d.code === 200 ? (d.result || {}) : null);
},
fail: () => resolve(null)
});
});
},
cancel() { uni.navigateBack(); }
/* ---------- 共享授权接口 POST(code / nickname / avatar 三选若干) ---------- */
postAuth(extra) {
return new Promise((resolve) => {
const openid = this.openid || getOpenid();
if (!openid) return resolve({ ok: false, res: {}, message: '未获得 openid,请先登录' });
/* unionid:用户中心身份键(预留)。没拿到也不影响授权(后端可映射兜底)。 */
const unionid = getUnionid();
let url = AUTH_API + '?openid=' + encodeURIComponent(openid);
if (unionid) url += '&unionid=' + encodeURIComponent(unionid);
const ex = extra || {};
['code', 'nickname', 'avatar'].forEach((k) => {
if (ex[k]) url += '&' + k + '=' + encodeURIComponent(ex[k]);
});
uni.request({
url: url,
method: 'POST',
timeout: 10000,
success: (r) => {
const d = r.data || {};
if (d.code === 200) {
resolve({ ok: true, res: d.result || {}, message: d.message || '' });
} else {
resolve({ ok: false, res: d.result || {}, message: d.message || '授权保存失败,请稍后重试' });
}
},
fail: () => resolve({ ok: false, res: {}, message: '授权请求失败,请检查网络' })
});
});
},
showToast(msg, tick, ms) {
if (this._toastTimer) clearTimeout(this._toastTimer);
this.toast.msg = msg || '';
this.toast.tick = !!tick;
this.toast.show = true;
this._toastTimer = setTimeout(() => { this.toast.show = false; }, ms || 1500);
},
noop() {}
}
}
</script>
<style scoped>
/* 清 button 默认边框/padding(避免撑出卡片 + 消除默认灰底边框)。
不改 margin —— 统配 margin:0 会压掉各按钮自己的 margin-top,导致元素紧贴。 */
⚠️ 两条纪律,踩过:
① 这里不设 background —— 否则会盖掉 .cta/.bmain 自己的底色;
② `.auth-page button` 的特异性是 (0,1,1)(scoped 编译后约 (0,2,1)),**高于**单类选择器
`.bsec`/`.bmain`/`.cta`/(0,1,0)。所以下面所有按钮自己的 padding / border 规则
都必须带上 `.auth-page ` 前缀提权,否则会被这条 reset 清成 0 ——
表现是「否」没有边框只剩文字、按钮高度塌成一行字高(2026-09-22 真机截图)。 */
.auth-page button::after { border: none; }
.auth-page button { padding: 0; line-height: normal; box-sizing: border-box; border: none; -webkit-appearance: none; }
.auth-page input { box-sizing: border-box; margin: 0; }
.auth-page { min-height: 100vh; background: #F8F3EF; padding: 24px 20px 40px; display: flex; flex-direction: column; align-items: center; }
.auth-head { text-align: center; margin-top: 16px; }
.auth-title { font-size: 20px; font-weight: 800; color: #2B2320; display: block; }
.auth-sub { font-size: 13px; color: #A79C95; margin-top: 8px; display: block; }
.auth-card { width: 100%; max-width: 340px; background: #fff; border-radius: 18px; padding: 28px 20px 20px; margin-top: 28px; box-shadow: 0 6px 24px rgba(0,0,0,.05); display: flex; flex-direction: column; align-items: center; }
.auth-page { min-height: 100vh; background: #F5EFE7; color: #4A3B33; font-size: 15px; line-height: 1.6; }
/* 头部:圆形头像选择区(图片填满圆并裁剪),说明文字放圆外下方 */
.auth-avatar-wrap { display: flex; flex-direction: column; align-items: center; }
.auth-avatar { width: 92px; height: 92px; border-radius: 50%; background: #F3E9E3; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px dashed #D9C4B8; flex: 0 0 auto; }
.auth-ava { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.auth-ava-plus { font-size: 30px; color: #C9B7AC; line-height: 1; }
.auth-ava-label { font-size: 11px; color: #A79C95; margin-top: 8px; }
/* ---------- 内容区 ---------- */
.home { padding: 14px 14px 0; }
/* 昵称输入 */
.auth-field { width: 100%; margin-top: 22px; }
.auth-input { width: 100%; height: 48px; border-radius: 12px; background: #F8F3EF; font-size: 14px; color: #2B2320; padding: 0 14px; box-sizing: border-box; }
.auth-ph { color: #C9B7AC; }
/* ---------- 价值主张卡(未授权时的主转化区) ---------- */
.pitch { background-image: linear-gradient(160deg, #FFFFFF 0%, #FFFCF8 46%, #FDF6EF 100%); border: 1px solid #EDE2D7; border-radius: 16px; padding: 17px 15px 15px; box-shadow: 0 6px 18px -8px rgba(122,88,66,.18), 0 1px 3px rgba(122,88,66,.06); text-align: center; }
.pbadge { display: inline-block; background-image: linear-gradient(90deg, #F7E4EB, #FBF0E7); color: #A9506C; font-size: 10.5px; font-weight: 700; border-radius: 20px; padding: 4px 11px; }
.pttl { font-size: 18.5px; line-height: 1.45; margin-top: 11px; font-weight: 700; color: #4A3B33; }
.pttl2 { margin-top: 0; }
.pttl.em { color: #A9506C; }
.psub { font-size: 11.5px; color: #7D6A5E; line-height: 1.8; }
.social { display: flex; align-items: center; justify-content: center; margin-top: 12px; }
.dots { display: flex; align-items: center; }
.dot { width: 19px; height: 19px; border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 1px 2px rgba(60,42,36,.12); }
.dot + .dot { margin-left: -7px; }
.social-txt { font-size: 11px; color: #A9506C; font-weight: 600; margin-left: 7px; }
.auth-page .cta { display: block; width: 100%; margin-top: 14px; border-radius: 13px; padding: 14px 0; font-size: 15.5px; font-weight: 700; color: #fff; background-image: linear-gradient(100deg, #B2546D 0%, #CE7089 100%); animation: ctapulse 2.6s ease-in-out infinite; }
.auth-page .cta:active { opacity: .88; }
@keyframes ctapulse {
0%, 100% { box-shadow: 0 8px 18px -9px rgba(169,80,108,.55); }
50% { box-shadow: 0 10px 26px -7px rgba(169,80,108,.85); }
}
.ctahint { font-size: 10.5px; color: #97867C; margin-top: 9px; }
.trust { display: flex; justify-content: center; margin-top: 13px; padding-top: 12px; border-top: 1px dashed #EDE2D7; }
.trust-i { font-size: 10.5px; color: #7D6A5E; margin: 0 5px; }
.trust-tick { color: #12A67F; font-size: 11px; margin-right: 3px; }
/* 获取手机号按钮 */
.auth-btn { width: 100%; height: 48px; border-radius: 12px; background: #F8F3EF; color: #2B2320; font-size: 14px; margin-top: 14px; display: flex; align-items: center; justify-content: center; }
/* ---------- 已授权:收起成一条状态 ---------- */
.hero { background-image: linear-gradient(160deg, #FFFFFF 0%, #FFFCF8 46%, #FDF6EF 100%); border: 1px solid #EDE2D7; border-radius: 15px; padding: 14px; box-shadow: 0 6px 18px -8px rgba(122,88,66,.18), 0 1px 3px rgba(122,88,66,.06); display: flex; align-items: center; }
.hero .ava { width: 44px; height: 44px; border-radius: 50%; background: #F7EFE6; flex: 0 0 44px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.ava-img { width: 100%; height: 100%; border-radius: 50%; }
.ava-emoji { font-size: 19px; }
.hero .who { flex: 1; min-width: 0; margin-left: 12px; }
.hero .nm { font-size: 14.5px; font-weight: 600; }
.hero .st { font-size: 11.5px; color: #7D6A5E; margin-top: 2px; }
.hero .ok { font-size: 11.5px; color: #12A67F; font-weight: 700; background: #E4F5EF; border-radius: 20px; padding: 5px 11px; }
.auth-actions { display: flex; gap: 12px; margin-top: 24px; width: 100%; }
.auth-cancel, .auth-ok { flex: 1; height: 46px; border-radius: 12px; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; }
.auth-cancel { background: #F1E6E0; color: #6B615B; }
.auth-ok { background: #F4593C; color: #fff; }
.auth-tip { font-size: 11px; color: #A79C95; margin-top: 20px; text-align: center; line-height: 1.5; }
/* ---------- 工具区 ---------- */
.secthead { display: flex; align-items: baseline; justify-content: space-between; margin: 16px 2px 9px; }
.secttl { font-size: 12px; color: #7D6A5E; font-weight: 600; }
.sectmore { font-size: 11px; color: #97867C; }
.freebar { display: flex; align-items: center; margin: 0 2px 11px; padding: 9px 11px; background-image: linear-gradient(120deg, #FDF1F4 0%, #FCF6F1 100%); border: 1px solid #F1DDE3; border-radius: 11px; }
.freepill { flex: 0 0 auto; background: #A9506C; color: #fff; font-size: 10px; font-weight: 700; border-radius: 20px; padding: 3px 8px; }
.freetxt { font-size: 11px; color: #8E4960; line-height: 1.5; margin-left: 8px; }
/* 底部留白要够,否则最后一行被常驻行动条压住 */
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; padding-bottom: 96px; }
.tile { background: #fff; border: 1px solid #EDE2D7; border-radius: 13px; padding: 11px 4px 9px; text-align: center; box-shadow: 0 6px 18px -8px rgba(122,88,66,.18), 0 1px 3px rgba(122,88,66,.06); position: relative; }
.tile .ic { height: 34px; display: flex; align-items: center; justify-content: center; }
.tile-logo { width: 34px; height: 34px; }
.tile-fb { font-size: 17px; color: #C4A18C; font-weight: 700; }
/* 工具名最长的要能折两行且不挤掉图标 */
.tile .tn { font-size: 9.5px; color: #5C4A41; margin-top: 6px; line-height: 1.3; min-height: 25px; }
/* 未解锁标记:制造"授权即可打开"的预期(仅展示,不响应点击) */
.lock { position: absolute; top: 6px; right: 6px; display: none; line-height: 0; }
.grid.locked .lock { display: block; }
.lock-ic { width: 12px; height: 12px; opacity: .5; }
/* ---------- 常驻底部行动条:上缘渐变淡出,避免硬生生切断内容 ---------- */
.stickycta { position: fixed; left: 0; right: 0; bottom: 0; padding: 14px 14px calc(14px + env(safe-area-inset-bottom)); z-index: 10; background-image: linear-gradient(180deg, rgba(245,239,231,0) 0%, rgba(245,239,231,.97) 38%); }
.auth-page .cta2 { margin-top: 0; font-size: 14.5px; padding: 13px 0; }
/* ---------- 遮罩 + 底部半弹窗 ---------- */
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(66,50,42,.42); opacity: 0; pointer-events: none; transition: opacity .26s; z-index: 20; }
.mask.show { opacity: 1; pointer-events: auto; }
.sheet { position: fixed; left: 0; right: 0; bottom: 0; background: #fff; border-radius: 20px 20px 0 0; padding: 20px 20px calc(24px + env(safe-area-inset-bottom)); z-index: 30; transform: translateY(102%); transition: transform .3s cubic-bezier(.22,.9,.28,1); box-shadow: 0 -8px 30px -10px rgba(96,70,54,.28); }
.sheet.show { transform: translateY(0); }
.sheet .grab { width: 34px; height: 4px; border-radius: 4px; background: #EBDCCF; margin: 0 auto 15px; }
.sheet-h3 { font-size: 16.5px; font-weight: 600; color: #4A3B33; margin-bottom: 7px; }
.sheet-p { font-size: 12.5px; color: #7D6A5E; line-height: 1.75; }
.btnrow { display: flex; gap: 10px; margin-top: 18px; }
.auth-page .bsec { flex: 1; border: 1px solid #EBDCCF; background: #fff; color: #A9506C; border-radius: 12px; padding: 12px 0; font-size: 14.5px; }
.auth-page .bsec:active { background: #FDF8F3; }
.auth-page .bmain { flex: 1.55; border-radius: 12px; padding: 12px 0; font-size: 14.5px; font-weight: 600; color: #fff; background: #A9506C; position: relative; }
.auth-page .bmain:active { opacity: .88; }
.auth-page .bmain.bwx { background: #07C160; }
/* ---------- ④-A 复用:库里的头像 + 昵称 ---------- */
.avarow { display: flex; align-items: center; margin-top: 18px; background: #FDF8F3; border: 1px solid #EBDCCF; border-radius: 12px; padding: 12px; }
.avarow-big { width: 52px; height: 52px; border-radius: 50%; flex: 0 0 52px; overflow: hidden; background: #F7EFE6; display: flex; align-items: center; justify-content: center; }
.avarow-img { width: 100%; height: 100%; border-radius: 50%; }
.avarow-emoji { font-size: 23px; }
.avarow-txt { min-width: 0; margin-left: 13px; }
.avarow-txt .t1 { font-size: 14px; font-weight: 600; color: #4A3B33; }
/* ---------- 页内 toast ---------- */
.toast { position: fixed; left: 50%; top: 44%; transform: translate(-50%,-50%) scale(.9); background: rgba(68,52,44,.92); color: #fff; border-radius: 12px; padding: 16px 22px; font-size: 14px; z-index: 60; opacity: 0; pointer-events: none; transition: .24s; text-align: center; min-width: 130px; }
.toast.show { opacity: 1; transform: translate(-50%,-50%) scale(1); }
.toast-tick { display: block; font-size: 26px; margin-bottom: 5px; color: #6EE7B7; }
.toast-msg { color: #fff; }
</style>

View File

@@ -0,0 +1,137 @@
<!--
小程序专用 web-view 壳页(2026-11-04)—— 只给「链接系统 / 福利系统」的小程序码用
方案:box_app/docs/lianjie-wxacode-plan.md(v4 硬编码,第四节之一 4.2)
入口:
1) 扫小程序码:options.scene = "l=g8I0yMDYzlCu" / "f=zanxte3x"(微信会 URL 编码 scene)
2) 转发卡片: /pages/annex/link_view/index?t=l&k=g8I0yMDYzlCu
只做三件事:解析 scene 或转发参数 → 拼地址 → web-view 打开;解析不出来显示兜底屏。
⚠️ TARGETS 必须和 box_app backend/app/services/link_wxacode_serv.py 的同名表一致
(短键、地址前缀、标识规则)。改这张表 = 两端一起改 + 小程序发版;
码里只有标识,所以发版后旧码照常可用。
⚠️ 不注入任何小程序身份(openid/unionid),也不反查分享图/标题:两家都走自己的
公众号网页授权(方案第五节),所以本页**不引入** webviewHost / shareCard mixin。
⚠️ 本页路径必须等于 box_app 生产 .env 的 LINK_WXACODE_PAGE(默认就是这个路径),
改路径要同步改那边,否则已生成的码全部扫不进页面。
⚠️ web-view 打开的域名必须在小程序后台「业务域名」里(方案 4.4):
wenda.douba.top / fuli.douba.top / dbh5.ywsz.com.cn。
-->
<template>
<view>
<web-view v-if="url" :src="url"></web-view>
<view v-else class="lv-fallback">
<view class="lv-emoji">🔗</view>
<view class="lv-title">链接已失效</view>
<view class="lv-desc">这个码可能已过期,或者链接已下架</view>
<button class="lv-btn" hover-class="lv-btn-hover" @tap="goHome">去首页</button>
</view>
</view>
</template>
<script>
/* 目标表(方案第三节):短键 → 地址前缀 + 标识规则。
base 写死在小程序里:换域名或新增系统要发版过审,这一点在方案第八节已知。 */
const TARGETS = {
l: { base: 'https://wenda.douba.top/s/', re: /^[0-9A-Za-z]{1,30}$/, title: '兜爸心选 · 好物推荐' },
f: { base: 'https://fuli.douba.top/a/', re: /^[a-z0-9-]{1,30}$/, title: '兜爸心选 · 福利活动' },
}
/* 微信给小程序的 scene 是 URL 编码的("l%3Dslug"),个别机型/版本还会二次编码。
必须 try/catch:裸 '%' 会让 decodeURIComponent 抛错,直接冲掉 onLoad → 白屏。
解不开就用原文,后面正则校验会把它挡成兜底屏,绝不会把野值塞进 web-view。 */
function decodeScene(raw) {
let s = String(raw == null ? '' : raw)
for (let i = 0; i < 2 && s.indexOf('%') !== -1; i++) {
try {
const next = decodeURIComponent(s)
if (next === s) break
s = next
} catch (e) {
break
}
}
return s
}
/* "l=slug&ch=XX" → { l: 'slug', ch: 'XX' }
按第一个 '=' 切,值里再出现 '=' 也不会被截断。 */
function parseScene(raw) {
const out = {}
String(raw || '').split('&').forEach((kv) => {
const i = kv.indexOf('=')
if (i > 0) out[kv.slice(0, i)] = kv.slice(i + 1)
})
return out
}
/* 返回 { t, k, url };返回 null 表示走兜底屏。
优先级:显式 t/k(转发落地)> scene(扫码)——同一条码两种情况都要能用。 */
function resolveTarget(options) {
const opt = options || {}
let t = opt.t ? String(opt.t) : ''
let k = opt.k ? String(opt.k) : ''
if (!t && opt.scene) {
const sc = parseScene(decodeScene(opt.scene))
for (const key in TARGETS) {
if (sc[key]) {
t = key
k = sc[key]
break
}
}
}
const target = TARGETS[t]
if (!target || !k || !target.re.test(k)) return null
return { t, k, url: target.base + k }
}
export default {
data() {
return {
url: '', // 为空 = 兜底屏(web-view 不渲染)
t: '', // 短键(分享时回填)
k: '', // 标识(分享时回填)
}
},
onLoad(options) {
const opt = options || {}
const hit = resolveTarget(opt)
if (!hit) {
// 真机排查用:一条日志区分「码/参数不对」和「域名没加业务域名」
console.warn('[link-view] 标识不合法,显示兜底屏: scene=' + (opt.scene || '') +
' t=' + (opt.t || '') + ' k=' + (opt.k || ''))
return
}
this.t = hit.t
this.k = hit.k
this.url = hit.url
console.log('[link-view] scene=' + (opt.scene || '') + ' 短键=' + hit.t +
' 标识=' + hit.k + ' → ' + hit.url)
},
onShareAppMessage() {
// 转发出去还是这个壳页:好友点开按 t/k 重新拼地址,不带上任何身份参数
if (!this.url) return { title: '兜爸心选', path: '/pages/index/index' }
return {
title: TARGETS[this.t].title,
path: '/pages/annex/link_view/index?t=' + this.t + '&k=' + this.k,
}
},
methods: {
goHome() {
uni.switchTab({ url: '/pages/index/index' })
},
},
}
</script>
<style scoped>
/* 兜底屏沿用宿主页配色(pages/annex/web_view):#2B2320 主文字 / #A79C95 次要 / #F4593C 主按钮 */
.lv-fallback { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 70vh; padding: 0 40rpx; }
.lv-emoji { font-size: 64rpx; line-height: 1; }
.lv-title { margin-top: 24rpx; font-size: 32rpx; font-weight: 700; color: #2B2320; }
.lv-desc { margin-top: 12rpx; font-size: 26rpx; color: #A79C95; line-height: 1.6; text-align: center; }
.lv-btn { margin-top: 36rpx; padding: 0 48rpx; height: 80rpx; line-height: 80rpx; border-radius: 40rpx; background: #F4593C; color: #fff; font-size: 28rpx; }
.lv-btn-hover { opacity: .85; }
</style>

View File

@@ -1,22 +1,33 @@
<template>
<view>
<web-view v-if="url" class="web-view" :webview-styles="webviewStyles" :src="url"
:style="{width: windowW + 'px', height: windowH + 'px'}" @message="handleMessage"></web-view>
:style="{width: windowW + 'px', height: windowH + 'px'}" @message="handleHostMessage"></web-view>
<!-- 身份未就绪兜底屏(2026-09-20 新增)
⚠️ 本页与首页壳同一策略:**拿到 openid 才加载 H5**,不再匿名加载。
以前 url 为空时这里是白屏(没有任何占位),所以必须给一块可见的屏,
否则「不加载」会变成「白屏 + 用户不知道该干嘛」。 -->
<view v-else class="wv-fallback">
<view class="wv-emoji">{{ identityFail ? '🥲' : '🍼' }}</view>
<view class="wv-title">{{ identityFail ? '未获取到微信身份' : '正在获取微信身份' }}</view>
<view class="wv-desc">{{ identityFail ? '请检查网络后点下方重试,或退出小程序重新进入' : '马上就好,请稍候(不会加载空数据的页面)' }}</view>
<button v-if="identityFail" class="wv-retry" hover-class="wv-retry-hover" @tap="retryIdentity">重新获取身份</button>
</view>
<!-- 共享原生授权面板(任意 H5 应用 postMessage({action:'requestProfile'}) 后显示) -->
<view v-if="showAuth" class="mb-auth-mask" @tap="closeAuth">
<view class="mb-auth-panel" @tap.stop="noop">
<view class="mb-auth-title">授权完善资料</view>
<view class="mb-auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</view>
<button class="mb-auth-btn avatar" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<button class="mb-auth-btn avatar" open-type="chooseAvatar" @chooseavatar="onChooseHostAvatar">
<image v-if="authAvatar" class="mb-auth-ava" :src="authAvatar" mode="aspectFill" />
<text v-else>选择头像</text>
</button>
<input class="mb-auth-input" type="nickname" v-model="authNickname" placeholder="输入昵称" maxlength="16" />
<button class="mb-auth-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">获取手机号</button>
<button class="mb-auth-btn" open-type="getPhoneNumber" @getphonenumber="onHostGetPhone">获取手机号</button>
<view class="mb-auth-actions">
<button class="mb-auth-cancel" @tap="closeAuth">取消</button>
<button class="mb-auth-ok" @tap="submitAuth">保存并返回</button>
<button class="mb-auth-ok" @tap="submitHostAuth(url)">保存并返回</button>
</view>
</view>
</view>
@@ -26,6 +37,8 @@
import store from '@/store';
import { HTTP_REQUEST_URL, TRUSTED_DOMAINS } from '@/config/app';
import { LOGIN_STATUS } from '@/config/cache';
import shareCard from '@/mixins/shareCard.js';
import webviewHost from '@/mixins/webviewHost.js';
function base64Decode(str) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
@@ -92,12 +105,36 @@ function getCurrentUser() {
return { openid, unionid };
}
/* 剥离分享链接里的身份/缓存参数。分享出去的链接必须干净:
① 不能把分享者的 openid/unionid 泄露给接收者;
② 接收者点开后由本页(web_view 容器)按自己的身份重新注入。
覆盖 query 的 openid/unionid/t/_v 与 hash 的 openid/unionid/phone/nickname/avatar
(hash 是授权回传通道,见 docs/h5-auth-standard.md)。
⚠️ 逐段按 key 过滤而不是正则替换:正则 [^&]+ 会连 #hash 一起吃掉。 */
const SHARE_DROP_KEYS = ['openid', 'unionid', 't', '_v', 'phone', 'nickname', 'avatar'];
function dropKeyValuePairs(str, keys) {
return String(str || '')
.split('&')
.filter(pair => !!pair && keys.indexOf(pair.split('=')[0]) === -1)
.join('&');
}
function stripAuthParams(url) {
return url
.replace(/([?&])openid=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || ''))
.replace(/([?&])unionid=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || ''))
.replace(/([?&])t=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || ''))
.replace(/\?&$/, '').replace(/\?$/, '').replace(/&$/, '');
let out = String(url || '');
const hashIdx = out.indexOf('#');
let base = hashIdx >= 0 ? out.slice(0, hashIdx) : out;
let hash = hashIdx >= 0 ? out.slice(hashIdx + 1) : '';
const qIdx = base.indexOf('?');
let query = qIdx >= 0 ? base.slice(qIdx + 1) : '';
base = qIdx >= 0 ? base.slice(0, qIdx) : base;
query = dropKeyValuePairs(query, SHARE_DROP_KEYS);
hash = dropKeyValuePairs(hash, SHARE_DROP_KEYS);
// 兜底:hash 是「路由 + ?参数」形态时(如 #/report?openid=x),再剥一层
const hq = hash.indexOf('?');
if (hq >= 0) {
const hqQuery = dropKeyValuePairs(hash.slice(hq + 1), SHARE_DROP_KEYS);
hash = hash.slice(0, hq) + (hqQuery ? '?' + hqQuery : '');
}
return base + (query ? '?' + query : '') + (hash ? '#' + hash : '');
}
function getUrlParam(url, name) {
@@ -108,25 +145,31 @@ function getUrlParam(url, name) {
}
export default {
mixins: [shareCard, webviewHost],
data() {
return {
windowH: 0, windowW: 0,
webviewStyles: { progress: { color: 'transparent' } },
url: '',
shareTitle: '',
shareSummary: '',
shareImageUrl: '',
shareTimelineImage: '',
shareHref: '',
// 分享字段由 mixins/shareCard.js 提供;
// 授权面板字段(showAuth/authNickname/authAvatar/_authCode)由 mixins/webviewHost.js 提供
savedUrl: '',
_shareFetched: false,
showAuth: false, // 授权面板开关(H5 postMessage 后显示)
authNickname: '',
authAvatar: '',
_authCode: '' // getPhoneNumber 的 code(提交时交 box_app 解密手机号)
identityPending: false, // 正在获取 openid(此期间**不加载 H5**)
identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口
_idAttempts: 0, // 自动重试计数
_pendingTarget: '' // 重试时要加载的目标 URL(重试按钮用)
}
},
onLoad(option) {
/* 诊断日志(过滤关键字 enter-app):容器页每次被打开都会打这一行 ——
它是「真的进入了某个 H5 应用」的最终证据(不管入口是恢复/分享/太阳码/商城宫格)。 */
try {
console.log('[enter-app] ===== 容器页 onLoad ===== option=' + JSON.stringify(option || {}));
} catch (e) {}
// 分享落地携带的推广人:与其它页面一致写进 globalData.spid(下单归因用)
if (option && option.spid) {
try { getApp().globalData.spid = option.spid; } catch (e) {}
}
// 太阳码 scene 解析:scene="app=<identifier>" 时调 box_app 查 url
// 例如 box_app /api/wxacode?app=pregnancy → scene="app=pregnancy"
// CRMEB 收到后查 box_app /api/mp/apps?isPublished=1 找 url,跳对应 H5
@@ -156,22 +199,12 @@ export default {
// 3) 退出登录后再次进入 web_view(url 已存在)→ 用缓存的 openid 重新注入
// 0) 授权页返回:读取 __AUTH_RESULT__,把授权结果写入当前 H5 URL 的 hash,让 H5 重载读取
try {
const authRes = uni.getStorageSync('__AUTH_RESULT__');
if (authRes && authRes.openid && this.url) {
uni.removeStorageSync('__AUTH_RESULT__');
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash → openid=' + (authRes.openid || '').substring(0, 8) + '...');
const base = this.url.split('#')[0];
const joiner = base.indexOf('?') !== -1 ? '&' : '?';
this.url = base + joiner + 't=' + Date.now() +
'#openid=' + encodeURIComponent(authRes.openid) +
'&phone=' + encodeURIComponent(authRes.phone || '') +
'&nickname=' + encodeURIComponent(authRes.nickname || '') +
'&avatar=' + encodeURIComponent(authRes.avatar || '');
return;
}
} catch (e) {
console.warn('[auth-flow] 读取授权结果失败:', e);
// (与首页壳共用 mixins/webviewHost.js 的 consumeAuthResult)
const authUrl = this.consumeAuthResult(this.url);
if (authUrl) {
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash');
this.url = authUrl;
return;
}
const { openid, unionid } = getCurrentUser();
@@ -182,8 +215,9 @@ export default {
if (openid) {
this._applyIdentity(targetUrl, openid, unionid, urlOpenid);
} else {
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,加载原始URL');
this._finalizeUrl(targetUrl);
/* ⚠️ 2026-09-20:不再「加载原始URL」(那会让应用进访客态),改走「取到才加载」 */
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
}
} else if (this.url && openid) {
// H5 授权登录返回 / 退出后回到 web_view:重新注入最新身份
@@ -219,9 +253,12 @@ export default {
const appUrl = app.url;
// 预取分享图片(转发给好友时用作分享卡片图)
if (app.shareImageUrl) {
this._applyShareImage(app.shareImageUrl);
this._applyShareImage(app.shareImageUrl, 'box', app.url);
console.log('[web_view] 分享图片: ' + app.shareImageUrl);
}
// 同一响应里就有分享标题(后台「分享标题」,留空 = 应用名称)→ 直接落上,
// 不必等反查表回来(太阳码是冷启动首发场景)
this._applyShareTitleFromApp(app);
let finalUrl = appUrl;
if (option._channel) {
const separator = finalUrl.includes('?') ? '&' : '?';
@@ -279,22 +316,26 @@ export default {
console.log('[auth-flow] ✅ 重试成功,注入身份并跳转');
this._applyIdentity(targetUrl, retry.openid, retry.unionid, getUrlParam(targetUrl, 'openid'));
} else {
console.warn('[auth-flow] ⚠️ 重试仍无 openid,匿名加载 H5');
this._finalizeUrl(targetUrl);
/* ⚠️ 2026-09-20:以前这里匿名加载 H5(→ 应用进访客态)。
改成走「取到 openid 才加载」的公共路径(自动重试 3 次 → 失败屏)。 */
console.warn('[auth-flow] ⚠️ 重试仍无 openid,改走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
}
} catch (e) {
console.error('[auth-flow] 重试异常,匿名加载:', e);
this._finalizeUrl(targetUrl);
console.error('[auth-flow] 重试异常,改走「取到才加载」:', e);
this._ensureIdentityThenLoad(targetUrl);
}
}, 500);
return;
}
// 无 token + 无 openid(新用户 / 退出后 / 静默授权失败 / 冷启动竞态兜底超时):
// 主动用 wx.login 拿 code 调 CRMEB silenceAuthNoLogin(v2/wechat/silence_auth)
// 拿到 openid 后注入 URL,让 H5 端完成 openid-login 建档
// 该端点不查 store_user_mobile,新用户也能拿到 openid
console.log('[auth-flow] 🔑 无 openid,调 silenceAuthNoLogin 拿 openid');
this._fetchOpenidAndLoad(targetUrl);
// 走「取到 openid 才加载」——内部用 mixin 的 _silenceAuthOpenid()
// (wx.login → CRMEB v2/wechat/silence_auth,兼容 auth_login 无 token / userInfo / JWT),
// 自动重试 3 次;拿不到就停在兜底屏,**不再匿名加载 H5**。
// ⚠️ 原来这里调本页的 _fetchOpenidAndLoad(同一套逻辑的另一份实现),
// 现已删除、统一到 mixin,避免两处分叉。
console.log('[auth-flow] 🔑 无 openid,走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
return;
} else {
console.log('[web_view] 非可信域,不做身份替换');
@@ -311,91 +352,60 @@ export default {
},
/**
* 主动 wx.login() 拿 code,调 CRMEB /v2/wechat/silence_auth 拿 openid
* 然后注入 URL 并加载 H5(即使后端返回 auth_login=1 无 token,openid 已够 H5 端走 openid-login 建档)
* @param {string} targetUrl - 原始 H5 URL
* 取到 openid 才加载 H5(2026-09-20 新增;取代原 _fetchOpenidAndLoad)
*
* 与首页壳同一策略:**拿不到 openid 就不加载**,自动重试 3 次,仍失败就停在兜底屏
* 等用户点「重新获取身份」—— 不再匿名加载(匿名加载会让 H5 进访客态:
* 孕周 `--`、档案查不到,用户看到「页面能用但全是空」,比多等几秒更糟,且事后难判断)。
*
* ⚠️ 每次尝试前都再读一次 getCurrentUser():App.vue 的 onLaunch 静默授权是并行的,
* 它先落地就直接命中缓存,不必自己再请求一次。
* ⚠️ openid 的来源与解析统一在 mixins/webviewHost.js 的 _silenceAuthOpenid()
* (兼容 auth_login 无 token / userInfo / JWT 三种,带 8s 超时保险丝)。
* @param {string} targetUrl - 要加载的 H5 URL(已含原始 query,身份由 _applyIdentity 注入)
*/
_fetchOpenidAndLoad(targetUrl) {
uni.login({
provider: 'weixin',
success: (loginRes) => {
const code = (loginRes && loginRes.code) || '';
if (!code) {
console.warn('[auth-flow] ⚠️ wx.login 返回空 code,匿名加载 H5');
this._finalizeUrl(targetUrl);
return;
}
console.log('[auth-flow] wx.login 拿到 code(前 8 位: ' + code.substring(0, 8) + '...),调 silenceAuthNoLogin');
// 调 api/public.js 已有的 silenceAuth(MP 端 → v2/wechat/silence_auth)
// 动态 import 避免在 web_view 页面外被 import 失败
const apiPath = '@/api/public.js';
// #ifdef MP
import('@/api/public.js').then(api => {
api.silenceAuth({ code: code }).then(res => {
const data = res.data || {};
// 兼容两种响应格式:
// 1) 新用户 notLogin=true: {auth_login:1, key, openid, unionid}(顶层 openid)
// 2) 老用户: {token, userInfo:{...}, expires_time, params:{openid}}(openid 在 JWT/userInfo 里)
let openid = data.openid || '';
let unionid = data.unionid || '';
if (!openid) {
// 从 userInfo 里取
const ui = data.userInfo || {};
openid = ui.openid || '';
unionid = ui.unionid || '';
}
if (!openid && data.token) {
// 从 JWT payload 里解 openid(兜底)
try {
const parts = String(data.token).split('.');
if (parts.length === 3) {
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4 !== 0) payload += '=';
const p = JSON.parse(base64Decode(payload));
openid = p.openid || '';
unionid = p.unionid || '';
}
} catch (e) {
console.warn('[auth-flow] JWT 解码 openid 失败:', e.message);
}
}
// 拿到 openid 后同步到 store/缓存(避免后续重复请求)
if (openid) {
try {
store.commit('SET_OPENID', openid);
uni.setStorageSync('OPENID', openid);
} catch (e) {}
if (unionid) {
try {
store.commit('SET_UNIONID', unionid);
uni.setStorageSync('UNIONID', unionid);
} catch (e) {}
}
}
console.log('[auth-flow] silenceAuthNoLogin 响应: status=' + res.status + ', has openid=' + !!openid + ', auth_login=' + data.auth_login);
if (openid) {
console.log('[auth-flow] ✅ 拿到 openid=' + openid.substring(0, 8) + '...,注入 URL');
this._applyIdentity(targetUrl, openid, unionid, getUrlParam(targetUrl, 'openid'));
} else {
console.warn('[auth-flow] ⚠️ silenceAuthNoLogin 未返回 openid,匿名加载 H5');
this._finalizeUrl(targetUrl);
}
}).catch(err => {
console.warn('[auth-flow] ⚠️ silenceAuthNoLogin 网络失败,匿名加载 H5:', err);
this._finalizeUrl(targetUrl);
});
}).catch(err => {
console.warn('[auth-flow] ⚠️ import api 失败,匿名加载 H5:', err);
this._finalizeUrl(targetUrl);
});
// #endif
},
fail: (err) => {
console.warn('[auth-flow] ⚠️ wx.login 失败,匿名加载 H5:', err);
this._finalizeUrl(targetUrl);
_ensureIdentityThenLoad(targetUrl) {
this._pendingTarget = targetUrl || this._pendingTarget || '';
const t = this._pendingTarget;
if (!t) return;
/* 1) 缓存命中(含 App.vue 并行登录刚落地的)→ 0 等待注入并把 H5 装进 src */
if (this._loadWithCachedIdentity(t)) return;
const seq = this._idAttempts; // 0 = 还没取过
this._idAttempts = seq + 1;
const MAX = 2; // ⚠️ 网络尝试上限 2 次(原 3 次)
this.identityPending = true;
this.identityFail = false;
console.log('[auth-flow] 🔑 容器页取 openid(第 ' + this._idAttempts + '/' + MAX + ' 次),此期间不加载 H5');
/* 2) 第 1 次先等 App.vue onLaunch 那次静默登录(同一次 exchange,别重复换 code);
之后的重试才自己 wx.login + 换 openid。理由与首页壳一致,见 mixins 的 _awaitHostLogin。 */
const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid();
task.then(() => {
if (this._loadWithCachedIdentity(t)) return;
if (this._idAttempts < MAX) {
setTimeout(() => this._ensureIdentityThenLoad(t), 500); // 退避固定 500ms(原 600×n)
return;
}
this.identityPending = false;
this.identityFail = true;
console.warn('[auth-flow] ⚠️ ' + MAX + ' 次仍未拿到 openid:停在兜底屏等待重试,**不匿名加载**');
});
},
/* 有缓存身份就直接注入并加载;返回 true 表示已发起(调用方别再取) */
_loadWithCachedIdentity(t) {
const cached = getCurrentUser();
if (!cached.openid) return false;
this.identityPending = false;
this.identityFail = false;
this._idAttempts = 0;
this._applyIdentity(t, cached.openid, cached.unionid, getUrlParam(t, 'openid'));
return true;
},
/* 兜底屏的「重新获取身份」:重置计数再走一遍(与首页壳 retryLoad 同一套行为) */
retryIdentity() {
this._idAttempts = 0;
this.identityFail = false;
this._ensureIdentityThenLoad(this._pendingTarget || this.savedUrl || this.url);
},
_applyIdentity(targetUrl, openid, unionid, urlOpenid) {
console.log('[auth-flow] _applyIdentity: openid=' + (openid || '(空)') + ', unionid=' + (unionid || '(空)') + ', urlOpenid=' + (urlOpenid || '(无)'));
@@ -444,6 +454,16 @@ export default {
// #endif
this.url = targetUrl;
console.log('[enter-app] ✅ 容器页开始加载 H5 应用: ' + targetUrl.split('#')[0]);
/* 宿主恢复:把「这次真正进入的 H5 应用」记到 box_app(/api/mp/public/last-app)。
覆盖商城宫格 / 分享落地 / 朋友圈 / 太阳码 / JumpPath 这些**不经过 mama-box 宫格**
的入口;经过 mama-box 宫格的由 H5 自己 sendBeacon 上报
(见 mama-box js/app.js → reportLastApp)。冷启动恢复由 mama-box 首屏完成。
⚠️ 只上报净地址(stripAuthParams):恢复时身份必须按**当前**用户重新注入。
⚠️ 失败静默:这是旁路能力,绝不影响 H5 加载。 */
this._reportLastApp(targetUrl);
try {
const res = uni.getWindowInfo();
this.windowW = res.windowWidth;
@@ -456,217 +476,104 @@ export default {
this._fetchShareImage(targetUrl);
},
/**
* 根据当前 H5 URL 反查 box_app 应用,获取分享图片(shareImageUrl)
* URL 形如 https://mp.yizhezhe.com/app_03/ 或 https://mp.yizhezhe.com/test_app/?ch=YFMM
* 通过 mp/apps 列表匹配 url 前缀,找到对应应用后取 shareImageUrl
* 命中本地缓存直接复用(进入页面后立刻分享也能带图),避免每次重复反查
* @param {string} targetUrl
*/
_fetchShareImage(targetUrl) {
if (!targetUrl) return;
// H5 已通过 postMessage 设置分享图片,不覆盖
if (this.shareImageUrl) return;
// 页面内只请求一次,避免重复拉取
if (this._shareFetched) return;
this._shareFetched = true;
/* ---------- 宿主恢复:上报「本次进入的 H5 应用」(2026-09-22) ----------
box_app /api/mp/public/last-app 是 Redis 里的 per-openid 记录(7 天 TTL),
冷启动时由 mama-box 首屏读取并 location.replace 恢复(见 mama-box js/app.js resumeLastApp)。
本页负责**非 mama-box 宫格**的入口:商城宫格 / 分享落地 / 朋友圈 / 太阳码 / JumpPath。
⚠️ 传净地址:恢复时身份按当前用户重新注入,不能把记录时的 openid 带回去。
⚠️ 失败静默:旁路能力,绝不影响 H5 加载。 */
_reportLastApp(targetUrl) {
try {
const originPath = targetUrl.split('?')[0].replace(/\/$/, '');
// 本地缓存:同一应用 URL 二次进入直接复用分享图,降低首帧分享无图概率
const cacheKey = 'web_view_share_img:' + originPath;
let cached = '';
try { cached = uni.getStorageSync(cacheKey); } catch (e) {}
if (cached) {
this._applyShareImage(cached);
console.log('[web_view] 分享图片(缓存命中): ' + cached);
const openid = getOpenid();
if (!openid || !targetUrl) {
console.log('[enter-app] ○ 容器页跳过 last-app 上报(openid=' + (openid ? '有' : '空') +
', url=' + (targetUrl ? '有' : '空') + ')');
return;
}
const clean = stripAuthParams(targetUrl);
if (!clean) return;
let api = 'https://mp.yizhezhe.com/api/mp/public/last-app?openid=' + encodeURIComponent(openid) +
'&url=' + encodeURIComponent(clean);
const id = this._lookupAppIdentifier(clean);
if (id) api += '&identifier=' + encodeURIComponent(id);
console.log('[enter-app] 容器页上报 last-app: url=' + clean + (id ? ' identifier=' + id : ''));
uni.request({
url: 'https://mp.yizhezhe.com/api/mp/apps?isPublished=1&pageSize=100',
method: 'GET',
url: api,
method: 'POST',
timeout: 5000,
success: (res) => {
try {
const items = res.data && res.data.result && res.data.result.items;
if (!items || !items.length) return;
const matched = items.find(it => {
if (!it.url) return false;
const appBase = it.url.split('?')[0].replace(/\/$/, '');
// 当前 URL 与应用的 url 前缀匹配(如 https://mp.yizhezhe.com/app_03 == https://mp.yizhezhe.com/app_03/)
return originPath === appBase || originPath.indexOf(appBase + '/') === 0;
});
if (matched && matched.shareImageUrl) {
try { uni.setStorageSync(cacheKey, matched.shareImageUrl); } catch (e) {}
this._applyShareImage(matched.shareImageUrl);
console.log('[web_view] 分享图片(兜底): ' + matched.shareImageUrl);
}
} catch (e) {
console.warn('[web_view] 分享图片解析失败:', e);
}
console.log('[enter-app] 容器页 last-app 上报结果: HTTP ' + ((res && res.statusCode) || '?') +
' body=' + JSON.stringify(res && res.data));
},
fail: (err) => {
// 常见原因:mp.yizhezhe.com 未加入小程序后台 request/downloadFile 合法域名白名单
console.warn('[web_view] 分享图片请求失败(请检查 mp.yizhezhe.com 是否已加入合法域名白名单):', err);
console.log('[enter-app] ✗ 容器页 last-app 上报失败: ' + JSON.stringify(err));
}
});
} catch (e) {
console.warn('[web_view] _fetchShareImage 异常:', e);
console.warn('[resume] 上报 last_app 失败(忽略):', e);
}
},
/**
* 统一设置分享图:同时准备好友转发图(shareImageUrl)与朋友圈本地图(shareTimelineImage)
* @param {string} url 网络图片地址(box_app 返回或 H5 postMessage 传入)
*/
_applyShareImage(url) {
if (!url) return;
if (url === this.shareImageUrl) return; // 同一张图不重复处理(H5 可能重复 postMessage)
this.shareImageUrl = url;
this._prepareTimelineImage(url);
},
/**
* 朋友圈分享图必须是本地路径/代码包路径(网络图会被微信静默忽略),
* 这里用 getImageInfo 把网络图转成本地临时路径;失败则留空,回退微信默认截图。
* 注意:getImageInfo 对网络图同样受 downloadFile 合法域名限制,域名未配置时朋友圈无自定义图。
* @param {string} url 网络图片地址
*/
_prepareTimelineImage(url) {
if (!url) return;
uni.getImageInfo({
src: url,
success: (res) => {
this.shareTimelineImage = res.path || '';
console.log('[web_view] 朋友圈分享图已转本地:', this.shareTimelineImage);
},
fail: (err) => {
console.warn('[web_view] 朋友圈分享图转本地失败(网络图/域名问题,回退默认截图):', err);
this.shareTimelineImage = '';
}
});
/* 在内存应用表里按 URL 前缀反查 identifier(与 mixins/shareCard.js 的匹配同口径);
表还没回来时返回空 —— identifier 是可选参数,服务端会按 URL 兜底校验。 */
_lookupAppIdentifier(targetUrl) {
const base = this._shareUrlBase(targetUrl);
if (!base) return '';
const apps = this.shareApps || [];
for (let i = 0; i < apps.length; i++) {
const a = apps[i];
if (!a || !a.url || !a.identifier) continue;
const ab = this._shareUrlBase(a.url);
if (ab && (base === ab || base.indexOf(ab + '/') === 0)) return a.identifier;
}
return '';
},
/* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 mixins/shareCard.js 提供:
_fetchShareImage(targetUrl) —— 本页 onLoad 兜底(按当前 H5 URL 反查 box_app
App.shareImageUrl;太阳码场景 _resolveAppScene 已直接设好)
_applyShareImage(url) —— H5 postMessage({action:'share', imageUrl}) 覆盖
_lookupShareImage(url) —— 同步匹配(先内存应用表,再单应用缓存兜底)
首页壳 pages/index/index.vue 复用同一 mixin(它不会随应用切换重新 onLoad,
必须在分享那一刻按 options.webViewUrl 同步匹配)。 */
},
onShareAppMessage() {
onShareAppMessage(options) {
// ⚠️ 深链以微信回调里的 options.webViewUrl 为准(= 当前 web-view 真实地址,
// 含 H5 的 SPA 路由);this.shareHref(H5 postMessage)/ this.url(进入时的地址)
// 都可能过期或落后,仅作兜底。
const target = (options && options.webViewUrl) || this.shareHref || this.url;
// 应用切换检测 + 按当前地址重新反查分享图/标题(H5 postMessage 覆盖优先)
this.resolveShareCard(target);
return {
// 标题来源优先级:H5 postMessage 上报 → box_app 反查(后台「分享标题」,留空 = 应用名称,
// 由 resolveShareCard 写进 shareTitle)→ 兜底文案。
title: this.shareTitle || '分享',
// 分享出去必须剥离 openid/unionid/t 身份参数:
// 分享出去必须剥离 openid/unionid/t 等身份参数:
// 1) 避免把分享者的微信匿名标识泄露给接收者;
// 2) 接收者打开后由 web_view 按自己的身份重新注入(onShow/onLoad 有兜底替换)。
path: '/pages/annex/web_view/index?url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
path: '/pages/annex/web_view/index?url=' + encodeURIComponent(stripAuthParams(target)),
imageUrl: this.shareImageUrl || ''
}
},
onShareTimeline() {
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底。
// 同一地址也解析一次:首次分享就走朋友圈时,标题/图不能还是空的。
const target = this.shareHref || this.url;
this.resolveShareCard(target);
return {
title: this.shareTitle || '分享',
query: 'url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
query: 'url=' + encodeURIComponent(stripAuthParams(target)),
// 朋友圈分享图必须是本地路径/代码包路径,网络图会被微信静默忽略;
// 网络图已在 _prepareTimelineImage 中通过 getImageInfo 转成本地临时路径。
// 网络图已在 _prepareTimelineImage 中通过 getImageInfo 转成临时路径。
imageUrl: this.shareTimelineImage || ''
}
},
handleMessage(e) {
const msgs = e.detail && e.detail.data ? e.detail.data : [];
const data = msgs[msgs.length - 1] || null;
if (!data) return
/* webviewHost hook:本页 web-view 地址字段是 url */
_hostSetWebviewUrl(url) { this.url = url; },
/* webviewHost hook:copyLink 未带 href 时的兜底地址 */
_hostCurrentUrl() { return this.url || ''; },
switch (data.action) {
case 'share':
// 存储分享数据,供 onShareAppMessage / onShareTimeline 使用
// title/imageUrl/href 各自独立处理,避免单个字段缺失导致整包分享数据被丢弃
if (data.title) {
this.shareTitle = data.title;
this.shareSummary = data.summary || '';
}
// H5 传了 imageUrl 才覆盖(否则保留 box_app 预取的分享图片)
if (data.imageUrl) {
this._applyShareImage(data.imageUrl);
}
if (data.href) {
this.shareHref = data.href;
}
// 小程序中无法用代码触发分享面板,引导用户点击右上角 ··· 按钮
uni.showToast({ title: '点击右上角 ··· 分享给好友', icon: 'none', duration: 2500 });
break
case 'copyLink':
const link = data.href || this.url
uni.setClipboardData({
data: link,
success() { uni.showToast({ title: '链接已复制', icon: 'success' }) },
fail(err) {
console.warn('[web_view] 复制失败:', err)
uni.showToast({ title: '复制失败', icon: 'none' })
}
})
break
case 'requestProfile':
// 任意 H5 应用请求授权 → 弹共享原生授权面板(手机号/昵称/头像)
this.showAuth = true;
break
case 'setTitle':
if (data.title) {
this.shareTitle = data.title;
uni.setNavigationBarTitle({ title: data.title })
}
break
}
},
/* ---------- 共享授权面板(任意 H5 应用 requestProfile → 弹面板 → box_app /auth/profile) ---------- */
onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl;
if (!avatarUrl) return uni.showToast({ title: '未获取到头像', icon: 'none' });
// 上传到 box_app 公开接口:授权链路不依赖 CRMEB 登录态(退出登录后 uploadImgs 会 401 弹「请登录」)
uni.uploadFile({
url: 'https://mp.yizhezhe.com/api/upload',
filePath: avatarUrl,
name: 'file',
success: (res) => {
try {
const d = JSON.parse(res.data);
if (d.code === 200 && d.result && d.result.url) this.authAvatar = d.result.url;
else uni.showToast({ title: '头像上传失败', icon: 'none' });
} catch (e) { uni.showToast({ title: '头像上传失败', icon: 'none' }); }
},
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' })
});
},
onGetPhone(e) {
if (e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) { this._authCode = e.detail.code; }
else { uni.showToast({ title: '未授权手机号', icon: 'none' }); }
},
submitAuth() {
if (!this.authNickname && !this.authAvatar && !this._authCode) {
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
}
const openid = getOpenid();
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
const api = 'https://mp.yizhezhe.com/api/mp/public/auth/profile?openid=' + encodeURIComponent(openid) +
(this._authCode ? '&code=' + encodeURIComponent(this._authCode) : '') +
(this.authNickname ? '&nickname=' + encodeURIComponent(this.authNickname) : '') +
(this.authAvatar ? '&avatar=' + encodeURIComponent(this.authAvatar) : '');
uni.request({ url: api, method: 'POST', timeout: 10000,
success: (r) => {
const d = r.data || {};
if (d.code === 200) {
const res = d.result || {};
this.showAuth = false; this._authCode = '';
// 更新 web-view src(保留 query + 追加 hash 结果)→ H5 重载读取
const base = this.url.split('#')[0];
this.url = base + (base.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now() +
'#openid=' + encodeURIComponent(openid) +
'&phone=' + encodeURIComponent(res.phone || '') +
'&nickname=' + encodeURIComponent(res.userName || this.authNickname || '') +
'&avatar=' + encodeURIComponent(res.avatar || this.authAvatar || '');
} else {
uni.showToast({ title: d.message || '授权保存失败', icon: 'none' });
}
},
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
});
},
closeAuth() { this.showAuth = false; },
noop() {},
/* handleHostMessage / onChooseHostAvatar / onHostGetPhone / submitHostAuth(url) /
consumeAuthResult(见 onShow) 由 mixins/webviewHost.js 统一提供 —— 与首页壳共用一套。 */
}
</script>
@@ -684,4 +591,13 @@ export default {
.mb-auth-cancel, .mb-auth-ok { flex: 1; height: 44px; line-height: 44px; text-align: center; border-radius: 12px; font-size: 14px; font-weight: 600; }
.mb-auth-cancel { background: #F1E6E0; color: #6B615B; }
.mb-auth-ok { background: #F4593C; color: #fff; }
/* ---- 身份未就绪兜底屏(2026-09-20)----
本页以前 url 为空时是白屏;现在「拿不到 openid 不加载」,必须给一块可见的屏。 */
.wv-fallback { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 70vh; padding: 0 40rpx; }
.wv-emoji { font-size: 64rpx; line-height: 1; }
.wv-title { margin-top: 24rpx; font-size: 32rpx; font-weight: 700; color: #2B2320; }
.wv-desc { margin-top: 12rpx; font-size: 26rpx; color: #A79C95; line-height: 1.6; text-align: center; }
.wv-retry { margin-top: 36rpx; padding: 0 48rpx; height: 80rpx; line-height: 80rpx; border-radius: 40rpx; background: #F4593C; color: #fff; font-size: 28rpx; }
.wv-retry-hover { opacity: .85; }
</style>

194
pages/debug_union/index.vue Normal file
View File

@@ -0,0 +1,194 @@
<template>
<view class="debug-page">
<view class="card">
<view class="title">UnionID 调试 · 获取 wx.login code</view>
<view class="desc">
点下方按钮执行 uni.login(),拿到 code 后:<br />
① 页面直接显示 code(长按可复制)<br />
② 同时 console.log 输出,方便开发者工具里复制<br />
③ 可选:直接调本地 code2session 接口
</view>
<button class="btn" type="primary" @click="getCode">1. 获取 code</button>
<button class="btn" type="warn" @click="copyCode" :disabled="!code">2. 复制 code</button>
<view v-if="code" class="code-box">
<view class="label">code(5 分钟有效,一次性):</view>
<view class="code" @longpress="copyCode">{{ code }}</view>
</view>
<view class="section">
<view class="label">可选:本地 code2session 接口地址(内网穿透公网地址)</view>
<input class="input" v-model="endpoint" placeholder="http://你的公网地址:8090/api/union/code2session" />
<button class="btn" type="default" @click="sendToLocal" :disabled="!code">3. 发送到本地接口</button>
</view>
<view v-if="result" class="result">
<view class="label">接口返回:</view>
<view class="code">{{ result }}</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
code: '',
endpoint: '',
result: ''
}
},
onLoad() {},
methods: {
getCode() {
this.code = ''
this.result = ''
uni.showLoading({
title: '获取中'
})
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
timeout: 10000,
success: (res) => {
uni.hideLoading()
this.code = res.code || ''
console.log('[debug-union] MY_CODE=', this.code)
uni.showToast({
title: '已获取 code',
icon: 'success'
})
},
fail: (err) => {
uni.hideLoading()
console.error('[debug-union] wx.login fail', err)
uni.showToast({
title: '获取失败',
icon: 'none'
})
}
})
// #endif
// #ifndef MP-WEIXIN
uni.hideLoading()
uni.showToast({
title: '请在微信小程序环境运行',
icon: 'none'
})
// #endif
},
copyCode() {
if (!this.code) return
uni.setClipboardData({
data: this.code,
success: () => {
uni.showToast({
title: '已复制',
icon: 'success'
})
}
})
},
sendToLocal() {
if (!this.code) {
uni.showToast({
title: '请先获取 code',
icon: 'none'
})
return
}
if (!this.endpoint) {
uni.showToast({
title: '请填接口地址',
icon: 'none'
})
return
}
uni.request({
url: this.endpoint,
method: 'POST',
data: {
js_code: this.code
},
header: {
'content-type': 'application/json'
},
success: (res) => {
this.result = JSON.stringify(res.data, null, 2)
console.log('[debug-union] response=', this.result)
},
fail: (err) => {
this.result = '请求失败: ' + JSON.stringify(err)
console.error('[debug-union] request fail', err)
}
})
}
}
}
</script>
<style scoped>
.debug-page {
padding: 24rpx;
background: #f7f8fa;
min-height: 100vh;
}
.card {
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
}
.title {
font-size: 34rpx;
font-weight: 600;
margin-bottom: 16rpx;
}
.desc {
font-size: 26rpx;
color: #666;
line-height: 1.7;
margin-bottom: 24rpx;
}
.btn {
margin-bottom: 20rpx;
}
.section {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #eee;
}
.label {
font-size: 26rpx;
color: #333;
margin-bottom: 12rpx;
}
.input {
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 16rpx;
font-size: 26rpx;
margin-bottom: 16rpx;
width: 100%;
box-sizing: border-box;
}
.code-box {
margin-top: 20rpx;
background: #f0f5ff;
border-radius: 12rpx;
padding: 20rpx;
}
.code {
font-size: 24rpx;
word-break: break-all;
color: #1a1a1a;
user-select: text;
}
.result {
margin-top: 20rpx;
background: #fffbe6;
border-radius: 12rpx;
padding: 20rpx;
}
</style>

View File

@@ -1,129 +0,0 @@
<template>
<view class="skeleton">
<!-- #ifdef MP || APP -->
<view class="blank"></view>
<!-- #endif -->
<view class="header acea-row row-between-wrapper">
<view class="left"></view>
<view class="right"></view>
</view>
<view class="tabNav acea-row row-between-wrapper">
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
</view>
<view class="swipers"></view>
<view class="nav acea-row row-between-wrapper">
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
</view>
<view class="nav acea-row row-between-wrapper">
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
<view class="item"></view>
</view>
<view class="header acea-row row-between-wrapper">
<view class="left"></view>
<view class="right"></view>
</view>
<view class="coupon acea-row row-between-wrapper">
<view class="item"></view>
<view class="item"></view>
</view>
<view class="goodList">
<view class="item acea-row row-between row-top">
<view class="pictrue"></view>
<view class="text">
<view class="name"></view>
<view class="money"></view>
</view>
</view>
</view>
</view>
</template>
<script>
</script>
<style lang="scss">
.skeleton{
padding: 0 30rpx;
.blank{
width: 100%;
height: 150rpx;
background-color: #F5F5F5;
}
.header{
.left{
width: 200rpx;
height: 50rpx;
background-color: #e9e9e9;
}
.right{
width: 460rpx;
height: 50rpx;
background-color: #e9e9e9;
}
}
.tabNav{
margin-top: 20rpx;
.item{
width: 150rpx;
height: 50rpx;
background-color: #e9e9e9;
}
}
.swipers{
margin-top: 20rpx;
width: 100%;
height: 400rpx;
background-color: #e9e9e9;
}
.nav{
margin: 20rpx 0;
.item{
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background-color: #e9e9e9;
}
}
.coupon{
margin-top: 20rpx;
.item{
width: 330rpx;
height: 120rpx;
background-color: #e9e9e9;
}
}
.goodList{
margin-top: 20rpx;
.item{
.pictrue{
width: 220rpx;
height: 220rpx;
background-color: #e9e9e9;
}
.text{
.name{
width: 440rpx;
height: 76rpx;
background-color: #e9e9e9;
}
.money{
width: 100rpx;
height: 34rpx;
background-color: #e9e9e9;
margin-top: 10rpx;
}
}
}
}
}
</style>

View File

@@ -5,11 +5,12 @@
本页替代了 CRMEB 原装修首页(原文件完整备份在同目录
index.vue.origin-bak,恢复方法见本文件底部注释)。
链路(2026-09 起:mama-box 不再是 box_app 的「应用」,URL 硬编码):
onLoad → loadMamaBox()
→ webUrl = https://mp.yizhezhe.com/mama-box/ + #openid=xxx(buildWebUrl)
链路(2026-10-08 起:首页地址后台可定制,不再写死):
onLoad → _homeUrlReady()(拉 box_app GET /api/mp/public/home/config;
本地缓存兜底,都没有才用内置默认 MAMA_HOME)
→ loadMamaBox() → webUrl = 生效首页地址(默认 /mama-box/)+ #openid=xxx(buildWebUrl)
→ <web-view> 加载该 H5
(更新首页 = 直接改服务端 box_app/mama-box/ 目录,无需发版即可刷新内容)
(更新首页内容 = 改服务端对应 H5 目录;换首页地址 = 管理后台「首页配置」,均无需发版)
两个坑(都来自 app_03 前一次接入的实测结论):
1. 微信 web-view 首次加载会丢弃 URL 的 query string(?a=b),
@@ -21,14 +22,17 @@
<view class="mamabox-home">
<web-view v-if="webUrl" :src="webUrl" @message="handleHostMessage" @error="onWebviewError"></web-view>
<!-- 加载中 / 加载失败兜底(web-view 未就绪或加载出错时才渲染) -->
<!-- 加载中 / 加载失败兜底(web-view 未就绪或加载出错时才渲染)
⚠️ 2026-09-20 起增加「正在获取微信身份」这一态:首页壳**必须拿到 openid 才加载 H5**,
拿不到就重试、并停在这一屏 —— 不再匿名加载(匿名加载会让 H5 进访客态:
孕周 `--`、档案空,用户看到的是「能用但都是空的」,比多等几秒更糟)。 -->
<view v-else class="mamabox-fallback">
<view class="mamabox-emoji">{{ loadError ? '🥲' : '🍼' }}</view>
<view class="mamabox-emoji">{{ fallbackEmoji }}</view>
<view class="mamabox-tip">
<view class="mamabox-title">{{ loadError ? '页面加载失败' : '正在加载' }}</view>
<view class="mamabox-desc">{{ loadError ? '请检查网络后重试' : '正在为你打开「兜爸心选」' }}</view>
<view class="mamabox-title">{{ fallbackTitle }}</view>
<view class="mamabox-desc">{{ fallbackDesc }}</view>
</view>
<button v-if="loadError" class="mamabox-retry" hover-class="mamabox-retry-hover" @tap="retryLoad">重新加载</button>
<button v-if="loadError || identityFail" class="mamabox-retry" hover-class="mamabox-retry-hover" @tap="retryLoad">{{ loadError ? '重新加载' : '重新获取身份' }}</button>
</view>
<!-- 小程序原生授权面板(H5 postMessage 请求后显示):手机号/昵称/头像 -->
@@ -65,8 +69,18 @@
const app = getApp();
// mama-box H5 托管地址(硬编码;更新首页 = 改服务端 box_app/mama-box/ 目录)
// 内置兜底首页地址(2026-10-08 起不再是唯一来源):
// 生效地址 = box_app 后台配置(GET /api/mp/public/home/config)→ 本地缓存 → 这里。
// 管理后台清空配置 = 回到本值;后端 DEFAULT_HOME_URL 与此保持一致。
const MAMA_HOME = 'https://mp.yizhezhe.com/mama-box/';
// 首页地址配置接口(box_app 免登录面;保留名见 docs/api-naming-convention.md 第四节)
const HOME_CONFIG_API = 'https://mp.yizhezhe.com/api/mp/public/home/config';
// 生效首页地址的本地缓存(离线/弱网时用上次拿到的地址,不回退默认)
const HOME_URL_CACHE_KEY = 'MP_HOME_URL_CACHE';
// 与后端校验同口径:只要 https 且无空白;域名合法性由微信「web-view 业务域名」白名单兜底
function isValidHomeUrl(u) {
return typeof u === 'string' && /^https:\/\//i.test(u) && !/\s/.test(u);
}
/* 剥离分享链接里的身份/缓存参数。分享出去的链接必须干净:
① 不能把分享者的 openid/unionid 泄露给接收者;
@@ -103,14 +117,39 @@
name: 'Index',
mixins: [shareCard, webviewHost],
computed: {
...mapGetters(['isLogin', 'uid', 'cartNum'])
...mapGetters(['isLogin', 'uid', 'cartNum']),
/* 兜底屏文案:三种状态(取身份中 / 取身份失败 / 页面加载失败)分开说,
别让用户对着「正在加载」干等却不知道在等什么。 */
fallbackTitle() {
if (this.loadError) return '页面加载失败';
if (this.identityFail) return '未获取到微信身份';
if (this.identityPending) return '正在获取微信身份';
return '正在加载';
},
fallbackDesc() {
if (this.loadError) return '请检查网络后重试';
if (this.identityFail) return '请检查网络后点下方重试,或退出小程序重新进入';
if (this.identityPending) return '马上就好,请稍候(不会加载空数据的页面)';
if (this.restoring) return '正在回到你上次使用的应用';
return '正在为你打开「兜爸心选」';
},
fallbackEmoji() {
return (this.loadError || this.identityFail) ? '🥲' : '🍼';
},
},
data() {
return {
webUrl: '', // web-view 地址(硬编码 + #openid/#unionid)
webUrl: '', // web-view 地址(首页地址/当前 H5 + #openid/#unionid)
_homeUrl: '', // 生效首页地址(后台配置;非响应式内部字段,见 _homeUrlReady)
_homeUrlTask: null, // 「拉首页地址配置」的单飞 Promise(一次冷启动只请求一次)
requesting: false, // 防重入
_shownOnce: false, // 首次展示标记(避免首次 onShow 重复加载)
_idAttempts: 0, // 「取到 openid 才加载」的自动重试计数(见 fetchIdentityThenLoad)
loadError: false, // web-view 加载失败(显示「重新加载」)
restoring: false, // 冷启动正在判定/打开「上次进入的 H5 应用」(只影响兜底屏文案)
_noRestore: false, // 本次加载 mama-box 是否要带 #norestore=1(禁止 H5 兜底再把用户送回应用)
identityPending: false, // 正在获取 openid(此期间**不加载 H5**,见 fetchIdentityThenLoad)
identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口
shareInfo: {}, // CRMEB 后台「分享设置」(标题/图,无 App 匹配时兜底)
// 授权面板字段由 mixins/webviewHost.js 提供:
// showAuth / authNickname / authAvatar / _authCode
@@ -119,6 +158,20 @@
};
},
onLoad(options) {
/* 诊断日志(过滤关键字 enter-app):首页壳每次「冷启动」都会打这一行;
如果重进时**看不到它**,说明是热启动(onShow),恢复走的是「保持不动」那条路。 */
try {
console.log('[enter-app] ===== 首页壳 onLoad(冷启动)===== options=' + JSON.stringify(options || {}));
} catch (e) {}
/* 消费「本次冷启动是否允许恢复」标记(App.onLaunch 置位,见其注释)。
放在最前面就消费:后面任何提前 return(商户/管理员跳工作台、显式落地)都不该把标记留给下次。 */
let restoreArmed = false;
try {
restoreArmed = !!(app && app.globalData && app.globalData.restoreArmed);
if (app && app.globalData) app.globalData.restoreArmed = false;
} catch (e) {}
console.log('[enter-app] restoreArmed=' + restoreArmed +
'(true=冷启动落在首页,可恢复;false=显式落地或从应用内回首页,不恢复)');
// #ifdef MP
// 开启「转发给好友 / 分享到朋友圈」两项菜单(onShareTimeline 需要它)
uni.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] });
@@ -129,6 +182,9 @@
this.getTemlIds();
getShare().then(res => { this.shareInfo = (res && res.data) || {}; }).catch(() => {});
// #endif
// 最早发出「首页地址配置」请求(与身份解析并行,通常先于它完成 → 零额外等待)。
// loadMamaBox / retryLoad / onShow 都通过 _homeUrlReady() / _homeBase() 消费它。
this._homeUrlReady();
// 预热 box_app 全量应用表(url → shareImageUrl)。
// 本页不会随 H5 应用切换重新 onLoad(应用宫格用 location.href 换页),
// 所以分享图只能在「点转发那一刻」按 options.webViewUrl 同步匹配,
@@ -151,21 +207,65 @@
if (options && typeof options.url === 'string' && options.url) {
let target = options.url;
try { target = decodeURIComponent(target); } catch (e) {}
this.loadMamaBox(); // 保留首页作为返回落点
console.log('[enter-app] 显式落地(options.url,分享/朋友圈)→ 直接进容器页: ' + target +
'(优先级高于「恢复上次应用」)');
this.loadMamaBox(true); // 保留首页作为返回落点;带 #norestore,避免 H5 兜底把落点换成别的应用
this.$nextTick(() => {
uni.navigateTo({ url: '/pages/annex/web_view/index?url=' + encodeURIComponent(target) });
});
return;
}
// ✅ 实测结论(微信开发者工具):首页的 <web-view> 是原生全屏组件,
// 会**盖住**原生 tabBar,所以首页只显示 H5 内部导航(无双条);
// 切到商城原生页(分类/购物车/我的)时 tabBar 正常显示,点「首页」tab 可回 H5。
// ⇒ 无需 uni.hideTabBar()(隐藏了反而让商城 tab 根页变死胡同),保持现状即可。
this.loadMamaBox();
// ⚠️ 首页必须主动隐藏原生 tabBar(2026-09-18 定论,推翻下面旧结论):
// 真机上 web-view **盖不住**原生 tabBar —— 旧结论是在 custom:true、
// 原生条根本不渲染的时期测的。H5 自带底部导航 + 原生 tabBar = 双条。
// hideTabBar 是全局状态:商城各 tab 页都挂着 pageFooter,由它按后台
// 「装修→底部导航」配置自管显隐(启用=hideTabBar+渲染 DIY 底栏,
// 禁用=showTabBar 露出原生条兜底),所以这里隐藏不会让商城页变死胡同。
// ⚠️ 但严禁在商城页主动 showTabBar 来「配对」——上一轮翻车根因
// (showTabBar + DIY 底栏同显 = 商品列表双 tabBar,见 onShow 旧警告)。
uni.hideTabBar();
/* 冷启动恢复(2026-09-22,档 3):先问 box_app「上次进入的 H5 应用」,
命中就直接把容器页压上去 —— **不加载 mama-box,因此没有首页闪屏**。
未命中 / 取数失败 / 无身份 → 回落到原路径(加载 mama-box)。
⚠️ 只有 options.url(分享/朋友圈落地)算显式落地并已在上面 return;
options.scene **每次冷启动都有值**(1001 最近使用 / 1007 分享 / 1011 扫码…),
不能拿它当「显式落地」判据,否则永远不恢复。 */
/* 只有「冷启动落在首页/引导页且无显式落地」才尝试恢复(restoreArmed 已在 onLoad 开头消费)。
扫码/分享进容器页、或从应用内点左上角「小房子」回首页时 armed=false → 不恢复,并且加载
mama-box 时带 #norestore=1(否则它的兜底恢复又会把用户送回刚才那个应用,出现
「点小房子回不了首页、要多点一次返回」的循环;2026-09-22 用户实测)。 */
if (!restoreArmed) {
console.log('[enter-app] 本次不是「冷启动落在首页」(显式落地 / 从应用内回首页)→ 不恢复,' +
'直接进首页(mama-box 带 #norestore=1)');
this.loadMamaBox(true);
return;
}
console.log('[enter-app] 非显式落地 → 尝试恢复「上次进入的 H5 应用」…');
this.restoreOrLoadHome();
},
onShow() {
// 通知自定义 tabBar 按当前路由刷新(首页=web-view,不显示 tabBar)
uni.$emit('tabbar-update');
/* 是否「小程序热启动恢复」(点胶囊关闭后再进 / 切后台再回),而不是从其它 tab 切回首页:
App.onShow 刚触发过 → 页面栈与 web-view 都还在,用户仍停在离开时的那个 H5 应用里。
⚠️ 这种情况**必须跳过下面的「重载 webview」**,否则会把用户从 H5 应用踢回兜爸心选首页
—— 2026-09-22 线上问题根因:热启动不触发 onLoad(恢复逻辑没机会跑),
却触发了这里的重载;而 shareHref 仍是 mama-box(H5 postMessage 被微信攒着没派发),
于是重载到首页,mama-box 的兜底又被 sessionStorage 闸门挡住 → 停在首页。
⚠️ 切 tab 不触发 App.onShow,所以那个场景仍按原行为重载回首页。 */
let isAppResume = false;
try {
const at = (app && app.globalData && app.globalData.appShowAt) || 0;
isAppResume = at > 0 && (Date.now() - at) < 2000;
} catch (e) {}
/* 诊断日志(过滤关键字 enter-app):这一行是「热启动还是切 tab」的唯一判据来源。
isAppResume=true → 热启动恢复(点胶囊关闭后再进):保持当前 H5 不动,不重载;
isAppResume=false → 从其它 tab 切回首页:按原行为重载 web-view 回首页。 */
console.log('[enter-app] 首页壳 onShow: isAppResume=' + isAppResume +
'(_shownOnce=' + this._shownOnce + ', webUrl=' + (this.webUrl ? '有' : '空') +
', shareHref=' + (this.shareHref || '空') + ')');
// 每次回到首页都再压一次 tabBar:hideTabBar 是全局状态,可能被别处
// showTabBar 抬回来(如后台停用 DIY 底部导航时商城页会露出原生条兜底)。
// (原这行的 uni.$emit('tabbar-update') 随 custom-tab-bar 死代码一并移除)
uni.hideTabBar();
// 0) 独立授权页(pages/annex/auth/index)返回:读取 __AUTH_RESULT__,
// 把手机号/昵称/头像写回 H5 URL 的 hash(授权结果只走 hash)。
// ⚠️ 必须在下面「重载 webview」之前处理,否则结果会被 ?t= 重载冲掉。
@@ -175,27 +275,49 @@
// 目标 H5 永远收不到 phone(2026-09-18 孕检日历授权弹层复现根因之一)。
// shareHref 由 H5 换页/发起授权前的 location 上报实时刷新(askProfile
// 会先 reportLocation 再 navigateTo),是「当前 H5」最可靠的来源。
const authBase = (this.shareHref || this.webUrl || MAMA_HOME).split('#')[0];
const authBase = (this.shareHref || this.webUrl || this._homeBase()).split('#')[0];
const authUrl = this.consumeAuthResult(authBase);
if (authUrl) {
this.loadError = false;
this._shownOnce = true;
this.webUrl = authUrl;
} else if (this._shownOnce && this.webUrl) {
} else if (this._shownOnce && this.webUrl && !isAppResume) {
// ⚠️ 从商城/其它 tab 切回首页时:webview 是同一个实例,SPA 会停留在切走前的屏。
// 改为「重载 webview」让 H5 落回首页(H5 默认即是首页)。首次展示不重复刷。
// 同理不能写死 MAMA_HOME —— 用户停在宫格切换的应用里时会被踢回 mama-box;
// 以 shareHref(当前 H5)为基址重载,停留在原应用首页。
// ⚠️ 热启动恢复(isAppResume)走不到这里:见 onShow 开头的说明。
this.loadError = false;
const reloadBase = (this.shareHref || MAMA_HOME).split('#')[0];
this.webUrl = this.buildWebUrl(reloadBase + '?t=' + Date.now());
const reloadBase = (this.shareHref || this._homeBase()).split('#')[0];
console.log('[enter-app] onShow: 非热启动(切 tab 回首页)→ 重载 web-view,基址=' + reloadBase);
// 宿主自己把首页重载回首页 H5 时,同步清掉 last_app(人已经回到首页了)。
// 覆盖「容器页返回箭头回首页」这类不经过 H5 后退的路径 —— H5 侧 clearLastAppOnBackHome()
// 只认浏览器后退(back_forward/pageshow.persisted)或 referrer,宿主 src 重载不一定带得上 referrer。
// ⚠️ 2026-10-08 起首页地址可定制:不能再写死判断 '/mama-box',要按生效首页地址比较
// (否则定制首页后回首页不清 last_app,下次冷启动会被送回旧应用)。
if (this._isHomeBase(reloadBase)) this._clearLastApp();
if (this._hostOpenid()) {
// ⚠️ 回首页 tab 的重载一律带 #norestore=1:这是「用户要回首页」,
// 不能让 mama-box 的兜底恢复又把他送回上次那个应用(见 onLoad 的入口判定)。
this.webUrl = this.buildWebUrl(reloadBase + '?t=' + Date.now(), ['norestore=1']);
} else {
/* ⚠️ 仍无 openid:走同一条「取到才加载」的路(自动重试 3 次 → 兜底屏),
不再匿名重载(原因见 fetchIdentityThenLoad 的注释)。 */
console.log('[auth-flow] 🔄 onShow: 仍无 openid,走「取到才加载」');
this._idAttempts = 0;
this._noRestore = true; // 同上:回首页 tab 不许 mama-box 兜底恢复
this.fetchIdentityThenLoad(reloadBase);
}
} else {
if (isAppResume) {
console.log('[enter-app] onShow: 热启动恢复 → 保持当前 web-view 不重载(用户仍停在上次的 H5 应用里)');
}
this._shownOnce = true;
}
// ⚠️ 实测:home/商城 之间 programmatic 切换 tabBar(hide/show) 会引发
// 「进入商城跳一下 + 商品列表双 tabBar」两个副作用。已回退该方案。
// 首页的底部导航由 web-view 自身决定(多数版本会盖住原生 tabBar,
// 首页只显示 H5 内部导航),不再手动 hideTabBar。
// 历史(勿照旧回退):曾试过「home hide + 商城页 show」双向 programmatic
// 切换,因商城页 showTabBar 与 DIY 底栏同显 →「商品列表双 tabBar」+跳一下,
// 当时整体回退。2026-09-18 起改为单向:只在首页 hideTabBar,商城页显隐
// 完全交给 pageFooter(后台 DIY 配置驱动),不再有任何 showTabBar 配对逻辑。
// 购物车角标(tabBar 索引 3 = 购物车),沿用原首页逻辑
if (this.cartNum > 0) {
uni.setTabBarBadge({
@@ -227,7 +349,7 @@
现在本页 onLoad 预热全量应用表(mixins/shareCard.js),转发时用
options.webViewUrl 同步匹配出图;H5 postMessage 的 imageUrl 有值才覆盖。 */
onShareAppMessage(options) {
const target = (options && options.webViewUrl) || this.shareHref || this.webUrl || MAMA_HOME;
const target = (options && options.webViewUrl) || this.shareHref || this.webUrl || this._homeBase();
// 按「当前 H5 地址」解析分享图:先做应用切换检测(首页壳不随 H5 换页重新 onLoad),
// 再每次重新反查 box_app —— 不能因为 shareImageUrl 已有值就跳过,
// 那是上一个应用的图(A 应用分享过、切到 B 后仍带 A 的图,就是这里出的错)。
@@ -235,6 +357,8 @@
const info = this.shareInfo || {};
const uid = this.uid || 0;
return {
// 标题来源优先级:H5 postMessage 上报 → box_app 反查(后台「分享标题」,留空 = 应用名称,
// 由 resolveShareCard 写进 shareTitle)→ CRMEB 后台分享设置 → 内置兜底。
title: this.shareTitle || info.title || '兜爸心选 · 孕育陪伴',
// spid:恢复原装修首页的分享推广归因(接收者容器页 / App.onShow 读入 globalData.spid)
path: '/pages/annex/web_view/index?url=' + encodeURIComponent(stripAuthParams(target)) + '&spid=' + uid,
@@ -242,8 +366,10 @@
};
},
onShareTimeline() {
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底
const target = this.shareHref || this.webUrl || MAMA_HOME;
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底。
// 同一地址也解析一次:首次分享就走朋友圈时,标题/图不能还是空的。
const target = this.shareHref || this.webUrl || this._homeBase();
this.resolveShareCard(target);
const info = this.shareInfo || {};
const uid = this.uid || 0;
return {
@@ -264,44 +390,290 @@
if (options.spid) app.globalData.spid = options.spid;
},
/**
* 查询 box_app 拿兜爸心选的托管地址。
* box_app 的 App 表是「产品盒子」的应用注册中心 —— 改后台这条记录的
* url 字段即可给 H5 换地址 / 切版本,小程序不用发版。
* 加载首页 H5。
* 2026-10-08 起:首页地址不再写死 —— 先等 _homeUrlReady()(box_app 后台配置,
* 本地缓存兜底,最多 2s),再按「有 openid 直接拼 hash 加载 / 无 openid 走取到才加载」。
*/
loadMamaBox() {
// 2026-09 起:mama-box 不再是 box_app 的「应用」(已从 App 表删除)。
// 它是小程序首页 + H5 入口,URL 在此【硬编码】;
// 更新首页 = 直接改服务端 box_app/mama-box/ 目录,无需发版即可刷新内容。
loadMamaBox(noRestore) {
/* @param {boolean} noRestore - 加载首页时给 URL 加 #norestore=1,禁止首页 H5 自己恢复
(它的兜底恢复会把用户从「用户明确要去的页面」再送回上次那个应用)。两种情况要带:
· 恢复成功后留下的「返回落点」;
· 本次明确判定「不恢复」(显式落地 / 从应用内回首页)。
只有「尝试恢复但取数失败」才不带 —— 那时让首页 H5 兜底重试一次。 */
this._noRestore = !!noRestore;
console.log('[enter-app] 加载首页(返回落点)noRestore=' + this._noRestore);
if (this.requesting) return;
this.requesting = true;
this.loadError = false;
this.webUrl = this.buildWebUrl(MAMA_HOME);
this.requesting = false;
this.restoring = false;
/* ⚠️ 身份兜底(2026-09-20 新增):store.openid / 缓存 OPENID / JWT 三处都读不到时,
**先主动取一次**(wx.login → silence_auth,见 mixin 的 _silenceAuthOpenid),
拿到再拼 hash 加载 —— 别匿名加载。
以前这里直接匿名加载,H5 于是进访客态(孕周 `--`、档案查不到、跳应用不带身份),
而且没人补:根因是新用户/未绑手机号分支的 openid 被 App.vue 丢掉 + 首页壳没有
容器页那样的 openid 兜底(两条都在 2026-09-20 修,现统一叫 _ensureIdentityThenLoad)。
取不到 openid 仍继续加载(保持可用),H5 侧会显示「未获取到微信身份」提示条。 */
this.identityFail = false;
this._homeUrlReady().then((homeUrl) => {
if (this._hostOpenid()) {
this.identityPending = false;
this._idAttempts = 0;
this.webUrl = this.buildWebUrl(homeUrl, this._noRestore ? ['norestore=1'] : []);
this.requesting = false;
return;
}
/* ⚠️⚠️ 拿不到 openid **就不加载**(2026-09-20 用户定的行为):
宁可让用户在这一屏多等几秒(自动重试 3 次),也不匿名加载 H5 ——
匿名加载会让 H5 进访客态(孕周 `--`、档案查不到),
用户看到「页面能用但全是空」比等一会儿更糟,且事后很难判断是身份问题。 */
this._idAttempts = 0;
this.fetchIdentityThenLoad(homeUrl);
this.requesting = false;
});
},
/* ---------- 首页地址:box_app 后台可定制(2026-10-08) ----------
过去 MAMA_HOME 写死在本文件,换首页要发小程序版。现在冷启动拉
GET /api/mp/public/home/config 拿生效地址(管理后台「首页配置」页可改,
homeUrl 清空 = 回内置默认);homeUrlReady 是单飞 Promise,onLoad 最先发出,
loadMamaBox / retryLoad 消费 —— 与身份解析并行,正常零额外等待。
可靠性分层(每一层都不能阻塞首页加载,AGENTS.md §5.6):
① 配置请求失败/超时(2s)→ 用「本地缓存的上次地址」(离线也能进上次的定制首页);
② 本地也没有 → 内置默认 MAMA_HOME;
③ 响应 code!==200(⚠️ 这个后端不存在路由时 HTTP 仍是 200,错误码在 body,见 §5.7)
或 url 非法 → 同②,但**不覆盖**本地缓存(分不清是接口坏了还是后台清空,
宁可沿用旧值,下次冷启动再试);
④ 只有拿到合法地址(含后台清空时下发的默认地址)才写缓存。
⚠️ 生效时机是「下一次冷启动」:已加载的 web-view 不因配置变化主动重载
(热启动不打扰用户,与 onShow 的 isAppResume 原则一致)。 */
_homeUrlReady() {
if (this._homeUrlTask) return this._homeUrlTask;
// 先用本地缓存占位(非响应式内部字段,只给 JS 逻辑用,与 _shownOnce 同款)
try {
const cached = uni.getStorageSync(HOME_URL_CACHE_KEY);
if (cached && isValidHomeUrl(cached.url)) this._homeUrl = cached.url;
} catch (e) {}
this._homeUrlTask = new Promise((resolve) => {
let done = false;
const finish = (url) => {
if (done) return;
done = true;
if (url) {
this._homeUrl = url;
try { uni.setStorageSync(HOME_URL_CACHE_KEY, { url: url, at: Date.now() }); } catch (e) {}
}
resolve(this._homeUrl || MAMA_HOME);
};
const timer = setTimeout(() => {
console.log('[enter-app] ⏱ 首页地址配置超时(2s)→ 按「本地缓存/默认」处理');
finish(null);
}, 2000);
uni.request({
url: HOME_CONFIG_API,
method: 'GET',
timeout: 2000,
success: (res) => {
clearTimeout(timer);
const body = res && res.data;
const url = body && body.code === 200 && body.result ? body.result.url : null;
if (isValidHomeUrl(url)) {
console.log('[enter-app] 首页地址生效: ' + url +
(url === MAMA_HOME ? '(内置默认)' : '(后台定制)'));
finish(url);
} else {
console.log('[enter-app] 首页地址配置无合法值(body=' + JSON.stringify(body) +
') → 沿用「本地缓存/默认」');
finish(null);
}
},
fail: (err) => {
clearTimeout(timer);
console.log('[enter-app] ✗ 首页地址配置请求失败 → 按「本地缓存/默认」处理: ' + JSON.stringify(err));
finish(null);
}
});
});
return this._homeUrlTask;
},
/* 当前生效的首页基址(同步取:后台配置 → 本地缓存 → 内置默认)。
用于分享兜底 / 授权回传落点 / 兜底屏重试这些「拿不到实时值时」的场景。 */
_homeBase() {
return this._homeUrl || MAMA_HOME;
},
/* base 是否就是首页 H5(去 hash/query、忽略末尾斜杠后比较)。
过去写死判断 indexOf('/mama-box');首页可定制后必须按生效地址比较,
否则定制首页时「宿主重载回首页」不会清 last_app(下次冷启动被送回旧应用)。 */
_isHomeBase(base) {
const norm = (u) => String(u || '').split('#')[0].split('?')[0].replace(/\/+$/, '');
return !!base && norm(base) === norm(this._homeBase());
},
/* ---------- 冷启动恢复:直接把容器页指向「上次进入的 H5 应用」(2026-09-22 档 3) ----------
需求:进入某 H5 应用后退出小程序,再进要恢复它。
做法:本页自己问 box_app `GET /api/mp/public/last-app?openid=`(Redis,per-openid,
7 天 TTL);命中就直接 navigateTo 容器页加载目标 —— **不加载 mama-box,无首页闪屏**。
优先级:显式落地(options.url 分享/朋友圈)> 恢复 > 首页。
回落:无记录 / 取数失败 / 超时 / 拿不到 openid → 照旧加载 mama-box
(mama-box 自己的 resumeLastApp 仍是「本页取数失败」时的兜底)。
⚠️ 身份注入交给容器页(它用 query 注入)。本页的 buildWebUrl 是 **hash** 注入,
对自带 hash 路由的应用(如 baobao-yingji)会破坏路由,所以恢复路径不自己拼身份。 */
restoreOrLoadHome() {
const decide = () => {
const openid = this._hostOpenid();
if (!openid) {
console.log('[enter-app] ✗ 身份仍未就绪(无 openid)→ 不查 last-app,交给原路径加载首页');
this.loadMamaBox();
return;
}
console.log('[enter-app] 查 last-app: openid=' + openid);
this._fetchLastApp(openid).then(rec => {
if (rec && rec.url) {
this._openRestoredApp(rec.url);
} else {
console.log('[enter-app] ○ last-app 无记录/未拿到 → 进兜爸心选首页');
this.loadMamaBox();
}
});
};
this.restoring = true;
if (this._hostOpenid()) {
console.log('[enter-app] 恢复判定开始:openid 命中缓存,直接查 last-app');
decide();
return;
}
/* 无缓存身份:先等 App.vue onLaunch 那次静默登录(与 fetchIdentityThenLoad 同款——
同一次 code exchange,不重复换 code),最多 1.5s;仍拿不到就交回原路径。 */
console.log('[enter-app] 恢复判定开始:无缓存 openid,先等 App.vue 静默登录(最多 1.5s)…');
this._awaitHostLogin(1500).then(decide);
},
/* 读一次 last_app;任何失败/超时都 resolve(null),绝不阻塞首页 */
_fetchLastApp(openid) {
return new Promise(resolve => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
const api = 'https://mp.yizhezhe.com/api/mp/public/last-app?openid=' + encodeURIComponent(openid);
const timer = setTimeout(() => {
console.log('[enter-app] ⏱ last-app 请求超时(1.5s)→ 按无记录处理');
finish(null);
}, 1500);
uni.request({
url: api,
method: 'GET',
timeout: 1500,
success: (res) => {
clearTimeout(timer);
const body = res && res.data;
const rec = body && body.result;
console.log('[enter-app] last-app 响应: HTTP ' + ((res && res.statusCode) || '?') +
' body=' + JSON.stringify(body));
finish(rec && rec.url ? rec : null);
},
fail: (err) => {
clearTimeout(timer);
console.log('[enter-app] ✗ last-app 请求失败 → 按无记录处理: ' + JSON.stringify(err));
finish(null);
}
});
});
},
/* 宿主侧兜底清除:小程序自己把首页重载回 mama-box 时同步清 last_app(见 onShow 调用处)。
与 H5 侧 clearLastAppOnBackHome() 互补:那条认浏览器后退/referrer,这条是宿主确定要回首页。 */
_clearLastApp() {
try {
const openid = this._hostOpenid();
if (!openid) return;
console.log('[enter-app] 宿主重载回首页 → 清除 last_app(下次冷启动不再回上次那个应用)');
uni.request({
url: 'https://mp.yizhezhe.com/api/mp/public/last-app?openid=' + encodeURIComponent(openid),
method: 'DELETE',
timeout: 5000,
fail: (err) => console.log('[enter-app] ✗ 清除 last_app 失败(忽略): ' + JSON.stringify(err))
});
} catch (e) {}
},
/* 命中恢复目标:保留首页作为返回落点 + 交容器页加载(与 options.url 分享落地同款)
⚠️ 落点必须带 #norestore=1:否则 mama-box 会在容器页底下**自己再恢复一次**,
把返回落点也换成目标应用,back 就回不到首页了。 */
_openRestoredApp(url) {
console.log('[enter-app] ✅ 命中 last-app → 直接进容器页加载: ' + url +
'(不经过 mama-box,无首页闪屏)');
this.restoring = false;
this.loadMamaBox(true);
this.$nextTick(() => {
uni.navigateTo({ url: '/pages/annex/web_view/index?url=' + encodeURIComponent(url) });
});
},
/**
* 取到 openid 才加载 H5;自动重试 3 次仍失败 → 停在兜底屏给「重新获取身份」入口。
* ⚠️ 每次尝试前都再读一次 _hostOpenid():App.vue 的 onLaunch 静默授权是并行的,
* 它先落地的话这里直接命中缓存,不必自己再请求一次。
* @param {string} base - 要加载的 H5 基址(首页壳是生效首页地址;onShow 重载时是当前 H5)
*/
fetchIdentityThenLoad(base) {
/* 1) 缓存命中(含 App.vue 并行登录刚落地的)→ 0 等待加载 */
if (this._loadWithCachedIdentity(base)) return;
this.identityPending = true;
this.identityFail = false;
const seq = this._idAttempts; // 0 = 还没取过
this._idAttempts = seq + 1;
const MAX = 2; // ⚠️ 网络尝试上限 2 次(原 3 次,见下方退避说明)
console.log('[auth-flow] 🔑 首页壳取 openid(第 ' + this._idAttempts + '/' + MAX + ' 次)');
/* 2) 第 1 次**不急着自己请求**:先等 App.vue onLaunch 那次静默登录(同一次 exchange)。
它先落地就直接加载 —— 避免两边各换一次 code 白花 200–300ms
(微信 code 一次性;实测 code2session 约 200–300ms)。
之后的重试才自己 wx.login + 换 openid(_silenceAuthOpenid)。 */
const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid();
task.then(() => {
if (this._loadWithCachedIdentity(base)) return;
if (this._idAttempts < MAX) {
/* 退避固定 500ms(原来 600×n,最坏 3 次 + 1.8s 退避 ≈ 3.5s 才出失败屏;
现在 2 次 + 0.5s ≈ 1.5s 量级) */
setTimeout(() => this.fetchIdentityThenLoad(base), 500);
return;
}
this.identityPending = false;
this.identityFail = true;
console.warn('[auth-flow] ⚠️ ' + MAX + ' 次仍未拿到 openid:停在兜底屏等待重试,**不匿名加载**');
});
},
/* 有缓存身份就直接发起加载;返回 true 表示已经发起(调用方别再取) */
_loadWithCachedIdentity(base) {
if (!this._hostOpenid()) return false;
this.identityPending = false;
this.identityFail = false;
this._idAttempts = 0;
this._shownOnce = true;
// 沿用本次的 _noRestore(见 loadMamaBox 注释):本轮加载是否需要禁止 mama-box 兜底恢复
this.webUrl = this.buildWebUrl(base + (base.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now(),
this._noRestore ? ['norestore=1'] : []);
console.log('[auth-flow] ✅ 首页壳拿到 openid,开始加载 H5');
return true;
},
/**
* 给 H5 拼 hash 参数(openid 单一身份键 + unionid 可选)。
* ⚠️ 只能拼 # 不能拼 ?:微信 web-view 首次加载丢 query(app_03 实测)。
* 身份解析统一由 mixins/webviewHost.js 的 _hostOpenid / _hostUnionid 提供。
* @param {string} url
* @param {string[]} [extraHash] 额外 hash 参数(如 ['norestore=1'];仅本页自用)
*/
buildWebUrl(url) {
buildWebUrl(url, extraHash) {
const parts = [];
const openid = this._hostOpenid();
const unionid = this._hostUnionid();
if (openid) parts.push('openid=' + openid);
if (unionid) parts.push('unionid=' + unionid);
if (!parts.length) return url;
return url + (url.indexOf('#') >= 0 ? '&' : '#') + parts.join('&');
const all = parts.concat(extraHash || []);
if (!all.length) return url;
return url + (url.indexOf('#') >= 0 ? '&' : '#') + all.join('&');
},
/* webviewHost hook:本页 web-view 地址字段是 webUrl */
_hostSetWebviewUrl(url) { this.webUrl = url; },
/* webviewHost hook:copyLink 未带 href 时的兜底地址 */
_hostCurrentUrl() { return this.webUrl || MAMA_HOME; },
_hostCurrentUrl() { return this.webUrl || this._homeBase(); },
/* 授权面板提交(base = 当前 H5 地址,同 onShow 的回传落点逻辑 ——
宫格换应用后 MAMA_HOME 不再是用户所在的 H5)→ mixin 的共享授权接口 + hash 回传 */
宫格换应用后首页地址不再是用户所在的 H5)→ mixin 的共享授权接口 + hash 回传 */
submitAuth() {
this.submitHostAuth((this.shareHref || this.webUrl || MAMA_HOME).split('#')[0]);
this.submitHostAuth((this.shareHref || this.webUrl || this._homeBase()).split('#')[0]);
},
/* MP:订阅消息模板预取(原装修首页行为;utils/SubscribeMessage.js 请求时用缓存) */
@@ -324,13 +696,23 @@
this.loadError = false;
this.requesting = false;
this._shownOnce = true;
this.webUrl = this.buildWebUrl(MAMA_HOME + '?t=' + Date.now());
this.identityFail = false;
/* 有身份直接重载;没有就再走一遍「取到才加载」(重置尝试次数,自动重试 3 次)
⚠️ 沿用 this._noRestore:重试首页时同样不许首页 H5 兜底恢复(见 loadMamaBox)。 */
const homeUrl = this._homeBase();
if (this._hostOpenid()) {
this.webUrl = this.buildWebUrl(homeUrl + '?t=' + Date.now(),
this._noRestore ? ['norestore=1'] : []);
return;
}
this._idAttempts = 0;
this.fetchIdentityThenLoad(homeUrl);
},
/* 授权完善资料(postMessage → 原生面板 → 共享授权接口 → hash 回传)与
pages/annex/web_view 共用 mixins/webviewHost.js:
handleHostMessage / onChooseHostAvatar / onHostGetPhone /
submitHostAuth(MAMA_HOME) / consumeAuthResult(见 onShow)
submitHostAuth(生效首页地址) / consumeAuthResult(见 onShow)
本页不再各写一份;分享图设置由 mixins/shareCard.js 提供。 */
}
}

875
pages/index/mall.vue Normal file
View File

@@ -0,0 +1,875 @@
<!-- ============================================================
商城首页(第 5 个 tabBar 页「商城」)
------------------------------------------------------------
本页 = 原装 CRMEB 装修首页(pages/index/index.vue.origin-bak)
原样恢复成独立路由。起因:pages/index/index 路由已被
「兜爸心选」H5 的 web-view 壳占用,H5 底部「兜爸心选」tab
(goMall(),见 mama-box 仓库 js/app.js)要跳回老商城首页,
只能把它恢复成一个新 tabBar 页(tabBar 上限 5,原 4 项)。
与原首页的两处差异(其余 1:1):
1. 已注册进 pages.json 的 tabBar(第 2 位「商城」)。⚠️ 底栏的真实渲染方是
后台「装修→底部导航」DIY 配置(/api/navigation → components/pageFooter),
商城项要在底栏出现必须在后台配置里加(链接填 /pages/index/mall,goRouter
走 switchTab);这里的 tabBar 注册只保证 switchTab 可达 + DIY 导航禁用时
原生 tabBar 兜底可显示(App.vue onLaunch 会 hideTabBar,DIY 启用时
pageFooter 也会 hideTabBar,两层兜底见 components/pageFooter/index.vue)。
2. 依赖的 4 个组件(appCenter/images/pictureCube/promotionList)
在壳替换时被从工作区删了,已按需从 HEAD 恢复,勿再删。
pages.json:pages 列表 + tabBar 第 2 项均已注册(enablePullDownRefresh
沿用原首页 true,与 web-view 壳页的 false 不同,别混)。
============================================================ -->
<template>
<!-- 首页 -->
<view v-if="pageShow" class="page"
:class="bgTabVal==2?'fullsize noRepeat':bgTabVal==1?'repeat ysize':'noRepeat ysize'"
:style="[pageStyle]">
<view v-if="!errorNetwork" :style="colorStyle">
<!-- 轮播搜索 -->
<homeComb v-if="showHomeComb" :dataConfig="homeCombData" @bindSortId="bindSortId":isScrolled="isScrolled"></homeComb>
<!-- 顶部搜索框 -->
<headerSerch v-if="isHeaderSerch" :dataConfig="headerSerchCombData"></headerSerch>
<tabNav v-if="showCateNav" :dataConfig="cateNavData" @bindHeight="bindHeighta"
@bindSortId="bindSortId" :isFixed="isFixed && !cateNavData.stickyConfig.tabVal"></tabNav>
<view class="index">
<!-- 自定义样式 -->
<block v-for="(item, index) in styleConfig" :key="index">
<!-- <shortVideo v-if="item.name == 'shortVideo'" :dataConfig="item">
</shortVideo> -->
<userInfor v-if="item.name == 'userInfor'" :dataConfig="item"
@changeLogin="changeLogin">
</userInfor>
<newVip v-if="item.name == 'newVip'" :dataConfig="item"></newVip>
<pageTopbar v-if="item.name == 'pageTopbar'" :dataConfig="item"></pageTopbar>
<!-- 文章列表 -->
<articleList v-if="item.name == 'articleList'" :dataConfig="item">
</articleList>
<bargain v-if="item.name == 'bargain'" :dataConfig="item" :pullReset="pullReset" @changeBarg="changeBarg"
></bargain>
<blankPage v-if="item.name == 'blankPage'" :dataConfig="item"></blankPage>
<combination v-if="item.name == 'combination'" :dataConfig="item" :pullReset="pullReset">
</combination>
<!-- 优惠券 -->
<coupon v-if="item.name == 'coupon'" :dataConfig="item"
@changeLogin="changeLogin"></coupon>
<!-- 客户服务 -->
<customerService v-if="item.name == 'customerService'" :dataConfig="item">
</customerService>
<!-- 商品列表 -->
<goodList v-if="item.name == 'goodList'" :dataConfig="item"
></goodList>
<guide v-if="item.name == 'guide'" :dataConfig="item"></guide>
<!-- 直播模块 -->
<!-- #ifdef MP-WEIXIN -->
<liveBroadcast v-if="item.name == 'liveBroadcast'" :dataConfig="item"></liveBroadcast>
<!-- #endif -->
<menus v-if="item.name == 'menus'" :dataConfig="item"></menus>
<!-- 实时消息 -->
<news v-if="item.name == 'news'" :dataConfig="item"></news>
<!-- 图片库 -->
<pictureCube v-if="item.name == 'pictureCube'" :dataConfig="item"></pictureCube>
<!-- 图片组件 -->
<images v-if="item.name == 'images'" :dataConfig="item"></images>
<!-- 促销列表 -->
<promotionList v-if="item.name == 'promotionList'" :dataConfig="item"
:productVideoStatus='product_video_status' :showHomeComb="showHomeComb" :isHeaderSerch="isHeaderSerch">
</promotionList>
<richText v-if="item.name == 'richText'" :dataConfig="item"></richText>
<seckill v-if="item.name == 'seckill'" :dataConfig="item" :pullReset="pullReset"></seckill>
<!-- 轮播图-->
<swiperBg v-if="item.name == 'swiperBg'" :dataConfig="item"></swiperBg>
<swipers v-if="item.name == 'swipers'" :dataConfig="item"></swipers>
<!-- 顶部选项卡 -->
<!-- 标题 -->
<titles v-if="item.name == 'titles'" :dataConfig="item"></titles>
<ranking v-if="item.name == 'ranking'" :dataConfig="item"></ranking>
<presale v-if="item.name == 'presale'" :dataConfig="item"></presale>
<pointsMall v-if="item.name == 'pointsMall'" :dataConfig="item"></pointsMall>
<videos v-if="item.name == 'videos'" :dataConfig="item"></videos>
<signIn v-if="item.name == 'signIn'" :dataConfig="item"></signIn>
<hotspot v-if="item.name == 'hotspot'" :dataConfig="item"></hotspot>
<follow v-if="item.name == 'follow'" :dataConfig="item"></follow>
<appCenter v-if="item.name == 'appCenter'" :dataConfig="item"></appCenter>
<community v-if="item.name == 'community'" :dataConfig="item"></community>
</block>
<!-- 分类商品模块 -->
<!-- #ifndef APP-PLUS -->
<view class="sort-product px-20">
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<!-- 商品排序 -->
<view class="sort-product px-20" :style="{ marginTop: sortMpTop + 'px' }">
<!-- #endif -->
<view class="rd-24rpx bg--w111-fff p-24 mb-24" v-if="sortList.children && sortList.children.length">
<scroll-view scroll-x="true" class="white-nowrap vertical-middle w-full" show-scrollbar="false">
<view class="inline-block mr-24" v-for="(item, index) in sortList.children" :key="index">
<view class="flex-col flex-center" @tap="changeSort(item, index)">
<view class="picture w-90 h-90 rd-50-p111-"
:class="{select: curSort == index}">
<image :src="item.pic" class="w-full h-full rd-50-p111-"></image>
</view>
<text class="fs-24 pt-14" :class="{'font-num': curSort == index}">{{item.cate_name}}</text>
</view>
</view>
</scroll-view>
</view>
<waterfallsFlow ref="waterfallsFlow" :wfList="goodList" :showCart="false" @itemTap="goDetail"></waterfallsFlow>
<Loading :loaded="loaded" :loading="loading"></Loading>
<view v-if="goodList.length == 0 && loaded">
<emptyPage title="暂无商品,去看点别的吧~" ></emptyPage>
</view>
</view>
<activityModal :pageIndex="1"></activityModal>
<!-- #ifdef H5 -->
<view v-if="configData.record_No" class="site-config" @click="goICP">{{ configData.record_No }}</view>
<!-- #endif -->
<view class="pb-safe h-100" v-if="isFooter"></view>
<pageFooter @newDataStatus="newDataStatus"></pageFooter>
<!-- #ifdef MP-WEIXIN -->
<add-tip></add-tip>
<!-- #endif -->
</view>
<!-- #ifdef APP -->
<app-update ref="appUpdate" :force="true" :tabbar="false"></app-update>
<!-- #endif -->
</view>
<view v-else>
<view class="o813d0">
<image :src="imgHost + '/statics/images/error-network.gif'"></image>
<view class="title">网络连接断开</view>
<view class="of0b21">
<view class="label">请检查情况:</view>
<view class="item">· 在设置中是否已开启网络权限</view>
<view class="item">· 当前是否处于弱网环境</view>
<view class="item">· 版本是否过低,升级试试吧</view>
</view>
<view class="btn" @click="reconnect">重新连接</view>
</view>
</view>
</view>
</template>
<script>
const app = getApp();
import colors from "@/mixins/color";
import couponWindow from '@/components/couponWindow/index';
import {
getCouponV2,
getCouponNewUser,
copyRight
} from '@/api/api.js';
import {
getShare
} from '@/api/public.js';
// #ifdef H5
import {
silenceAuth
} from '@/api/public.js';
// #endif
import Cache from '@/utils/cache';
import userInfor from './components/userInfor';
import homeComb from './components/homeComb';
import newVip from './components/newVip';
import pageTopbar from './components/pageTopbar';
// import shortVideo from './components/shortVideo';
import community from './components/community'
import headerSerch from './components/headerSerch';
import swipers from './components/swipers';
import coupon from './components/coupon';
import articleList from './components/articleList';
import bargain from './components/bargain';
import blankPage from './components/blankPage';
import combination from './components/combination';
import customerService from './components/customerService';
import goodList from './components/goodList';
import guide from './components/guide';
import liveBroadcast from './components/liveBroadcast';
import menus from './components/menus';
import news from './components/news';
import pictureCube from './components/pictureCube';
import images from './components/images';
import promotionList from './components/promotionList';
import richText from './components/richText';
import seckill from './components/seckill';
import swiperBg from './components/swiperBg';
import tabNav from './components/tabNav';
import titles from './components/titles';
import ranking from './components/ranking';
import presale from './components/presale'
import pointsMall from './components/pointsMall';
import videos from './components/videos';
import signIn from './components/signIn';
import hotspot from './components/hotspot';
import follow from './components/follow';
import appCenter from './components/appCenter';
import waterfallsFlow from "@/components/WaterfallsFlow/WaterfallsFlow.vue";
import emptyPage from '@/components/emptyPage.vue';
import activityModal from "@/components/activityModal/index.vue"
// #ifdef MP
import addTip from '@/components/weixinTip/index.vue'
import {getTemlIds} from '@/api/api.js';
import {
SUBSCRIBE_MESSAGE,
TIPS_KEY
} from '@/config/cache';
// #endif
import {mapGetters,mapMutations} from 'vuex';
import {getDiy, getDiyVersion} from '@/api/api.js';
import {
getCategoryList,
getProductslist,
getProductHot,
diyProductApi
} from '@/api/store.js';
import { goShopDetail } from '@/libs/order.js';
import { toLogin } from '@/libs/login.js';
import { HTTP_REQUEST_URL } from '@/config/app';
import pageFooter from '@/components/pageFooter/index.vue';
import Loading from '@/components/Loading/index.vue';
import recommend from '@/components/recommend';
import appUpdate from '@/components/update/app-update.vue';
export default {
computed: {
pageStyle(){
return {
backgroundColor: this.bgColor,
backgroundImage: this.bgPic ? `url(${this.bgPic})` : '',
minHeight: this.windowHeight + 'px'
}
},
...mapGetters(['isLogin', 'uid', 'cartNum']),
},
mixins: [colors],
components: {
recommend,
Loading,
pageFooter,
couponWindow,
homeComb,
newVip,
pageTopbar,
userInfor,
community,
headerSerch,
swipers,
coupon,
articleList,
bargain,
blankPage,
combination,
customerService,
goodList,
guide,
menus,
pictureCube,
images,
news,
promotionList,
richText,
seckill,
swiperBg,
tabNav,
titles,
ranking,
presale,
pointsMall,
videos,
signIn,
hotspot,
follow,
appCenter,
waterfallsFlow,
activityModal,
emptyPage,
// #ifdef APP-PLUS
appUpdate,
// #endif
// #ifdef MP-WEIXIN
addTip,
liveBroadcast,
// #endif
},
data() {
return {
__debugDiyRender: false,
styleConfig: [],
loading: false,
loadend: false,
loadTitle: '下拉加载更多', //提示语
page: 1,
limit: this.$config.LIMIT,
numConfig: 0,
code: '',
isCouponShow: false,
shareInfo: {},
sortList: '',
sortAll: [],
goodPage: 1,
goodList: [],
sid: 0,
curSort: 0,
sortMpTop: 0,
loaded: false,
loading: false,
domOffsetTop: 50,
// #ifdef APP-PLUS || MP
isFixed: true,
// #endif
// #ifdef H5
isFixed: false,
// #endif
site_config: '',
errorNetwork: false, // 是否断网
isHeaderSerch: false,
showHomeComb: false,
showCateNav: false,
homeCombData:{},
headerSerchCombData:{},
cateNavData:{},
bgColor: '',
bgPic: '',
bgTabVal: '',
pageShow: true,
windowHeight: 0,
imgHost: HTTP_REQUEST_URL,
isShowAuth: false,
isScrolled: false,
product_video_status: false,
confirm_video_status: false,
isFooter: false,
pullReset: 0,
configData: Cache.get('BASIC_CONFIG') || {},
};
},
onLoad(options) {
let that = this;
this.__debugDiyRender = uni.getStorageSync('DEBUG_DIY_RENDER') === true
that.getOptions(options);
this.$nextTick(function() {
uni.getSystemInfo({
success: function(res) {
that.windowHeight = res.windowHeight;
}
});
})
const {state, scope} = options;
let identity = this.$store.state.app.identity;
if(identity == 1 && this.isLogin){
uni.redirectTo({
url: "/pages/merchant/index/goods"
})
}else if(identity == 2 && this.isLogin){
uni.redirectTo({
url:"/pages/admin/work/index"
})
}else{
this.diyData();
}
// #ifdef H5
this.setOpenShare();
// #endif
// #ifdef MP
this.getTemlIds();
// #endif
getShare().then(res => {
this.shareInfo = res.data;
});
},
onShow() {
if (this.cartNum > 0) {
uni.setTabBarBadge({
index: 3,
text: this.cartNum > 99 ? '99+' : this.cartNum + ''
})
} else {
uni.hideTabBarRedDot({
index: 3
})
}
uni.removeStorageSync('form_type_cart')
},
methods: {
__logDiy(step, payload) {
if (!this.__debugDiyRender) return;
const safe = payload === undefined ? '' : payload
console.log('[DIY][home]', step, safe)
},
...mapMutations(['SET_AUTOPLAY']),
getOptions(options) {
let that = this;
// #ifdef MP
if (options.scene) {
let value = that.$util.getUrlParams(decodeURIComponent(options.scene));
//记录推广人uid
if (value.spid) app.globalData.spid = value.spid;
}
// #endif
if (options.spid) app.globalData.spid = options.spid;
},
// 重新链接
reconnect() {
this.diyData();
getShare().then(res => {
this.shareInfo = res.data;
});
},
goICP() {
// #ifdef H5
window.open('http://beian.miit.gov.cn/');
// #endif
// #ifdef MP
uni.navigateTo({
url: `/pages/annex/web_view/index?url=https://beian.miit.gov.cn/`
});
// #endif
},
bindHeighta(data) {
// #ifdef APP-PLUS
this.sortMpTop = data.top + data.height;
// #endif
},
bindHeight(data) {
uni.hideLoading();
this.domOffsetTop = data.top;
},
// 分类点击
changeSort(item, index) {
if (this.curSort == index) return;
this.curSort = index;
this.sid = item.id;
this.goodList = [];
this.goodPage = 1;
this.loaded = false;
this.getGoodsList();
},
/**
* @param data {
classPage: 0 分类id
microPage: 0 微页面id
type: 1 0 微页面 1 商品分类
}*/
bindSortId(data) {
this.styleConfig = [];
this.loaded = false;
if (data.type == 1) {
this.getProductList(data.classPage);
}else{
this.sortList = [];
this.getMicroPage(data.microPage, true);
}
},
/**
* 获取DIY
* @param {number} id
* @param {boolean} type 区分是否是微页面
*/
getMicroPage(id, type) {
let that = this;
that.__logDiy('micro_fetch_start', { id })
that.styleConfig = [];
that.goodList = [];
uni.showLoading({
title: '加载中...'
});
getDiy(id).then(res => {
uni.hideLoading();
let data = res.data;
let diyArr = that.objToArr(res.data.value);
that.__logDiy('micro_fetch_ok', { title: data?.title, count: diyArr.length, names: diyArr.map(v => v?.name) })
diyArr = diyArr.filter(item => item.isHide !== '1');
diyArr.forEach((item,index) => {
if(['headerSerch','homeComb'].includes(item.name)){
diyArr.splice(index, 1);
}
});
this.styleConfig = diyArr;
this.$nextTick(() => {
this.__logDiy('micro_render_nextTick', { count: this.styleConfig.length, names: this.styleConfig.map(v => v?.name) })
})
}).catch(err => {
return that.$util.Tips({
title: err
});
uni.hideLoading();
});
},
getProductList(data) {
let tempObj = '';
this.curSort = 0;
this.loaded = false;
if (this.sortAll.length > 0) {
this.sortAll.forEach((el, index) => {
if (el.id == data) {
this.$set(this, 'sortList', el);
this.sid = el.children.length ? el.children[0].id : '';
}
});
this.goodList = [];
this.goodPage = 1;
this.$nextTick(() => {
if (this.sortList != '') this.getGoodsList();
});
} else {
getCategoryList().then(res => {
this.sortAll = res.data;
res.data.forEach((el, index) => {
if (el.id == data) {
this.sortList = el;
this.sid = el.children.length ? el.children[0].id : '';
}
});
this.goodList = [];
this.goodPage = 1;
this.$nextTick(() => {
if (this.sortList != '') this.getGoodsList();
});
});
}
},
// 商品列表
getGoodsList() {
if (this.loading || this.loaded) return;
this.loading = true;
getProductslist({
sid: this.sid,
keyword: '',
priceOrder: '',
salesOrder: '',
news: 0,
page: this.goodPage,
limit: 10,
cid: this.sortList.id
}).then(res => {
this.loading = false;
this.loaded = res.data.length < 10;
this.goodPage++;
this.goodList = this.goodList.concat(res.data);
});
},
// #ifdef MP
getTemlIds() {
let messageTmplIds = wx.getStorageSync(SUBSCRIBE_MESSAGE);
if (!messageTmplIds) {
getTemlIds().then(res => {
if (res.data) wx.setStorageSync(SUBSCRIBE_MESSAGE, JSON.stringify(res.data));
});
}
},
// #endif
// 对象转数组
objToArr(data) {
let obj = Object.keys(data).sort();
let m = obj.map(key => data[key]);
return m;
},
setDiyData(data) {
this.__logDiy('home_setDiyData_start', { title: data?.title })
this.errorNetwork = false
if (data.is_bg_color) {
this.bgColor = data.color_picker
}
if (data.is_bg_pic) {
this.bgPic = data.bg_pic
this.bgTabVal = data.bg_tab_val
}
this.pageShow = data.is_show
uni.setNavigationBarTitle({
title: data.title
});
let temp = [];
let lastArr = this.objToArr(data.value);
lastArr.forEach((item, index, arr) => {
if (item.name === 'homeComb') {
this.showHomeComb = true
this.homeCombData = item;
}
if (item.name == 'headerSerch') {
this.isHeaderSerch = true;
this.headerSerchCombData = item;
}
if (item.name == 'tabNav') {
this.showCateNav = true;
this.cateNavData = item;
}
if (item.isHide !== '1') {
temp.push(item);
}
});
function sortNumber(a, b) {
return a.timestamp - b.timestamp;
}
temp.sort(sortNumber)
this.styleConfig = temp;
this.__logDiy('home_setDiyData_done', { count: this.styleConfig.length, names: this.styleConfig.map(v => v?.name) })
this.$nextTick(() => {
this.__logDiy('home_render_nextTick', { count: this.styleConfig.length, names: this.styleConfig.map(v => v?.name) })
})
},
getDiyData() {
this.__logDiy('home_fetch_start', { id: 0 })
getDiy(0).then(res => {
this.__logDiy('home_fetch_ok', { title: res?.data?.title, keys: Object.keys(res?.data?.value || {}).length })
uni.setStorageSync('diyData', JSON.stringify(res.data));
this.setDiyData(res.data);
}).catch(error => {
// #ifdef APP-PLUS
if (error.status) {
uni.hideLoading()
if (this.errorNetwork) {
uni.showToast({
title: '请开启网络连接',
icon: 'none',
duration: 2000
})
}
this.errorNetwork = true;
}
// #endif
});
},
diyData() {
let diyData = uni.getStorageSync('diyData');
if (diyData) {
getDiyVersion(0).then(res => {
let diyVersion = uni.getStorageSync('diyVersion');
if ((res.data.version + '0') === diyVersion) {
this.setDiyData(JSON.parse(diyData));
} else {
uni.setStorageSync('diyVersion', (res.data.version + '0'));
this.getDiyData();
}
});
} else {
this.getDiyData();
}
},
changeLogin() {
this.getIsLogin();
},
getIsLogin() {
toLogin()
},
changeBarg(item) {
if (!this.isLogin) {
this.getIsLogin();
} else {
uni.navigateTo({
url: `/pages/activity/goods_bargain_details/index?id=${item.id}&spid=${this.$store.state.app.uid}`
});
}
},
goDetail(item) {
goShopDetail(item, this.$store.state.app.uid).catch(res => {
uni.navigateTo({
url: `/pages/goods_details/index?id=${item.id}`
});
});
},
newDataStatus(val) {
this.isFooter = val ? true : false;
},
checked(){
console.log('点击了')
},
// #ifdef H5
// 微信分享;
setOpenShare: function() {
let that = this;
let uid = this.uid ? this.uid : 0;
if (that.$wechat.isWeixin()) {
getShare().then(res => {
let data = res.data;
let configAppMessage = {
desc: data.synopsis,
title: data.title,
link: location.href + '?spid=' + uid,
imgUrl: data.img
};
that.$wechat.wechatEvevt(['updateAppMessageShareData', 'updateTimelineShareData',
'onMenuShareAppMessage', 'onMenuShareTimeline'
],
configAppMessage);
});
}
}
// #endif
},
onReachBottom() {
if(this.goodList.length){
this.getGoodsList();
}
},
onPullDownRefresh() {
this.goodList = [];
this.sortList = [];
this.diyData();
this.pullReset = 1;
setTimeout(function () {
uni.stopPullDownRefresh();
}, 1000);
},
onPageScroll(e) {
// #ifdef H5
if (this.isHeaderSerch) {
if (e.scrollTop > this.domOffsetTop) {
this.isFixed = true;
}
if (e.scrollTop < this.domOffsetTop) {
this.$nextTick(() => {
this.isFixed = false;
});
}
} else {
this.isFixed = false
}
// #endif
if (e.scrollTop > 10) {
this.isScrolled = true;
} else {
this.isScrolled = false;
}
uni.$emit('scroll');
uni.$emit('onPageScroll', e.scrollTop);
},
//#ifdef MP
onShareAppMessage() {
let uid = this.uid ? this.uid : 0;
if(this.shareInfo.img){
return {
title: this.shareInfo.title,
path: '/pages/index/index?spid=' + uid,
imageUrl: this.shareInfo.img,
desc: this.shareInfo.synopsis
};
}else{
return {
title: this.shareInfo.title,
path: '/pages/index/index?spid=' + uid
// imageUrl: this.shareInfo.img,
// desc: this.shareInfo.synopsis
};
}
},
//分享到朋友圈
onShareTimeline() {
let uid = this.uid ? this.uid : 0;
return {
title: this.shareInfo.title,
path: '/pages/index/index?spid=' + uid,
imageUrl: this.shareInfo.img,
desc: this.shareInfo.synopsis
};
}
//#endif
};
</script>
<style lang="scss">
// page {
// padding-bottom: 50px;
// }
.pictrue_log_class {
background-color: var(--view-theme);
}
.ysize {
background-size: 100%;
}
.fullsize {
background-size: 100% 100%;
}
.repeat {
background-repeat: repeat;
}
.noRepeat {
background-repeat: no-repeat;
}
.o813d0 {
position: fixed;
left: 0;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding-top: 40rpx;
background: #fff;
image {
width: 414rpx;
height: 336rpx;
}
.title {
position: relative;
top: -40rpx;
font-size: 32rpx;
color: #666;
}
.of0b21 {
font-size: 24rpx;
color: #999;
.label {
margin-bottom: 20rpx;
}
.item {
margin-bottom: 20rpx;
}
}
.btn {
display: flex;
align-items: center;
justify-content: center;
width: 508rpx;
height: 86rpx;
margin-top: 100rpx;
border: 1px solid #D74432;
color: #E93323;
font-size: 30rpx;
border-radius: 120rpx;
}
}
.sort-scroll {
background-color: #fff;
}
.sort-product {
margin-top: 20rpx;
}
.site-config {
margin: 40rpx 0;
font-size: 24rpx;
text-align: center;
color: #666;
&.fixed {
position: fixed;
bottom: 69px;
left: 0;
width: 100%;
}
}
.select{
border: 1px solid var(--view-theme);
}
</style>

View File

@@ -1,7 +1,8 @@
{
"projectname": "crmeb_pro_uniapp",
"setting": {
"compileHotReLoad": false
"compileHotReLoad": false,
"urlCheck": false
},
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html"
}

View File

@@ -262,8 +262,8 @@ export default {
console.log('[auth-flow] JumpPath: ✅ 有 openid (token=' + (token ? '有' : '无') + '),注入身份跳转 web_view (unionid ' + (unionid ? '已携带' : '为空') + ')');
} else {
// 无 openid:调 silenceAuthNoLogin 拿 openid
// 走 web_view 让 _initWebView 用 _fetchOpenidAndLoad 拿 openid
console.log('[auth-flow] JumpPath: 🔑 无 openid,跳转 web_view 由 _fetchOpenidAndLoad 处理');
// 走 web_view 让 _initWebView 用 _ensureIdentityThenLoad 拿 openid(2026-09-20 起改名为该名)
console.log('[auth-flow] JumpPath: 🔑 无 openid,跳转 web_view 由 _ensureIdentityThenLoad 处理');
}
}
uni.navigateTo({