Files
crmeb-mp/App.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

449 lines
16 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.
<script>
import {checkLogin} from './libs/login';
import {HTTP_REQUEST_URL} from './config/app';
import { LOGIN_STATUS, SUBSCRIBE_MESSAGE } from "./config/cache.js"
import { getShopConfig, silenceAuth, getLogo, remoteRegister } from '@/api/public';
import { silenceBindingSpread } from '@/utils';
import Auth from '@/libs/wechat.js';
import Routine from './libs/routine.js';
import { colorChange, copyRight } from '@/api/api.js';
import { getUserInfo } from "@/api/user.js"
import { mapGetters } from "vuex"
// #ifdef MP
// let livePlayer = requirePlugin('live-player-plugin')
// #endif
export default {
globalData: {
spid: 0,
code: 0,
isLogin: false,
userInfo: {},
globalData: false,
windowHeight: uni.getWindowInfo().windowHeight + 'px',
sysHeight:uni.getWindowInfo().statusBarHeight,
},
computed: mapGetters(['isLogin', 'cartNum']),
watch: {
cartNum(newCart, b) {
this.$store.commit('indexData/setCartNum', newCart + '')
if (newCart > 0) {
uni.setTabBarBadge({
index: 3,
text: newCart>99?'99+':newCart+''
})
} else {
uni.hideTabBarRedDot({
index: 3
})
}
}
},
onShow(options) {
const queryData = uni.getEnterOptionsSync(); // uni-app版本 3.5.1+ 支持
if (queryData.query.spid) {
this.$Cache.set('spread', queryData.query.spid);
this.globalData.spid = queryData.query.spid;
this.globalData.pid = queryData.query.spid;
silenceBindingSpread(this.globalData);
}
// #ifdef MP
if (queryData.query.scene) {
let param = this.$util.getUrlParams(decodeURIComponent(queryData.query.scene));
if (param.spid) {
this.$Cache.set('spread', param.spid);
this.globalData.spid = param.spid;
}
/** 直播间分享**/
// const sceneList = [1007, 1008, 1014, 1044, 1045, 1046, 1047, 1048, 1049, 1073, 1154, 1155];
// if (sceneList.includes(queryData.query.scene)) {
// livePlayer.getShareParams().then(res => {
// //记录推广人uid
// if(res.custom_params.pid){
// this.$Cache.set('spread', res.custom_params.pid);
// this.globalData.spid = res.custom_params.pid;
// }
// }).catch(err => {
// })
// }
silenceBindingSpread(this.globalData);
}
// #endif
},
onLaunch(option) {
uni.hideTabBar()
//#ifdef APP
plus.screen.lockOrientation("portrait-primary");
//#endif
let that = this;
//获取并存储配置
this.setConfig();
//主题换色
this.setTheme();
// 通过vuex获取并储存公共配置
this.$store.dispatch("getBasicConfig");
// 通过vuex获取并储存底部菜单的数据
this.$store.dispatch("getPageFooter");
// 通过vuex获取并储存活动弹窗的数据
this.$store.dispatch("getActivityModal");
// #ifdef H5
if (!this.isLogin) {
if (option.query && option.query.hasOwnProperty('remote_token')) {
this.remoteRegister(option.query.remote_token);
} else {
let token = option.query && option.query.token ? option.query.token : '';
if (!token) token = this.getTokenFromHash();
if (token) this.loginByToken(token);
}
}
this.setScript();
const queryData = uni.getEnterOptionsSync();
uni.getSystemInfo({
success(e) {
/* 窗口宽度大于420px且不在PC页面且不在移动设备时跳转至 PC.html 页面 */
if (e.windowWidth > 420 && !window.top.isPC && !/iOS|Android/i.test(e.system)) {
window.location.pathname = '/static/html/pc.html';
}
}
});
// #endif
// #ifdef MP
// 小程序静默授权
console.log('[auth-flow] App onLaunch: isLogin=' + this.$store.getters.isLogin);
if (!this.$store.getters.isLogin) {
Routine.getCode().then(code => {
console.log('[auth-flow] App onLaunch: wx.login 拿到 code=' + (code ? code.substring(0, 10) + '...' : '(空)'));
this.silenceAuth(code);
}).catch(res => {
console.warn('[auth-flow] App onLaunch: wx.login 失败', res);
uni.hideLoading();
});
}else if(this.$store.getters.isLogin && this.getExpired()){
//处理小程序有token情况下但是静默授权失败
this.$Cache.clear(LOGIN_STATUS);
Routine.getCode().then(code => {
console.log('[auth-flow] App onLaunch: token过期,重新静默授权 code=' + (code ? code.substring(0, 10) + '...' : '(空)'));
this.silenceAuth(code);
})
}
// #endif
uni.setStorageSync('DEBUG_DIY_RENDER', true)
},
methods: {
getTokenFromHash() {
try {
let hash = window.location.hash || '';
let qIndex = hash.indexOf('?');
if (qIndex === -1) return '';
let queryStr = hash.slice(qIndex + 1);
let parts = queryStr.split('&');
for (let i = 0; i < parts.length; i++) {
if (!parts[i]) continue;
let eqIndex = parts[i].indexOf('=');
let k = eqIndex === -1 ? parts[i] : parts[i].slice(0, eqIndex);
if (k === 'token') {
let v = eqIndex === -1 ? '' : parts[i].slice(eqIndex + 1);
return decodeURIComponent(v);
}
}
return '';
} catch (e) {
return '';
}
},
cleanTokenFromUrl() {
try {
let href = window.location.href || '';
if (href.indexOf('token=') === -1) return;
let urlObj = new URL(href);
if (urlObj.searchParams && urlObj.searchParams.has('token')) {
urlObj.searchParams.delete('token');
let search = urlObj.searchParams.toString();
history.replaceState(null, '', urlObj.pathname + (search ? '?' + search : '') + urlObj.hash);
return;
}
} catch (e) {}
try {
let hash = window.location.hash || '';
let qIndex = hash.indexOf('?');
if (qIndex === -1) return;
let hashPath = hash.slice(1, qIndex);
let queryStr = hash.slice(qIndex + 1);
let parts = queryStr.split('&').filter(Boolean);
let newParts = [];
for (let i = 0; i < parts.length; i++) {
let eqIndex = parts[i].indexOf('=');
let k = eqIndex === -1 ? parts[i] : parts[i].slice(0, eqIndex);
if (k !== 'token') newParts.push(parts[i]);
}
let newHash = '#' + hashPath + (newParts.length ? '?' + newParts.join('&') : '');
history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
} catch (e) {}
},
loginByToken(token) {
this.$store.commit("LOGIN", {
'token': token,
'time': 0
});
// 从 JWT payload 中立即提取 uid,无需等 getUserInfo
const payload = this.$util.parseJwt(token);
if (payload) {
if (payload.uid) this.$store.commit("SETUID", payload.uid);
// phone 和 openid 也可从 JWT 读取
// 后端需在签发 JWT 时加入 uid/phone/openid claims
}
getUserInfo().then(res => {
this.$store.commit("SETUID", res.data.uid);
this.$store.commit("UPDATE_USERINFO", res.data);
this.cleanTokenFromUrl();
}).catch(() => {
this.cleanTokenFromUrl();
})
},
remoteRegister(remote_token) {
remoteRegister({ remote_token }).then((res) => {
let data = res.data;
if (data.get_remote_login_url) {
location.href = data.get_remote_login_url
} else {
this.$store.commit("LOGIN", {
'token': data.token,
'time': data.expires_time - this.$Cache.time()
});
getUserInfo().then(res => {
this.$store.commit("SETUID", res.data.uid);
this.$store.commit("UPDATE_USERINFO", res.data);
location.reload()
}).catch(error => {
return this.$util.Tips({
title:err
})
})
}
});
},
// 小程序静默授权
silenceAuth(code) {
let that = this;
let spid = that.globalData.spid ? that.globalData.spid : '';
console.log('[auth-flow] silenceAuth 发起, code=' + (code ? code.substring(0, 10) + '...' : '(空)'));
const authPromise = silenceAuth({
code: code,
spread_spid: spid,
spread_code: that.globalData.code
})
.then(res => {
console.log('[auth-flow] silenceAuth 响应:', JSON.stringify(res.data || res).substring(0, 200));
if (res.data.token !== undefined && res.data.token) {
uni.hideLoading();
let time = res.data.expires_time - this.$Cache.time();
that.$store.commit('LOGIN', {
token: res.data.token,
time: time
});
/* ⚠️ 2026-09-20:`userInfo` 不是每个响应都有(v2/wechat/silence_auth
只回 token+expires_time),直接取 `.uid` 会抛异常,把后面的
commit 一起带走。加一层判空,缺了也不影响身份(openid 由 LOGIN 从 JWT 解出)。 */
if (res.data.userInfo) {
that.$store.commit('SETUID', res.data.userInfo.uid);
that.$store.commit('UPDATE_USERINFO', res.data.userInfo);
}
console.log('[auth-flow] ✅ silenceAuth 成功, token 已存 store (含 openid)');
} else if (res.data.openid) {
/* ⚠️⚠️ 2026-09-20 修:新用户 / 后台开启了「强制绑定手机号」时,
该接口返回的是 {auth_login:1, key, openid, unionid} —— **有 openid、没有 token**。
以前这段落到下面的 else,只打一条警告就把 openid 丢了;于是
store.openid / 缓存 OPENID / JWT 三处全空,首页壳 _hostOpenid() 拿不到身份
→ **匿名加载 mama-box**(H5 进访客态:孕周 `--`、档案空、跳应用跳裸 URL)。
这里必须把它落 store + 缓存。 */
that.$store.commit('SET_OPENID', res.data.openid);
try { uni.setStorageSync('OPENID', res.data.openid); } catch (e) {}
if (res.data.unionid) {
that.$store.commit('SET_UNIONID', res.data.unionid);
try { uni.setStorageSync('UNIONID', res.data.unionid); } catch (e) {}
}
console.log('[auth-flow] ✅ 无 token 但拿到 openid,已缓存(首页壳据此注入身份): ' + String(res.data.openid).substring(0, 8) + '...');
} else {
console.warn('[auth-flow] ⚠️ silenceAuth 返回无 token 也无 openid:', JSON.stringify(res.data || res).substring(0, 200));
}
}).catch(err => {
console.warn('[auth-flow] ❌ silenceAuth 失败:', JSON.stringify(err).substring(0, 300));
return that.$util.Tips({
title: err
})
});
/* ⚠️ 2026-09-20:把这次静默登录的 promise 挂到 globalData,
让宿主页(首页壳 / 容器页)在缓存未命中时**先 await 它**、而不是自己再换一次 code
(见 mixins/webviewHost.js 的 _awaitHostLogin)。
动机:微信 code 一次性,两边各换一次要多花 200–300ms(实测 code2session 就这么贵),
而 openid 本来就该由这一条链路落缓存(本文件上面的 SET_OPENID 分支 / LOGIN 从 JWT 提取)。
settle 后清掉,避免下次误等一个已经结束的 promise。 */
try { that.globalData._silenceAuthPromise = authPromise; } catch (e) {}
authPromise.then(() => {
try { if (that.globalData._silenceAuthPromise === authPromise) that.globalData._silenceAuthPromise = null; } catch (e) {}
});
return authPromise;
},
/**
* 检测当前的小程序
* 是否是最新版本,是否需要下载、更新
*/
checkUpdateVersion() {
//判断微信版本是否 兼容小程序更新机制API的使用
if (wx.canIUse('getUpdateManager')) {
const updateManager = wx.getUpdateManager();
//检测版本更新
updateManager.onCheckForUpdate(function(res) {
if (res.hasUpdate) {
updateManager.onUpdateReady(function() {
wx.showModal({
title: '温馨提示',
content: '检测到新版本,是否重启小程序?',
showCancel: false,
success: function(res) {
if (res.confirm) {
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(function() {
// 新版本下载失败
wx.showModal({
title: '已有新版本',
content: '请您删除小程序,重新搜索进入',
})
})
}
})
} else {
wx.showModal({
title: '溫馨提示',
content: '当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。'
})
}
},
async getExpired(){
// 通过一个个人中心的接口判断token是否生效,catch捕获到就证明token过期,返回true
try {
await getUserInfo();
return false; // token有效
} catch (err) {
return true; // token过期
}
},
setConfig(){
uni.removeStorageSync(SUBSCRIBE_MESSAGE);
this.$Cache.clear('homeTop');
let that = this;
// #ifdef MP
if (HTTP_REQUEST_URL == '') {
uni.showToast({
title: "请配置根目录下的config/app.js文件中的HTTP_REQUEST_URL",
icon:'error'
})
}
this.checkUpdateVersion();
let menuButtonInfo = uni.getMenuButtonBoundingClientRect();
that.globalData.navH = menuButtonInfo.top * 2 + menuButtonInfo.height / 2;
const version = wx.getAppBaseInfo().SDKVersion;
if (Routine.compareVersion(version, '2.21.2') >= 0) {
that.$Cache.set('MP_VERSION_ISNEW', true)
} else {
that.$Cache.set('MP_VERSION_ISNEW', false)
}
// #endif
getLogo().then(res => {
uni.setStorageSync('BASIC_CONFIG', res.data)
});
},
setTheme(){
let green =
'--view-theme: #42CA4D;--view-minorColorT:rgba(66, 202, 77, 0.1);--view-bntColor:#FE960F;--view-gradient:#4DEA4D'
let red =
'--view-theme: #e93323;--view-minorColorT:rgba(233, 51, 35, 0.1);--view-bntColor:#FE960F;--view-gradient:#FF7931'
let blue =
'--view-theme: #1DB0FC;--view-minorColorT:rgba(9, 139, 243, 0.1);--view-bntColor:#22CAFD;--view-gradient:#5ACBFF'
let pink =
'--view-theme: #FF448F;--view-minorColorT:rgba(255, 68, 143, 0.1);--view-bntColor:#282828;--view-gradient:#FF67AD'
let orange =
'--view-theme: #FE5C2D;--view-minorColorT:rgba(254, 92, 45, 0.1);--view-bntColor:#FDB000;--view-gradient:#FF9451'
let gold =
'--view-theme: #E0A558;--view-minorColorT:rgba(224, 165, 88, 0.1);--view-bntColor:#1A1A1A;--view-gradient:#FFCD8C'
colorChange('color_change').then(res => {
let statusColor = res.data.status; //判断显示啥颜色
let themeType = res.data.color_data?.theme_type || 1;
if(themeType == 1){
uni.setStorageSync('statusColor', statusColor);
uni.$emit('colorOk', statusColor);
let themeList = {
1:blue, 2:green, 3:red, 4:pink, 5:orange, 6:gold
};
Object.entries(themeList).forEach(([key, themeSelect]) => {
if (key == statusColor) {
uni.setStorageSync('viewColor', themeSelect);
uni.$emit('ok', themeSelect);
}
});
}else{
let colorData = res.data.color_data;
let themeStr = `--view-theme: ${colorData.theme_color};--view-minorColorT:${colorData.light_color};--view-bntColor:${colorData.aux_color};--view-gradient:${colorData.gradient_color}`;
uni.setStorageSync('viewColor', themeStr);
uni.$emit('ok', themeStr);
}
});
},
setScript(){
// 添加crmeb chat 统计
var __s = document.createElement('script');
__s.src = `${HTTP_REQUEST_URL}/api/get_script`;
document.head.appendChild(__s);
},
},
};
</script>
<style lang="scss">
/* #ifndef APP-PLUS-NVUE || APP-NVUE */
@import url('@/plugin/emoji-awesome/css/tuoluojiang.css');
@import 'static/css/base.css';
@import 'static/iconfont/iconfont.css';
@import 'static/css/unocss.css';
view {
box-sizing: border-box;
}
page {
font-family: PingFang SC;
}
.placeholder{
color: #ccc;
}
.mer-bg{
background-color: $primary-merchant;
}
.uni-scroll-view::-webkit-scrollbar {
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
display: none;
}
::-webkit-scrollbar {
width: 0;
height: 0;
color: transparent;
}
.uni-system-open-location .map-content.fix-position {
height: 100vh;
top: 0;
bottom: 0;
}
/* #endif */
</style>