fix(身份): 宿主页「拿到 openid 才加载 H5」+ 复用 App.vue 并行登录(不重复换 code)
背景(2026-09-20 线上排查):删掉小程序缓存重进时 H5 以访客态加载(孕周 `--`、档案空、
跳应用不带 openid,实测跳授权示例是裸 URL)。服务端证据:该设备只打了 `/api/mp/apps`,
没有任何带 openid 的 profile/pregnancy 调用 —— 宿主那次没注入身份,不是数据丢了。
根因:
1) App.vue 静默授权只处理 token 分支:v2/wechat/silence_auth 对新用户 / 强制绑定手机号返回
{auth_login:1, key, openid, unionid}(无 token),openid 被丢掉;且 res.data.userInfo 缺失时
取 .uid 会抛异常把后续 commit 一起带走。
2) 两个宿主页只从 store.openid / 缓存 OPENID / JWT 读身份,读不到就匿名加载(首页壳不等不重试,
容器页 _finalizeUrl / _fetchOpenidAndLoad 兜底)。
改法:
- App.vue:补「无 token 但有 openid」分支(SET_OPENID + 缓存,unionid 同理);userInfo 判空;
**并把这次静默登录的 promise 挂到 globalData._silenceAuthPromise**(供宿主页复用,见下)。
- mixins/webviewHost.js:新增
· _silenceAuthOpenid()(wx.login → v2/wechat/silence_auth,兼容 auth_login/userInfo/JWT 三种
openid 来源,拿到即落 store + 缓存,带 8s 超时保险丝);
· _awaitHostLogin(timeout)(等 App.vue 那次登录结束,**不重复换 code**)。
- 两个宿主页统一 fail-closed「拿到 openid 才加载」,并且**第 1 次先 await App.vue 的并行登录**、
命中缓存就直接加载;之后才自己取。**网络尝试上限 3 → 2,退避 600×n → 固定 500ms**
(实测:code2session 约 200–300ms,重复换一次就是白花这个钱;最坏等待从 ~3.5s 降到 ~1–2.5s)。
- 首页壳:读不到身份就不设 webUrl(停在小程序自己的加载屏,文案分「取身份中/失败/页面加载失败」三态);
2 次仍失败 → 「未获取到微信身份」+「重新获取身份」。
- 容器页:三条失败分支(有 token 无 openid 的重试失败、无 token 无 openid、onShow 仍无 openid)
全部改走 _ensureIdentityThenLoad();删除本页重复实现的 _fetchOpenidAndLoad;新增兜底屏
(以前 url 为空是白屏)。非可信域(商城自身域名等)不注入身份、照常直接加载。
- utils/util.js:注释里的方法名同步(_fetchOpenidAndLoad → _ensureIdentityThenLoad)。
⚠️ 提交同时包含这两处**此前已存在的未提交 WIP**(同文件、改动交织):pages/annex/web_view/index.vue
的 mixin 重构(handleMessage→handleHostMessage 等 4 处 handler 改名)与 pages/index/index.vue
的两行 tabBar 调整。
⚠️ 刻意 fail-closed:CRMEB silence_auth 整体不可用时会停在失败屏而不是降级加载。
⚠️ 未动 manifest.json 版本号(见另一次改动:已统一为 0.3.6 / 36)。
This commit is contained in:
@@ -1,22 +1,33 @@
|
||||
<template>
|
||||
<view>
|
||||
<web-view v-if="url" class="web-view" :webview-styles="webviewStyles" :src="url"
|
||||
:style="{width: windowW + 'px', height: windowH + 'px'}" @message="handleMessage"></web-view>
|
||||
:style="{width: windowW + 'px', height: windowH + 'px'}" @message="handleHostMessage"></web-view>
|
||||
|
||||
<!-- 身份未就绪兜底屏(2026-09-20 新增)
|
||||
⚠️ 本页与首页壳同一策略:**拿到 openid 才加载 H5**,不再匿名加载。
|
||||
以前 url 为空时这里是白屏(没有任何占位),所以必须给一块可见的屏,
|
||||
否则「不加载」会变成「白屏 + 用户不知道该干嘛」。 -->
|
||||
<view v-else class="wv-fallback">
|
||||
<view class="wv-emoji">{{ identityFail ? '🥲' : '🍼' }}</view>
|
||||
<view class="wv-title">{{ identityFail ? '未获取到微信身份' : '正在获取微信身份' }}</view>
|
||||
<view class="wv-desc">{{ identityFail ? '请检查网络后点下方重试,或退出小程序重新进入' : '马上就好,请稍候(不会加载空数据的页面)' }}</view>
|
||||
<button v-if="identityFail" class="wv-retry" hover-class="wv-retry-hover" @tap="retryIdentity">重新获取身份</button>
|
||||
</view>
|
||||
|
||||
<!-- 共享原生授权面板(任意 H5 应用 postMessage({action:'requestProfile'}) 后显示) -->
|
||||
<view v-if="showAuth" class="mb-auth-mask" @tap="closeAuth">
|
||||
<view class="mb-auth-panel" @tap.stop="noop">
|
||||
<view class="mb-auth-title">授权完善资料</view>
|
||||
<view class="mb-auth-sub">用于在应用里展示你的昵称 / 头像 / 手机号</view>
|
||||
<button class="mb-auth-btn avatar" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
|
||||
<button class="mb-auth-btn avatar" open-type="chooseAvatar" @chooseavatar="onChooseHostAvatar">
|
||||
<image v-if="authAvatar" class="mb-auth-ava" :src="authAvatar" mode="aspectFill" />
|
||||
<text v-else>选择头像</text>
|
||||
</button>
|
||||
<input class="mb-auth-input" type="nickname" v-model="authNickname" placeholder="输入昵称" maxlength="16" />
|
||||
<button class="mb-auth-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">获取手机号</button>
|
||||
<button class="mb-auth-btn" open-type="getPhoneNumber" @getphonenumber="onHostGetPhone">获取手机号</button>
|
||||
<view class="mb-auth-actions">
|
||||
<button class="mb-auth-cancel" @tap="closeAuth">取消</button>
|
||||
<button class="mb-auth-ok" @tap="submitAuth">保存并返回</button>
|
||||
<button class="mb-auth-ok" @tap="submitHostAuth(url)">保存并返回</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -26,6 +37,8 @@
|
||||
import store from '@/store';
|
||||
import { HTTP_REQUEST_URL, TRUSTED_DOMAINS } from '@/config/app';
|
||||
import { LOGIN_STATUS } from '@/config/cache';
|
||||
import shareCard from '@/mixins/shareCard.js';
|
||||
import webviewHost from '@/mixins/webviewHost.js';
|
||||
|
||||
function base64Decode(str) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
@@ -92,12 +105,36 @@ function getCurrentUser() {
|
||||
return { openid, unionid };
|
||||
}
|
||||
|
||||
/* 剥离分享链接里的身份/缓存参数。分享出去的链接必须干净:
|
||||
① 不能把分享者的 openid/unionid 泄露给接收者;
|
||||
② 接收者点开后由本页(web_view 容器)按自己的身份重新注入。
|
||||
覆盖 query 的 openid/unionid/t/_v 与 hash 的 openid/unionid/phone/nickname/avatar
|
||||
(hash 是授权回传通道,见 docs/h5-auth-standard.md)。
|
||||
⚠️ 逐段按 key 过滤而不是正则替换:正则 [^&]+ 会连 #hash 一起吃掉。 */
|
||||
const SHARE_DROP_KEYS = ['openid', 'unionid', 't', '_v', 'phone', 'nickname', 'avatar'];
|
||||
function dropKeyValuePairs(str, keys) {
|
||||
return String(str || '')
|
||||
.split('&')
|
||||
.filter(pair => !!pair && keys.indexOf(pair.split('=')[0]) === -1)
|
||||
.join('&');
|
||||
}
|
||||
function stripAuthParams(url) {
|
||||
return url
|
||||
.replace(/([?&])openid=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || ''))
|
||||
.replace(/([?&])unionid=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || ''))
|
||||
.replace(/([?&])t=[^&]+(&|$)/g, (_, pre, post) => pre === '?' ? '?' : (post || ''))
|
||||
.replace(/\?&$/, '').replace(/\?$/, '').replace(/&$/, '');
|
||||
let out = String(url || '');
|
||||
const hashIdx = out.indexOf('#');
|
||||
let base = hashIdx >= 0 ? out.slice(0, hashIdx) : out;
|
||||
let hash = hashIdx >= 0 ? out.slice(hashIdx + 1) : '';
|
||||
const qIdx = base.indexOf('?');
|
||||
let query = qIdx >= 0 ? base.slice(qIdx + 1) : '';
|
||||
base = qIdx >= 0 ? base.slice(0, qIdx) : base;
|
||||
query = dropKeyValuePairs(query, SHARE_DROP_KEYS);
|
||||
hash = dropKeyValuePairs(hash, SHARE_DROP_KEYS);
|
||||
// 兜底:hash 是「路由 + ?参数」形态时(如 #/report?openid=x),再剥一层
|
||||
const hq = hash.indexOf('?');
|
||||
if (hq >= 0) {
|
||||
const hqQuery = dropKeyValuePairs(hash.slice(hq + 1), SHARE_DROP_KEYS);
|
||||
hash = hash.slice(0, hq) + (hqQuery ? '?' + hqQuery : '');
|
||||
}
|
||||
return base + (query ? '?' + query : '') + (hash ? '#' + hash : '');
|
||||
}
|
||||
|
||||
function getUrlParam(url, name) {
|
||||
@@ -108,25 +145,26 @@ function getUrlParam(url, name) {
|
||||
}
|
||||
|
||||
export default {
|
||||
mixins: [shareCard, webviewHost],
|
||||
data() {
|
||||
return {
|
||||
windowH: 0, windowW: 0,
|
||||
webviewStyles: { progress: { color: 'transparent' } },
|
||||
url: '',
|
||||
shareTitle: '',
|
||||
shareSummary: '',
|
||||
shareImageUrl: '',
|
||||
shareTimelineImage: '',
|
||||
shareHref: '',
|
||||
// 分享字段由 mixins/shareCard.js 提供;
|
||||
// 授权面板字段(showAuth/authNickname/authAvatar/_authCode)由 mixins/webviewHost.js 提供
|
||||
savedUrl: '',
|
||||
_shareFetched: false,
|
||||
showAuth: false, // 授权面板开关(H5 postMessage 后显示)
|
||||
authNickname: '',
|
||||
authAvatar: '',
|
||||
_authCode: '' // getPhoneNumber 的 code(提交时交 box_app 解密手机号)
|
||||
identityPending: false, // 正在获取 openid(此期间**不加载 H5**)
|
||||
identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口
|
||||
_idAttempts: 0, // 自动重试计数
|
||||
_pendingTarget: '' // 重试时要加载的目标 URL(重试按钮用)
|
||||
}
|
||||
},
|
||||
onLoad(option) {
|
||||
// 分享落地携带的推广人:与其它页面一致写进 globalData.spid(下单归因用)
|
||||
if (option && option.spid) {
|
||||
try { getApp().globalData.spid = option.spid; } catch (e) {}
|
||||
}
|
||||
// 太阳码 scene 解析:scene="app=<identifier>" 时调 box_app 查 url
|
||||
// 例如 box_app /api/wxacode?app=pregnancy → scene="app=pregnancy"
|
||||
// CRMEB 收到后查 box_app /api/mp/apps?isPublished=1 找 url,跳对应 H5
|
||||
@@ -156,22 +194,12 @@ export default {
|
||||
// 3) 退出登录后再次进入 web_view(url 已存在)→ 用缓存的 openid 重新注入
|
||||
|
||||
// 0) 授权页返回:读取 __AUTH_RESULT__,把授权结果写入当前 H5 URL 的 hash,让 H5 重载读取
|
||||
try {
|
||||
const authRes = uni.getStorageSync('__AUTH_RESULT__');
|
||||
if (authRes && authRes.openid && this.url) {
|
||||
uni.removeStorageSync('__AUTH_RESULT__');
|
||||
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash → openid=' + (authRes.openid || '').substring(0, 8) + '...');
|
||||
const base = this.url.split('#')[0];
|
||||
const joiner = base.indexOf('?') !== -1 ? '&' : '?';
|
||||
this.url = base + joiner + 't=' + Date.now() +
|
||||
'#openid=' + encodeURIComponent(authRes.openid) +
|
||||
'&phone=' + encodeURIComponent(authRes.phone || '') +
|
||||
'&nickname=' + encodeURIComponent(authRes.nickname || '') +
|
||||
'&avatar=' + encodeURIComponent(authRes.avatar || '');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[auth-flow] 读取授权结果失败:', e);
|
||||
// (与首页壳共用 mixins/webviewHost.js 的 consumeAuthResult)
|
||||
const authUrl = this.consumeAuthResult(this.url);
|
||||
if (authUrl) {
|
||||
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash');
|
||||
this.url = authUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
const { openid, unionid } = getCurrentUser();
|
||||
@@ -182,8 +210,9 @@ export default {
|
||||
if (openid) {
|
||||
this._applyIdentity(targetUrl, openid, unionid, urlOpenid);
|
||||
} else {
|
||||
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,加载原始URL');
|
||||
this._finalizeUrl(targetUrl);
|
||||
/* ⚠️ 2026-09-20:不再「加载原始URL」(那会让应用进访客态),改走「取到才加载」 */
|
||||
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,走「取到才加载」');
|
||||
this._ensureIdentityThenLoad(targetUrl);
|
||||
}
|
||||
} else if (this.url && openid) {
|
||||
// H5 授权登录返回 / 退出后回到 web_view:重新注入最新身份
|
||||
@@ -219,7 +248,7 @@ export default {
|
||||
const appUrl = app.url;
|
||||
// 预取分享图片(转发给好友时用作分享卡片图)
|
||||
if (app.shareImageUrl) {
|
||||
this._applyShareImage(app.shareImageUrl);
|
||||
this._applyShareImage(app.shareImageUrl, 'box', app.url);
|
||||
console.log('[web_view] 分享图片: ' + app.shareImageUrl);
|
||||
}
|
||||
let finalUrl = appUrl;
|
||||
@@ -279,22 +308,26 @@ export default {
|
||||
console.log('[auth-flow] ✅ 重试成功,注入身份并跳转');
|
||||
this._applyIdentity(targetUrl, retry.openid, retry.unionid, getUrlParam(targetUrl, 'openid'));
|
||||
} else {
|
||||
console.warn('[auth-flow] ⚠️ 重试仍无 openid,匿名加载 H5');
|
||||
this._finalizeUrl(targetUrl);
|
||||
/* ⚠️ 2026-09-20:以前这里匿名加载 H5(→ 应用进访客态)。
|
||||
改成走「取到 openid 才加载」的公共路径(自动重试 3 次 → 失败屏)。 */
|
||||
console.warn('[auth-flow] ⚠️ 重试仍无 openid,改走「取到才加载」');
|
||||
this._ensureIdentityThenLoad(targetUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[auth-flow] 重试异常,匿名加载:', e);
|
||||
this._finalizeUrl(targetUrl);
|
||||
console.error('[auth-flow] 重试异常,改走「取到才加载」:', e);
|
||||
this._ensureIdentityThenLoad(targetUrl);
|
||||
}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
// 无 token + 无 openid(新用户 / 退出后 / 静默授权失败 / 冷启动竞态兜底超时):
|
||||
// 主动用 wx.login 拿 code 调 CRMEB silenceAuthNoLogin(v2/wechat/silence_auth)
|
||||
// 拿到 openid 后注入 URL,让 H5 端完成 openid-login 建档
|
||||
// 该端点不查 store_user_mobile,新用户也能拿到 openid
|
||||
console.log('[auth-flow] 🔑 无 openid,调 silenceAuthNoLogin 拿 openid');
|
||||
this._fetchOpenidAndLoad(targetUrl);
|
||||
// 走「取到 openid 才加载」——内部用 mixin 的 _silenceAuthOpenid()
|
||||
// (wx.login → CRMEB v2/wechat/silence_auth,兼容 auth_login 无 token / userInfo / JWT),
|
||||
// 自动重试 3 次;拿不到就停在兜底屏,**不再匿名加载 H5**。
|
||||
// ⚠️ 原来这里调本页的 _fetchOpenidAndLoad(同一套逻辑的另一份实现),
|
||||
// 现已删除、统一到 mixin,避免两处分叉。
|
||||
console.log('[auth-flow] 🔑 无 openid,走「取到才加载」');
|
||||
this._ensureIdentityThenLoad(targetUrl);
|
||||
return;
|
||||
} else {
|
||||
console.log('[web_view] 非可信域,不做身份替换');
|
||||
@@ -311,91 +344,60 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* 主动 wx.login() 拿 code,调 CRMEB /v2/wechat/silence_auth 拿 openid
|
||||
* 然后注入 URL 并加载 H5(即使后端返回 auth_login=1 无 token,openid 已够 H5 端走 openid-login 建档)
|
||||
* @param {string} targetUrl - 原始 H5 URL
|
||||
* 取到 openid 才加载 H5(2026-09-20 新增;取代原 _fetchOpenidAndLoad)
|
||||
*
|
||||
* 与首页壳同一策略:**拿不到 openid 就不加载**,自动重试 3 次,仍失败就停在兜底屏
|
||||
* 等用户点「重新获取身份」—— 不再匿名加载(匿名加载会让 H5 进访客态:
|
||||
* 孕周 `--`、档案查不到,用户看到「页面能用但全是空」,比多等几秒更糟,且事后难判断)。
|
||||
*
|
||||
* ⚠️ 每次尝试前都再读一次 getCurrentUser():App.vue 的 onLaunch 静默授权是并行的,
|
||||
* 它先落地就直接命中缓存,不必自己再请求一次。
|
||||
* ⚠️ openid 的来源与解析统一在 mixins/webviewHost.js 的 _silenceAuthOpenid()
|
||||
* (兼容 auth_login 无 token / userInfo / JWT 三种,带 8s 超时保险丝)。
|
||||
* @param {string} targetUrl - 要加载的 H5 URL(已含原始 query,身份由 _applyIdentity 注入)
|
||||
*/
|
||||
_fetchOpenidAndLoad(targetUrl) {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (loginRes) => {
|
||||
const code = (loginRes && loginRes.code) || '';
|
||||
if (!code) {
|
||||
console.warn('[auth-flow] ⚠️ wx.login 返回空 code,匿名加载 H5');
|
||||
this._finalizeUrl(targetUrl);
|
||||
return;
|
||||
}
|
||||
console.log('[auth-flow] wx.login 拿到 code(前 8 位: ' + code.substring(0, 8) + '...),调 silenceAuthNoLogin');
|
||||
// 调 api/public.js 已有的 silenceAuth(MP 端 → v2/wechat/silence_auth)
|
||||
// 动态 import 避免在 web_view 页面外被 import 失败
|
||||
const apiPath = '@/api/public.js';
|
||||
// #ifdef MP
|
||||
import('@/api/public.js').then(api => {
|
||||
api.silenceAuth({ code: code }).then(res => {
|
||||
const data = res.data || {};
|
||||
// 兼容两种响应格式:
|
||||
// 1) 新用户 notLogin=true: {auth_login:1, key, openid, unionid}(顶层 openid)
|
||||
// 2) 老用户: {token, userInfo:{...}, expires_time, params:{openid}}(openid 在 JWT/userInfo 里)
|
||||
let openid = data.openid || '';
|
||||
let unionid = data.unionid || '';
|
||||
if (!openid) {
|
||||
// 从 userInfo 里取
|
||||
const ui = data.userInfo || {};
|
||||
openid = ui.openid || '';
|
||||
unionid = ui.unionid || '';
|
||||
}
|
||||
if (!openid && data.token) {
|
||||
// 从 JWT payload 里解 openid(兜底)
|
||||
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 = p.unionid || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[auth-flow] JWT 解码 openid 失败:', e.message);
|
||||
}
|
||||
}
|
||||
// 拿到 openid 后同步到 store/缓存(避免后续重复请求)
|
||||
if (openid) {
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
console.log('[auth-flow] silenceAuthNoLogin 响应: status=' + res.status + ', has openid=' + !!openid + ', auth_login=' + data.auth_login);
|
||||
if (openid) {
|
||||
console.log('[auth-flow] ✅ 拿到 openid=' + openid.substring(0, 8) + '...,注入 URL');
|
||||
this._applyIdentity(targetUrl, openid, unionid, getUrlParam(targetUrl, 'openid'));
|
||||
} else {
|
||||
console.warn('[auth-flow] ⚠️ silenceAuthNoLogin 未返回 openid,匿名加载 H5');
|
||||
this._finalizeUrl(targetUrl);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[auth-flow] ⚠️ silenceAuthNoLogin 网络失败,匿名加载 H5:', err);
|
||||
this._finalizeUrl(targetUrl);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.warn('[auth-flow] ⚠️ import api 失败,匿名加载 H5:', err);
|
||||
this._finalizeUrl(targetUrl);
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn('[auth-flow] ⚠️ wx.login 失败,匿名加载 H5:', err);
|
||||
this._finalizeUrl(targetUrl);
|
||||
_ensureIdentityThenLoad(targetUrl) {
|
||||
this._pendingTarget = targetUrl || this._pendingTarget || '';
|
||||
const t = this._pendingTarget;
|
||||
if (!t) return;
|
||||
/* 1) 缓存命中(含 App.vue 并行登录刚落地的)→ 0 等待注入并把 H5 装进 src */
|
||||
if (this._loadWithCachedIdentity(t)) return;
|
||||
const seq = this._idAttempts; // 0 = 还没取过
|
||||
this._idAttempts = seq + 1;
|
||||
const MAX = 2; // ⚠️ 网络尝试上限 2 次(原 3 次)
|
||||
this.identityPending = true;
|
||||
this.identityFail = false;
|
||||
console.log('[auth-flow] 🔑 容器页取 openid(第 ' + this._idAttempts + '/' + MAX + ' 次),此期间不加载 H5');
|
||||
/* 2) 第 1 次先等 App.vue onLaunch 那次静默登录(同一次 exchange,别重复换 code);
|
||||
之后的重试才自己 wx.login + 换 openid。理由与首页壳一致,见 mixins 的 _awaitHostLogin。 */
|
||||
const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid();
|
||||
task.then(() => {
|
||||
if (this._loadWithCachedIdentity(t)) return;
|
||||
if (this._idAttempts < MAX) {
|
||||
setTimeout(() => this._ensureIdentityThenLoad(t), 500); // 退避固定 500ms(原 600×n)
|
||||
return;
|
||||
}
|
||||
this.identityPending = false;
|
||||
this.identityFail = true;
|
||||
console.warn('[auth-flow] ⚠️ ' + MAX + ' 次仍未拿到 openid:停在兜底屏等待重试,**不匿名加载**');
|
||||
});
|
||||
},
|
||||
/* 有缓存身份就直接注入并加载;返回 true 表示已发起(调用方别再取) */
|
||||
_loadWithCachedIdentity(t) {
|
||||
const cached = getCurrentUser();
|
||||
if (!cached.openid) return false;
|
||||
this.identityPending = false;
|
||||
this.identityFail = false;
|
||||
this._idAttempts = 0;
|
||||
this._applyIdentity(t, cached.openid, cached.unionid, getUrlParam(t, 'openid'));
|
||||
return true;
|
||||
},
|
||||
/* 兜底屏的「重新获取身份」:重置计数再走一遍(与首页壳 retryLoad 同一套行为) */
|
||||
retryIdentity() {
|
||||
this._idAttempts = 0;
|
||||
this.identityFail = false;
|
||||
this._ensureIdentityThenLoad(this._pendingTarget || this.savedUrl || this.url);
|
||||
},
|
||||
|
||||
_applyIdentity(targetUrl, openid, unionid, urlOpenid) {
|
||||
console.log('[auth-flow] _applyIdentity: openid=' + (openid || '(空)') + ', unionid=' + (unionid || '(空)') + ', urlOpenid=' + (urlOpenid || '(无)'));
|
||||
@@ -456,104 +458,32 @@ export default {
|
||||
this._fetchShareImage(targetUrl);
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据当前 H5 URL 反查 box_app 应用,获取分享图片(shareImageUrl)
|
||||
* URL 形如 https://mp.yizhezhe.com/app_03/ 或 https://mp.yizhezhe.com/test_app/?ch=YFMM
|
||||
* 通过 mp/apps 列表匹配 url 前缀,找到对应应用后取 shareImageUrl
|
||||
* 命中本地缓存直接复用(进入页面后立刻分享也能带图),避免每次重复反查
|
||||
* @param {string} targetUrl
|
||||
*/
|
||||
_fetchShareImage(targetUrl) {
|
||||
if (!targetUrl) return;
|
||||
// H5 已通过 postMessage 设置分享图片,不覆盖
|
||||
if (this.shareImageUrl) return;
|
||||
// 页面内只请求一次,避免重复拉取
|
||||
if (this._shareFetched) return;
|
||||
this._shareFetched = true;
|
||||
try {
|
||||
const originPath = targetUrl.split('?')[0].replace(/\/$/, '');
|
||||
// 本地缓存:同一应用 URL 二次进入直接复用分享图,降低首帧分享无图概率
|
||||
const cacheKey = 'web_view_share_img:' + originPath;
|
||||
let cached = '';
|
||||
try { cached = uni.getStorageSync(cacheKey); } catch (e) {}
|
||||
if (cached) {
|
||||
this._applyShareImage(cached);
|
||||
console.log('[web_view] 分享图片(缓存命中): ' + cached);
|
||||
return;
|
||||
}
|
||||
uni.request({
|
||||
url: 'https://mp.yizhezhe.com/api/mp/apps?isPublished=1&pageSize=100',
|
||||
method: 'GET',
|
||||
success: (res) => {
|
||||
try {
|
||||
const items = res.data && res.data.result && res.data.result.items;
|
||||
if (!items || !items.length) return;
|
||||
const matched = items.find(it => {
|
||||
if (!it.url) return false;
|
||||
const appBase = it.url.split('?')[0].replace(/\/$/, '');
|
||||
// 当前 URL 与应用的 url 前缀匹配(如 https://mp.yizhezhe.com/app_03 == https://mp.yizhezhe.com/app_03/)
|
||||
return originPath === appBase || originPath.indexOf(appBase + '/') === 0;
|
||||
});
|
||||
if (matched && matched.shareImageUrl) {
|
||||
try { uni.setStorageSync(cacheKey, matched.shareImageUrl); } catch (e) {}
|
||||
this._applyShareImage(matched.shareImageUrl);
|
||||
console.log('[web_view] 分享图片(兜底): ' + matched.shareImageUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[web_view] 分享图片解析失败:', e);
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
// 常见原因:mp.yizhezhe.com 未加入小程序后台 request/downloadFile 合法域名白名单
|
||||
console.warn('[web_view] 分享图片请求失败(请检查 mp.yizhezhe.com 是否已加入合法域名白名单):', err);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[web_view] _fetchShareImage 异常:', e);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 统一设置分享图:同时准备好友转发图(shareImageUrl)与朋友圈本地图(shareTimelineImage)
|
||||
* @param {string} url 网络图片地址(box_app 返回或 H5 postMessage 传入)
|
||||
*/
|
||||
_applyShareImage(url) {
|
||||
if (!url) return;
|
||||
if (url === this.shareImageUrl) return; // 同一张图不重复处理(H5 可能重复 postMessage)
|
||||
this.shareImageUrl = url;
|
||||
this._prepareTimelineImage(url);
|
||||
},
|
||||
/**
|
||||
* 朋友圈分享图必须是本地路径/代码包路径(网络图会被微信静默忽略),
|
||||
* 这里用 getImageInfo 把网络图转成本地临时路径;失败则留空,回退微信默认截图。
|
||||
* 注意:getImageInfo 对网络图同样受 downloadFile 合法域名限制,域名未配置时朋友圈无自定义图。
|
||||
* @param {string} url 网络图片地址
|
||||
*/
|
||||
_prepareTimelineImage(url) {
|
||||
if (!url) return;
|
||||
uni.getImageInfo({
|
||||
src: url,
|
||||
success: (res) => {
|
||||
this.shareTimelineImage = res.path || '';
|
||||
console.log('[web_view] 朋友圈分享图已转本地:', this.shareTimelineImage);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn('[web_view] 朋友圈分享图转本地失败(网络图/域名问题,回退默认截图):', err);
|
||||
this.shareTimelineImage = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
/* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 mixins/shareCard.js 提供:
|
||||
_fetchShareImage(targetUrl) —— 本页 onLoad 兜底(按当前 H5 URL 反查 box_app
|
||||
App.shareImageUrl;太阳码场景 _resolveAppScene 已直接设好)
|
||||
_applyShareImage(url) —— H5 postMessage({action:'share', imageUrl}) 覆盖
|
||||
_lookupShareImage(url) —— 同步匹配(先内存应用表,再单应用缓存兜底)
|
||||
首页壳 pages/index/index.vue 复用同一 mixin(它不会随应用切换重新 onLoad,
|
||||
必须在分享那一刻按 options.webViewUrl 同步匹配)。 */
|
||||
},
|
||||
onShareAppMessage() {
|
||||
onShareAppMessage(options) {
|
||||
// ⚠️ 深链以微信回调里的 options.webViewUrl 为准(= 当前 web-view 真实地址,
|
||||
// 含 H5 的 SPA 路由);this.shareHref(H5 postMessage)/ this.url(进入时的地址)
|
||||
// 都可能过期或落后,仅作兜底。
|
||||
const target = (options && options.webViewUrl) || this.shareHref || this.url;
|
||||
// 应用切换检测 + 按当前地址重新反查分享图(H5 postMessage 覆盖优先)
|
||||
this.resolveShareCard(target);
|
||||
return {
|
||||
title: this.shareTitle || '分享',
|
||||
// 分享出去必须剥离 openid/unionid/t 身份参数:
|
||||
// 分享出去必须剥离 openid/unionid/t 等身份参数:
|
||||
// 1) 避免把分享者的微信匿名标识泄露给接收者;
|
||||
// 2) 接收者打开后由 web_view 按自己的身份重新注入(onShow/onLoad 有兜底替换)。
|
||||
path: '/pages/annex/web_view/index?url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
|
||||
path: '/pages/annex/web_view/index?url=' + encodeURIComponent(stripAuthParams(target)),
|
||||
imageUrl: this.shareImageUrl || ''
|
||||
}
|
||||
},
|
||||
onShareTimeline() {
|
||||
// onShareTimeline 无回调参数,拿不到 webViewUrl,只能用 H5 传来的 href 兜底
|
||||
return {
|
||||
title: this.shareTitle || '分享',
|
||||
query: 'url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
|
||||
@@ -562,111 +492,13 @@ export default {
|
||||
imageUrl: this.shareTimelineImage || ''
|
||||
}
|
||||
},
|
||||
handleMessage(e) {
|
||||
const msgs = e.detail && e.detail.data ? e.detail.data : [];
|
||||
const data = msgs[msgs.length - 1] || null;
|
||||
if (!data) return
|
||||
/* webviewHost hook:本页 web-view 地址字段是 url */
|
||||
_hostSetWebviewUrl(url) { this.url = url; },
|
||||
/* webviewHost hook:copyLink 未带 href 时的兜底地址 */
|
||||
_hostCurrentUrl() { return this.url || ''; },
|
||||
|
||||
switch (data.action) {
|
||||
case 'share':
|
||||
// 存储分享数据,供 onShareAppMessage / onShareTimeline 使用
|
||||
// title/imageUrl/href 各自独立处理,避免单个字段缺失导致整包分享数据被丢弃
|
||||
if (data.title) {
|
||||
this.shareTitle = data.title;
|
||||
this.shareSummary = data.summary || '';
|
||||
}
|
||||
// H5 传了 imageUrl 才覆盖(否则保留 box_app 预取的分享图片)
|
||||
if (data.imageUrl) {
|
||||
this._applyShareImage(data.imageUrl);
|
||||
}
|
||||
if (data.href) {
|
||||
this.shareHref = data.href;
|
||||
}
|
||||
// 小程序中无法用代码触发分享面板,引导用户点击右上角 ··· 按钮
|
||||
uni.showToast({ title: '点击右上角 ··· 分享给好友', icon: 'none', duration: 2500 });
|
||||
break
|
||||
|
||||
case 'copyLink':
|
||||
const link = data.href || this.url
|
||||
uni.setClipboardData({
|
||||
data: link,
|
||||
success() { uni.showToast({ title: '链接已复制', icon: 'success' }) },
|
||||
fail(err) {
|
||||
console.warn('[web_view] 复制失败:', err)
|
||||
uni.showToast({ title: '复制失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'requestProfile':
|
||||
// 任意 H5 应用请求授权 → 弹共享原生授权面板(手机号/昵称/头像)
|
||||
this.showAuth = true;
|
||||
break
|
||||
|
||||
case 'setTitle':
|
||||
if (data.title) {
|
||||
this.shareTitle = data.title;
|
||||
uni.setNavigationBarTitle({ title: data.title })
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
/* ---------- 共享授权面板(任意 H5 应用 requestProfile → 弹面板 → box_app /auth/profile) ---------- */
|
||||
onChooseAvatar(e) {
|
||||
const avatarUrl = e.detail.avatarUrl;
|
||||
if (!avatarUrl) return uni.showToast({ title: '未获取到头像', icon: 'none' });
|
||||
// 上传到 box_app 公开接口:授权链路不依赖 CRMEB 登录态(退出登录后 uploadImgs 会 401 弹「请登录」)
|
||||
uni.uploadFile({
|
||||
url: 'https://mp.yizhezhe.com/api/upload',
|
||||
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 (e) { uni.showToast({ title: '头像上传失败', icon: 'none' }); }
|
||||
},
|
||||
fail: () => uni.showToast({ title: '头像上传失败,请检查网络', icon: 'none' })
|
||||
});
|
||||
},
|
||||
onGetPhone(e) {
|
||||
if (e.detail.errMsg === 'getPhoneNumber:ok' && e.detail.code) { this._authCode = e.detail.code; }
|
||||
else { uni.showToast({ title: '未授权手机号', icon: 'none' }); }
|
||||
},
|
||||
submitAuth() {
|
||||
if (!this.authNickname && !this.authAvatar && !this._authCode) {
|
||||
return uni.showToast({ title: '请至少授权一项', icon: 'none' });
|
||||
}
|
||||
const openid = getOpenid();
|
||||
if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' });
|
||||
const api = 'https://mp.yizhezhe.com/api/mp/public/auth/profile?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 || {};
|
||||
this.showAuth = false; this._authCode = '';
|
||||
// 更新 web-view src(保留 query + 追加 hash 结果)→ H5 重载读取
|
||||
const base = this.url.split('#')[0];
|
||||
this.url = base + (base.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now() +
|
||||
'#openid=' + encodeURIComponent(openid) +
|
||||
'&phone=' + encodeURIComponent(res.phone || '') +
|
||||
'&nickname=' + encodeURIComponent(res.userName || this.authNickname || '') +
|
||||
'&avatar=' + encodeURIComponent(res.avatar || this.authAvatar || '');
|
||||
} else {
|
||||
uni.showToast({ title: d.message || '授权保存失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => uni.showToast({ title: '授权请求失败,请检查网络', icon: 'none' })
|
||||
});
|
||||
},
|
||||
closeAuth() { this.showAuth = false; },
|
||||
noop() {},
|
||||
/* handleHostMessage / onChooseHostAvatar / onHostGetPhone / submitHostAuth(url) /
|
||||
consumeAuthResult(见 onShow) 由 mixins/webviewHost.js 统一提供 —— 与首页壳共用一套。 */
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -684,4 +516,13 @@ export default {
|
||||
.mb-auth-cancel, .mb-auth-ok { flex: 1; height: 44px; line-height: 44px; text-align: center; border-radius: 12px; font-size: 14px; font-weight: 600; }
|
||||
.mb-auth-cancel { background: #F1E6E0; color: #6B615B; }
|
||||
.mb-auth-ok { background: #F4593C; color: #fff; }
|
||||
|
||||
/* ---- 身份未就绪兜底屏(2026-09-20)----
|
||||
本页以前 url 为空时是白屏;现在「拿不到 openid 不加载」,必须给一块可见的屏。 */
|
||||
.wv-fallback { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 70vh; padding: 0 40rpx; }
|
||||
.wv-emoji { font-size: 64rpx; line-height: 1; }
|
||||
.wv-title { margin-top: 24rpx; font-size: 32rpx; font-weight: 700; color: #2B2320; }
|
||||
.wv-desc { margin-top: 12rpx; font-size: 26rpx; color: #A79C95; line-height: 1.6; text-align: center; }
|
||||
.wv-retry { margin-top: 36rpx; padding: 0 48rpx; height: 80rpx; line-height: 80rpx; border-radius: 40rpx; background: #F4593C; color: #fff; font-size: 28rpx; }
|
||||
.wv-retry-hover { opacity: .85; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user