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

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

633 lines
31 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">{{ appNeedLogin ? '🔐' : (identityFail ? '🥲' : '🍼') }}</view>
<view class="wv-title">{{ appNeedLogin ? '登录后继续' : (identityFail ? '未获取到微信身份' : '正在获取微信身份') }}</view>
<view class="wv-desc">{{ appNeedLogin ? '使用手机号验证码登录;登录后会获取微信身份并自动打开该应用' : (identityFail ? '请检查网络后点下方重试,或退出小程序重新进入' : '马上就好,请稍候(不会加载空数据的页面)') }}</view>
<button v-if="appNeedLogin" class="wv-retry" hover-class="wv-retry-hover" @tap="goAppLogin">登录后继续</button>
<button v-else-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 → 停在兜底屏,给「重新获取身份」入口
appNeedLogin: false, // App:未登录 → 兜底屏给「登录后继续」
_idAttempts: 0, // 自动重试计数
_pendingTarget: '' // 重试时要加载的目标 URL(重试按钮用)
}
},
onLoad(option) {
/* 诊断日志(过滤关键字 enter-app):容器页每次被打开都会打这一行 ——
它是「真的进入了某个 H5 应用」的最终证据(不管入口是恢复/分享/太阳码/商城宫格)。 */
try {
console.log('[enter-app] ===== 容器页 onLoad ===== option=' + JSON.stringify(option || {}));
} catch (e) {}
// 分享落地携带的推广人:与其它页面一致写进 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);
}
// 同一响应里就有分享标题(后台「分享标题」,留空 = 应用名称)→ 直接落上,
// 不必等反查表回来(太阳码是冷启动首发场景)
this._applyShareTitleFromApp(app);
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。 */
let task;
// #ifdef APP-PLUS
task = this._silenceAuthOpenid();
// #endif
// #ifndef APP-PLUS
task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid();
// #endif
task.then((r) => {
if (this._loadWithCachedIdentity(t)) return;
/* App 未登录:停止自动重试,兜底屏给「登录后继续」 */
if (r && r.needLogin) {
this.identityPending = false;
this.identityFail = false;
this.appNeedLogin = true;
console.log('[auth-flow] App 未登录(容器页)→ 等待用户登录');
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.appNeedLogin = 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.appNeedLogin = false;
this._ensureIdentityThenLoad(this._pendingTarget || this.savedUrl || this.url);
},
/* App:去登录;登录成功 reLaunch 回容器页(带原目标 URL) */
goAppLogin() {
const back = this._pendingTarget || this.savedUrl || this.url || '';
try {
/* 必须走 $Cache.set(loginMobile 读的是 $Cache.get(BACK_URL)) */
this.$Cache.set('login_back_url',
back ? '/pages/annex/web_view/index?url=' + encodeURIComponent(back) : '/pages/index/index');
} catch (e) {}
uni.navigateTo({ url: '/pages/users/login/index' });
},
_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;
console.log('[enter-app] ✅ 容器页开始加载 H5 应用: ' + targetUrl.split('#')[0]);
/* 宿主恢复:把「这次真正进入的 H5 应用」记到 box_app(/api/mp/public/last-app)。
覆盖商城宫格 / 分享落地 / 朋友圈 / 太阳码 / JumpPath 这些**不经过 mama-box 宫格**
的入口;经过 mama-box 宫格的由 H5 自己 sendBeacon 上报
(见 mama-box js/app.js → reportLastApp)。冷启动恢复由 mama-box 首屏完成。
⚠️ 只上报净地址(stripAuthParams):恢复时身份必须按**当前**用户重新注入。
⚠️ 失败静默:这是旁路能力,绝不影响 H5 加载。 */
this._reportLastApp(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);
},
/* ---------- 宿主恢复:上报「本次进入的 H5 应用」(2026-09-22) ----------
box_app /api/mp/public/last-app 是 Redis 里的 per-openid 记录(7 天 TTL),
冷启动时由 mama-box 首屏读取并 location.replace 恢复(见 mama-box js/app.js resumeLastApp)。
本页负责**非 mama-box 宫格**的入口:商城宫格 / 分享落地 / 朋友圈 / 太阳码 / JumpPath。
⚠️ 传净地址:恢复时身份按当前用户重新注入,不能把记录时的 openid 带回去。
⚠️ 失败静默:旁路能力,绝不影响 H5 加载。 */
_reportLastApp(targetUrl) {
try {
const openid = getOpenid();
if (!openid || !targetUrl) {
console.log('[enter-app] ○ 容器页跳过 last-app 上报(openid=' + (openid ? '有' : '空') +
', url=' + (targetUrl ? '有' : '空') + ')');
return;
}
const clean = stripAuthParams(targetUrl);
if (!clean) return;
let api = 'https://mp.yizhezhe.com/api/mp/public/last-app?openid=' + encodeURIComponent(openid) +
'&url=' + encodeURIComponent(clean);
const id = this._lookupAppIdentifier(clean);
if (id) api += '&identifier=' + encodeURIComponent(id);
console.log('[enter-app] 容器页上报 last-app: url=' + clean + (id ? ' identifier=' + id : ''));
uni.request({
url: api,
method: 'POST',
timeout: 5000,
success: (res) => {
console.log('[enter-app] 容器页 last-app 上报结果: HTTP ' + ((res && res.statusCode) || '?') +
' body=' + JSON.stringify(res && res.data));
},
fail: (err) => {
console.log('[enter-app] ✗ 容器页 last-app 上报失败: ' + JSON.stringify(err));
}
});
} catch (e) {
console.warn('[resume] 上报 last_app 失败(忽略):', e);
}
},
/* 在内存应用表里按 URL 前缀反查 identifier(与 mixins/shareCard.js 的匹配同口径);
表还没回来时返回空 —— identifier 是可选参数,服务端会按 URL 兜底校验。 */
_lookupAppIdentifier(targetUrl) {
const base = this._shareUrlBase(targetUrl);
if (!base) return '';
const apps = this.shareApps || [];
for (let i = 0; i < apps.length; i++) {
const a = apps[i];
if (!a || !a.url || !a.identifier) continue;
const ab = this._shareUrlBase(a.url);
if (ab && (base === ab || base.indexOf(ab + '/') === 0)) return a.identifier;
}
return '';
},
/* 分享图反查 / 单应用缓存 / 朋友圈本地图转换,统一由 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 {
// 标题来源优先级:H5 postMessage 上报 → box_app 反查(后台「分享标题」,留空 = 应用名称,
// 由 resolveShareCard 写进 shareTitle)→ 兜底文案。
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 兜底。
// 同一地址也解析一次:首次分享就走朋友圈时,标题/图不能还是空的。
const target = this.shareHref || this.url;
this.resolveShareCard(target);
return {
title: this.shareTitle || '分享',
query: 'url=' + encodeURIComponent(stripAuthParams(target)),
// 朋友圈分享图必须是本地路径/代码包路径,网络图会被微信静默忽略;
// 网络图已在 _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>