Files
crmeb-mp/pages/index/index.vue
Karlcarlo b45a190a59 chore: 入库生产基线(2026-10-26 工作树快照)
- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动
- 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用)
- 排除本地备份文件(*.bak* / *.origin-bak)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 17:07:49 +08:00

822 lines
43 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- ============================================================
首页(临时版):兜爸心选 H5 · web-view 壳
------------------------------------------------------------
本页替代了 CRMEB 原装修首页(原文件完整备份在同目录
index.vue.origin-bak,恢复方法见本文件底部注释)。
链路(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
(更新首页内容 = 改服务端对应 H5 目录;换首页地址 = 管理后台「首页配置」,均无需发版)
两个坑(都来自 app_03 前一次接入的实测结论):
1. 微信 web-view 首次加载会丢弃 URL 的 query string(?a=b),
hash(#a=b)不丢 —— 需要透传给 H5 的参数一律拼在 # 后面。
2. 开发者工具必须勾选「不校验合法域名」才能请求本地 box_app;
线上需要在小程序后台把 box_app 域名同时加进
request 合法域名 和 web-view 业务域名。
============================================================ -->
<view class="mamabox-home">
<web-view v-if="webUrl" :src="webUrl" @message="handleHostMessage" @error="onWebviewError"></web-view>
<!-- 加载中 / 加载失败兜底(web-view 未就绪或加载出错时才渲染)
⚠️ 2026-09-20 起增加「正在获取微信身份」这一态:首页壳**必须拿到 openid 才加载 H5**,
拿不到就重试、并停在这一屏 —— 不再匿名加载(匿名加载会让 H5 进访客态:
孕周 `--`、档案空,用户看到的是「能用但都是空的」,比多等几秒更糟)。 -->
<view v-else class="mamabox-fallback">
<view class="mamabox-emoji">{{ fallbackEmoji }}</view>
<view class="mamabox-tip">
<view class="mamabox-title">{{ fallbackTitle }}</view>
<view class="mamabox-desc">{{ fallbackDesc }}</view>
</view>
<button v-if="loadError || identityFail" class="mamabox-retry" hover-class="mamabox-retry-hover" @tap="retryLoad">{{ loadError ? '重新加载' : '重新获取身份' }}</button>
</view>
<!-- 小程序原生授权面板(H5 postMessage 请求后显示):手机号/昵称/头像 -->
<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="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="onHostGetPhone">获取手机号</button>
<view class="mb-auth-actions">
<button class="mb-auth-cancel" @tap="closeAuth">取消</button>
<button class="mb-auth-ok" @tap="submitAuth">保存并返回</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { mapGetters } from 'vuex';
import { getShare } from '@/api/public.js';
import { getTemlIds } from '@/api/api.js';
import { SUBSCRIBE_MESSAGE } from '@/config/cache';
import shareCard from '@/mixins/shareCard.js';
import webviewHost from '@/mixins/webviewHost.js';
const app = getApp();
// 内置兜底首页地址(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 泄露给接收者;
② 接收者点开后由宿主页(pages/annex/web_view)按自己的身份重新注入。
覆盖 query 的 openid/unionid/t/_v 与 hash 的 openid/unionid/phone/nickname/avatar
(hash 是授权回传通道,见 docs/h5-auth-standard.md)。 */
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) {
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 : '');
}
export default {
name: 'Index',
mixins: [shareCard, webviewHost],
computed: {
...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 地址(首页地址/当前 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
// 分享字段由 mixins/shareCard.js 提供:
// shareTitle / shareHref / shareImageUrl / shareApps…
};
},
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'] });
// #endif
// #ifdef MP
// 恢复原装修首页的两项预热:订阅消息模板(getTemlIds)+ CRMEB 后台「分享设置」
// (getShare 的标题/图在 box_app 没有匹配应用时兜底,如 mama-box 首页本身)
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 同步匹配,
// 依赖这张常驻内存(+1h 本地缓存)的应用表,见 onShareAppMessage。
this._loadShareApps();
this.getOptions(options);
// 商户 / 管理员登录后跳各自工作台 —— 与原首页行为保持一致,
// 不能因为首页换成了 H5 就把这两类人堵在门外。
let identity = this.$store.state.app.identity;
if (identity == 1 && this.isLogin) {
uni.redirectTo({ url: '/pages/merchant/index/goods' });
return;
} else if (identity == 2 && this.isLogin) {
uni.redirectTo({ url: '/pages/admin/work/index' });
return;
}
/* 朋友圈转发落地:onShareTimeline 不能自定义 path(只会重开当前页 + query),
目标 H5 只能经 query 回传。本页不自己加载它(身份注入 / 分享图都在
web_view 容器里),转交容器处理,避免接收者又落回首页。 */
if (options && typeof options.url === 'string' && options.url) {
let target = options.url;
try { target = decodeURIComponent(target); } catch (e) {}
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;
}
// ⚠️ 首页必须主动隐藏原生 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() {
/* 是否「小程序热启动恢复」(点胶囊关闭后再进 / 切后台再回),而不是从其它 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= 重载冲掉。
// ⚠️ 回传落点必须是「发起授权时所在的那个 H5」:应用宫格用
// location.href 换 H5(页面栈不变、webUrl 数据还是 mama-box 的),
// 写死 MAMA_HOME 会把结果送回 mama-box 首页 —— 授权了也等于白授权,
// 目标 H5 永远收不到 phone(2026-09-18 孕检日历授权弹层复现根因之一)。
// shareHref 由 H5 换页/发起授权前的 location 上报实时刷新(askProfile
// 会先 reportLocation 再 navigateTo),是「当前 H5」最可靠的来源。
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 && !isAppResume) {
// ⚠️ 从商城/其它 tab 切回首页时:webview 是同一个实例,SPA 会停留在切走前的屏。
// 改为「重载 webview」让 H5 落回首页(H5 默认即是首页)。首次展示不重复刷。
// 同理不能写死 MAMA_HOME —— 用户停在宫格切换的应用里时会被踢回 mama-box;
// 以 shareHref(当前 H5)为基址重载,停留在原应用首页。
// ⚠️ 热启动恢复(isAppResume)走不到这里:见 onShow 开头的说明。
this.loadError = false;
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 hide + 商城页 show」双向 programmatic
// 切换,因商城页 showTabBar 与 DIY 底栏同显 →「商品列表双 tabBar」+跳一下,
// 当时整体回退。2026-09-18 起改为单向:只在首页 hideTabBar,商城页显隐
// 完全交给 pageFooter(后台 DIY 配置驱动),不再有任何 showTabBar 配对逻辑。
// 购物车角标(tabBar 索引 3 = 购物车),沿用原首页逻辑
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')
},
/* 分享(2026-09 修复:接收者必须进入「发送者当时所在的那个 H5」,而不是首页)
------------------------------------------------------------
背景:小程序 web-view 里的 H5 无法自己拉起分享面板,卡片只能由承载
<web-view> 的页面产出。而本页的 web-view 会被 mama-box 的应用宫格用
location.href 换成别的 H5 应用(见 mama-box/js/app.js jumpApp),
此时小程序页面栈仍然只有本页 —— 所以「分享」永远发生在本页。
微信在 web-view 内触发分享时会回调 onShareAppMessage(options),
options.webViewUrl 就是当前 web-view 的真实地址(含 H5 的 SPA 路由),
这是唯一可靠的深链来源;H5 postMessage 来的 href 只作兜底(投递有延迟)。
接收者统一走 pages/annex/web_view 容器:容器会剥掉发送者身份、
按接收者自己的 openid 重新注入,并预取分享图。
分享图(2026-09 补):本页的 web-view 会被应用宫格换成任意 H5 应用,
但页面不会重新 onLoad —— 之前只有容器页会按 URL 反查 box_app 的
App.shareImageUrl,所以从宫格直接进的应用(如数胎动)转发时没有后台配的图。
现在本页 onLoad 预热全量应用表(mixins/shareCard.js),转发时用
options.webViewUrl 同步匹配出图;H5 postMessage 的 imageUrl 有值才覆盖。 */
onShareAppMessage(options) {
const target = (options && options.webViewUrl) || this.shareHref || this.webUrl || this._homeBase();
// 按「当前 H5 地址」解析分享图:先做应用切换检测(首页壳不随 H5 换页重新 onLoad),
// 再每次重新反查 box_app —— 不能因为 shareImageUrl 已有值就跳过,
// 那是上一个应用的图(A 应用分享过、切到 B 后仍带 A 的图,就是这里出的错)。
this.resolveShareCard(target);
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,
imageUrl: this.shareImageUrl || info.img || ''
};
},
onShareTimeline() {
// 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 {
title: this.shareTitle || info.title || '兜爸心选 · 孕育陪伴',
query: 'url=' + encodeURIComponent(stripAuthParams(target)) + '&spid=' + uid,
imageUrl: this.shareTimelineImage || info.img || ''
};
},
methods: {
getOptions(options) {
// #ifdef MP
if (options.scene) {
let value = this.$util.getUrlParams(decodeURIComponent(options.scene));
// 记录推广人uid(与原首页一致)
if (value.spid) app.globalData.spid = value.spid;
}
// #endif
if (options.spid) app.globalData.spid = options.spid;
},
/**
* 加载首页 H5。
* 2026-10-08 起:首页地址不再写死 —— 先等 _homeUrlReady()(box_app 后台配置,
* 本地缓存兜底,最多 2s),再按「有 openid 直接拼 hash 加载 / 无 openid 走取到才加载」。
*/
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.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, extraHash) {
const parts = [];
const openid = this._hostOpenid();
const unionid = this._hostUnionid();
if (openid) parts.push('openid=' + openid);
if (unionid) parts.push('unionid=' + unionid);
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 || this._homeBase(); },
/* 授权面板提交(base = 当前 H5 地址,同 onShow 的回传落点逻辑 ——
宫格换应用后首页地址不再是用户所在的 H5)→ mixin 的共享授权接口 + hash 回传 */
submitAuth() {
this.submitHostAuth((this.shareHref || this.webUrl || this._homeBase()).split('#')[0]);
},
/* MP:订阅消息模板预取(原装修首页行为;utils/SubscribeMessage.js 请求时用缓存) */
getTemlIds() {
try { if (uni.getStorageSync(SUBSCRIBE_MESSAGE)) return; } catch (e) {}
getTemlIds().then(res => {
if (res && res.data) {
try { uni.setStorageSync(SUBSCRIBE_MESSAGE, JSON.stringify(res.data)); } catch (e) {}
}
}).catch(() => {});
},
/* web-view 加载失败 → 卸载 web-view 并显示「重新加载」 */
onWebviewError(e) {
console.warn('[home] web-view 加载失败:', e);
this.loadError = true;
this.webUrl = '';
},
retryLoad() {
this.loadError = false;
this.requesting = false;
this._shownOnce = true;
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(生效首页地址) / consumeAuthResult(见 onShow)
本页不再各写一份;分享图设置由 mixins/shareCard.js 提供。 */
}
}
</script>
<style lang="scss" scoped>
.mamabox-home {
width: 100%;
height: 100vh;
background-color: #F8F3EF;
}
/* web-view 未就绪时的兜底态(加载中 / 失败) */
.mamabox-fallback {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 80rpx;
box-sizing: border-box;
background-color: #F8F3EF;
}
.mamabox-emoji {
font-size: 96rpx;
line-height: 1;
margin-bottom: 40rpx;
}
.mamabox-tip {
text-align: center;
margin-bottom: 56rpx;
}
.mamabox-title {
font-size: 34rpx;
font-weight: 600;
color: #2B2320;
margin-bottom: 16rpx;
}
.mamabox-desc {
font-size: 26rpx;
color: #6B615B;
line-height: 1.6;
}
.mamabox-retry {
width: 320rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
background-color: #F4593C;
color: #FFFFFF;
font-size: 30rpx;
text-align: center;
padding: 0;
margin: 0;
border: none;
&::after {
border: none;
}
}
.mamabox-retry-hover {
opacity: .85;
}
/* 授权完善资料 弹层 */
.mb-auth-mask {
position: fixed; inset: 0; z-index: 999;
background: rgba(0, 0, 0, .45);
display: flex; align-items: center; justify-content: center;
}
.mb-auth-panel {
width: 82%; max-width: 340px;
background: #fff; border-radius: 18px;
padding: 24px 20px 18px;
}
.mb-auth-title { font-size: 17px; font-weight: 700; color: #2B2320; text-align: center; }
.mb-auth-sub { font-size: 12px; color: #A79C95; text-align: center; margin: 8px 0 18px; line-height: 1.5; }
.mb-auth-btn {
width: 100%; height: 46px; line-height: 46px; text-align: center;
border-radius: 12px; background: #F8F3EF; color: #2B2320; font-size: 14px; margin-top: 12px;
}
.mb-auth-btn.avatar { display: flex; align-items: center; justify-content: center; gap: 8px; }
.mb-auth-ava { width: 40px; height: 40px; border-radius: 50%; }
.mb-auth-input {
width: 100%; height: 46px; border-radius: 12px; background: #F8F3EF;
font-size: 14px; color: #2B2320; padding: 0 14px; margin-top: 12px;
}
.mb-auth-actions { display: flex; gap: 12px; margin-top: 20px; }
.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; }
</style>
<!-- ================================================================
恢复原 CRMEB 装修首页:
cp pages/index/index.vue.origin-bak pages/index/index.vue
并把 pages.json 里 pages/index/index 的 enablePullDownRefresh
改回 true(本版改成了 false —— web-view 页面下拉刷新无意义)。
================================================================ -->