Compare commits

...

2 Commits

Author SHA1 Message Date
c1407a84bb chore:H5登录正常 2026-08-29 18:22:10 +08:00
6ffbae0f33 chore:表单弹窗正常 2026-08-29 17:44:26 +08:00
7 changed files with 131 additions and 19 deletions

View File

@@ -325,14 +325,69 @@ export default {
// #ifdef MP-WEIXIN
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
// "privacy permission is not authorized or buttonId is invalid"。
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
this.ensureWxPrivacyAuthorized(() => {
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
});
});
// #endif
},
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
ensureWxPrivacyAuthorized(done) {
try {
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
// 低版本基础库无隐私 API, 直接放行
done();
return;
}
wx.getPrivacySetting({
success: (res) => {
if (!res || !res.needAuthorization) {
// 已授权或无隐私指引配置, 直接放行
done();
return;
}
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
const onAgreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
done();
};
const onDisagreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
};
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
uni.$on('privacy:agreed', onAgreed);
uni.$on('privacy:disagreed', onDisagreed);
popup.showPrivacyPopup();
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
// 无 privacy-popup 实例时退回微信官方隐私弹窗
wx.requirePrivacyAuthorize({
success: () => done(),
fail: () => { }
});
}
},
fail: () => done()
});
} catch (e) {
done();
}
},
// 跳转去登录页
toLoginH5Page(mode = 'navigateTo') {
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
@@ -614,8 +669,24 @@ export default {
}
});
} else {
const errMsg = (e.detail && e.detail.errMsg) || '';
// #ifdef MP-WEIXIN
// 隐私协议未授权时 getPhoneNumber 会直接失败:
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
if (errMsg.indexOf('privacy') !== -1) {
this.$util.showToast({
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
});
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
popup.showPrivacyPopup();
}
return;
}
// #endif
this.$util.showToast({
title: e.detail.errMsg
title: errMsg
})
}
},

View File

@@ -281,18 +281,18 @@ export default {
// #ifdef MP-WEIXIN
if (wx.onNeedPrivacyAuthorization) {
wx.onNeedPrivacyAuthorization((resolve) => {
// 检查是否已同意
if (!PrivacyManager.needShow()) {
// 已同意, 直接放行
resolve({ event: 'agree' })
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve)
} else {
// 需要弹窗, 存 resolve 等用户操作
if (typeof this.privacyAuthorization === 'function') {
this.privacyAuthorization(resolve)
} else {
// 兜底: 组件未就绪, 直接放行避免 API 卡死
resolve({ event: 'agree' })
}
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
// (disagree 无需 buttonId, 不会产生非法 resolve)
resolve({ event: 'disagree' })
}
})
}

View File

@@ -81,6 +81,7 @@
"loggingIn": "登录中",
"loginFailed": "登录失败",
"pleaseAuthorizeMobile": "请授权您的手机号码",
"pleaseAgreePrivacy": "请先同意《隐私条款》后重试",
"pleaseEnterNickname": "请输入昵称",
"avatarPullFailed": "头像拉取失败"
},

View File

@@ -45,6 +45,7 @@ export const lang = {
loggingIn: 'Logging in',
loginFailed: 'Login failed',
pleaseAuthorizeMobile: 'Please authorize your mobile number',
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
pleaseEnterNickname: 'Please enter nickname',
avatarPullFailed: 'Avatar pull failed',
}

View File

@@ -45,6 +45,7 @@ export const lang = {
loggingIn: '登录中',
loginFailed: '登录失败',
pleaseAuthorizeMobile: '请授权您的手机号码',
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
pleaseEnterNickname: '请输入昵称',
avatarPullFailed: '头像拉取失败',
}

View File

@@ -251,6 +251,9 @@
this.resumeSubmitAfterLogin();
// 本地缓存一份字段 JSON:下次再打开时如果仍被登录拦截,可作为 skeleton 兜底
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩:
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
return;
}
@@ -279,7 +282,12 @@
!isFormNotOpen &&
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
if (shouldTriggerLogin) {
if (shouldTriggerLogin && this.storeToken) {
// 已登录(storeToken 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
this.formError = this.$lang('form.form.loginRequired');
} else if (shouldTriggerLogin) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else if (isFormNotOpen) {
@@ -311,6 +319,11 @@
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
const now = Date.now();
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
this._lastAutoLoginAt = now;
const url = this.backUrl;
let attempts = 0;
const delays = [0, 60, 320];

View File

@@ -170,7 +170,9 @@ export default {
// diy-form 内部 mergedFormConfig 同时兼容 snake_case 后再交给 ns-newform
// - show_banner / form_banner:交给 diy-form 渲染 banner,样式走 form_config.bannerPaddingX;form_banner 给保底图
// - show_tip / form_tip:交给 diy-form 渲染 tip,样式走 form_config;form_tip 给保底文案
// 注:本 computed 只在 <block v-if="detail"> 内被消费,无需 null 守卫
// 注:小程序端模板引用的 computed 会进入 setData 数据流、每次更新都重新求值,
// 即使消费节点都在 v-if="detail" 内,detail 置空瞬间(提交成功/登录回跳等)也会求值,
// 必须做空值守卫,否则真机抛 Cannot read properties of null (reading 'json_data') 导致渲染中断
diyValue() {
// 新视觉默认样式:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 使表单在前端统一呈现为新设计稿样式;后台仍保留行为类配置(如 captcha / behavior / submitText)。
@@ -195,6 +197,16 @@ export default {
submitColor: '#ffffff',
submitBgColor: '#3b82f6'
};
if (!this.detail) {
return {
jsonData: null,
formConfig: Object.assign({}, styleDefaults),
show_banner: 0,
form_banner: '',
show_tip: 0,
form_tip: ''
};
}
return {
jsonData: this.detail.json_data,
formConfig: Object.assign({}, this.detail.form_config || {}, styleDefaults),
@@ -459,6 +471,9 @@ export default {
this.resumeSubmitAfterLogin();
// 本地缓存:下次再打开时若被登录拦截,可作为 skeleton 数据使用
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩:
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
this.$refs.loadingCover && this.$refs.loadingCover.hide();
return;
}
@@ -487,7 +502,12 @@ export default {
!isFormNotOpen &&
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
if (shouldTriggerLogin) {
if (shouldTriggerLogin && this.storeToken) {
// 已登录(storeToken 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
this.formError = this.$lang('form.form.loginRequired');
} else if (shouldTriggerLogin) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else if (isFormNotOpen) {
@@ -518,6 +538,11 @@ export default {
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
const now = Date.now();
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
this._lastAutoLoginAt = now;
const url = this.backUrl;
let attempts = 0;
const delays = [0, 60, 320];