Files
crmeb-mp/mixins/webviewHost.js
Karlcarlo bd7f07d7d6 feat(App): 宿主接入短信登录 + app_identity/app_bind
- mixin 新增 _appIdentityFlow:有 token → GET wechat/app_identity;
  无 openid → uni.login(weixin) → POST wechat/app_bind → 再取身份
- _silenceAuthOpenid 在 APP-PLUS 下改走该流程(不碰 wx.login)
- 首页壳/容器页:未登录时停止自动重试,兜底屏给「登录后继续」;
  登录页成功后 reLaunch 回原页(首页 / 容器页带原 url)
- App 的 requestProfile 改为直接查共享资料并回传 hash(不弹面板、
  不调 getPhoneNumber,规避 web-view 覆盖页内浮层)
- api/user.js 新增 appIdentity / appBind

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 20:04:03 +08:00

497 lines
22 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 空串,由调用方决定是否匿名加载。 */
/* ============================================================
App(Android 一期)身份链路 —— 2026-10-26
------------------------------------------------------------
App 没有 wx.login / openid;本地身份来源是「短信登录后的 uid」:
① 有 token → GET /api/wechat/app_identity(routine openid 优先)
② 没有 openid(全新用户)→ uni.login({provider:'weixin'}) 拿 code
→ POST /api/wechat/app_bind 绑定到当前 uid → 再取 app_identity
③ 没装微信/拒绝授权 → 返回空,页面给「登录后继续 / 重试」
⚠️ 不在启动时弹微信:只有走到这里(且确实没有 openid)才授权。
============================================================ */
_appIdentityFlow() {
const needLogin = { openid: '', unionid: '', needLogin: true };
const empty = { openid: '', unionid: '', needLogin: false };
let hasToken = false;
try { hasToken = !!(store.state.app.token || uni.getStorageSync(LOGIN_STATUS)); } catch (e) { hasToken = !!store.state.app.token; }
if (!hasToken) return Promise.resolve(needLogin);
const save = (openid, unionid) => {
if (!openid) return 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) {}
}
return { openid: openid, unionid: unionid || '', needLogin: false };
};
const read = (res) => {
const d = (res && res.data) || {};
return { openid: d.openid || '', unionid: d.unionid || '', source: d.source || '' };
};
return import('@/api/user.js').then((api) => {
return api.appIdentity().then((res) => {
const id = read(res);
console.log('[auth-flow] App app_identity: openid=' + (id.openid ? '有' : '无') + ' source=' + (id.source || '?'));
if (id.openid) return save(id.openid, id.unionid);
return this._weixinLoginCode().then((code) => {
if (!code) {
console.warn('[auth-flow] App:未拿到微信 code(未安装微信/拒绝授权)');
return empty;
}
return api.appBind({ code: code }).then(() => {
return api.appIdentity().then((r2) => {
const id2 = read(r2);
console.log('[auth-flow] App app_bind 后身份: openid=' + (id2.openid ? '有' : '无'));
return save(id2.openid, id2.unionid);
});
});
});
});
}).catch((err) => {
console.warn('[auth-flow] App 身份解析失败:', err && (err.message || err));
return empty;
});
},
_weixinLoginCode() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
uni.login({
provider: 'weixin',
success: (r) => resolve((r && r.code) || ''),
fail: (e) => { console.warn('[auth-flow] uni.login(weixin) 失败:', e); resolve(''); }
});
// #endif
// #ifndef APP-PLUS
resolve('');
// #endif
});
},
_silenceAuthOpenid() {
// #ifdef APP-PLUS
/* App:走短信登录 + app_identity / app_bind(不碰 wx.login) */
return this._appIdentityFlow();
// #endif
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':
/* App:没有小程序授权页,也不该弹页内面板(web-view 是原生组件、会盖住)。
直接用当前 openid 查共享资料,把 hash 写回 web-view 即可。 */
// #ifdef APP-PLUS
this._pushAppProfile(m.href);
// #endif
// #ifndef APP-PLUS
this.showAuth = true;
/* 面板回传基址:H5 在消息里带 href(发起面板时的当前地址)→ 提交后
按它回传。消息送达=面板弹出,href 同一条消息到达,确定性强于
赌 shareHref 的 postMessage 派发时机(宫格换应用后可能还是旧地址)。 */
if (m.href) this._panelBaseUrl = m.href;
// #endif
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 || '');
},
/* App:H5 发 requestProfile 时,直接用当前 openid 查共享资料并回传 hash。
—— 不弹面板、不调 getPhoneNumber(短信登录已验证手机号);
—— 也绕开「App 的 web-view 是原生组件、页内面板会被盖住」的问题。 */
_pushAppProfile(href) {
const openid = this._hostOpenid();
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
const current = (typeof this._hostCurrentUrl === 'function') ? this._hostCurrentUrl() : '';
const base = String(href || this.shareHref || current || '').split('#')[0];
if (!base) return;
uni.showLoading({ title: '获取资料中', mask: true });
uni.request({
url: SHARED_AUTH_API + '?openid=' + encodeURIComponent(openid),
method: 'GET',
timeout: 10000,
success: (r) => {
uni.hideLoading();
const d = r.data || {};
if (d.code === 200) {
const res = d.result || {};
this._hostSetWebviewUrl(this.hostAuthUrl(base, {
openid: openid,
phone: res.phone || '',
nickname: res.userName || '',
avatar: res.avatar || ''
}));
} else {
uni.showToast({ title: d.message || '资料获取失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '资料请求失败,请检查网络', icon: 'none' });
}
});
},
/**
* 读取独立授权页(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() {},
}
};