fix(身份): 宿主页「拿到 openid 才加载 H5」+ 复用 App.vue 并行登录(不重复换 code)

背景(2026-09-20 线上排查):删掉小程序缓存重进时 H5 以访客态加载(孕周 `--`、档案空、
跳应用不带 openid,实测跳授权示例是裸 URL)。服务端证据:该设备只打了 `/api/mp/apps`,
没有任何带 openid 的 profile/pregnancy 调用 —— 宿主那次没注入身份,不是数据丢了。

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

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

⚠️ 提交同时包含这两处**此前已存在的未提交 WIP**(同文件、改动交织):pages/annex/web_view/index.vue
   的 mixin 重构(handleMessage→handleHostMessage 等 4 处 handler 改名)与 pages/index/index.vue
   的两行 tabBar 调整。
⚠️ 刻意 fail-closed:CRMEB silence_auth 整体不可用时会停在失败屏而不是降级加载。
⚠️ 未动 manifest.json 版本号(见另一次改动:已统一为 0.3.6 / 36)。
This commit is contained in:
2026-09-20 23:10:05 +08:00
parent c216865852
commit e99b4a475c
5 changed files with 431 additions and 346 deletions

38
App.vue
View File

@@ -227,7 +227,7 @@
let that = this; let that = this;
let spid = that.globalData.spid ? that.globalData.spid : ''; let spid = that.globalData.spid ? that.globalData.spid : '';
console.log('[auth-flow] silenceAuth 发起, code=' + (code ? code.substring(0, 10) + '...' : '(空)')); console.log('[auth-flow] silenceAuth 发起, code=' + (code ? code.substring(0, 10) + '...' : '(空)'));
silenceAuth({ const authPromise = silenceAuth({
code: code, code: code,
spread_spid: spid, spread_spid: spid,
spread_code: that.globalData.code spread_code: that.globalData.code
@@ -241,11 +241,30 @@
token: res.data.token, token: res.data.token,
time: time time: time
}); });
that.$store.commit('SETUID', res.data.userInfo.uid); /* ⚠️ 2026-09-20:`userInfo` 不是每个响应都有(v2/wechat/silence_auth
that.$store.commit('UPDATE_USERINFO', res.data.userInfo); 只回 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)'); 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 { } 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 => { }).catch(err => {
console.warn('[auth-flow] ❌ silenceAuth 失败:', JSON.stringify(err).substring(0, 300)); console.warn('[auth-flow] ❌ silenceAuth 失败:', JSON.stringify(err).substring(0, 300));
@@ -253,6 +272,17 @@
title: err title: err
}) })
}); });
/* ⚠️ 2026-09-20:把这次静默登录的 promise 挂到 globalData,
让宿主页(首页壳 / 容器页)在缓存未命中时**先 await 它**、而不是自己再换一次 code
(见 mixins/webviewHost.js 的 _awaitHostLogin)。
动机:微信 code 一次性,两边各换一次要多花 200–300ms(实测 code2session 就这么贵),
而 openid 本来就该由这一条链路落缓存(本文件上面的 SET_OPENID 分支 / LOGIN 从 JWT 提取)。
settle 后清掉,避免下次误等一个已经结束的 promise。 */
try { that.globalData._silenceAuthPromise = authPromise; } catch (e) {}
authPromise.then(() => {
try { if (that.globalData._silenceAuthPromise === authPromise) that.globalData._silenceAuthPromise = null; } catch (e) {}
});
return authPromise;
}, },
/** /**
* 检测当前的小程序 * 检测当前的小程序

View File

@@ -99,6 +99,111 @@ export default {
return uid; 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) ---------- */ /* ---------- H5 postMessage 统一处理(4 个 action) ---------- */
handleHostMessage(e) { handleHostMessage(e) {
const msgs = (e.detail && e.detail.data) || []; const msgs = (e.detail && e.detail.data) || [];

View File

@@ -1,22 +1,33 @@
<template> <template>
<view> <view>
<web-view v-if="url" class="web-view" :webview-styles="webviewStyles" :src="url" <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'}) 后显示) --> <!-- 共享原生授权面板(任意 H5 应用 postMessage({action:'requestProfile'}) 后显示) -->
<view v-if="showAuth" class="mb-auth-mask" @tap="closeAuth"> <view v-if="showAuth" class="mb-auth-mask" @tap="closeAuth">
<view class="mb-auth-panel" @tap.stop="noop"> <view class="mb-auth-panel" @tap.stop="noop">
<view class="mb-auth-title">授权完善资料</view> <view class="mb-auth-title">授权完善资料</view>
<view class="mb-auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</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" /> <image v-if="authAvatar" class="mb-auth-ava" :src="authAvatar" mode="aspectFill" />
<text v-else>选择头像</text> <text v-else>选择头像</text>
</button> </button>
<input class="mb-auth-input" type="nickname" v-model="authNickname" placeholder="输入昵称" maxlength="16" /> <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"> <view class="mb-auth-actions">
<button class="mb-auth-cancel" @tap="closeAuth">取消</button> <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> </view>
</view> </view>
@@ -26,6 +37,8 @@
import store from '@/store'; import store from '@/store';
import { HTTP_REQUEST_URL, TRUSTED_DOMAINS } from '@/config/app'; import { HTTP_REQUEST_URL, TRUSTED_DOMAINS } from '@/config/app';
import { LOGIN_STATUS } from '@/config/cache'; import { LOGIN_STATUS } from '@/config/cache';
import shareCard from '@/mixins/shareCard.js';
import webviewHost from '@/mixins/webviewHost.js';
function base64Decode(str) { function base64Decode(str) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
@@ -92,12 +105,36 @@ function getCurrentUser() {
return { openid, unionid }; 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) { function stripAuthParams(url) {
return url let out = String(url || '');
.replace(/([?&])openid=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || '')) const hashIdx = out.indexOf('#');
.replace(/([?&])unionid=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || '')) let base = hashIdx >= 0 ? out.slice(0, hashIdx) : out;
.replace(/([?&])t=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || '')) let hash = hashIdx >= 0 ? out.slice(hashIdx + 1) : '';
.replace(/\?&$/, '').replace(/\?$/, '').replace(/&$/, ''); 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) { function getUrlParam(url, name) {
@@ -108,25 +145,26 @@ function getUrlParam(url, name) {
} }
export default { export default {
mixins: [shareCard, webviewHost],
data() { data() {
return { return {
windowH: 0, windowW: 0, windowH: 0, windowW: 0,
webviewStyles: { progress: { color: 'transparent' } }, webviewStyles: { progress: { color: 'transparent' } },
url: '', url: '',
shareTitle: '', // 分享字段由 mixins/shareCard.js 提供;
shareSummary: '', // 授权面板字段(showAuth/authNickname/authAvatar/_authCode)由 mixins/webviewHost.js 提供
shareImageUrl: '',
shareTimelineImage: '',
shareHref: '',
savedUrl: '', savedUrl: '',
_shareFetched: false, identityPending: false, // 正在获取 openid(此期间**不加载 H5**)
showAuth: false, // 授权面板开关(H5 postMessage 后显示) identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口
authNickname: '', _idAttempts: 0, // 自动重试计数
authAvatar: '', _pendingTarget: '' // 重试时要加载的目标 URL(重试按钮用)
_authCode: '' // getPhoneNumber 的 code(提交时交 box_app 解密手机号)
} }
}, },
onLoad(option) { onLoad(option) {
// 分享落地携带的推广人:与其它页面一致写进 globalData.spid(下单归因用)
if (option && option.spid) {
try { getApp().globalData.spid = option.spid; } catch (e) {}
}
// 太阳码 scene 解析:scene="app=<identifier>" 时调 box_app 查 url // 太阳码 scene 解析:scene="app=<identifier>" 时调 box_app 查 url
// 例如 box_app /api/wxacode?app=pregnancy → scene="app=pregnancy" // 例如 box_app /api/wxacode?app=pregnancy → scene="app=pregnancy"
// CRMEB 收到后查 box_app /api/mp/apps?isPublished=1 找 url,跳对应 H5 // CRMEB 收到后查 box_app /api/mp/apps?isPublished=1 找 url,跳对应 H5
@@ -156,22 +194,12 @@ export default {
// 3) 退出登录后再次进入 web_view(url 已存在)→ 用缓存的 openid 重新注入 // 3) 退出登录后再次进入 web_view(url 已存在)→ 用缓存的 openid 重新注入
// 0) 授权页返回:读取 __AUTH_RESULT__,把授权结果写入当前 H5 URL 的 hash,让 H5 重载读取 // 0) 授权页返回:读取 __AUTH_RESULT__,把授权结果写入当前 H5 URL 的 hash,让 H5 重载读取
try { // (与首页壳共用 mixins/webviewHost.js 的 consumeAuthResult)
const authRes = uni.getStorageSync('__AUTH_RESULT__'); const authUrl = this.consumeAuthResult(this.url);
if (authRes && authRes.openid && this.url) { if (authUrl) {
uni.removeStorageSync('__AUTH_RESULT__'); console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash');
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash → openid=' + (authRes.openid || '').substring(0, 8) + '...'); this.url = authUrl;
const base = this.url.split('#')[0]; return;
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);
} }
const { openid, unionid } = getCurrentUser(); const { openid, unionid } = getCurrentUser();
@@ -182,8 +210,9 @@ export default {
if (openid) { if (openid) {
this._applyIdentity(targetUrl, openid, unionid, urlOpenid); this._applyIdentity(targetUrl, openid, unionid, urlOpenid);
} else { } else {
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,加载原始URL'); /* ⚠️ 2026-09-20:不再「加载原始URL」(那会让应用进访客态),改走「取到才加载」 */
this._finalizeUrl(targetUrl); console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
} }
} else if (this.url && openid) { } else if (this.url && openid) {
// H5 授权登录返回 / 退出后回到 web_view:重新注入最新身份 // H5 授权登录返回 / 退出后回到 web_view:重新注入最新身份
@@ -219,7 +248,7 @@ export default {
const appUrl = app.url; const appUrl = app.url;
// 预取分享图片(转发给好友时用作分享卡片图) // 预取分享图片(转发给好友时用作分享卡片图)
if (app.shareImageUrl) { if (app.shareImageUrl) {
this._applyShareImage(app.shareImageUrl); this._applyShareImage(app.shareImageUrl, 'box', app.url);
console.log('[web_view] 分享图片: ' + app.shareImageUrl); console.log('[web_view] 分享图片: ' + app.shareImageUrl);
} }
let finalUrl = appUrl; let finalUrl = appUrl;
@@ -279,22 +308,26 @@ export default {
console.log('[auth-flow] ✅ 重试成功,注入身份并跳转'); console.log('[auth-flow] ✅ 重试成功,注入身份并跳转');
this._applyIdentity(targetUrl, retry.openid, retry.unionid, getUrlParam(targetUrl, 'openid')); this._applyIdentity(targetUrl, retry.openid, retry.unionid, getUrlParam(targetUrl, 'openid'));
} else { } else {
console.warn('[auth-flow] ⚠️ 重试仍无 openid,匿名加载 H5'); /* ⚠️ 2026-09-20:以前这里匿名加载 H5(→ 应用进访客态)。
this._finalizeUrl(targetUrl); 改成走「取到 openid 才加载」的公共路径(自动重试 3 次 → 失败屏)。 */
console.warn('[auth-flow] ⚠️ 重试仍无 openid,改走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
} }
} catch (e) { } catch (e) {
console.error('[auth-flow] 重试异常,匿名加载:', e); console.error('[auth-flow] 重试异常,改走「取到才加载」:', e);
this._finalizeUrl(targetUrl); this._ensureIdentityThenLoad(targetUrl);
} }
}, 500); }, 500);
return; return;
} }
// 无 token + 无 openid(新用户 / 退出后 / 静默授权失败 / 冷启动竞态兜底超时): // 无 token + 无 openid(新用户 / 退出后 / 静默授权失败 / 冷启动竞态兜底超时):
// 主动用 wx.login 拿 code 调 CRMEB silenceAuthNoLogin(v2/wechat/silence_auth) // 走「取到 openid 才加载」——内部用 mixin 的 _silenceAuthOpenid()
// 拿到 openid 后注入 URL,让 H5 端完成 openid-login 建档 // (wx.login → CRMEB v2/wechat/silence_auth,兼容 auth_login 无 token / userInfo / JWT),
// 该端点不查 store_user_mobile,新用户也能拿到 openid // 自动重试 3 次;拿不到就停在兜底屏,**不再匿名加载 H5**。
console.log('[auth-flow] 🔑 无 openid,调 silenceAuthNoLogin 拿 openid'); // ⚠️ 原来这里调本页的 _fetchOpenidAndLoad(同一套逻辑的另一份实现),
this._fetchOpenidAndLoad(targetUrl); // 现已删除、统一到 mixin,避免两处分叉。
console.log('[auth-flow] 🔑 无 openid,走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
return; return;
} else { } else {
console.log('[web_view] 非可信域,不做身份替换'); console.log('[web_view] 非可信域,不做身份替换');
@@ -311,91 +344,60 @@ export default {
}, },
/** /**
* 主动 wx.login() 拿 code,调 CRMEB /v2/wechat/silence_auth 拿 openid * 取到 openid 才加载 H5(2026-09-20 新增;取代原 _fetchOpenidAndLoad)
* 然后注入 URL 并加载 H5(即使后端返回 auth_login=1 无 token,openid 已够 H5 端走 openid-login 建档) *
* @param {string} targetUrl - 原始 H5 URL * 与首页壳同一策略:**拿不到 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) { _ensureIdentityThenLoad(targetUrl) {
uni.login({ this._pendingTarget = targetUrl || this._pendingTarget || '';
provider: 'weixin', const t = this._pendingTarget;
success: (loginRes) => { if (!t) return;
const code = (loginRes && loginRes.code) || ''; /* 1) 缓存命中(含 App.vue 并行登录刚落地的)→ 0 等待注入并把 H5 装进 src */
if (!code) { if (this._loadWithCachedIdentity(t)) return;
console.warn('[auth-flow] ⚠️ wx.login 返回空 code,匿名加载 H5'); const seq = this._idAttempts; // 0 = 还没取过
this._finalizeUrl(targetUrl); this._idAttempts = seq + 1;
return; const MAX = 2; // ⚠️ 网络尝试上限 2 次(原 3 次)
} this.identityPending = true;
console.log('[auth-flow] wx.login 拿到 code(前 8 位: ' + code.substring(0, 8) + '...),调 silenceAuthNoLogin'); this.identityFail = false;
// 调 api/public.js 已有的 silenceAuth(MP 端 → v2/wechat/silence_auth) console.log('[auth-flow] 🔑 容器页取 openid(第 ' + this._idAttempts + '/' + MAX + ' 次),此期间不加载 H5');
// 动态 import 避免在 web_view 页面外被 import 失败 /* 2) 第 1 次先等 App.vue onLaunch 那次静默登录(同一次 exchange,别重复换 code);
const apiPath = '@/api/public.js'; 之后的重试才自己 wx.login + 换 openid。理由与首页壳一致,见 mixins 的 _awaitHostLogin。 */
// #ifdef MP const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid();
import('@/api/public.js').then(api => { task.then(() => {
api.silenceAuth({ code: code }).then(res => { if (this._loadWithCachedIdentity(t)) return;
const data = res.data || {}; if (this._idAttempts < MAX) {
// 兼容两种响应格式: setTimeout(() => this._ensureIdentityThenLoad(t), 500); // 退避固定 500ms(原 600×n)
// 1) 新用户 notLogin=true: {auth_login:1, key, openid, unionid}(顶层 openid) return;
// 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);
} }
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) { _applyIdentity(targetUrl, openid, unionid, urlOpenid) {
console.log('[auth-flow] _applyIdentity: openid=' + (openid || '(空)') + ', unionid=' + (unionid || '(空)') + ', urlOpenid=' + (urlOpenid || '(无)')); console.log('[auth-flow] _applyIdentity: openid=' + (openid || '(空)') + ', unionid=' + (unionid || '(空)') + ', urlOpenid=' + (urlOpenid || '(无)'));
@@ -456,104 +458,32 @@ export default {
this._fetchShareImage(targetUrl); this._fetchShareImage(targetUrl);
}, },
/** /* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 mixins/shareCard.js 提供:
* 根据当前 H5 URL 反查 box_app 应用,获取分享图片(shareImageUrl) _fetchShareImage(targetUrl) —— 本页 onLoad 兜底(按当前 H5 URL 反查 box_app
* URL 形如 https://mp.yizhezhe.com/app_03/ 或 https://mp.yizhezhe.com/test_app/?ch=YFMM App.shareImageUrl;太阳码场景 _resolveAppScene 已直接设好)
* 通过 mp/apps 列表匹配 url 前缀,找到对应应用后取 shareImageUrl _applyShareImage(url) —— H5 postMessage({action:'share', imageUrl}) 覆盖
* 命中本地缓存直接复用(进入页面后立刻分享也能带图),避免每次重复反查 _lookupShareImage(url) —— 同步匹配(先内存应用表,再单应用缓存兜底)
* @param {string} targetUrl 首页壳 pages/index/index.vue 复用同一 mixin(它不会随应用切换重新 onLoad,
*/ 必须在分享那一刻按 options.webViewUrl 同步匹配)。 */
_fetchShareImage(targetUrl) {
if (!targetUrl) return;
// H5 已通过 postMessage 设置分享图片,不覆盖
if (this.shareImageUrl) return;
// 页面内只请求一次,避免重复拉取
if (this._shareFetched) return;
this._shareFetched = true;
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);
return;
}
uni.request({
url: 'https://mp.yizhezhe.com/api/mp/apps?isPublished=1&pageSize=100',
method: 'GET',
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);
}
},
fail: (err) => {
// 常见原因:mp.yizhezhe.com 未加入小程序后台 request/downloadFile 合法域名白名单
console.warn('[web_view] 分享图片请求失败(请检查 mp.yizhezhe.com 是否已加入合法域名白名单):', err);
}
});
} catch (e) {
console.warn('[web_view] _fetchShareImage 异常:', 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 = '';
}
});
},
}, },
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 { return {
title: this.shareTitle || '分享', title: this.shareTitle || '分享',
// 分享出去必须剥离 openid/unionid/t 身份参数: // 分享出去必须剥离 openid/unionid/t 等身份参数:
// 1) 避免把分享者的微信匿名标识泄露给接收者; // 1) 避免把分享者的微信匿名标识泄露给接收者;
// 2) 接收者打开后由 web_view 按自己的身份重新注入(onShow/onLoad 有兜底替换)。 // 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 || '' imageUrl: this.shareImageUrl || ''
} }
}, },
onShareTimeline() { onShareTimeline() {
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底
return { return {
title: this.shareTitle || '分享', title: this.shareTitle || '分享',
query: 'url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)), query: 'url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
@@ -562,111 +492,13 @@ export default {
imageUrl: this.shareTimelineImage || '' imageUrl: this.shareTimelineImage || ''
} }
}, },
handleMessage(e) { /* webviewHost hook:本页 web-view 地址字段是 url */
const msgs = e.detail && e.detail.data ? e.detail.data : []; _hostSetWebviewUrl(url) { this.url = url; },
const data = msgs[msgs.length - 1] || null; /* webviewHost hook:copyLink 未带 href 时的兜底地址 */
if (!data) return _hostCurrentUrl() { return this.url || ''; },
switch (data.action) { /* handleHostMessage / onChooseHostAvatar / onHostGetPhone / submitHostAuth(url) /
case 'share': consumeAuthResult(见 onShow) 由 mixins/webviewHost.js 统一提供 —— 与首页壳共用一套。 */
// 存储分享数据,供 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() {},
} }
</script> </script>
@@ -684,4 +516,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, .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-cancel { background: #F1E6E0; color: #6B615B; }
.mb-auth-ok { background: #F4593C; color: #fff; } .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> </style>

View File

@@ -21,14 +21,17 @@
<view class="mamabox-home"> <view class="mamabox-home">
<web-view v-if="webUrl" :src="webUrl" @message="handleHostMessage" @error="onWebviewError"></web-view> <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 v-else class="mamabox-fallback">
<view class="mamabox-emoji">{{ loadError ? '🥲' : '🍼' }}</view> <view class="mamabox-emoji">{{ fallbackEmoji }}</view>
<view class="mamabox-tip"> <view class="mamabox-tip">
<view class="mamabox-title">{{ loadError ? '页面加载失败' : '正在加载' }}</view> <view class="mamabox-title">{{ fallbackTitle }}</view>
<view class="mamabox-desc">{{ loadError ? '请检查网络后重试' : '正在为你打开「兜爸心选」' }}</view> <view class="mamabox-desc">{{ fallbackDesc }}</view>
</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> </view>
<!-- 小程序原生授权面板(H5 postMessage 请求后显示):手机号/昵称/头像 --> <!-- 小程序原生授权面板(H5 postMessage 请求后显示):手机号/昵称/头像 -->
@@ -103,14 +106,34 @@
name: 'Index', name: 'Index',
mixins: [shareCard, webviewHost], mixins: [shareCard, webviewHost],
computed: { 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 '马上就好,请稍候(不会加载空数据的页面)';
return '正在为你打开「兜爸心选」';
},
fallbackEmoji() {
return (this.loadError || this.identityFail) ? '🥲' : '🍼';
},
}, },
data() { data() {
return { return {
webUrl: '', // web-view 地址(硬编码 + #openid/#unionid) webUrl: '', // web-view 地址(硬编码 + #openid/#unionid)
requesting: false, // 防重入 requesting: false, // 防重入
_shownOnce: false, // 首次展示标记(避免首次 onShow 重复加载) _shownOnce: false, // 首次展示标记(避免首次 onShow 重复加载)
_idAttempts: 0, // 「取到 openid 才加载」的自动重试计数(见 fetchIdentityThenLoad)
loadError: false, // web-view 加载失败(显示「重新加载」) loadError: false, // web-view 加载失败(显示「重新加载」)
identityPending: false, // 正在获取 openid(此期间**不加载 H5**,见 fetchIdentityThenLoad)
identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口
shareInfo: {}, // CRMEB 后台「分享设置」(标题/图,无 App 匹配时兜底) shareInfo: {}, // CRMEB 后台「分享设置」(标题/图,无 App 匹配时兜底)
// 授权面板字段由 mixins/webviewHost.js 提供: // 授权面板字段由 mixins/webviewHost.js 提供:
// showAuth / authNickname / authAvatar / _authCode // showAuth / authNickname / authAvatar / _authCode
@@ -157,15 +180,22 @@
}); });
return; return;
} }
// ✅ 实测结论(微信开发者工具):首页的 <web-view> 是原生全屏组件, // ⚠️ 首页必须主动隐藏原生 tabBar(2026-09-18 定论,推翻下面旧结论):
// 会**盖住**原生 tabBar,所以首页只显示 H5 内部导航(无双条); // 真机上 web-view **盖不住**原生 tabBar —— 旧结论是在 custom:true、
// 切到商城原生页(分类/购物车/我的)时 tabBar 正常显示,点「首页」tab 可回 H5。 // 原生条根本不渲染的时期测的。H5 自带底部导航 + 原生 tabBar = 双条。
// ⇒ 无需 uni.hideTabBar()(隐藏了反而让商城 tab 根页变死胡同),保持现状即可。 // hideTabBar 是全局状态:商城各 tab 页都挂着 pageFooter,由它按后台
// 「装修→底部导航」配置自管显隐(启用=hideTabBar+渲染 DIY 底栏,
// 禁用=showTabBar 露出原生条兜底),所以这里隐藏不会让商城页变死胡同。
// ⚠️ 但严禁在商城页主动 showTabBar 来「配对」——上一轮翻车根因
// (showTabBar + DIY 底栏同显 = 商品列表双 tabBar,见 onShow 旧警告)。
uni.hideTabBar();
this.loadMamaBox(); this.loadMamaBox();
}, },
onShow() { onShow() {
// 通知自定义 tabBar 按当前路由刷新(首页=web-view,不显示 tabBar) // 每次回到首页都再压一次 tabBar:hideTabBar 是全局状态,可能被别处
uni.$emit('tabbar-update'); // showTabBar 抬回来(如后台停用 DIY 底部导航时商城页会露出原生条兜底)。
// (原这行的 uni.$emit('tabbar-update') 随 custom-tab-bar 死代码一并移除)
uni.hideTabBar();
// 0) 独立授权页(pages/annex/auth/index)返回:读取 __AUTH_RESULT__, // 0) 独立授权页(pages/annex/auth/index)返回:读取 __AUTH_RESULT__,
// 把手机号/昵称/头像写回 H5 URL 的 hash(授权结果只走 hash)。 // 把手机号/昵称/头像写回 H5 URL 的 hash(授权结果只走 hash)。
// ⚠️ 必须在下面「重载 webview」之前处理,否则结果会被 ?t= 重载冲掉。 // ⚠️ 必须在下面「重载 webview」之前处理,否则结果会被 ?t= 重载冲掉。
@@ -188,14 +218,22 @@
// 以 shareHref(当前 H5)为基址重载,停留在原应用首页。 // 以 shareHref(当前 H5)为基址重载,停留在原应用首页。
this.loadError = false; this.loadError = false;
const reloadBase = (this.shareHref || MAMA_HOME).split('#')[0]; const reloadBase = (this.shareHref || MAMA_HOME).split('#')[0];
this.webUrl = this.buildWebUrl(reloadBase + '?t=' + Date.now()); if (this._hostOpenid()) {
this.webUrl = this.buildWebUrl(reloadBase + '?t=' + Date.now());
} else {
/* ⚠️ 仍无 openid:走同一条「取到才加载」的路(自动重试 3 次 → 兜底屏),
不再匿名重载(原因见 fetchIdentityThenLoad 的注释)。 */
console.log('[auth-flow] 🔄 onShow: 仍无 openid,走「取到才加载」');
this._idAttempts = 0;
this.fetchIdentityThenLoad(reloadBase);
}
} else { } else {
this._shownOnce = true; this._shownOnce = true;
} }
// ⚠️ 实测:home/商城 之间 programmatic 切换 tabBar(hide/show) 会引发 // 历史(勿照旧回退):曾试过「home hide + 商城页 show」双向 programmatic
// 「进入商城跳一下 + 商品列表双 tabBar」两个副作用。已回退该方案。 // 切换,因商城页 showTabBar 与 DIY 底栏同显 →「商品列表双 tabBar」+跳一下,
// 首页的底部导航由 web-view 自身决定(多数版本会盖住原生 tabBar, // 当时整体回退。2026-09-18 起改为单向:只在首页 hideTabBar,商城页显隐
// 首页只显示 H5 内部导航),不再手动 hideTabBar。 // 完全交给 pageFooter(后台 DIY 配置驱动),不再有任何 showTabBar 配对逻辑。
// 购物车角标(tabBar 索引 3 = 购物车),沿用原首页逻辑 // 购物车角标(tabBar 索引 3 = 购物车),沿用原首页逻辑
if (this.cartNum > 0) { if (this.cartNum > 0) {
uni.setTabBarBadge({ uni.setTabBarBadge({
@@ -275,9 +313,73 @@
if (this.requesting) return; if (this.requesting) return;
this.requesting = true; this.requesting = true;
this.loadError = false; this.loadError = false;
this.webUrl = this.buildWebUrl(MAMA_HOME); /* ⚠️ 身份兜底(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;
if (this._hostOpenid()) {
this.identityPending = false;
this._idAttempts = 0;
this.webUrl = this.buildWebUrl(MAMA_HOME);
this.requesting = false;
return;
}
/* ⚠️⚠️ 拿不到 openid **就不加载**(2026-09-20 用户定的行为):
宁可让用户在这一屏多等几秒(自动重试 3 次),也不匿名加载 H5 ——
匿名加载会让 H5 进访客态(孕周 `--`、档案查不到),
用户看到「页面能用但全是空」比等一会儿更糟,且事后很难判断是身份问题。 */
this._idAttempts = 0;
this.fetchIdentityThenLoad(MAMA_HOME);
this.requesting = false; this.requesting = false;
}, },
/**
* 取到 openid 才加载 H5;自动重试 3 次仍失败 → 停在兜底屏给「重新获取身份」入口。
* ⚠️ 每次尝试前都再读一次 _hostOpenid():App.vue 的 onLaunch 静默授权是并行的,
* 它先落地的话这里直接命中缓存,不必自己再请求一次。
* @param {string} base - 要加载的 H5 基址(首页壳是 MAMA_HOME;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;
this.webUrl = this.buildWebUrl(base + (base.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now());
console.log('[auth-flow] ✅ 首页壳拿到 openid,开始加载 H5');
return true;
},
/** /**
* 给 H5 拼 hash 参数(openid 单一身份键 + unionid 可选)。 * 给 H5 拼 hash 参数(openid 单一身份键 + unionid 可选)。
* ⚠️ 只能拼 # 不能拼 ?:微信 web-view 首次加载丢 query(app_03 实测)。 * ⚠️ 只能拼 # 不能拼 ?:微信 web-view 首次加载丢 query(app_03 实测)。
@@ -324,7 +426,14 @@
this.loadError = false; this.loadError = false;
this.requesting = false; this.requesting = false;
this._shownOnce = true; this._shownOnce = true;
this.webUrl = this.buildWebUrl(MAMA_HOME + '?t=' + Date.now()); this.identityFail = false;
/* 有身份直接重载;没有就再走一遍「取到才加载」(重置尝试次数,自动重试 3 次) */
if (this._hostOpenid()) {
this.webUrl = this.buildWebUrl(MAMA_HOME + '?t=' + Date.now());
return;
}
this._idAttempts = 0;
this.fetchIdentityThenLoad(MAMA_HOME);
}, },
/* 授权完善资料(postMessage → 原生面板 → 共享授权接口 → hash 回传)与 /* 授权完善资料(postMessage → 原生面板 → 共享授权接口 → hash 回传)与

View File

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