Files
crmeb-mp/mixins/webviewHost.js
Karlcarlo e99b4a475c fix(身份): 宿主页「拿到 openid 才加载 H5」+ 复用 App.vue 并行登录(不重复换 code)
背景(2026-09-20 线上排查):删掉小程序缓存重进时 H5 以访客态加载(孕周 `--`、档案空、
跳应用不带 openid,实测跳授权示例是裸 URL)。服务端证据:该设备只打了 `/api/mp/apps`,
没有任何带 openid 的 profile/pregnancy 调用 —— 宿主那次没注入身份,不是数据丢了。

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

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

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

376 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// +----------------------------------------------------------------------
// | 宿主页公共能力: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 || '';
if (href) {
this.shareHref = href;
if (typeof this.resolveShareCard === 'function') this.resolveShareCard(href);
}
if (m.title) { this.shareTitle = m.title; this.shareSummary = m.summary || ''; }
// 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 || ''; }
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;
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 || MAMA_HOME)。 */
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 || MAMA_HOME)。清掉已用值,
避免下次面板在没有新消息 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() {},
}
};