Files
crmeb-mp/pages/annex/web_view/index.vue
Karlcarlo d9263d2ea2 feat: 微信审核适配 — 移除提现/分销页面 + 分享图片预取 + 登录链路修复
- pages.json 移除 14 个提现/分销页面注册(user_cash/user_spread/spreadData/promoter/distributor),文件保留可恢复
- template4.vue 隐藏'立即提现'区块(用户中心可见入口,分销功能已关闭)
- record.vue 抽奖记录移除提现收款跳转(改 toast 提示)
- web_view 分享图片:太阳码/点击应用时从 box_app 预取 shareImageUrl,H5 postMessage 传图优先
- web_view _fetchOpenidAndLoad 兼容老用户响应(openid 从 userInfo/JWT 兜底解析)并写回缓存
- App.vue 移除 expired=[object Promise] 无意义日志
2026-08-15 16:36:49 +08:00

559 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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="handleMessage"></web-view>
</view>
</template>
<script>
import store from '@/store';
import { HTTP_REQUEST_URL, TRUSTED_DOMAINS } from '@/config/app';
import { LOGIN_STATUS } from '@/config/cache';
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 };
}
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(/&$/, '');
}
function getUrlParam(url, name) {
try {
const match = url.match(new RegExp('[?&]' + name + '=([^&]*)'));
return match ? decodeURIComponent(match[1]) : '';
} catch (e) { return ''; }
}
export default {
data() {
return {
windowH: 0, windowW: 0,
webviewStyles: { progress: { color: 'transparent' } },
url: '',
shareTitle: '',
shareSummary: '',
shareImageUrl: '',
shareTimelineImage: '',
shareHref: '',
savedUrl: '',
_shareFetched: false
}
},
onLoad(option) {
// 太阳码 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 重新注入
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 {
console.warn('[auth-flow] ⚠️ onShow: 仍无 openid,加载原始URL');
this._finalizeUrl(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);
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 {
console.warn('[auth-flow] ⚠️ 重试仍无 openid,匿名加载 H5');
this._finalizeUrl(targetUrl);
}
} catch (e) {
console.error('[auth-flow] 重试异常,匿名加载:', e);
this._finalizeUrl(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);
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 (_) {}
}
},
/**
* 主动 wx.login() 拿 code,调 CRMEB /v2/wechat/silence_auth 拿 openid
* 然后注入 URL 并加载 H5(即使后端返回 auth_login=1 无 token,openid 已够 H5 端走 openid-login 建档)
* @param {string} targetUrl - 原始 H5 URL
*/
_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);
}
});
},
_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 (_) {}
// #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);
},
/**
* 根据当前 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 = '';
}
});
},
},
onShareAppMessage() {
return {
title: this.shareTitle || '分享',
// 分享出去必须剥离 openid/unionid/t 身份参数:
// 1) 避免把分享者的微信匿名标识泄露给接收者;
// 2) 接收者打开后由 web_view 按自己的身份重新注入(onShow/onLoad 有兜底替换)。
path: '/pages/annex/web_view/index?url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
imageUrl: this.shareImageUrl || ''
}
},
onShareTimeline() {
return {
title: this.shareTitle || '分享',
query: 'url=' + encodeURIComponent(stripAuthParams(this.shareHref || this.url)),
// 朋友圈分享图必须是本地路径/代码包路径,网络图会被微信静默忽略;
// 网络图已在 _prepareTimelineImage 中通过 getImageInfo 转成本地临时路径。
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
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 'setTitle':
if (data.title) {
this.shareTitle = data.title;
uni.setNavigationBarTitle({ title: data.title })
}
break
}
},
}
</script>