feat(passport): 实现统一扫码功能并迁移二维码登录页面

将原有的扫码登录和扫码核销功能合并为统一扫码功能,支持智能识别二维码类型,
自动执行相应操作。同时将二维码登录相关页面迁移到 /passport 路径下,优化用户体验与代码结构。

主要变更:
- 新增统一扫码 Hook (useUnifiedQRScan) 和按钮组件 (UnifiedQRButton)- 新增统一扫码页面 /passport/unified-qr/index
- 迁移二维码登录页面路径:/pages/qr-login → /passport/qr-login
- 更新管理员面板及用户卡片中的扫码入口为统一扫码- 移除旧的 QRLoginDemo 和 qr-test 测试页面- 补充统一扫码使用指南文档```
This commit is contained in:
2025-09-22 15:44:44 +08:00
parent 09af5c158b
commit e47e34825a
20 changed files with 1036 additions and 302 deletions

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '确认登录',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
}

View File

@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import { Button, Loading, Card } from '@nutui/nutui-react-taro';
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
import Taro, { useRouter } from '@tarojs/taro';
import { confirmQRLogin } from '@/api/qr-login';
import { useUser } from '@/hooks/useUser';
/**
* 扫码登录确认页面
* 用于处理从二维码跳转过来的登录确认
*/
const QRConfirmPage: React.FC = () => {
const router = useRouter();
const { user, getDisplayName } = useUser();
const [loading, setLoading] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string>('');
const [token, setToken] = useState<string>('');
useEffect(() => {
// 从URL参数中获取token
const { qrCodeKey, token: urlToken } = router.params;
const loginToken = qrCodeKey || urlToken;
if (loginToken) {
setToken(loginToken);
} else {
setError('无效的登录链接');
}
}, [router.params]);
// 确认登录
const handleConfirmLogin = async () => {
if (!token) {
setError('缺少登录token');
return;
}
if (!user?.userId) {
setError('请先登录小程序');
return;
}
try {
setLoading(true);
setError('');
const result = await confirmQRLogin({
token,
userId: user.userId,
platform: 'wechat',
wechatInfo: {
nickname: user.nickname,
avatar: user.avatar
}
});
if (result.success) {
setConfirmed(true);
Taro.showToast({
title: '登录确认成功',
icon: 'success',
duration: 2000
});
// 3秒后自动返回
setTimeout(() => {
Taro.navigateBack();
}, 3000);
} else {
setError(result.message || '登录确认失败');
}
} catch (err: any) {
setError(err.message || '登录确认失败');
} finally {
setLoading(false);
}
};
// 取消登录
const handleCancel = () => {
Taro.navigateBack();
};
// 重试
const handleRetry = () => {
setError('');
setConfirmed(false);
handleConfirmLogin();
};
return (
<View className="qr-confirm-page min-h-screen bg-gray-50">
<View className="p-4">
{/* 主要内容卡片 */}
<Card className="bg-white rounded-lg shadow-sm">
<View className="p-6 text-center">
{/* 图标 */}
<View className="mb-6">
{loading ? (
<View className="w-16 h-16 mx-auto flex items-center justify-center">
<Loading className="text-blue-500" />
</View>
) : confirmed ? (
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center">
<Success className="text-green-500" />
</View>
) : error ? (
<View className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
<Failure className="text-red-500" />
</View>
) : (
<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>
{/* 标题 */}
<Text className="text-xl font-bold text-gray-800 mb-2 block">
{loading ? '正在确认登录...' :
confirmed ? '登录确认成功' :
error ? '登录确认失败' : '确认登录'}
</Text>
{/* 描述 */}
<Text className="text-gray-600 mb-6 block">
{loading ? '请稍候,正在为您确认登录' :
confirmed ? '您已成功确认登录,网页端将自动登录' :
error ? error :
`确认使用 ${getDisplayName()} 登录网页端?`}
</Text>
{/* 用户信息 */}
{!loading && !confirmed && !error && user && (
<View className="bg-gray-50 rounded-lg p-4 mb-6">
<View className="flex items-center justify-center">
<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">
{user.nickname || user.username || '用户'}
</Text>
<Text className="text-xs text-gray-500 block">
ID: {user.userId}
</Text>
</View>
</View>
</View>
)}
{/* 操作按钮 */}
<View className="space-y-3">
{loading ? (
<Button
type="default"
size="large"
disabled
className="w-full"
>
...
</Button>
) : confirmed ? (
<Button
type="success"
size="large"
onClick={handleCancel}
className="w-full"
>
</Button>
) : error ? (
<View className="space-y-2">
<Button
type="primary"
size="large"
onClick={handleRetry}
className="w-full"
>
</Button>
<Button
type="default"
size="large"
onClick={handleCancel}
className="w-full"
>
</Button>
</View>
) : (
<View className="mt-3">
<Button
type="primary"
size="large"
onClick={handleConfirmLogin}
className="w-full mb-2"
disabled={!token || !user?.userId}
>
</Button>
<Button
type="default"
size="large"
onClick={handleCancel}
className="w-full"
>
</Button>
</View>
)}
</View>
</View>
</Card>
{/* 安全提示 */}
<Card className="bg-yellow-50 border border-yellow-200 rounded-lg 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>
</View>
);
};
export default QRConfirmPage;

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '扫码登录',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import { View, Text } from '@tarojs/components';
import { Card, Divider, Button } from '@nutui/nutui-react-taro';
import { Scan, Success, Failure, Tips } from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro';
import QRLoginScanner from '@/components/QRLoginScanner';
import { useUser } from '@/hooks/useUser';
/**
* 扫码登录页面
*/
const QRLoginPage: React.FC = () => {
const [loginHistory, setLoginHistory] = useState<any[]>([]);
const { getDisplayName } = useUser();
// 处理扫码成功
const handleScanSuccess = (result: any) => {
console.log('扫码登录成功:', result);
// 添加到登录历史
const newRecord = {
id: Date.now(),
time: new Date().toLocaleString(),
userInfo: result.userInfo,
success: true
};
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
// 显示成功提示
Taro.showToast({
title: '登录确认成功',
icon: 'success',
duration: 2000
});
};
// 处理扫码失败
const handleScanError = (error: string) => {
console.error('扫码登录失败:', error);
// 添加到登录历史
const newRecord = {
id: Date.now(),
time: new Date().toLocaleString(),
error,
success: false
};
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]);
};
// 返回上一页
// const handleBack = () => {
// Taro.navigateBack();
// };
// 清除历史记录
const clearHistory = () => {
setLoginHistory([]);
Taro.showToast({
title: '已清除历史记录',
icon: 'success'
});
};
return (
<View className="qr-login-page min-h-screen bg-gray-50">
{/* 导航栏 */}
{/*<NavBar*/}
{/* title="扫码登录"*/}
{/* leftShow*/}
{/* onBackClick={handleBack}*/}
{/* leftText={<ArrowLeft />}*/}
{/* className="bg-white"*/}
{/*/>*/}
{/* 主要内容 */}
<View className="p-4">
{/* 用户信息卡片 */}
<Card className="bg-white rounded-lg shadow-sm">
<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">
<Scan className="text-blue-500" size="24" />
</View>
<View>
<Text className="text-lg font-bold text-gray-800">
{getDisplayName()}
</Text>
<Text className="text-sm text-gray-500">
使
</Text>
</View>
</View>
{/* 扫码登录组件 */}
<QRLoginScanner
onSuccess={handleScanSuccess}
onError={handleScanError}
buttonText="开始扫码登录"
showStatus={true}
/>
</View>
</Card>
{/* 使用说明 */}
<Card className="bg-white rounded-lg shadow-sm">
<View className="p-4">
<View className="flex items-center mb-3">
<Tips className="text-orange-500 mr-2" />
<Text className="font-medium text-gray-800">使</Text>
</View>
<View className="text-sm text-gray-600">
<Text className="block">1. </Text>
<Text className="block">2. "扫码登录"</Text>
<Text className="block">3. 使</Text>
<Text className="block">4. </Text>
</View>
</View>
</Card>
{/* 登录历史 */}
{loginHistory.length > 0 && (
<Card className="bg-white rounded-lg shadow-sm">
<View className="p-4">
<View className="flex items-center justify-between mb-3">
<Text className="font-medium text-gray-800"></Text>
<Button
size="small"
type="default"
onClick={clearHistory}
className="text-xs"
>
</Button>
</View>
<View>
{loginHistory.map((record, index) => (
<View key={record.id}>
<View className="flex items-center justify-between">
<View className="flex items-center">
{record.success ? (
<Success className="text-green-500 mr-2" size="16" />
) : (
<Failure className="text-red-500 mr-2" size="16" />
)}
<View>
<Text className="text-sm text-gray-800">
{record.success ? '登录成功' : '登录失败'}
</Text>
{record.error && (
<Text className="text-xs text-red-500">
{record.error}
</Text>
)}
</View>
</View>
<Text className="text-xs text-gray-500">
{record.time}
</Text>
</View>
{index < loginHistory.length - 1 && (
<Divider className="my-2" />
)}
</View>
))}
</View>
</View>
</Card>
)}
{/* 安全提示 */}
<Card className="bg-yellow-50 border border-yellow-200 rounded-lg">
<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">
</Text>
<Text className="text-xs text-yellow-700">
</Text>
</View>
</View>
</View>
</Card>
</View>
</View>
);
};
export default QRLoginPage;

View File

@@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '统一扫码',
navigationBarTextStyle: 'black'
}

View File

@@ -0,0 +1,320 @@
import React, { useState } from 'react';
import { View, Text } from '@tarojs/components';
import { Card, Button, Tag } from '@nutui/nutui-react-taro';
import { Scan, Success, Failure, Tips, ArrowLeft } from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro';
import { useUnifiedQRScan, ScanType, type UnifiedScanResult } from '@/hooks/useUnifiedQRScan';
/**
* 统一扫码页面
* 支持登录和核销两种类型的二维码扫描
*/
const UnifiedQRPage: React.FC = () => {
const [scanHistory, setScanHistory] = useState<any[]>([]);
const {
startScan,
isLoading,
canScan,
state,
result,
error,
scanType,
reset
} = useUnifiedQRScan();
// 处理扫码成功
const handleScanSuccess = (result: UnifiedScanResult) => {
console.log('扫码成功:', result);
// 添加到扫码历史
const newRecord = {
id: Date.now(),
time: new Date().toLocaleString(),
type: result.type,
data: result.data,
message: result.message,
success: true
};
setScanHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
// 根据类型给出不同提示
if (result.type === ScanType.VERIFICATION) {
// 核销成功后询问是否继续扫码
setTimeout(() => {
Taro.showModal({
title: '核销成功',
content: '是否继续扫码核销其他礼品卡?',
success: (res) => {
if (res.confirm) {
handleStartScan();
}
}
});
}, 2000);
}
};
// 处理扫码失败
const handleScanError = (error: string) => {
console.error('扫码失败:', error);
// 添加到扫码历史
const newRecord = {
id: Date.now(),
time: new Date().toLocaleString(),
error,
success: false
};
setScanHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
};
// 开始扫码
const handleStartScan = async () => {
try {
const scanResult = await startScan();
if (scanResult) {
handleScanSuccess(scanResult);
}
} catch (error: any) {
handleScanError(error.message || '扫码失败');
}
};
// 返回上一页
const handleGoBack = () => {
Taro.navigateBack();
};
// 获取状态图标
const getStatusIcon = (success: boolean, type?: ScanType) => {
console.log(type,'获取状态图标')
if (success) {
return <Success className="text-green-500" size="16" />;
} else {
return <Failure className="text-red-500" size="16" />;
}
};
// 获取类型标签
const getTypeTag = (type: ScanType) => {
switch (type) {
case ScanType.LOGIN:
return <Tag type="success"></Tag>;
case ScanType.VERIFICATION:
return <Tag type="warning"></Tag>;
default:
return <Tag type="default"></Tag>;
}
};
return (
<View className="unified-qr-page min-h-screen bg-gray-50">
{/* 页面头部 */}
<View className="bg-white px-4 py-3 border-b border-gray-100 flex items-center">
<ArrowLeft
className="text-gray-600 mr-3"
size="20"
onClick={handleGoBack}
/>
<View className="flex-1">
<Text className="text-lg font-bold"></Text>
<Text className="text-sm text-gray-600 block">
</Text>
</View>
</View>
{/* 主要扫码区域 */}
<Card className="m-4">
<View className="text-center py-6">
{/* 状态显示 */}
{state === 'idle' && (
<>
<Scan className="text-blue-500 mx-auto mb-4" size="48" />
<Text className="text-lg font-medium text-gray-800 mb-2 block">
</Text>
<Text className="text-gray-600 mb-6 block">
</Text>
<Button
type="primary"
size="large"
onClick={handleStartScan}
disabled={!canScan() || isLoading}
className="w-full"
>
{canScan() ? '开始扫码' : '请先登录'}
</Button>
</>
)}
{state === 'scanning' && (
<>
<Scan className="text-blue-500 mx-auto mb-4" size="48" />
<Text className="text-lg font-medium text-blue-600 mb-2 block">
...
</Text>
<Text className="text-gray-600 mb-6 block">
</Text>
<Button
type="default"
size="large"
onClick={reset}
className="w-full"
>
</Button>
</>
)}
{state === 'processing' && (
<>
<View className="text-orange-500 mx-auto mb-4">
<Tips size="48" />
</View>
<Text className="text-lg font-medium text-orange-600 mb-2 block">
...
</Text>
<Text className="text-gray-600 mb-6 block">
{scanType === ScanType.LOGIN ? '正在确认登录' :
scanType === ScanType.VERIFICATION ? '正在核销礼品卡' : '正在处理'}
</Text>
</>
)}
{state === 'success' && result && (
<>
<Success className="text-green-500 mx-auto mb-4" size="48" />
<Text className="text-lg font-medium text-green-600 mb-2 block">
{result.message}
</Text>
{result.type === ScanType.VERIFICATION && result.data && (
<View className="bg-green-50 rounded-lg p-3 mb-4">
<Text className="text-sm text-green-800 block">
{result.data.goodsName || '未知商品'}
</Text>
<Text className="text-sm text-green-800 block">
¥{result.data.faceValue}
</Text>
</View>
)}
<View className="mt-2">
<Button
type="primary"
size="large"
onClick={handleStartScan}
className="w-full mb-2"
>
</Button>
<Button
type="default"
size="normal"
onClick={reset}
className="w-full"
>
</Button>
</View>
</>
)}
{state === 'error' && (
<>
<Failure className="text-red-500 mx-auto mb-4" size="48" />
<Text className="text-lg font-medium text-red-600 mb-2 block">
</Text>
<Text className="text-gray-600 mb-6 block">
{error || '未知错误'}
</Text>
<View className="mt-2">
<Button
type="primary"
size="large"
onClick={handleStartScan}
className="w-full mb-2"
>
</Button>
<Button
type="default"
size="normal"
onClick={reset}
className="w-full"
>
</Button>
</View>
</>
)}
</View>
</Card>
{/* 扫码历史 */}
{scanHistory.length > 0 && (
<Card className="m-4">
<View className="pb-4">
<Text className="text-lg font-medium text-gray-800 mb-3 block">
</Text>
{scanHistory.map((record, index) => (
<View
key={record.id}
className={`flex items-center justify-between p-3 bg-gray-50 rounded-lg ${index < scanHistory.length - 1 ? 'mb-2' : ''}`}
>
<View className="flex items-center flex-1">
{getStatusIcon(record.success, record.type)}
<View className="ml-3 flex-1">
<View className="flex items-center mb-1">
{record.type && getTypeTag(record.type)}
<Text className="text-sm text-gray-600 ml-2">
{record.time}
</Text>
</View>
<Text className="text-sm text-gray-800">
{record.success ? record.message : record.error}
</Text>
{record.success && record.type === ScanType.VERIFICATION && record.data && (
<Text className="text-xs text-gray-500">
{record.data.goodsName} - ¥{record.data.faceValue}
</Text>
)}
</View>
</View>
</View>
))}
</View>
</Card>
)}
{/* 功能说明 */}
<Card className="m-4 bg-blue-50 border border-blue-200">
<View className="p-4">
<View className="flex items-start">
<Tips className="text-blue-600 mr-2 mt-1" size="16" />
<View>
<Text className="text-sm font-medium text-blue-800 mb-1 block">
</Text>
<Text className="text-xs text-blue-700 block mb-1">
</Text>
<Text className="text-xs text-blue-700 block mb-1">
</Text>
<Text className="text-xs text-blue-700 block">
</Text>
</View>
</View>
</View>
</Card>
</View>
);
};
export default UnifiedQRPage;