fix(auth): 授权回传落点改确定性通道 —— backUrl 经 navigateTo query 传递

- 授权页 onLoad 读 options.backUrl(H5 发起授权时带上的当前地址),成功时写进
  __AUTH_RESULT__.backUrl
- consumeAuthResult 优先用 res.backUrl 做回传基址(不再赌 postMessage 派发时机:
  微信只在后退/销毁/分享时批量派发,宫格 location.href 换应用后 shareHref 可能
  还是上一个应用的地址 → 授权后被踢回 mama-box 首页)
- 配套:mama-box wx-auth.js requestProfile / goAuth 已带 backUrl(mama-box d39e52c)

需重新发版生效
This commit is contained in:
2026-09-19 16:56:00 +08:00
parent 67eaae0ceb
commit 3df3e08252
2 changed files with 271 additions and 2 deletions

260
mixins/webviewHost.js Normal file
View File

@@ -0,0 +1,260 @@
// +----------------------------------------------------------------------
// | 宿主页公共能力: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 解密手机号)
};
},
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;
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' });
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(baseUrl, {
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() {},
}
};