diff --git a/.workbuddy/expert-history.json b/.workbuddy/expert-history.json index e378275..e8fc3ce 100644 --- a/.workbuddy/expert-history.json +++ b/.workbuddy/expert-history.json @@ -13,5 +13,5 @@ } ] }, - "lastUpdated": 1775579765675 + "lastUpdated": 1775583343042 } \ No newline at end of file diff --git a/src/passport/phone-auth/index.tsx b/src/passport/phone-auth/index.tsx index 4ea0154..2b8db84 100644 --- a/src/passport/phone-auth/index.tsx +++ b/src/passport/phone-auth/index.tsx @@ -1,7 +1,7 @@ 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 { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite"; import { TenantId } from "@/config/app"; import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server"; @@ -183,6 +183,10 @@ const PhoneAuthLogin = () => { try { setLoading(true) + // 获取存储的邀请参数 + const inviteParams = getStoredInviteParams() + const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0 + const res = await Taro.request({ url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`, method: 'POST', @@ -190,7 +194,10 @@ const PhoneAuthLogin = () => { code: phoneCode, encryptedData, iv, - tenantId: TenantId + tenantId: TenantId, + notVerifyPhone: true, // 用户未注册时自动注册 + refereeId: refereeId, // 推荐人ID + sceneType: 'save_referee' }, header: { 'content-type': 'application/json', diff --git a/src/passport/qr-confirm/index.tsx b/src/passport/qr-confirm/index.tsx index 2ecb6d2..7acfa5e 100644 --- a/src/passport/qr-confirm/index.tsx +++ b/src/passport/qr-confirm/index.tsx @@ -5,45 +5,68 @@ import { Success, Failure, Tips, User } from '@nutui/icons-react-taro'; import Taro, { useRouter } from '@tarojs/taro'; import { confirmQRLogin } from '@/api/passport/qr-login'; import { loginByOpenId } from '@/api/passport/wx-login'; +import { TenantId } from "@/config/app"; +import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server"; +import { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite"; /** * 扫码登录确认页面 - * + * * 支持两种场景: * 1. 主动扫码:用户点击按钮调用微信扫码,扫描 PC 端二维码 * 2. URL 扫码(小程序码):用户扫描小程序码后,微信自动打开此页面 - * + * * URL 扫码场景: * - 微信「扫普通链接二维码打开小程序」配置的二维码规则:`https://websopy.websoft.top/wx-scan/` * - 扫码后 URL:`https://websopy.websoft.top/wx-scan?token=xxx` * - 小程序接收到参数后自动确认登录 - * - * 登录流程(2026-04-07 实现): + * + * 登录流程(2026-04-08 更新): * 1. 用户扫码 → 进入 qr-confirm 页面 * 2. 页面立即调用 wx.login() 获取 code * 3. 用 code 调用 /api/wx-login/loginByOpenId 获取/验证用户身份 - * 4. 如果用户不存在 → 跳转到用户页引导注册 + * 4. 如果用户不存在 → 显示微信手机号授权按钮(一键注册登录) * 5. 如果用户存在 → 自动调用 confirmQRLogin 确认登录 */ + +// 微信获取手机号回调参数类型 +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 QRConfirmPage: React.FC = () => { const router = useRouter(); - // 移除 useUser 依赖,改用 wx.login() + loginByOpenId 方式验证用户身份 const [loading, setLoading] = useState(false); const [confirmed, setConfirmed] = useState(false); const [error, setError] = useState(''); const [token, setToken] = useState(''); const [loginMethod, setLoginMethod] = useState<'scan' | 'url'>('url'); const [userInfo, setUserInfo] = useState(null); + const [needAuth, setNeedAuth] = useState(false); // 是否需要手机号授权 + const [authLoading, setAuthLoading] = useState(false); // 授权中状态 useEffect(() => { // 从 URL 参数中获取 token const params = router.params; - + // 兼容多种参数名 - // 1. 小程序码场景:?scene=xxx(微信会将 scene 参数透传到小程序) - // 2. 直接参数:?token=xxx - // 3. URL 编码参数:?q=xxx(扫普通链接二维码场景) - // 4. 旧版参数:?qrCodeKey=xxx let loginToken = params.scene || params.token || params.qrCodeKey || ''; // 如果是 q 参数(URL 编码的完整 URL),需要解析 @@ -51,17 +74,15 @@ const QRConfirmPage: React.FC = () => { try { const decodedUrl = decodeURIComponent(params.q); console.log('[QRConfirm] 解码后的 URL:', decodedUrl); - - // 解析 token + const url = new URL(decodedUrl); - loginToken = url.searchParams.get('token') || - url.searchParams.get('qrCodeKey') || + loginToken = url.searchParams.get('token') || + url.searchParams.get('qrCodeKey') || ''; - + setLoginMethod('url'); } catch (e) { console.error('[QRConfirm] 解析 q 参数失败:', e); - // 尝试直接使用 q 作为 token loginToken = decodeURIComponent(params.q); setLoginMethod('url'); } @@ -72,9 +93,8 @@ const QRConfirmPage: React.FC = () => { if (loginToken) { setToken(loginToken); console.log('[QRConfirm] 获取到 token:', loginToken); - + // 扫码场景:自动确认登录 - // scene 参数说明是扫描小程序码进来的,token 参数说明是扫码跳转过来的 if (params.scene || params.token || params.qrCodeKey || params.q) { console.log('[QRConfirm] 检测到扫码参数,自动确认登录'); setTimeout(() => { @@ -88,99 +108,163 @@ const QRConfirmPage: React.FC = () => { /** * 自动确认登录(URL 扫码场景) - * - * 新的登录流程(2026-04-07): - * 1. 调用 wx.login() 获取 code - * 2. 用 code 调用后端 loginByOpenId 接口验证用户身份 - * 3. 用户存在 → 调用 confirmQRLogin 确认登录 - * 4. 用户不存在 → 跳转到用户页引导注册 */ const handleAutoConfirm = async (loginToken: string) => { try { setLoading(true); - + // 1. 调用微信登录获取 code console.log('[QRConfirm] 调用 wx.login() 获取 code...'); const loginResult = await Taro.login(); - + if (!loginResult.code) { throw new Error('获取微信登录凭证失败'); } console.log('[QRConfirm] 获取到 code:', loginResult.code); - + // 2. 用 code 调用后端接口验证用户身份 console.log('[QRConfirm] 调用后端 loginByOpenId...'); const wxLoginResult = await loginByOpenId({ code: loginResult.code, - tenantId: 10398 // 使用固定租户ID + tenantId: 5 }); - + console.log('[QRConfirm] loginByOpenId 结果:', wxLoginResult); - + // 3. 判断用户是否存在 if (wxLoginResult.success && wxLoginResult.data) { // 用户已注册,保存用户信息并继续确认登录 console.log('[QRConfirm] 用户已注册,开始确认登录...'); setUserInfo(wxLoginResult.data.user); - + // 调用确认登录 await handleConfirmLogin(loginToken, wxLoginResult.data.user); } else { - // 用户未注册,跳转到手机号授权登录页面 - console.log('[QRConfirm] 用户未注册,跳转到手机号授权登录页面'); - - Taro.showToast({ - title: '请先授权登录小程序', - icon: 'none', - duration: 2000 - }); - - setTimeout(() => { - // 跳转到手机号授权登录页面,登录/注册成功后返回扫码确认页面 - Taro.navigateTo({ - url: '/passport/phone-auth/index?redirect=/passport/qr-confirm' - }); - }, 2000); + // 用户未注册,显示手机号授权界面 + console.log('[QRConfirm] 用户未注册,显示手机号授权界面'); + setNeedAuth(true); + setLoading(false); } } catch (err: any) { console.error('[QRConfirm] 自动确认登录失败:', err); setError(err.message || '自动确认登录失败'); - } finally { setLoading(false); } }; + /** + * 处理微信手机号授权 + */ + const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => { + const { code, encryptedData, iv, errMsg } = detail; + + // 用户拒绝授权 + if (errMsg && errMsg.includes('fail')) { + Taro.showToast({ + title: '需要授权手机号才能完成登录', + icon: 'none' + }); + return; + } + + if (!code) { + Taro.showToast({ + title: '获取授权信息失败,请重试', + icon: 'none' + }); + return; + } + + // 执行授权登录 + await handleAuthLogin(code, encryptedData, iv); + }; + + /** + * 授权登录(未注册用户) + */ + const handleAuthLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => { + try { + setAuthLoading(true); + + // 获取存储的邀请参数 + const inviteParams = getStoredInviteParams(); + const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0; + + const res = await Taro.request({ + url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`, + method: 'POST', + data: { + code: phoneCode, + encryptedData, + iv, + tenantId: TenantId, + notVerifyPhone: true, + refereeId: refereeId, + sceneType: 'save_referee' + }, + 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); + setUserInfo(res.data.data.user); + + // 处理邀请关系 + if (hasPendingInvite()) { + try { + await checkAndHandleInviteRelation(); + } catch (e) { + console.error('授权登录后处理邀请关系失败:', e); + } + } + + Taro.showToast({ + title: '授权成功,正在确认登录...', + icon: 'success' + }); + + // 延迟后自动确认扫码登录 + setTimeout(() => { + handleConfirmLogin(token, res.data.data.user); + }, 1500); + } + + } catch (error: any) { + Taro.showToast({ + title: error.message || '授权失败', + icon: 'error' + }); + } finally { + setAuthLoading(false); + } + }; + /** * 确认登录 - * @param loginToken - 可选的登录token,默认使用页面token - * @param wxUserInfo - 微信登录获取的用户信息(可选) */ const handleConfirmLogin = async (loginToken?: string, wxUserInfo?: any) => { const confirmToken = loginToken || token; - + if (!confirmToken) { setError('缺少登录token'); return; } - // 优先使用传入的用户信息,否则尝试从本地存储获取 const currentUser = wxUserInfo || userInfo; - + if (!currentUser?.userId) { - // 没有用户信息,尝试从本地存储获取 const userId = Taro.getStorageSync('UserId'); if (!userId) { setError('请先登录小程序'); - Taro.showToast({ - title: '请先登录小程序', - icon: 'none' - }); - - setTimeout(() => { - Taro.switchTab({ - url: '/pages/user/user' - }); - }, 1500); + setNeedAuth(true); return; } currentUser && (currentUser.userId = Number(userId)); @@ -200,25 +284,22 @@ const QRConfirmPage: React.FC = () => { } }); - // 根据 status 判断成功:confirmed 表示登录成功 const isConfirmed = result.status === 'confirmed' || result.success === true; - + if (isConfirmed) { setConfirmed(true); + setNeedAuth(false); Taro.showToast({ title: result.successMessage || result.message || '登录确认成功', icon: 'success', duration: 2000 }); - // 3秒后自动关闭或返回 setTimeout(() => { - // 尝试返回上一页,如果没有则关闭 const pages = Taro.getCurrentPages(); if (pages.length > 1) { Taro.navigateBack(); } else { - // 小程序场景下,提示用户回到 PC 端 Taro.showModal({ title: '登录成功', content: '请回到电脑端刷新页面', @@ -228,7 +309,6 @@ const QRConfirmPage: React.FC = () => { } }, 3000); } else if (result.status === 'bind_phone' || result.needBindPhone) { - // 需要绑定手机号 Taro.showToast({ title: '请先绑定手机号', icon: 'none' @@ -274,7 +354,10 @@ const QRConfirmPage: React.FC = () => { const handleRetry = () => { setError(''); setConfirmed(false); - handleConfirmLogin(); + setNeedAuth(false); + if (token) { + handleAutoConfirm(token); + } }; /** @@ -284,34 +367,32 @@ const QRConfirmPage: React.FC = () => { Taro.scanCode({ success: async (res) => { console.log('[QRConfirm] 扫码成功:', res); - - // 解析二维码内容 + let scanToken = ''; const qrContent = res.result; - + try { - // 尝试解析 URL if (qrContent.includes('http')) { const url = new URL(qrContent); - scanToken = url.searchParams.get('token') || - url.searchParams.get('qrCodeKey') || + scanToken = url.searchParams.get('token') || + url.searchParams.get('qrCodeKey') || ''; } - - // 尝试解析 JSON + if (!scanToken && qrContent.startsWith('{')) { const parsed = JSON.parse(qrContent); scanToken = parsed.token || parsed.qrCodeKey || ''; } - - // 直接作为 token + if (!scanToken && qrContent.length >= 32) { scanToken = qrContent; } - + if (scanToken) { setToken(scanToken); setLoginMethod('scan'); + setNeedAuth(false); + setError(''); handleConfirmLogin(scanToken); } else { setError('无效的二维码内容'); @@ -330,46 +411,56 @@ const QRConfirmPage: React.FC = () => { }); }; - // 渲染状态:加载中 - const renderLoading = () => ( - - - + // 渲染状态图标 + const renderStatusIcon = () => { + if (loading || authLoading) return ( + + + + - - ); + ); - // 渲染状态:成功 - const renderSuccess = () => ( - - - + if (confirmed) return ( + + + + - - ); + ); - // 渲染状态:错误 - const renderError = () => ( - - - + if (error && !needAuth) return ( + + + + - - ); + ); - // 渲染状态:初始(用户未扫码) - const renderInitial = () => ( - - - + if (needAuth) return ( + + + + + + - - ); + ); + + return ( + + + + + + ); + }; // 获取标题 const getTitle = () => { - if (loading) return '正在确认登录...'; + if (loading || authLoading) return '正在处理...'; if (confirmed) return '登录确认成功'; + if (needAuth) return '首次登录授权'; if (error) return '登录确认失败'; return loginMethod === 'url' ? '扫码登录确认' : '确认登录'; }; @@ -377,7 +468,9 @@ const QRConfirmPage: React.FC = () => { // 获取描述 const getDescription = () => { if (loading) return '请稍候,正在为您确认登录'; + if (authLoading) return '正在授权登录...'; if (confirmed) return '您已成功确认登录,网页端将自动登录'; + if (needAuth) return '检测到您是首次使用,请授权手机号完成注册并登录'; if (error) return error; if (loginMethod === 'url') { return '检测到登录请求,是否确认登录?'; @@ -386,6 +479,130 @@ const QRConfirmPage: React.FC = () => { return `确认使用 ${displayName} 登录网页端?`; }; + // 渲染操作按钮 + const renderActions = () => { + // 需要授权登录 + if (needAuth) { + return ( + + + + + ); + } + + if (loading) { + return ( + + ); + } + + if (confirmed) { + return ( + + ); + } + + if (error) { + return ( + + + + + + ); + } + + if (loginMethod === 'scan') { + return ( + + + + + ); + } + + return ( + + ); + }; + return ( @@ -401,7 +618,7 @@ const QRConfirmPage: React.FC = () => { {/* 状态图标 */} - {loading ? renderLoading() : confirmed ? renderSuccess() : error ? renderError() : renderInitial()} + {renderStatusIcon()} {/* 标题 */} @@ -414,11 +631,11 @@ const QRConfirmPage: React.FC = () => { {/* 用户信息 */} - {!loading && !confirmed && !error && userInfo && ( + {!loading && !confirmed && !error && !needAuth && userInfo && ( {userInfo.avatar ? ( - @@ -440,7 +657,7 @@ const QRConfirmPage: React.FC = () => { )} {/* Token 信息 */} - {token && !loading && !confirmed && ( + {token && !loading && !confirmed && !needAuth && ( 登录令牌:{token.substring(0, 20)}...{token.substring(token.length - 10)} @@ -449,105 +666,28 @@ const QRConfirmPage: React.FC = () => { )} {/* 操作按钮 */} - - {loading ? ( - - ) : confirmed ? ( - - ) : error ? ( - - - - - - ) : loginMethod === 'scan' ? ( - - - - - ) : ( - // URL 扫码场景:自动确认中 - - )} - + {renderActions()} {/* 安全提示 */} - - - - - - - 安全提示 - - - 请确认这是您本人的登录操作。如果不是,请点击取消并检查账户安全。 - + {!needAuth && ( + + + + + + + 安全提示 + + + 请确认这是您本人的登录操作。如果不是,请点击取消并检查账户安全。 + + - - + + )} {/* 底部说明 */}