feat(qr-login): 实现扫码登录功能模块

This commit is contained in:
2025-09-21 22:10:22 +08:00
parent 611f0e3216
commit 16559c76ed
15 changed files with 1329 additions and 54 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Button } from '@nutui/nutui-react-taro';
import {View} from '@tarojs/components'
import { Scan } from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro';
import { useQRLogin } from '@/hooks/useQRLogin';
@@ -31,7 +32,6 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
size = 'normal',
text = '扫码登录',
showIcon = true,
className = '',
onSuccess,
onError,
usePageMode = false
@@ -40,6 +40,7 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
// 处理点击事件
const handleClick = async () => {
console.log('处理点击事件handleClick', usePageMode)
if (usePageMode) {
// 跳转到专门的扫码登录页面
if (canScan()) {
@@ -74,12 +75,13 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
loading={isLoading}
disabled={disabled}
onClick={handleClick}
className={className}
>
{showIcon && !isLoading && (
<Scan className="mr-1" />
)}
{isLoading ? '扫码中...' : (disabled && !canScan() ? '请先登录' : text)}
<View className="flex items-center justify-center">
{showIcon && !isLoading && (
<Scan className="mr-1" />
)}
{isLoading ? '扫码中...' : (disabled && !canScan() ? '请先登录' : text)}
</View>
</Button>
);
};

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { View, Text } from '@tarojs/components';
import { Button, Card } from '@nutui/nutui-react-taro';
import { Scan, User } from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro';
import QRLoginButton from './QRLoginButton';
import QRScanModal from './QRScanModal';
import { useUser } from '@/hooks/useUser';
/**
* 扫码登录功能演示组件
* 展示如何在页面中集成扫码登录功能
*/
const QRLoginDemo: React.FC = () => {
const { user, getDisplayName } = useUser();
const [showScanModal, setShowScanModal] = useState(false);
const [loginHistory, setLoginHistory] = useState<any[]>([]);
// 处理扫码成功
const handleScanSuccess = (result: any) => {
console.log('扫码登录成功:', result);
// 添加到历史记录
const newRecord = {
id: Date.now(),
time: new Date().toLocaleString(),
success: true,
userInfo: result.userInfo
};
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]);
};
// 处理扫码失败
const handleScanError = (error: string) => {
console.error('扫码登录失败:', error);
// 添加到历史记录
const newRecord = {
id: Date.now(),
time: new Date().toLocaleString(),
success: false,
error
};
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]);
};
// 跳转到专门的扫码页面
const goToQRLoginPage = () => {
Taro.navigateTo({
url: '/pages/qr-login/index'
});
};
return (
<View className="qr-login-demo p-4">
{/* 用户信息 */}
<Card className="mb-4">
<View className="p-4">
<View className="flex items-center mb-4">
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
<User className="text-blue-500" size="24" />
</View>
<View>
<Text className="text-lg font-bold text-gray-800 block">
{getDisplayName()}
</Text>
<Text className="text-sm text-gray-500 block">
</Text>
</View>
</View>
</View>
</Card>
{/* 扫码登录方式 */}
<Card className="mb-4">
<View className="p-4">
<Text className="text-lg font-bold mb-4 block"></Text>
<View className="space-y-3">
{/* 方式1: 直接扫码按钮 */}
<View>
<Text className="text-sm text-gray-600 mb-2 block">
方式1: 直接扫码登录
</Text>
<QRLoginButton
text="立即扫码登录"
onSuccess={handleScanSuccess}
onError={handleScanError}
/>
</View>
{/* 方式2: 弹窗扫码 */}
<View>
<Text className="text-sm text-gray-600 mb-2 block">
方式2: 弹窗扫码
</Text>
<Button
type="success"
size="normal"
onClick={() => setShowScanModal(true)}
>
<Scan className="mr-2" />
</Button>
</View>
{/* 方式3: 跳转到专门页面 */}
<View>
<Text className="text-sm text-gray-600 mb-2 block">
方式3: 专门页面
</Text>
<QRLoginButton
text="进入扫码页面"
type="warning"
usePageMode={true}
/>
</View>
{/* 方式4: 自定义按钮 */}
<View>
<Text className="text-sm text-gray-600 mb-2 block">
方式4: 自定义跳转
</Text>
<Button
type="default"
size="normal"
onClick={goToQRLoginPage}
>
</Button>
</View>
</View>
</View>
</Card>
{/* 登录历史 */}
{loginHistory.length > 0 && (
<Card>
<View className="p-4">
<Text className="text-lg font-bold mb-4 block"></Text>
<View className="space-y-2">
{loginHistory.map((record) => (
<View
key={record.id}
className="p-3 bg-gray-50 rounded-lg"
>
<View className="flex items-center justify-between">
<View>
<Text className={`text-sm font-medium ${
record.success ? 'text-green-600' : 'text-red-600'
} block`}>
{record.success ? '登录成功' : '登录失败'}
</Text>
{record.error && (
<Text className="text-xs text-red-500 block">
{record.error}
</Text>
)}
</View>
<Text className="text-xs text-gray-500">
{record.time}
</Text>
</View>
</View>
))}
</View>
</View>
</Card>
)}
{/* 扫码弹窗 */}
<QRScanModal
visible={showScanModal}
onClose={() => setShowScanModal(false)}
onSuccess={handleScanSuccess}
onError={handleScanError}
/>
</View>
);
};
export default QRLoginDemo;

View File

@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { View, Text } from '@tarojs/components';
import { Button, Popup, Loading } from '@nutui/nutui-react-taro';
import { Scan, Close, Success, Failure } from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro';
import { parseQRContent, confirmQRLogin } from '@/api/qr-login';
import { useUser } from '@/hooks/useUser';
export interface QRScanModalProps {
/** 是否显示弹窗 */
visible: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 扫码成功回调 */
onSuccess?: (result: any) => void;
/** 扫码失败回调 */
onError?: (error: string) => void;
/** 弹窗标题 */
title?: string;
/** 描述文本 */
description?: string;
/** 是否自动确认登录 */
autoConfirm?: boolean;
}
/**
* 二维码扫描弹窗组件(用于扫码登录)
*/
const QRScanModal: React.FC<QRScanModalProps> = ({
visible,
onClose,
onSuccess,
onError,
title = '扫描登录二维码',
description = '扫描网页端显示的登录二维码',
autoConfirm = true
}) => {
const { user } = useUser();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<'idle' | 'scanning' | 'confirming' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// 开始扫码
const handleScan = async () => {
if (!user?.userId) {
onError?.('请先登录小程序');
return;
}
try {
setLoading(true);
setStatus('scanning');
setErrorMsg('');
// 扫码
const scanResult = await new Promise<string>((resolve, reject) => {
Taro.scanCode({
onlyFromCamera: true,
scanType: ['qrCode'],
success: (res) => {
if (res.result) {
resolve(res.result);
} else {
reject(new Error('扫码结果为空'));
}
},
fail: (err) => {
reject(new Error(err.errMsg || '扫码失败'));
}
});
});
// 解析二维码内容
const token = parseQRContent(scanResult);
if (!token) {
throw new Error('无效的登录二维码');
}
if (autoConfirm) {
// 自动确认登录
setStatus('confirming');
const result = await confirmQRLogin({
token,
userId: user.userId,
platform: 'wechat',
wechatInfo: {
nickname: user.nickname,
avatar: user.avatar
}
});
if (result.success) {
setStatus('success');
onSuccess?.(result);
// 显示成功提示
Taro.showToast({
title: '登录确认成功',
icon: 'success'
});
// 延迟关闭
setTimeout(() => {
onClose();
setStatus('idle');
}, 1500);
} else {
throw new Error(result.message || '登录确认失败');
}
} else {
// 只返回扫码结果
onSuccess?.(scanResult);
onClose();
setStatus('idle');
}
} catch (error: any) {
setStatus('error');
const errorMessage = error.message || '操作失败';
setErrorMsg(errorMessage);
onError?.(errorMessage);
} finally {
setLoading(false);
}
};
// 重试
const handleRetry = () => {
setStatus('idle');
setErrorMsg('');
handleScan();
};
// 关闭弹窗
const handleClose = () => {
setStatus('idle');
setErrorMsg('');
setLoading(false);
onClose();
};
// 获取状态显示内容
const getStatusContent = () => {
switch (status) {
case 'scanning':
return {
icon: <Loading className="text-blue-500" />,
title: '正在扫码...',
description: '请将二维码对准摄像头'
};
case 'confirming':
return {
icon: <Loading className="text-orange-500" />,
title: '正在确认登录...',
description: '请稍候,正在为您确认登录'
};
case 'success':
return {
icon: <Success size="32" className="text-green-500" />,
title: '登录确认成功',
description: '网页端将自动完成登录'
};
case 'error':
return {
icon: <Failure size="32" className="text-red-500" />,
title: '操作失败',
description: errorMsg || '请重试'
};
default:
return {
icon: <Scan size="32" className="text-blue-500" />,
title,
description
};
}
};
const statusContent = getStatusContent();
return (
<Popup
visible={visible}
position="center"
closeable={false}
onClose={handleClose}
style={{ width: '85%', borderRadius: '12px' }}
>
<View className="p-6 text-center relative">
{/* 关闭按钮 */}
{status !== 'scanning' && status !== 'confirming' && (
<View className="absolute top-4 right-4">
<Button
size="small"
type="default"
onClick={handleClose}
className="w-8 h-8 p-0"
>
<Close size="16" />
</Button>
</View>
)}
{/* 图标 */}
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
{statusContent.icon}
</View>
{/* 标题 */}
<Text className="text-lg font-bold text-gray-800 mb-2 block">
{statusContent.title}
</Text>
{/* 描述 */}
<Text className="text-gray-600 mb-6 block">
{statusContent.description}
</Text>
{/* 操作按钮 */}
{status === 'idle' && (
<Button
type="primary"
size="large"
onClick={handleScan}
className="w-full"
disabled={!user?.userId}
>
<Scan className="mr-2" />
{user?.userId ? '开始扫码' : '请先登录'}
</Button>
)}
{status === 'error' && (
<View className="space-y-2">
<Button
type="primary"
size="large"
onClick={handleRetry}
className="w-full"
>
</Button>
<Button
type="default"
size="normal"
onClick={handleClose}
className="w-full"
>
</Button>
</View>
)}
{(status === 'scanning' || status === 'confirming') && (
<Button
type="default"
size="large"
onClick={handleClose}
className="w-full"
>
</Button>
)}
</View>
{loading}
</Popup>
);
};
export default QRScanModal;