From 3df3e08252ffda672565ce1a45d5725ea54e966c Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Sat, 19 Sep 2026 16:56:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E6=8E=88=E6=9D=83=E5=9B=9E?= =?UTF-8?q?=E4=BC=A0=E8=90=BD=E7=82=B9=E6=94=B9=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E9=80=9A=E9=81=93=20=E2=80=94=E2=80=94=20backUrl=20=E7=BB=8F?= =?UTF-8?q?=20navigateTo=20query=20=E4=BC=A0=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 授权页 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) 需重新发版生效 --- mixins/webviewHost.js | 260 +++++++++++++++++++++++++++++++++++++ pages/annex/auth/index.vue | 13 +- 2 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 mixins/webviewHost.js diff --git a/mixins/webviewHost.js b/mixins/webviewHost.js new file mode 100644 index 0000000..afebb37 --- /dev/null +++ b/mixins/webviewHost.js @@ -0,0 +1,260 @@ +// +---------------------------------------------------------------------- +// | 宿主页公共能力:H5 postMessage / 原生授权面板 / 授权回传 +// +---------------------------------------------------------------------- +// 适用:承载 的小程序页 —— 有两个 +// 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) 设置本页 的 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() {}, + } +}; diff --git a/pages/annex/auth/index.vue b/pages/annex/auth/index.vue index 2aa5dbe..be7b553 100644 --- a/pages/annex/auth/index.vue +++ b/pages/annex/auth/index.vue @@ -88,8 +88,16 @@ openid: '' } }, - onLoad() { + onLoad(options) { this.openid = getOpenid(); + /* H5 经 navigateTo query 带来的「发起授权的当前地址」(backUrl,已 encode): + 成功时写进 __AUTH_RESULT__,宿主 onShow 优先按它回传 —— 从哪个 H5 触发的 + 授权就回到哪个 H5(宫格 location.href 换应用后,shareHref 可能还是旧应用的)。 */ + let backUrl = (options && options.backUrl) || ''; + if (backUrl) { + try { backUrl = decodeURIComponent(backUrl); } catch (e) { /* 已是原文 */ } + } + this._backUrl = backUrl; if (!this.openid) uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' }); }, methods: { @@ -154,7 +162,8 @@ openid: openid, phone: res.phone || '', nickname: res.userName || this.authNickname || '', - avatar: res.avatar || this.authAvatar || '' + avatar: res.avatar || this.authAvatar || '', + backUrl: this._backUrl || '' }); uni.showToast({ title: '授权成功', icon: 'success' }); setTimeout(() => {