Files
crmeb-mp/pages/annex/web_view/index.vue
Karlcarlo e99b4a475c 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)。
2026-09-20 23:50:31 +08:00

529 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<web-view v-if="url" class="web-view" :webview-styles="webviewStyles" :src="url"
: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="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="onHostGetPhone">获取手机号</button>
<view class="mb-auth-actions">
<button class="mb-auth-cancel" @tap="closeAuth">取消</button>
<button class="mb-auth-ok" @tap="submitHostAuth(url)">保存并返回</button>
</view>
</view>
</view>
</view>
</template>
<script>
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+/=';
var output = '';
var i = 0;
str = String(str).replace(/[^A-Za-z0-9+/=]/g, '');
while (i < str.length) {
var b1 = chars.indexOf(str.charAt(i++));
var b2 = chars.indexOf(str.charAt(i++));
var b3 = chars.indexOf(str.charAt(i++));
var b4 = chars.indexOf(str.charAt(i++));
output += String.fromCharCode((b1 << 2) | (b2 >> 4));
if (b3 !== 64) output += String.fromCharCode(((b2 & 15) << 4) | (b3 >> 2));
if (b4 !== 64) output += String.fromCharCode(((b3 & 3) << 6) | b4);
}
return output;
}
function getOpenid() {
let oid = store.state.app.openid;
if (oid) return oid;
try { oid = uni.getStorageSync('OPENID'); } catch (e) {}
if (oid) return oid;
try {
const token = store.state.app.token || uni.getStorageSync(LOGIN_STATUS);
if (token) {
let parts = token.split('.');
if (parts.length === 3) {
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4 !== 0) payload += '=';
const data = JSON.parse(base64Decode(payload));
oid = data.openid || '';
if (oid) {
store.commit('SET_OPENID', oid);
return oid;
}
}
}
} catch (e) {
console.warn('[web_view] JWT decode openid failed:', e.message);
}
return '';
}
function getCurrentUser() {
// 即使 token 失效/退出登录,也从 store + 缓存中读取 openid/unionid。
// 微信匿名标识同小程序内永久稳定,退出后仍需向可信 H5 注入身份。
const token = store.state.app.token || '';
let openid = getOpenid();
let unionid = store.state.app.unionid || '';
if (!unionid) { try { unionid = uni.getStorageSync('UNIONID'); } catch (e) {} }
if ((!openid || !unionid) && token) {
try {
let parts = token.split('.');
if (parts.length === 3) {
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4 !== 0) payload += '=';
const data = JSON.parse(base64Decode(payload));
if (!openid) openid = data.openid || '';
if (!unionid) unionid = data.unionid || '';
}
} catch (e) {}
}
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) {
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) {
try {
const match = url.match(new RegExp('[?&]' + name + '=([^&]*)'));
return match ? decodeURIComponent(match[1]) : '';
} catch (e) { return ''; }
}
export default {
mixins: [shareCard, webviewHost],
data() {
return {
windowH: 0, windowW: 0,
webviewStyles: { progress: { color: 'transparent' } },
url: '',
// 分享字段由 mixins/shareCard.js 提供;
// 授权面板字段(showAuth/authNickname/authAvatar/_authCode)由 mixins/webviewHost.js 提供
savedUrl: '',
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
const sceneRaw = decodeURIComponent(option.scene || option.wxscene || '');
if (sceneRaw.indexOf('app=') === 0) {
// scene 格式: "app=pregnancy" 或 "app=pregnancy&ch=YFMM"
const source = sceneRaw;
const identifier = source.slice(4).split('&')[0];
// 提取渠道码
let channel = '';
const chMatch = source.match(/&ch=([A-Z]+)/);
if (chMatch) channel = chMatch[1];
console.log('[web_view] 太阳码 scene 解析: identifier=' + identifier + (channel ? ' ch=' + channel : ''));
// 把 channel 存到 option 中传给 _resolveAppScene
option._channel = channel;
// 异步处理:uni.request 拿 H5 url,赋值给 option.url 走后续逻辑
this._resolveAppScene(identifier, option);
return;
}
this._initWebView(option);
},
onShow() {
// 登录返回后:页面仍在栈中,onLoad 不会再次触发
// 1) 首次登录返回(savedUrl 有值,url 未设置)→ 用保存的 URL 重新处理身份
// 2) H5 内 requireAuth 跳登录页返回 → url 已有值,但 token/身份可能刚更新 → 强制刷新注入新身份
// 3) 退出登录后再次进入 web_view(url 已存在)→ 用缓存的 openid 重新注入
// 0) 授权页返回:读取 __AUTH_RESULT__,把授权结果写入当前 H5 URL 的 hash,让 H5 重载读取
// (与首页壳共用 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();
if (this.savedUrl && !this.url) {
console.log('[auth-flow] 🔄 onShow: savedUrl 重新处理 → openid=' + (openid || '(空)'));
const targetUrl = this.savedUrl;
const urlOpenid = getUrlParam(targetUrl, 'openid');
if (openid) {
this._applyIdentity(targetUrl, openid, unionid, urlOpenid);
} else {
/* ⚠️ 2026-09-20:不再「加载原始URL」(那会让应用进访客态),改走「取到才加载」 */
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
}
} else if (this.url && openid) {
// H5 授权登录返回 / 退出后回到 web_view:重新注入最新身份
const currentUrl = this.url;
const urlOpenid = getUrlParam(currentUrl, 'openid');
console.log('[auth-flow] 🔄 onShow: 身份变更检测 → openid=' + openid + ', urlOpenid=' + (urlOpenid || '(无)'));
if (urlOpenid !== openid) {
console.log('[auth-flow] ✅ 授权返回,刷新身份 openid=', openid, ', unionid=', unionid || '(空)');
this._applyIdentity(currentUrl, openid, unionid, urlOpenid);
} else {
console.log('[auth-flow] onShow: 身份一致,不刷新');
}
}
},
methods: {
/**
* 太阳码 scene 解析:调 box_app /api/mp/apps 找 url,找到后注入到 option.url
* 然后调用 _initWebView(option) 走标准 web_view 加载流程
*/
_resolveAppScene(identifier, option) {
const appToken = store.state.app.token || '';
uni.request({
url: 'https://mp.yizhezhe.com/api/mp/apps?identifier=' + encodeURIComponent(identifier) + '&isPublished=1&pageSize=1',
method: 'GET',
header: appToken ? { 'Authorization': 'Bearer ' + appToken } : {},
success: (res) => {
console.log('[web_view] 太阳码 scene 解析响应:', JSON.stringify(res.statusCode));
const items = res.data && res.data.result && res.data.result.items;
if (res.statusCode === 200 && items && items.length > 0) {
// identifier 严格匹配,避免误用其他应用的 URL / 分享图(张冠李戴)
const app = items.find(it => it.identifier === identifier);
if (app && app.url) {
const appUrl = app.url;
// 预取分享图片(转发给好友时用作分享卡片图)
if (app.shareImageUrl) {
this._applyShareImage(app.shareImageUrl, 'box', app.url);
console.log('[web_view] 分享图片: ' + app.shareImageUrl);
}
let finalUrl = appUrl;
if (option._channel) {
const separator = finalUrl.includes('?') ? '&' : '?';
finalUrl += separator + 'ch=' + option._channel;
}
console.log('[web_view] 解析到 H5 url:', appUrl, option._channel ? ' ch=' + option._channel : '');
option.url = encodeURIComponent(finalUrl);
} else {
console.warn('[web_view] 太阳码 scene 解析: 未找到对应 app,降级到 CRMEB 首页');
option.url = encodeURIComponent('https://shop.douba.top');
}
} else {
console.warn('[web_view] 太阳码 scene 解析响应异常,降级到 CRMEB 首页');
option.url = encodeURIComponent('https://shop.douba.top');
}
this._initWebView(option);
},
fail: (err) => {
console.error('[web_view] 太阳码 scene 网络请求失败:', err);
option.url = encodeURIComponent('https://shop.douba.top');
this._initWebView(option);
}
});
},
_initWebView(option) {
try {
let targetUrl = decodeURIComponent(option.url);
console.log('[web_view] 原始URL:', targetUrl);
const isTrustedDomain = targetUrl.indexOf(HTTP_REQUEST_URL) === 0 ||
(TRUSTED_DOMAINS || []).some(d => targetUrl.indexOf(d) === 0);
if (isTrustedDomain) {
const token = store.state.app.token;
console.log('[auth-flow] ===== 进入 H5 跳转流程 =====');
console.log('[auth-flow] 入口: 太阳码/点击应用');
console.log('[auth-flow] token:', token ? '有' : '无');
console.log('[auth-flow] store openid:', store.state.app.openid || '(空)', ', unionid:', store.state.app.unionid || '(空)');
const { openid, unionid } = getCurrentUser();
if (openid) {
const urlOpenid = getUrlParam(targetUrl, 'openid');
console.log('[auth-flow] 静默身份: openid=' + (openid || '(空)') + ', unionid=' + (unionid || '(空)'));
console.log('[auth-flow] URL 已有 openid:', urlOpenid || '(无)');
console.log('[auth-flow] ✅ 有 openid,注入身份跳转(unionid ' + (unionid ? '已携带' : '为空,可接受') + ')');
this._applyIdentity(targetUrl, openid, unionid, urlOpenid);
return; // _applyIdentity 内部已调用 _finalizeUrl
}
if (token) {
// 有 token 但 openid 仍空(极少见:JWT 不含 openid 字段):500ms 重试一次
console.warn('[auth-flow] ⚠️ 有 token 但 openid 为空,500ms 后重试');
setTimeout(() => {
try {
const retry = getCurrentUser();
if (retry.openid) {
console.log('[auth-flow] ✅ 重试成功,注入身份并跳转');
this._applyIdentity(targetUrl, retry.openid, retry.unionid, getUrlParam(targetUrl, 'openid'));
} else {
/* ⚠️ 2026-09-20:以前这里匿名加载 H5(→ 应用进访客态)。
改成走「取到 openid 才加载」的公共路径(自动重试 3 次 → 失败屏)。 */
console.warn('[auth-flow] ⚠️ 重试仍无 openid,改走「取到才加载」');
this._ensureIdentityThenLoad(targetUrl);
}
} catch (e) {
console.error('[auth-flow] 重试异常,改走「取到才加载」:', e);
this._ensureIdentityThenLoad(targetUrl);
}
}, 500);
return;
}
// 无 token + 无 openid(新用户 / 退出后 / 静默授权失败 / 冷启动竞态兜底超时):
// 走「取到 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] 非可信域,不做身份替换');
}
this._finalizeUrl(targetUrl);
} catch (e) {
console.error('[web_view] onLoad 异常:', e);
// 异常时直接加载原始 URL
try {
this.url = decodeURIComponent(option.url);
} catch (_) {}
}
},
/**
* 取到 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 注入)
*/
_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 || '(无)'));
if (urlOpenid && urlOpenid !== openid) {
targetUrl = stripAuthParams(targetUrl);
const joiner = targetUrl.indexOf('?') !== -1 ? '&' : '?';
const t = Math.floor(Date.now() / 1000);
targetUrl = targetUrl + joiner + 'openid=' + encodeURIComponent(openid);
if (unionid) targetUrl += '&unionid=' + encodeURIComponent(unionid);
targetUrl += '&t=' + t;
console.log('[auth-flow] 🔁 替换为当前用户身份');
} else if (!urlOpenid) {
targetUrl = stripAuthParams(targetUrl);
const joiner = targetUrl.indexOf('?') !== -1 ? '&' : '?';
const t = Math.floor(Date.now() / 1000);
targetUrl = targetUrl + joiner + 'openid=' + encodeURIComponent(openid);
if (unionid) targetUrl += '&unionid=' + encodeURIComponent(unionid);
targetUrl += '&t=' + t;
console.log('[auth-flow] ➕ 补充身份参数');
} else {
console.log('[auth-flow] 身份一致,保留原 URL');
}
console.log('[auth-flow] 最终注入 URL:', targetUrl.substring(0, 120));
this._finalizeUrl(targetUrl);
},
_finalizeUrl(targetUrl) {
console.log('[web_view] 最终URL:', targetUrl);
try {
const qIdx = targetUrl.indexOf('?');
if (qIdx !== -1) console.log('[web_view] params:', targetUrl.slice(qIdx + 1));
} catch (_) {}
// 强制缓存刷新:微信 webview 对相同 URL 会复用缓存。
// 每次进入都追加唯一 _v 参数(不受身份分支影响),保证加载最新 H5。
try {
const base = targetUrl.split('#')[0];
const joiner = base.indexOf('?') !== -1 ? '&' : '?';
targetUrl = base + joiner + '_v=' + Date.now() + (targetUrl.indexOf('#') !== -1 ? '#' + targetUrl.split('#')[1] : '');
console.log('[web_view] 已加缓存版本参数 _v →', targetUrl.substring(0, 120));
} catch (e) {
console.warn('[web_view] 加缓存版本参数失败:', e);
}
// #ifdef MP
uni.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] });
// #endif
this.url = targetUrl;
try {
const res = uni.getWindowInfo();
this.windowW = res.windowWidth;
this.windowH = res.windowHeight;
} catch (_) {}
// 预取分享图片:太阳码场景 _resolveAppScene 已设置 shareImageUrl
// 点击应用场景(JumpPath 无 identifier)或 H5 未传 imageUrl 时兜底拉取
// 优先保留 H5 postMessage 传来的分享数据,未设置时才用 box_app 的
this._fetchShareImage(targetUrl);
},
/* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 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(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 等身份参数:
// 1) 避免把分享者的微信匿名标识泄露给接收者;
// 2) 接收者打开后由 web_view 按自己的身份重新注入(onShow/onLoad 有兜底替换)。
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)),
// 朋友圈分享图必须是本地路径/代码包路径,网络图会被微信静默忽略;
// 网络图已在 _prepareTimelineImage 中通过 getImageInfo 转成本地临时路径。
imageUrl: this.shareTimelineImage || ''
}
},
/* webviewHost hook:本页 web-view 地址字段是 url */
_hostSetWebviewUrl(url) { this.url = url; },
/* webviewHost hook:copyLink 未带 href 时的兜底地址 */
_hostCurrentUrl() { return this.url || ''; },
/* handleHostMessage / onChooseHostAvatar / onHostGetPhone / submitHostAuth(url) /
consumeAuthResult(见 onShow) 由 mixins/webviewHost.js 统一提供 —— 与首页壳共用一套。 */
}
</script>
<style scoped>
.mb-auth-mask { position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; }
.mb-auth-panel { width: 82%; max-width: 340px; background: #fff; border-radius: 18px; padding: 24px 20px 18px; }
.mb-auth-title { font-size: 17px; font-weight: 700; color: #2B2320; text-align: center; }
.mb-auth-sub { font-size: 12px; color: #A79C95; text-align: center; margin: 8px 0 18px; line-height: 1.5; }
.mb-auth-btn { width: 100%; height: 46px; line-height: 46px; text-align: center; border-radius: 12px; background: #F8F3EF; color: #2B2320; font-size: 14px; margin-top: 12px; }
.mb-auth-btn.avatar { display: flex; align-items: center; justify-content: center; gap: 8px; }
.mb-auth-ava { width: 40px; height: 40px; border-radius: 50%; }
.mb-auth-input { width: 100%; height: 46px; border-radius: 12px; background: #F8F3EF; font-size: 14px; color: #2B2320; padding: 0 14px; margin-top: 12px; }
.mb-auth-actions { display: flex; gap: 12px; margin-top: 20px; }
.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>