- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动 - 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用) - 排除本地备份文件(*.bak* / *.origin-bak) Co-authored-by: Cursor <cursoragent@cursor.com>
383 lines
17 KiB
JavaScript
383 lines
17 KiB
JavaScript
// +----------------------------------------------------------------------
|
||
// | 宿主页公共能力: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 || '';
|
||
// 诊断日志(过滤关键字 enter-app):这条**不是实时**的 ——
|
||
// 微信把 web-view 的 postMessage 攒到「后退 / 组件销毁 / 分享 / 复制链接」才派发,
|
||
// 所以它出现的时间点通常晚于用户实际换应用;宫格换应用后宿主就是靠它才知道当前在哪。
|
||
console.log('[enter-app] 宿主收到 H5 location 上报(延迟派发)href=' + href);
|
||
// ⚠️ 顺序:先落 H5 上报的标题,再 resolveShareCard ——
|
||
// 后者只在标题来源不是 'h5' 时才用 box_app 反查标题覆盖,顺序反了会白上报。
|
||
if (m.title) { this.shareTitle = m.title; this.shareSummary = m.summary || ''; this.shareTitleSource = 'h5'; }
|
||
if (href) {
|
||
this.shareHref = href;
|
||
if (typeof this.resolveShareCard === 'function') this.resolveShareCard(href);
|
||
}
|
||
// 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 || ''; this.shareTitleSource = 'h5'; }
|
||
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;
|
||
this.shareTitleSource = 'h5'; // H5 明确设的标题 → 后续反查不覆盖
|
||
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 || 生效首页地址)。 */
|
||
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 || 生效首页地址)。清掉已用值,
|
||
避免下次面板在没有新消息 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() {},
|
||
}
|
||
};
|