- 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>
632 lines
31 KiB
Vue
632 lines
31 KiB
Vue
<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 {
|
||
uni.setStorageSync('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>
|