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:
2026-09-20 23:10:05 +08:00
parent c216865852
commit e99b4a475c
5 changed files with 431 additions and 346 deletions

View File

@@ -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>