feat(passport): 添加手机号授权登录功能支持扫码登录场景

- 在app配置中新增手机号授权登录页面路由
- 修改扫码确认登录逻辑,未注册用户跳转手机号授权登录而非用户页
- 优化扫码登录成功后的跳转逻辑,支持返回扫码确认页面或跳转指定页面
- 新增手机号授权登录组件,实现微信手机号快速授权登录流程
- 手机号授权登录页面包括服务协议和隐私政策勾选及弹窗展示
- 登录成功后处理邀请绑定逻辑,支持扫码场景自动返回确认页
- 配置postcss禁用autoprefixer自动添加浏览器前缀避免冲突
This commit is contained in:
2026-04-08 00:48:43 +08:00
parent 27641e4c8c
commit cba374f3aa
7 changed files with 404 additions and 12 deletions

View File

@@ -0,0 +1,17 @@
{
"version": 2,
"sessions": {
"8485265d66ab43638f13f71cec8f191d": [
{
"expertId": "SeniorDeveloper",
"name": "Will",
"profession": "高级开发工程师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
"usedAt": 1775579277895,
"industryId": "all"
}
]
},
"lastUpdated": 1775579765675
}

View File

View File

@@ -1,6 +1,8 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
autoprefixer: {
remove: true // 禁用 autoprefixer避免添加浏览器前缀
},
},
}

View File

@@ -15,6 +15,7 @@ export default {
"setting",
"agreement",
"sms-login",
"phone-auth/index",
'qr-login/index',
'qr-confirm/index',
'unified-qr/index'

View File

@@ -0,0 +1,350 @@
import { useEffect, useState } from "react";
import Taro, { useRouter } from '@tarojs/taro'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { checkAndHandleInviteRelation, hasPendingInvite } from "@/utils/invite";
import { TenantId } from "@/config/app";
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
/**
* 手机号授权登录页面(扫码登录场景专用)
* 使用微信手机号快速验证组件
*
* 流程:
* 1. 用户勾选同意服务协议和隐私政策
* 2. 点击微信手机号授权按钮
* 3. 微信弹出授权确认框
* 4. 获取 code 后调用后端接口完成登录
* 5. 登录成功后返回扫码确认页面
*/
// 微信获取手机号回调参数类型
interface GetPhoneNumberDetail {
code?: string;
encryptedData?: string;
iv?: string;
errMsg: string;
}
interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail;
}
// 登录接口返回数据类型
interface LoginResponse {
data: {
access_token: string;
user: any;
};
code: number;
message: string;
}
const AgreementPopup = ({ visible, onClose }: { visible: boolean; onClose: () => void }) => (
<Popup
visible={visible}
position="bottom"
closeable
onClose={onClose}
style={{ height: '70%' }}
>
<div className="p-5 overflow-y-auto" style={{ height: '100%' }}>
<h3 className="text-lg font-bold mb-4"></h3>
<div className="text-sm text-gray-600 leading-relaxed">
<p className="mb-4">
<br/>
使 WebSoft <br/>
</p>
<p className="mb-4">
<br/>
WebSoft
</p>
<p className="mb-4">
<br/>
使
</p>
<p className="mb-4">
使<br/>
</p>
<p className="mb-4">
<br/>
</p>
<p>
<br/>
</p>
</div>
<div className="mt-4">
<Button type="primary" onClick={onClose}></Button>
</div>
</div>
</Popup>
);
const PrivacyPopup = ({ visible, onClose }: { visible: boolean; onClose: () => void }) => (
<Popup
visible={visible}
position="bottom"
closeable
onClose={onClose}
style={{ height: '70%' }}
>
<div className="p-5 overflow-y-auto" style={{ height: '100%' }}>
<h3 className="text-lg font-bold mb-4"></h3>
<div className="text-sm text-gray-600 leading-relaxed">
<p className="mb-4">
<br/>
使
</p>
<p className="mb-4">
<br/>
使访
</p>
<p className="mb-4">
使<br/>
使
</p>
<p className="mb-4">
<br/>
</p>
<p className="mb-4">
<br/>
</p>
<p className="mb-4">
<br/>
访
</p>
<p>
<br/>
</p>
</div>
<div className="mt-4">
<Button type="primary" onClick={onClose}></Button>
</div>
</div>
</Popup>
);
const PhoneAuthLogin = () => {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false)
const [agreed, setAgreed] = useState<boolean>(false)
const [showAgreement, setShowAgreement] = useState<boolean>(false)
const [showPrivacy, setShowPrivacy] = useState<boolean>(false)
// 获取 redirect 参数(扫码登录场景)
const redirectUrl = router.params.redirect || '';
useEffect(() => {
Taro.hideTabBar()
}, [])
// 处理微信手机号授权
const handleGetPhoneNumber = ({ detail }: GetPhoneNumberEvent) => {
const { code, encryptedData, iv, errMsg } = detail
// 用户拒绝授权
if (errMsg && errMsg.includes('fail')) {
Taro.showToast({
title: '需要授权手机号才能登录',
icon: 'none'
})
return
}
// 未同意协议
if (!agreed) {
Taro.showToast({
title: '请先阅读并同意服务协议和隐私政策',
icon: 'none'
})
return
}
if (!code) {
Taro.showToast({
title: '获取授权信息失败,请重试',
icon: 'none'
})
return
}
// 执行登录
handleLogin(code, encryptedData, iv)
}
// 执行登录
const handleLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => {
try {
setLoading(true)
const res = await Taro.request<LoginResponse>({
url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
method: 'POST',
data: {
code: phoneCode,
encryptedData,
iv,
tenantId: TenantId
},
header: {
'content-type': 'application/json',
'TenantId': TenantId
}
})
if (res.data.code !== 0) {
throw new Error(res.data.message || '登录失败')
}
// 保存登录信息
if (res.data.data?.user) {
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user)
}
// 登录成功后,检查是否存在待处理的邀请关系并尝试绑定
if (hasPendingInvite()) {
try {
await checkAndHandleInviteRelation()
} catch (e) {
console.error('授权登录后处理邀请关系失败:', e)
}
}
Taro.showToast({
title: '授权成功',
icon: 'success'
})
// 延迟跳转
setTimeout(() => {
if (redirectUrl) {
// 扫码登录场景:返回扫码确认页面
Taro.navigateBack();
} else {
// 普通场景:跳转到首页
Taro.reLaunch({
url: '/pages/index/index'
})
}
}, 1500)
} catch (error: any) {
Taro.showToast({
title: error.message || '授权失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
return (
<>
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-blue-50 to-white px-6">
{/* Logo/Icon 区域 */}
<div className="mb-10">
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg shadow-blue-200">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none">
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
{/* 标题 */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-800 mb-2"></h1>
<p className="text-sm text-gray-500"></p>
</div>
{/* 微信授权按钮 */}
<div className="w-full mb-8">
<Button
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white font-medium py-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-green-200 active:scale-95 transition-transform disabled:opacity-50 disabled:active:scale-100"
open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
disabled={loading}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>...</span>
</>
) : (
<>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>
<span></span>
</>
)}
</Button>
</div>
{/* 协议勾选 */}
<div className="flex items-start mb-6 px-2">
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 mt-1 flex-shrink-0 cursor-pointer transition-colors ${
agreed ? 'bg-blue-500 border-blue-500' : 'border-gray-300 bg-white hover:border-blue-400'
}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
<div className="text-sm text-gray-600">
<span
className="text-blue-500 cursor-pointer hover:text-blue-600"
onClick={(e) => {
e.stopPropagation();
setShowAgreement(true);
}}
>
</span>
<span
className="text-blue-500 cursor-pointer hover:text-blue-600"
onClick={(e) => {
e.stopPropagation();
setShowPrivacy(true);
}}
>
</span>
</div>
</div>
{/* 底部提示 */}
<div className="text-center text-xs text-gray-400 mt-auto pb-8">
<p></p>
<p className="mt-1"></p>
</div>
</div>
{/* 服务协议弹窗 */}
<AgreementPopup
visible={showAgreement}
onClose={() => setShowAgreement(false)}
/>
{/* 隐私政策弹窗 */}
<PrivacyPopup
visible={showPrivacy}
onClose={() => setShowPrivacy(false)}
/>
</>
);
}
export default PhoneAuthLogin;

View File

@@ -126,19 +126,19 @@ const QRConfirmPage: React.FC = () => {
// 调用确认登录
await handleConfirmLogin(loginToken, wxLoginResult.data.user);
} else {
// 用户未注册,跳转到用户页引导注册
console.log('[QRConfirm] 用户未注册,跳转到用户页引导注册');
// 用户未注册,跳转到手机号授权登录页面
console.log('[QRConfirm] 用户未注册,跳转到手机号授权登录页面');
Taro.showToast({
title: '请先注册/登录小程序',
title: '请先授权登录小程序',
icon: 'none',
duration: 2000
});
setTimeout(() => {
// 跳转到用户中心,在那里可以完成注册和登录
Taro.switchTab({
url: '/pages/user/user'
// 跳转到手机号授权登录页面,登录/注册成功后返回扫码确认页面
Taro.navigateTo({
url: '/passport/phone-auth/index?redirect=/passport/qr-confirm'
});
}, 2000);
}

View File

@@ -1,11 +1,12 @@
import {useEffect, useState} from "react";
import Taro from '@tarojs/taro'
import Taro, { useRouter } from '@tarojs/taro'
import {Input, Button} from '@nutui/nutui-react-taro'
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
import {LoginParam} from "@/api/passport/login/model";
import {checkAndHandleInviteRelation, hasPendingInvite} from "@/utils/invite";
const SmsLogin = () => {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false)
const [sendingCode, setSendingCode] = useState<boolean>(false)
const [countdown, setCountdown] = useState<number>(0)
@@ -14,6 +15,9 @@ const SmsLogin = () => {
code: ''
})
// 获取 redirect 参数(扫码登录场景)
const redirectUrl = router.params.redirect || '';
const reload = () => {
Taro.hideTabBar()
}
@@ -146,11 +150,29 @@ const SmsLogin = () => {
icon: 'success'
})
// 延迟跳转到首页
// 延迟跳转
setTimeout(() => {
if (redirectUrl) {
// 扫码登录场景:返回扫码确认页面
// 构建带原参数的完整 URL
const currentPages = Taro.getCurrentPages();
const prevPage = currentPages[currentPages.length - 2];
if (prevPage && prevPage.route && prevPage.route.includes('qr-confirm')) {
// 如果上一页是扫码确认页面,直接返回
Taro.navigateBack();
} else {
// 否则跳转到指定页面
Taro.redirectTo({
url: redirectUrl
});
}
} else {
// 普通场景:跳转到首页
Taro.reLaunch({
url: '/pages/index/index'
})
}
}, 1500)
} catch (error: any) {