Files
crmeb-mp/mixins/webviewHost.js
Karlcarlo 6f033db7d4 fix(auth): 链路B原生面板回传基址 —— requestProfile 消息带 href 优先用作面板基址
- 面板弹出=requestProfile 消息送达;同一条消息带 href(H5 发起面板时的当前地址)
  → 提交后按它回传,不赌 shareHref 的 postMessage 派发时机(宫格换应用后可能旧地址)
- 不带 href 时回退调用处基址(shareHref || webUrl || MAMA_HOME),向后兼容
- 配套协议:h5-auth-standard.md §4.2 requestProfile 增加 href(推荐带)

需重新发版生效
2026-09-19 17:14:12 +08:00

271 lines
11 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;
},
/* ---------- 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() {},
}
};