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:
34
App.vue
34
App.vue
@@ -227,7 +227,7 @@
|
||||
let that = this;
|
||||
let spid = that.globalData.spid ? that.globalData.spid : '';
|
||||
console.log('[auth-flow] silenceAuth 发起, code=' + (code ? code.substring(0, 10) + '...' : '(空)'));
|
||||
silenceAuth({
|
||||
const authPromise = silenceAuth({
|
||||
code: code,
|
||||
spread_spid: spid,
|
||||
spread_code: that.globalData.code
|
||||
@@ -241,11 +241,30 @@
|
||||
token: res.data.token,
|
||||
time: time
|
||||
});
|
||||
/* ⚠️ 2026-09-20:`userInfo` 不是每个响应都有(v2/wechat/silence_auth
|
||||
只回 token+expires_time),直接取 `.uid` 会抛异常,把后面的
|
||||
commit 一起带走。加一层判空,缺了也不影响身份(openid 由 LOGIN 从 JWT 解出)。 */
|
||||
if (res.data.userInfo) {
|
||||
that.$store.commit('SETUID', res.data.userInfo.uid);
|
||||
that.$store.commit('UPDATE_USERINFO', res.data.userInfo);
|
||||
}
|
||||
console.log('[auth-flow] ✅ silenceAuth 成功, token 已存 store (含 openid)');
|
||||
} else if (res.data.openid) {
|
||||
/* ⚠️⚠️ 2026-09-20 修:新用户 / 后台开启了「强制绑定手机号」时,
|
||||
该接口返回的是 {auth_login:1, key, openid, unionid} —— **有 openid、没有 token**。
|
||||
以前这段落到下面的 else,只打一条警告就把 openid 丢了;于是
|
||||
store.openid / 缓存 OPENID / JWT 三处全空,首页壳 _hostOpenid() 拿不到身份
|
||||
→ **匿名加载 mama-box**(H5 进访客态:孕周 `--`、档案空、跳应用跳裸 URL)。
|
||||
这里必须把它落 store + 缓存。 */
|
||||
that.$store.commit('SET_OPENID', res.data.openid);
|
||||
try { uni.setStorageSync('OPENID', res.data.openid); } catch (e) {}
|
||||
if (res.data.unionid) {
|
||||
that.$store.commit('SET_UNIONID', res.data.unionid);
|
||||
try { uni.setStorageSync('UNIONID', res.data.unionid); } catch (e) {}
|
||||
}
|
||||
console.log('[auth-flow] ✅ 无 token 但拿到 openid,已缓存(首页壳据此注入身份): ' + String(res.data.openid).substring(0, 8) + '...');
|
||||
} else {
|
||||
console.warn('[auth-flow] ⚠️ silenceAuth 返回无 token:', JSON.stringify(res.data || res).substring(0, 200));
|
||||
console.warn('[auth-flow] ⚠️ silenceAuth 返回无 token 也无 openid:', JSON.stringify(res.data || res).substring(0, 200));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[auth-flow] ❌ silenceAuth 失败:', JSON.stringify(err).substring(0, 300));
|
||||
@@ -253,6 +272,17 @@
|
||||
title: err
|
||||
})
|
||||
});
|
||||
/* ⚠️ 2026-09-20:把这次静默登录的 promise 挂到 globalData,
|
||||
让宿主页(首页壳 / 容器页)在缓存未命中时**先 await 它**、而不是自己再换一次 code
|
||||
(见 mixins/webviewHost.js 的 _awaitHostLogin)。
|
||||
动机:微信 code 一次性,两边各换一次要多花 200–300ms(实测 code2session 就这么贵),
|
||||
而 openid 本来就该由这一条链路落缓存(本文件上面的 SET_OPENID 分支 / LOGIN 从 JWT 提取)。
|
||||
settle 后清掉,避免下次误等一个已经结束的 promise。 */
|
||||
try { that.globalData._silenceAuthPromise = authPromise; } catch (e) {}
|
||||
authPromise.then(() => {
|
||||
try { if (that.globalData._silenceAuthPromise === authPromise) that.globalData._silenceAuthPromise = null; } catch (e) {}
|
||||
});
|
||||
return authPromise;
|
||||
},
|
||||
/**
|
||||
* 检测当前的小程序
|
||||
|
||||
@@ -99,6 +99,111 @@ export default {
|
||||
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) || [];
|
||||
|
||||
@@ -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,23 +194,13 @@ 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 || '');
|
||||
// (与首页壳共用 mixins/webviewHost.js 的 consumeAuthResult)
|
||||
const authUrl = this.consumeAuthResult(this.url);
|
||||
if (authUrl) {
|
||||
console.log('[auth-flow] 🎯 授权页返回,刷新身份 hash');
|
||||
this.url = authUrl;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[auth-flow] 读取授权结果失败:', e);
|
||||
}
|
||||
|
||||
const { openid, unionid } = getCurrentUser();
|
||||
if (this.savedUrl && !this.url) {
|
||||
@@ -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,90 +344,59 @@ 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);
|
||||
_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;
|
||||
}
|
||||
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);
|
||||
this.identityPending = false;
|
||||
this.identityFail = true;
|
||||
console.warn('[auth-flow] ⚠️ ' + MAX + ' 次仍未拿到 openid:停在兜底屏等待重试,**不匿名加载**');
|
||||
});
|
||||
}).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);
|
||||
}
|
||||
});
|
||||
/* 有缓存身份就直接注入并加载;返回 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) {
|
||||
@@ -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);
|
||||
}
|
||||
/* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 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 同步匹配)。 */
|
||||
},
|
||||
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 = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -21,14 +21,17 @@
|
||||
<view class="mamabox-home">
|
||||
<web-view v-if="webUrl" :src="webUrl" @message="handleHostMessage" @error="onWebviewError"></web-view>
|
||||
|
||||
<!-- 加载中 / 加载失败兜底(web-view 未就绪或加载出错时才渲染) -->
|
||||
<!-- 加载中 / 加载失败兜底(web-view 未就绪或加载出错时才渲染)
|
||||
⚠️ 2026-09-20 起增加「正在获取微信身份」这一态:首页壳**必须拿到 openid 才加载 H5**,
|
||||
拿不到就重试、并停在这一屏 —— 不再匿名加载(匿名加载会让 H5 进访客态:
|
||||
孕周 `--`、档案空,用户看到的是「能用但都是空的」,比多等几秒更糟)。 -->
|
||||
<view v-else class="mamabox-fallback">
|
||||
<view class="mamabox-emoji">{{ loadError ? '🥲' : '🍼' }}</view>
|
||||
<view class="mamabox-emoji">{{ fallbackEmoji }}</view>
|
||||
<view class="mamabox-tip">
|
||||
<view class="mamabox-title">{{ loadError ? '页面加载失败' : '正在加载' }}</view>
|
||||
<view class="mamabox-desc">{{ loadError ? '请检查网络后重试' : '正在为你打开「兜爸心选」' }}</view>
|
||||
<view class="mamabox-title">{{ fallbackTitle }}</view>
|
||||
<view class="mamabox-desc">{{ fallbackDesc }}</view>
|
||||
</view>
|
||||
<button v-if="loadError" class="mamabox-retry" hover-class="mamabox-retry-hover" @tap="retryLoad">重新加载</button>
|
||||
<button v-if="loadError || identityFail" class="mamabox-retry" hover-class="mamabox-retry-hover" @tap="retryLoad">{{ loadError ? '重新加载' : '重新获取身份' }}</button>
|
||||
</view>
|
||||
|
||||
<!-- 小程序原生授权面板(H5 postMessage 请求后显示):手机号/昵称/头像 -->
|
||||
@@ -103,14 +106,34 @@
|
||||
name: 'Index',
|
||||
mixins: [shareCard, webviewHost],
|
||||
computed: {
|
||||
...mapGetters(['isLogin', 'uid', 'cartNum'])
|
||||
...mapGetters(['isLogin', 'uid', 'cartNum']),
|
||||
/* 兜底屏文案:三种状态(取身份中 / 取身份失败 / 页面加载失败)分开说,
|
||||
别让用户对着「正在加载」干等却不知道在等什么。 */
|
||||
fallbackTitle() {
|
||||
if (this.loadError) return '页面加载失败';
|
||||
if (this.identityFail) return '未获取到微信身份';
|
||||
if (this.identityPending) return '正在获取微信身份';
|
||||
return '正在加载';
|
||||
},
|
||||
fallbackDesc() {
|
||||
if (this.loadError) return '请检查网络后重试';
|
||||
if (this.identityFail) return '请检查网络后点下方重试,或退出小程序重新进入';
|
||||
if (this.identityPending) return '马上就好,请稍候(不会加载空数据的页面)';
|
||||
return '正在为你打开「兜爸心选」';
|
||||
},
|
||||
fallbackEmoji() {
|
||||
return (this.loadError || this.identityFail) ? '🥲' : '🍼';
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
webUrl: '', // web-view 地址(硬编码 + #openid/#unionid)
|
||||
requesting: false, // 防重入
|
||||
_shownOnce: false, // 首次展示标记(避免首次 onShow 重复加载)
|
||||
_idAttempts: 0, // 「取到 openid 才加载」的自动重试计数(见 fetchIdentityThenLoad)
|
||||
loadError: false, // web-view 加载失败(显示「重新加载」)
|
||||
identityPending: false, // 正在获取 openid(此期间**不加载 H5**,见 fetchIdentityThenLoad)
|
||||
identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口
|
||||
shareInfo: {}, // CRMEB 后台「分享设置」(标题/图,无 App 匹配时兜底)
|
||||
// 授权面板字段由 mixins/webviewHost.js 提供:
|
||||
// showAuth / authNickname / authAvatar / _authCode
|
||||
@@ -157,15 +180,22 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ✅ 实测结论(微信开发者工具):首页的 <web-view> 是原生全屏组件,
|
||||
// 会**盖住**原生 tabBar,所以首页只显示 H5 内部导航(无双条);
|
||||
// 切到商城原生页(分类/购物车/我的)时 tabBar 正常显示,点「首页」tab 可回 H5。
|
||||
// ⇒ 无需 uni.hideTabBar()(隐藏了反而让商城 tab 根页变死胡同),保持现状即可。
|
||||
// ⚠️ 首页必须主动隐藏原生 tabBar(2026-09-18 定论,推翻下面旧结论):
|
||||
// 真机上 web-view **盖不住**原生 tabBar —— 旧结论是在 custom:true、
|
||||
// 原生条根本不渲染的时期测的。H5 自带底部导航 + 原生 tabBar = 双条。
|
||||
// hideTabBar 是全局状态:商城各 tab 页都挂着 pageFooter,由它按后台
|
||||
// 「装修→底部导航」配置自管显隐(启用=hideTabBar+渲染 DIY 底栏,
|
||||
// 禁用=showTabBar 露出原生条兜底),所以这里隐藏不会让商城页变死胡同。
|
||||
// ⚠️ 但严禁在商城页主动 showTabBar 来「配对」——上一轮翻车根因
|
||||
// (showTabBar + DIY 底栏同显 = 商品列表双 tabBar,见 onShow 旧警告)。
|
||||
uni.hideTabBar();
|
||||
this.loadMamaBox();
|
||||
},
|
||||
onShow() {
|
||||
// 通知自定义 tabBar 按当前路由刷新(首页=web-view,不显示 tabBar)
|
||||
uni.$emit('tabbar-update');
|
||||
// 每次回到首页都再压一次 tabBar:hideTabBar 是全局状态,可能被别处
|
||||
// showTabBar 抬回来(如后台停用 DIY 底部导航时商城页会露出原生条兜底)。
|
||||
// (原这行的 uni.$emit('tabbar-update') 随 custom-tab-bar 死代码一并移除)
|
||||
uni.hideTabBar();
|
||||
// 0) 独立授权页(pages/annex/auth/index)返回:读取 __AUTH_RESULT__,
|
||||
// 把手机号/昵称/头像写回 H5 URL 的 hash(授权结果只走 hash)。
|
||||
// ⚠️ 必须在下面「重载 webview」之前处理,否则结果会被 ?t= 重载冲掉。
|
||||
@@ -188,14 +218,22 @@
|
||||
// 以 shareHref(当前 H5)为基址重载,停留在原应用首页。
|
||||
this.loadError = false;
|
||||
const reloadBase = (this.shareHref || MAMA_HOME).split('#')[0];
|
||||
if (this._hostOpenid()) {
|
||||
this.webUrl = this.buildWebUrl(reloadBase + '?t=' + Date.now());
|
||||
} else {
|
||||
/* ⚠️ 仍无 openid:走同一条「取到才加载」的路(自动重试 3 次 → 兜底屏),
|
||||
不再匿名重载(原因见 fetchIdentityThenLoad 的注释)。 */
|
||||
console.log('[auth-flow] 🔄 onShow: 仍无 openid,走「取到才加载」');
|
||||
this._idAttempts = 0;
|
||||
this.fetchIdentityThenLoad(reloadBase);
|
||||
}
|
||||
} else {
|
||||
this._shownOnce = true;
|
||||
}
|
||||
// ⚠️ 实测:home/商城 之间 programmatic 切换 tabBar(hide/show) 会引发
|
||||
// 「进入商城跳一下 + 商品列表双 tabBar」两个副作用。已回退该方案。
|
||||
// 首页的底部导航由 web-view 自身决定(多数版本会盖住原生 tabBar,
|
||||
// 首页只显示 H5 内部导航),不再手动 hideTabBar。
|
||||
// 历史(勿照旧回退):曾试过「home hide + 商城页 show」双向 programmatic
|
||||
// 切换,因商城页 showTabBar 与 DIY 底栏同显 →「商品列表双 tabBar」+跳一下,
|
||||
// 当时整体回退。2026-09-18 起改为单向:只在首页 hideTabBar,商城页显隐
|
||||
// 完全交给 pageFooter(后台 DIY 配置驱动),不再有任何 showTabBar 配对逻辑。
|
||||
// 购物车角标(tabBar 索引 3 = 购物车),沿用原首页逻辑
|
||||
if (this.cartNum > 0) {
|
||||
uni.setTabBarBadge({
|
||||
@@ -275,8 +313,72 @@
|
||||
if (this.requesting) return;
|
||||
this.requesting = true;
|
||||
this.loadError = false;
|
||||
/* ⚠️ 身份兜底(2026-09-20 新增):store.openid / 缓存 OPENID / JWT 三处都读不到时,
|
||||
**先主动取一次**(wx.login → silence_auth,见 mixin 的 _silenceAuthOpenid),
|
||||
拿到再拼 hash 加载 —— 别匿名加载。
|
||||
以前这里直接匿名加载,H5 于是进访客态(孕周 `--`、档案查不到、跳应用不带身份),
|
||||
而且没人补:根因是新用户/未绑手机号分支的 openid 被 App.vue 丢掉 + 首页壳没有
|
||||
容器页那样的 openid 兜底(两条都在 2026-09-20 修,现统一叫 _ensureIdentityThenLoad)。
|
||||
取不到 openid 仍继续加载(保持可用),H5 侧会显示「未获取到微信身份」提示条。 */
|
||||
this.identityFail = false;
|
||||
if (this._hostOpenid()) {
|
||||
this.identityPending = false;
|
||||
this._idAttempts = 0;
|
||||
this.webUrl = this.buildWebUrl(MAMA_HOME);
|
||||
this.requesting = false;
|
||||
return;
|
||||
}
|
||||
/* ⚠️⚠️ 拿不到 openid **就不加载**(2026-09-20 用户定的行为):
|
||||
宁可让用户在这一屏多等几秒(自动重试 3 次),也不匿名加载 H5 ——
|
||||
匿名加载会让 H5 进访客态(孕周 `--`、档案查不到),
|
||||
用户看到「页面能用但全是空」比等一会儿更糟,且事后很难判断是身份问题。 */
|
||||
this._idAttempts = 0;
|
||||
this.fetchIdentityThenLoad(MAMA_HOME);
|
||||
this.requesting = false;
|
||||
},
|
||||
/**
|
||||
* 取到 openid 才加载 H5;自动重试 3 次仍失败 → 停在兜底屏给「重新获取身份」入口。
|
||||
* ⚠️ 每次尝试前都再读一次 _hostOpenid():App.vue 的 onLaunch 静默授权是并行的,
|
||||
* 它先落地的话这里直接命中缓存,不必自己再请求一次。
|
||||
* @param {string} base - 要加载的 H5 基址(首页壳是 MAMA_HOME;onShow 重载时是当前 H5)
|
||||
*/
|
||||
fetchIdentityThenLoad(base) {
|
||||
/* 1) 缓存命中(含 App.vue 并行登录刚落地的)→ 0 等待加载 */
|
||||
if (this._loadWithCachedIdentity(base)) return;
|
||||
this.identityPending = true;
|
||||
this.identityFail = false;
|
||||
const seq = this._idAttempts; // 0 = 还没取过
|
||||
this._idAttempts = seq + 1;
|
||||
const MAX = 2; // ⚠️ 网络尝试上限 2 次(原 3 次,见下方退避说明)
|
||||
console.log('[auth-flow] 🔑 首页壳取 openid(第 ' + this._idAttempts + '/' + MAX + ' 次)');
|
||||
/* 2) 第 1 次**不急着自己请求**:先等 App.vue onLaunch 那次静默登录(同一次 exchange)。
|
||||
它先落地就直接加载 —— 避免两边各换一次 code 白花 200–300ms
|
||||
(微信 code 一次性;实测 code2session 约 200–300ms)。
|
||||
之后的重试才自己 wx.login + 换 openid(_silenceAuthOpenid)。 */
|
||||
const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid();
|
||||
task.then(() => {
|
||||
if (this._loadWithCachedIdentity(base)) return;
|
||||
if (this._idAttempts < MAX) {
|
||||
/* 退避固定 500ms(原来 600×n,最坏 3 次 + 1.8s 退避 ≈ 3.5s 才出失败屏;
|
||||
现在 2 次 + 0.5s ≈ 1.5s 量级) */
|
||||
setTimeout(() => this.fetchIdentityThenLoad(base), 500);
|
||||
return;
|
||||
}
|
||||
this.identityPending = false;
|
||||
this.identityFail = true;
|
||||
console.warn('[auth-flow] ⚠️ ' + MAX + ' 次仍未拿到 openid:停在兜底屏等待重试,**不匿名加载**');
|
||||
});
|
||||
},
|
||||
/* 有缓存身份就直接发起加载;返回 true 表示已经发起(调用方别再取) */
|
||||
_loadWithCachedIdentity(base) {
|
||||
if (!this._hostOpenid()) return false;
|
||||
this.identityPending = false;
|
||||
this.identityFail = false;
|
||||
this._idAttempts = 0;
|
||||
this._shownOnce = true;
|
||||
this.webUrl = this.buildWebUrl(base + (base.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now());
|
||||
console.log('[auth-flow] ✅ 首页壳拿到 openid,开始加载 H5');
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* 给 H5 拼 hash 参数(openid 单一身份键 + unionid 可选)。
|
||||
@@ -324,7 +426,14 @@
|
||||
this.loadError = false;
|
||||
this.requesting = false;
|
||||
this._shownOnce = true;
|
||||
this.identityFail = false;
|
||||
/* 有身份直接重载;没有就再走一遍「取到才加载」(重置尝试次数,自动重试 3 次) */
|
||||
if (this._hostOpenid()) {
|
||||
this.webUrl = this.buildWebUrl(MAMA_HOME + '?t=' + Date.now());
|
||||
return;
|
||||
}
|
||||
this._idAttempts = 0;
|
||||
this.fetchIdentityThenLoad(MAMA_HOME);
|
||||
},
|
||||
|
||||
/* 授权完善资料(postMessage → 原生面板 → 共享授权接口 → hash 回传)与
|
||||
|
||||
@@ -262,8 +262,8 @@ export default {
|
||||
console.log('[auth-flow] JumpPath: ✅ 有 openid (token=' + (token ? '有' : '无') + '),注入身份跳转 web_view (unionid ' + (unionid ? '已携带' : '为空') + ')');
|
||||
} else {
|
||||
// 无 openid:调 silenceAuthNoLogin 拿 openid
|
||||
// 走 web_view 让 _initWebView 用 _fetchOpenidAndLoad 拿 openid
|
||||
console.log('[auth-flow] JumpPath: 🔑 无 openid,跳转 web_view 由 _fetchOpenidAndLoad 处理');
|
||||
// 走 web_view 让 _initWebView 用 _ensureIdentityThenLoad 拿 openid(2026-09-20 起改名为该名)
|
||||
console.log('[auth-flow] JumpPath: 🔑 无 openid,跳转 web_view 由 _ensureIdentityThenLoad 处理');
|
||||
}
|
||||
}
|
||||
uni.navigateTo({
|
||||
|
||||
Reference in New Issue
Block a user