Compare commits
10 Commits
d276956a76
...
b45a190a59
| Author | SHA1 | Date | |
|---|---|---|---|
| b45a190a59 | |||
| 91f1ee849c | |||
| dcc84dd7b7 | |||
| da18d9685e | |||
| b06a617f13 | |||
| e99b4a475c | |||
| c216865852 | |||
| 6f033db7d4 | |||
| 3df3e08252 | |||
| 67eaae0ceb |
161
AGENTS.md
Normal file
161
AGENTS.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 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 remote,本仓库是纯本地库**) |
|
||||
| 版本 | `manifest.json` 的 `versionName` / `versionCode` |
|
||||
|
||||
## 2. 关键文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
| --- | --- |
|
||||
| `pages/index/index.vue` | 首页壳:身份 fail-closed、加载 mama-box、**冷启动恢复 last_app**、分享卡片、tabBar 显隐 |
|
||||
| `pages/annex/web_view/index.vue` | 容器页:太阳码 scene 解析、身份注入(query)、`_reportLastApp` 上报 |
|
||||
| `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 扫码…),拿它判落地会导致恢复永远不执行。
|
||||
|
||||
## 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
70
App.vue
@@ -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;
|
||||
},
|
||||
/**
|
||||
* 检测当前的小程序
|
||||
|
||||
179
docs/分享图片获取逻辑.md
179
docs/分享图片获取逻辑.md
@@ -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 节。
|
||||
|
||||
@@ -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
306
mixins/shareCard.js
Normal 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
382
mixins/webviewHost.js
Normal 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() {},
|
||||
}
|
||||
};
|
||||
4049
pages.json
4049
pages.json
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
194
pages/debug_union/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
875
pages/index/mall.vue
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user