Files
template-5/src/passport/qr-confirm/index.tsx
赵忠林 d87e9d3f13 refactor(passport): 优化二维码确认登录页面流程和界面
- 移除根据扫码参数自动确认登录逻辑,改为统一显示授权登录界面
- 简化用户登录判断,统一走手机号授权登录流程,提升用户体验
- 替换按钮组件由 TaroButton 改为 NutUI Button,统一样式风格
- 更新授权登录页面 UI,调整背景、品牌名和标语内容
- 优化手机号授权登录按钮样式,使用渐变色背景和中心对齐
2026-04-08 02:35:17 +08:00

827 lines
26 KiB
TypeScript
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.

import React, { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import { Loading, Card, Button } from '@nutui/nutui-react-taro';
import { Success, Failure, Tips, User, Checklist } 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-08 更新):
* 1. 用户扫码 → 进入 qr-confirm 页面
* 2. 页面立即调用 wx.login() 获取 code
* 3. 用 code 调用 /api/wx-login/loginByOpenId 获取/验证用户身份
* 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;
}
// 协议弹窗类型
type AgreementType = 'service' | 'privacy' | null;
const QRConfirmPage: React.FC = () => {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string>('');
const [token, setToken] = useState<string>('');
const [loginMethod, setLoginMethod] = useState<'scan' | 'url'>('url');
const [userInfo, setUserInfo] = useState<any>(null);
const [needAuth, setNeedAuth] = useState(false); // 是否需要手机号授权
const [authLoading, setAuthLoading] = useState(false); // 授权中状态
const [agreementChecked, setAgreementChecked] = useState(false); // 协议勾选状态
const [showAgreementPopup, setShowAgreementPopup] = useState<AgreementType>(null); // 协议弹窗
useEffect(() => {
// 从 URL 参数中获取 token
const params = router.params;
// 兼容多种参数名
let loginToken = params.scene || params.token || params.qrCodeKey || '';
// 如果是 q 参数URL 编码的完整 URL需要解析
if (params.q && !loginToken) {
try {
const decodedUrl = decodeURIComponent(params.q);
console.log('[QRConfirm] 解码后的 URL:', decodedUrl);
const url = new URL(decodedUrl);
loginToken = url.searchParams.get('token') ||
url.searchParams.get('qrCodeKey') ||
'';
setLoginMethod('url');
} catch (e) {
console.error('[QRConfirm] 解析 q 参数失败:', e);
loginToken = decodeURIComponent(params.q);
setLoginMethod('url');
}
} else if (loginToken) {
setLoginMethod('url');
}
// 扫码场景:直接显示授权登录界面
console.log('[QRConfirm] 显示授权登录界面');
setToken(loginToken);
setNeedAuth(true);
}, [router.params]);
/**
* 自动确认登录URL 扫码场景)
*/
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: 5
});
console.log('[QRConfirm] loginByOpenId 结果:', wxLoginResult);
// 3. 统一显示授权登录界面,让用户一键授权完成登录
// 无论用户是否注册、是否绑定手机号,都走授权登录流程
console.log('[QRConfirm] 显示手机号授权登录界面');
setNeedAuth(true);
setLoading(false);
} catch (err: any) {
console.error('[QRConfirm] 自动确认登录失败:', err);
setError(err.message || '自动确认登录失败');
setLoading(false);
}
};
/**
* 处理微信手机号授权
*/
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
const { code, encryptedData, iv, errMsg } = detail;
// 检查协议是否勾选
if (!agreementChecked) {
Taro.showToast({
title: '请先同意服务协议和隐私政策',
icon: 'none'
});
return;
}
// 用户拒绝授权
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<LoginResponse>({
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);
}
};
/**
* 确认登录
*/
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('请先登录小程序');
setNeedAuth(true);
return;
}
currentUser && (currentUser.userId = Number(userId));
}
try {
setLoading(true);
setError('');
const result = await confirmQRLogin({
token: confirmToken,
userId: currentUser.userId,
platform: 'wechat',
wechatInfo: {
nickname: currentUser.nickname || currentUser.username,
avatar: currentUser.avatar
}
});
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
});
setTimeout(() => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
} else {
Taro.showModal({
title: '登录成功',
content: '请回到电脑端刷新页面',
showCancel: false,
confirmText: '我知道了'
});
}
}, 3000);
} else if (result.status === 'bind_phone' || result.needBindPhone) {
Taro.showToast({
title: '请先绑定手机号',
icon: 'none'
});
setTimeout(() => {
Taro.redirectTo({ url: '/passport/sms-login' });
}, 1500);
} else {
setError(result.message || '登录确认失败');
}
} catch (err: any) {
console.error('[QRConfirm] 确认登录失败:', err);
setError(err.message || '登录确认失败');
} finally {
setLoading(false);
}
};
/**
* 手动确认登录(主动扫码场景)
*/
const handleManualConfirm = () => {
handleConfirmLogin();
};
/**
* 取消登录
*/
const handleCancel = () => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
} else {
Taro.switchTab({
url: '/pages/user/user'
});
}
};
/**
* 重试
*/
const handleRetry = () => {
setError('');
setConfirmed(false);
setNeedAuth(false);
if (token) {
handleAutoConfirm(token);
}
};
/**
* 打开微信扫码
*/
const handleScan = () => {
Taro.scanCode({
success: async (res) => {
console.log('[QRConfirm] 扫码成功:', res);
let scanToken = '';
const qrContent = res.result;
try {
if (qrContent.includes('http')) {
const url = new URL(qrContent);
scanToken = url.searchParams.get('token') ||
url.searchParams.get('qrCodeKey') ||
'';
}
if (!scanToken && qrContent.startsWith('{')) {
const parsed = JSON.parse(qrContent);
scanToken = parsed.token || parsed.qrCodeKey || '';
}
if (!scanToken && qrContent.length >= 32) {
scanToken = qrContent;
}
if (scanToken) {
setToken(scanToken);
setLoginMethod('scan');
setNeedAuth(false);
setError('');
handleConfirmLogin(scanToken);
} else {
setError('无效的二维码内容');
}
} catch (e) {
console.error('[QRConfirm] 解析二维码失败:', e);
setError('二维码解析失败');
}
},
fail: (err) => {
console.error('[QRConfirm] 扫码失败:', err);
if (err.errMsg !== 'scanCode:fail cancel') {
setError('扫码失败,请重试');
}
}
});
};
// 打开协议弹窗
const openAgreement = (type: AgreementType) => {
setShowAgreementPopup(type);
};
// 关闭协议弹窗
const closeAgreement = () => {
setShowAgreementPopup(null);
};
// 渲染协议弹窗内容
const renderAgreementContent = () => {
if (showAgreementPopup === 'service') {
return (
<View className="p-4">
<View className="text-center mb-4">
<Text className="text-lg font-bold"></Text>
</View>
<View className="text-sm text-gray-600 leading-relaxed max-h-64 overflow-y-auto">
<Text className="block mb-2">使</Text>
<Text className="block mb-2">1. </Text>
<Text className="block mb-2">使</Text>
<Text className="block mb-2">2. </Text>
<Text className="block mb-2">使</Text>
<Text className="block mb-2">3. 使</Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">4. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">5. </Text>
<Text className="block mb-2"></Text>
</View>
<Button
className="mt-4 bg-orange-500 text-white rounded-full"
onClick={closeAgreement}
>
</Button>
</View>
);
}
if (showAgreementPopup === 'privacy') {
return (
<View className="p-4">
<View className="text-center mb-4">
<Text className="text-lg font-bold"></Text>
</View>
<View className="text-sm text-gray-600 leading-relaxed max-h-64 overflow-y-auto">
<Text className="block mb-2"></Text>
<Text className="block mb-2">1. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">2. 使</Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">3. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">4. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">5. </Text>
<Text className="block mb-2"></Text>
</View>
<Button
className="mt-4 bg-orange-500 text-white rounded-full"
onClick={closeAgreement}
>
</Button>
</View>
);
}
return null;
};
// 授权登录页面(参考权大师风格)
const renderAuthPage = () => {
return (
<View className="min-h-screen bg-gray-50 flex flex-col">
{/* Logo 区域 */}
<View className="flex-1 flex flex-col items-center justify-center px-8 -mt-20">
{/* Logo */}
<View className="w-20 h-20 mb-4">
</View>
{/* 品牌名 */}
<Text className="text-2xl font-bold text-gray-900 mb-2">websopy</Text>
{/* 标语 */}
<Text className="text-sm text-gray-500 tracking-widest"> AI </Text>
</View>
{/* 底部操作区域 */}
<View className="px-6 pb-12">
{/* 主按钮 - 手机号授权登录 */}
<div className={'flex flex-col w-full text-white rounded-full justify-between items-center my-2'} style={{ background: 'linear-gradient(to right, #7e22ce, #9333ea)'}}>
<Button open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
</Button>
</div>
{/*<button*/}
{/* className="w-full h-12 bg-orange-500 text-white text-base font-medium rounded-lg flex items-center justify-center mb-4 border-0"*/}
{/* open-type="getPhoneNumber"*/}
{/* onGetPhoneNumber={handleGetPhoneNumber}*/}
{/* disabled={authLoading}*/}
{/*>*/}
{/* {authLoading ? '授权中...' : '手机号授权登录'}*/}
{/*</button>*/}
{/* 取消按钮 */}
<View
className="w-full h-12 flex items-center justify-center text-gray-500 text-base cursor-pointer"
onClick={handleCancel}
>
</View>
{/* 协议勾选 */}
<View className="flex items-center justify-center mt-6">
<View
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center mr-2 ${
agreementChecked ? 'bg-orange-500 border-orange-500' : 'border-gray-300'
}`}
onClick={() => setAgreementChecked(!agreementChecked)}
>
{agreementChecked && (
<Checklist size="12" color="#fff" />
)}
</View>
<Text className="text-xs text-gray-500">
</Text>
<Text
className="text-xs text-orange-500 ml-1"
onClick={(e) => {
e.stopPropagation();
openAgreement('service');
}}
>
</Text>
<Text className="text-xs text-gray-500"></Text>
<Text
className="text-xs text-orange-500 ml-1"
onClick={(e) => {
e.stopPropagation();
openAgreement('privacy');
}}
>
</Text>
</View>
</View>
{/* 协议弹窗 */}
{showAgreementPopup && (
<View className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 px-6">
<View className="bg-white rounded-xl w-full max-w-sm">
{renderAgreementContent()}
</View>
</View>
)}
</View>
);
};
// 渲染状态图标
const renderStatusIcon = () => {
if (loading || authLoading) return (
<View className="mb-6">
<View className="w-16 h-16 mx-auto flex items-center justify-center">
<Loading className="text-blue-500" />
</View>
</View>
);
if (confirmed) return (
<View className="mb-6">
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center">
<Success className="text-green-500" size="32" />
</View>
</View>
);
if (error && !needAuth) return (
<View className="mb-6">
<View className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
<Failure className="text-red-500" size="32" />
</View>
</View>
);
if (needAuth) {
// 需要授权时显示授权页面
return renderAuthPage();
}
return (
<View className="mb-6">
<View className="w-16 h-16 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
<User size="32" className="text-blue-500" />
</View>
</View>
);
};
// 获取标题
const getTitle = () => {
if (loading || authLoading) return '正在处理...';
if (confirmed) return '登录确认成功';
if (needAuth) return ''; // 授权页面有自己的标题
if (error) return '登录确认失败';
return loginMethod === 'url' ? '扫码登录确认' : '确认登录';
};
// 获取描述
const getDescription = () => {
if (loading) return '请稍候,正在为您确认登录';
if (authLoading) return '正在授权登录...';
if (confirmed) return '您已成功确认登录,网页端将自动登录';
if (needAuth) return ''; // 授权页面有自己的描述
if (error) return error;
if (loginMethod === 'url') {
return '检测到登录请求,是否确认登录?';
}
const displayName = userInfo?.nickname || userInfo?.username || '当前用户';
return `确认使用 ${displayName} 登录网页端?`;
};
// 渲染操作按钮
const renderActions = () => {
// 需要授权登录时,按钮在授权页面内部渲染
if (needAuth) {
return null;
}
if (loading) {
return (
<View
className="w-full h-12 bg-gray-300 text-white text-base font-medium rounded-lg flex items-center justify-center"
>
...
</View>
);
}
if (confirmed) {
return (
<View
className="w-full h-12 bg-green-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
onClick={handleCancel}
>
</View>
);
}
if (error) {
return (
<View className="space-y-3">
<View
className="w-full h-12 bg-blue-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
onClick={handleRetry}
>
</View>
<View
className="w-full h-12 bg-gray-100 text-gray-700 text-base font-medium rounded-lg flex items-center justify-center"
onClick={handleScan}
>
</View>
<View
className="w-full h-10 text-gray-500 text-sm flex items-center justify-center"
onClick={handleCancel}
>
</View>
</View>
);
}
if (loginMethod === 'scan') {
return (
<View className="space-y-3">
<View
className="w-full h-12 bg-blue-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
onClick={handleManualConfirm}
>
</View>
<View
className="w-full h-12 text-gray-500 text-base flex items-center justify-center"
onClick={handleCancel}
>
</View>
</View>
);
}
return (
<View
className="w-full h-12 bg-blue-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
onClick={handleManualConfirm}
>
</View>
);
};
// 如果是授权页面,直接返回授权页面
if (needAuth) {
return renderAuthPage();
}
return (
<View className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
<View className="p-4">
{/* Logo/品牌区域 */}
<View className="text-center pt-8 pb-6">
<View className="w-20 h-20 mx-auto bg-white rounded-2xl shadow-lg flex items-center justify-center mb-4">
<Text className="text-3xl">🔐</Text>
</View>
<Text className="text-gray-400 text-sm">WebSoft Platform</Text>
</View>
{/* 主要内容卡片 */}
<Card className="bg-white rounded-2xl shadow-xl -mt-4">
<View className="p-6 text-center">
{/* 状态图标 */}
{renderStatusIcon()}
{/* 标题 */}
{getTitle() && (
<Text className="text-xl font-bold text-gray-800 mb-2 block">
{getTitle()}
</Text>
)}
{/* 描述 */}
{getDescription() && (
<Text className="text-gray-600 mb-6 block text-sm">
{getDescription()}
</Text>
)}
{/* 用户信息 */}
{!loading && !confirmed && !error && !needAuth && userInfo && (
<View className="bg-gray-50 rounded-xl p-4 mb-6">
<View className="flex items-center justify-center">
{userInfo.avatar ? (
<View
className="w-12 h-12 rounded-full bg-blue-100 mr-3 overflow-hidden"
style={{ backgroundImage: `url(${userInfo.avatar})`, backgroundSize: 'cover' }}
/>
) : (
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
<User className="text-blue-500" size="20" />
</View>
)}
<View className="text-left">
<Text className="text-sm font-medium text-gray-800 block">
{userInfo.nickname || userInfo.username || '用户'}
</Text>
<Text className="text-xs text-gray-500 block">
ID: {userInfo.userId}
</Text>
</View>
</View>
</View>
)}
{/* Token 信息 */}
{token && !loading && !confirmed && !needAuth && (
<View className="bg-blue-50 rounded-lg p-3 mb-4">
<Text className="text-xs text-blue-600">
{token.substring(0, 20)}...{token.substring(token.length - 10)}
</Text>
</View>
)}
{/* 操作按钮 */}
{renderActions()}
</View>
</Card>
{/* 安全提示 */}
{!needAuth && (
<Card className="bg-yellow-50 border border-yellow-200 rounded-xl mt-4">
<View className="p-4">
<View className="flex items-start">
<Tips className="text-yellow-600 mr-2 mt-1" size="16" />
<View>
<Text className="text-sm font-medium text-yellow-800 mb-1 block">
</Text>
<Text className="text-xs text-yellow-700 block">
</Text>
</View>
</View>
</View>
</Card>
)}
{/* 底部说明 */}
<View className="text-center mt-6 pb-8">
<Text className="text-xs text-gray-400">
</Text>
</View>
</View>
</View>
);
};
export default QRConfirmPage;