Browse Source
feat(passport): 实现统一扫码功能并迁移二维码登录页面 将原有的扫码登录和扫码核销功能合并为统一扫码功能,支持智能识别二维码类型, 自动执行相应操作。同时将二维码登录相关页面迁移到 /passport 路径下,优化用户体验与代码结构。 主要变更: - 新增统一扫码 Hook (useUnifiedQRScan) 和按钮组件 (UnifiedQRButton)- 新增统一扫码页面 /passport/unified-qr/index - 迁移二维码登录页面路径:/pages/qr-login → /passport/qr-login - 更新管理员面板及用户卡片中的扫码入口为统一扫码- 移除旧的 QRLoginDemo 和 qr-test 测试页面- 补充统一扫码使用指南文档```master
20 changed files with 1036 additions and 302 deletions
@ -0,0 +1,212 @@ |
|||||
|
# 统一扫码功能使用指南 |
||||
|
|
||||
|
## 🎯 功能概述 |
||||
|
|
||||
|
统一扫码功能将原有的**扫码登录**和**扫码核销**合并为一个入口,通过智能识别二维码内容自动执行相应操作。 |
||||
|
|
||||
|
## 📋 功能特性 |
||||
|
|
||||
|
### ✨ 智能识别 |
||||
|
- 自动识别登录二维码和核销二维码 |
||||
|
- 根据二维码内容自动执行相应操作 |
||||
|
- 支持多种二维码格式(JSON加密、纯文本等) |
||||
|
|
||||
|
### 🔄 统一体验 |
||||
|
- 一个按钮解决两种扫码需求 |
||||
|
- 统一的UI界面和交互逻辑 |
||||
|
- 一致的错误处理和状态提示 |
||||
|
|
||||
|
### 📱 多入口支持 |
||||
|
- 用户卡片中的统一扫码按钮 |
||||
|
- 管理员面板中的统一扫码功能 |
||||
|
- 独立的统一扫码页面 |
||||
|
|
||||
|
## 🛠️ 技术实现 |
||||
|
|
||||
|
### 核心文件 |
||||
|
``` |
||||
|
src/ |
||||
|
├── hooks/ |
||||
|
│ └── useUnifiedQRScan.ts # 统一扫码Hook |
||||
|
├── components/ |
||||
|
│ └── UnifiedQRButton.tsx # 统一扫码按钮组件 |
||||
|
└── pages/ |
||||
|
└── unified-qr/ |
||||
|
├── index.tsx # 统一扫码页面 |
||||
|
└── index.config.ts # 页面配置 |
||||
|
``` |
||||
|
|
||||
|
### Hook:useUnifiedQRScan |
||||
|
|
||||
|
```typescript |
||||
|
import { useUnifiedQRScan, ScanType } from '@/hooks/useUnifiedQRScan'; |
||||
|
|
||||
|
const { |
||||
|
startScan, // 开始扫码 |
||||
|
isLoading, // 加载状态 |
||||
|
canScan, // 是否可以扫码 |
||||
|
state, // 当前状态 |
||||
|
result, // 扫码结果 |
||||
|
scanType, // 识别的扫码类型 |
||||
|
reset // 重置状态 |
||||
|
} = useUnifiedQRScan(); |
||||
|
``` |
||||
|
|
||||
|
### 组件:UnifiedQRButton |
||||
|
|
||||
|
```jsx |
||||
|
<UnifiedQRButton |
||||
|
text="扫码" |
||||
|
size="small" |
||||
|
onSuccess={(result) => { |
||||
|
console.log('扫码成功:', result); |
||||
|
// result.type: 'login' | 'verification' |
||||
|
// result.data: 具体数据 |
||||
|
// result.message: 成功消息 |
||||
|
}} |
||||
|
onError={(error) => { |
||||
|
console.error('扫码失败:', error); |
||||
|
}} |
||||
|
/> |
||||
|
``` |
||||
|
|
||||
|
## 🎮 使用方式 |
||||
|
|
||||
|
### 1. 直接使用统一按钮 |
||||
|
```jsx |
||||
|
import UnifiedQRButton from '@/components/UnifiedQRButton'; |
||||
|
|
||||
|
// 在需要的地方使用 |
||||
|
<UnifiedQRButton |
||||
|
text="智能扫码" |
||||
|
onSuccess={(result) => { |
||||
|
if (result.type === 'login') { |
||||
|
// 处理登录成功 |
||||
|
} else if (result.type === 'verification') { |
||||
|
// 处理核销成功 |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
``` |
||||
|
|
||||
|
### 2. 跳转到统一扫码页面 |
||||
|
```jsx |
||||
|
// 跳转到统一扫码页面 |
||||
|
Taro.navigateTo({ |
||||
|
url: '/passport/unified-qr/index' |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
### 3. 在管理员面板中使用 |
||||
|
管理员面板已更新,原来的"门店核销"和"扫码登录"合并为"统一扫码"。 |
||||
|
|
||||
|
## 🔍 二维码识别逻辑 |
||||
|
|
||||
|
### 登录二维码 |
||||
|
- **格式**: 包含登录token的URL或纯文本 |
||||
|
- **处理**: 自动解析token并确认登录 |
||||
|
- **示例**: `https://example.com/login?token=xxx` |
||||
|
|
||||
|
### 核销二维码 |
||||
|
- **JSON格式**: `{"businessType":"gift","token":"xxx","data":"encrypted_data"}` |
||||
|
- **纯文本格式**: 6位数字核销码 |
||||
|
- **处理**: 解密数据(如需要)-> 验证核销码 -> 执行核销 |
||||
|
|
||||
|
### 识别优先级 |
||||
|
1. 首先检查是否为JSON格式的核销二维码 |
||||
|
2. 然后检查是否为登录二维码 |
||||
|
3. 最后检查是否为纯数字核销码 |
||||
|
4. 如果都不匹配,提示"不支持的二维码类型" |
||||
|
|
||||
|
## 📊 用户体验优化 |
||||
|
|
||||
|
### 智能提示 |
||||
|
- 扫码过程中显示当前状态 |
||||
|
- 根据识别结果给出相应提示 |
||||
|
- 核销成功后询问是否继续扫码 |
||||
|
|
||||
|
### 历史记录 |
||||
|
- 保留最近5次扫码记录 |
||||
|
- 显示扫码类型、时间和结果 |
||||
|
- 方便用户查看操作历史 |
||||
|
|
||||
|
### 错误处理 |
||||
|
- 统一的错误提示机制 |
||||
|
- 具体的错误原因说明 |
||||
|
- 便捷的重试和重置功能 |
||||
|
|
||||
|
## 🔄 迁移指南 |
||||
|
|
||||
|
### 从原有功能迁移 |
||||
|
|
||||
|
#### 替换扫码登录按钮 |
||||
|
```jsx |
||||
|
// 原来 |
||||
|
<QRLoginButton /> |
||||
|
|
||||
|
// 现在 |
||||
|
<UnifiedQRButton text="扫码登录" /> |
||||
|
``` |
||||
|
|
||||
|
#### 替换核销按钮 |
||||
|
```jsx |
||||
|
// 原来 |
||||
|
<Button onClick={() => navTo('/user/store/verification')}> |
||||
|
扫码核销 |
||||
|
</Button> |
||||
|
|
||||
|
// 现在 |
||||
|
<UnifiedQRButton text="扫码核销" /> |
||||
|
``` |
||||
|
|
||||
|
#### 管理员面板更新 |
||||
|
管理员面板自动合并了原有的两个扫码功能,无需额外操作。 |
||||
|
|
||||
|
## 🚀 优势总结 |
||||
|
|
||||
|
### 用户体验 |
||||
|
- ✅ **简化操作**: 一个按钮处理所有扫码需求 |
||||
|
- ✅ **智能识别**: 无需用户手动选择扫码类型 |
||||
|
- ✅ **统一界面**: 一致的交互体验 |
||||
|
|
||||
|
### 开发维护 |
||||
|
- ✅ **代码复用**: 统一的扫码逻辑和错误处理 |
||||
|
- ✅ **易于扩展**: 新增扫码类型只需修改识别逻辑 |
||||
|
- ✅ **降低复杂度**: 减少重复代码和功能入口 |
||||
|
|
||||
|
### 功能完整性 |
||||
|
- ✅ **保留所有原功能**: 登录和核销功能完全保留 |
||||
|
- ✅ **增强用户体验**: 添加历史记录和智能提示 |
||||
|
- ✅ **向后兼容**: 原有的单独页面仍然可用 |
||||
|
|
||||
|
## 🔧 配置说明 |
||||
|
|
||||
|
### 页面路由配置 |
||||
|
需要在 `app.config.ts` 中添加新页面路由: |
||||
|
|
||||
|
```typescript |
||||
|
export default { |
||||
|
pages: [ |
||||
|
// ... 其他页面 |
||||
|
'passport/unified-qr/index' |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 权限要求 |
||||
|
- **扫码权限**: 所有用户都可以扫码 |
||||
|
- **登录功能**: 需要用户已登录小程序 |
||||
|
- **核销功能**: 需要管理员权限 |
||||
|
|
||||
|
## 🎯 未来规划 |
||||
|
|
||||
|
### 扩展可能性 |
||||
|
- 支持更多类型的二维码(商品码、活动码等) |
||||
|
- 增加扫码统计和分析功能 |
||||
|
- 支持批量扫码操作 |
||||
|
- 增加扫码记录的云端同步 |
||||
|
|
||||
|
### 性能优化 |
||||
|
- 扫码响应速度优化 |
||||
|
- 二维码识别准确率提升 |
||||
|
- 网络请求优化和缓存机制 |
@ -1,184 +0,0 @@ |
|||||
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; |
|
@ -0,0 +1,126 @@ |
|||||
|
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 { useUnifiedQRScan, ScanType, type UnifiedScanResult } from '@/hooks/useUnifiedQRScan'; |
||||
|
|
||||
|
export interface UnifiedQRButtonProps { |
||||
|
/** 按钮类型 */ |
||||
|
type?: 'primary' | 'success' | 'warning' | 'danger' | 'default'; |
||||
|
/** 按钮大小 */ |
||||
|
size?: 'large' | 'normal' | 'small'; |
||||
|
/** 按钮文本 */ |
||||
|
text?: string; |
||||
|
/** 是否显示图标 */ |
||||
|
showIcon?: boolean; |
||||
|
/** 自定义样式类名 */ |
||||
|
className?: string; |
||||
|
/** 扫码成功回调 */ |
||||
|
onSuccess?: (result: UnifiedScanResult) => void; |
||||
|
/** 扫码失败回调 */ |
||||
|
onError?: (error: string) => void; |
||||
|
/** 是否使用页面模式(跳转到专门页面) */ |
||||
|
usePageMode?: boolean; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 统一扫码按钮组件 |
||||
|
* 支持登录和核销两种类型的二维码扫描 |
||||
|
*/ |
||||
|
const UnifiedQRButton: React.FC<UnifiedQRButtonProps> = ({ |
||||
|
type = 'default', |
||||
|
size = 'small', |
||||
|
text = '扫码', |
||||
|
showIcon = true, |
||||
|
onSuccess, |
||||
|
onError, |
||||
|
usePageMode = false |
||||
|
}) => { |
||||
|
const { startScan, isLoading, canScan, state, result } = useUnifiedQRScan(); |
||||
|
console.log(result,'useUnifiedQRScan>>result') |
||||
|
// 处理点击事件
|
||||
|
const handleClick = async () => { |
||||
|
if (usePageMode) { |
||||
|
// 跳转到专门的统一扫码页面
|
||||
|
if (canScan()) { |
||||
|
Taro.navigateTo({ |
||||
|
url: '/passport/unified-qr/index' |
||||
|
}); |
||||
|
} else { |
||||
|
Taro.showToast({ |
||||
|
title: '请先登录小程序', |
||||
|
icon: 'error' |
||||
|
}); |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 直接执行扫码
|
||||
|
try { |
||||
|
const scanResult = await startScan(); |
||||
|
if (scanResult) { |
||||
|
onSuccess?.(scanResult); |
||||
|
|
||||
|
// 根据扫码类型给出不同的后续提示
|
||||
|
if (scanResult.type === ScanType.VERIFICATION) { |
||||
|
// 核销成功后可以继续扫码
|
||||
|
setTimeout(() => { |
||||
|
Taro.showModal({ |
||||
|
title: '核销成功', |
||||
|
content: '是否继续扫码核销其他礼品卡?', |
||||
|
success: (res) => { |
||||
|
if (res.confirm) { |
||||
|
handleClick(); // 递归调用继续扫码
|
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}, 2000); |
||||
|
} |
||||
|
} |
||||
|
} catch (error: any) { |
||||
|
onError?.(error.message || '扫码失败'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const disabled = !canScan() || isLoading; |
||||
|
|
||||
|
// 根据当前状态动态显示文本
|
||||
|
const getButtonText = () => { |
||||
|
if (isLoading) { |
||||
|
switch (state) { |
||||
|
case 'scanning': |
||||
|
return '扫码中...'; |
||||
|
case 'processing': |
||||
|
return '处理中...'; |
||||
|
default: |
||||
|
return '扫码中...'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (disabled && !canScan()) { |
||||
|
return '请先登录'; |
||||
|
} |
||||
|
|
||||
|
return text; |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Button |
||||
|
type={type} |
||||
|
size={size} |
||||
|
loading={isLoading} |
||||
|
disabled={disabled} |
||||
|
onClick={handleClick} |
||||
|
> |
||||
|
<View className="flex items-center justify-center"> |
||||
|
{showIcon && !isLoading && ( |
||||
|
<Scan className="mr-1" /> |
||||
|
)} |
||||
|
{getButtonText()} |
||||
|
</View> |
||||
|
</Button> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default UnifiedQRButton; |
@ -0,0 +1,332 @@ |
|||||
|
import { useState, useCallback, useRef, useEffect } from 'react'; |
||||
|
import Taro from '@tarojs/taro'; |
||||
|
import { |
||||
|
confirmWechatQRLogin, |
||||
|
parseQRContent |
||||
|
} from '@/api/qr-login'; |
||||
|
import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; |
||||
|
import { useUser } from "@/hooks/useUser"; |
||||
|
import { isValidJSON } from "@/utils/jsonUtils"; |
||||
|
import dayjs from 'dayjs'; |
||||
|
|
||||
|
/** |
||||
|
* 统一扫码状态 |
||||
|
*/ |
||||
|
export enum UnifiedScanState { |
||||
|
IDLE = 'idle', // 空闲状态
|
||||
|
SCANNING = 'scanning', // 正在扫码
|
||||
|
PROCESSING = 'processing', // 正在处理
|
||||
|
SUCCESS = 'success', // 处理成功
|
||||
|
ERROR = 'error' // 处理失败
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 扫码类型 |
||||
|
*/ |
||||
|
export enum ScanType { |
||||
|
LOGIN = 'login', // 登录二维码
|
||||
|
VERIFICATION = 'verification', // 核销二维码
|
||||
|
UNKNOWN = 'unknown' // 未知类型
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 统一扫码结果 |
||||
|
*/ |
||||
|
export interface UnifiedScanResult { |
||||
|
type: ScanType; |
||||
|
data: any; |
||||
|
message: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 统一扫码Hook |
||||
|
* 可以处理登录和核销两种类型的二维码 |
||||
|
*/ |
||||
|
export function useUnifiedQRScan() { |
||||
|
const { isAdmin } = useUser(); |
||||
|
const [state, setState] = useState<UnifiedScanState>(UnifiedScanState.IDLE); |
||||
|
const [error, setError] = useState<string>(''); |
||||
|
const [result, setResult] = useState<UnifiedScanResult | null>(null); |
||||
|
const [isLoading, setIsLoading] = useState(false); |
||||
|
const [scanType, setScanType] = useState<ScanType>(ScanType.UNKNOWN); |
||||
|
|
||||
|
// 用于取消操作的引用
|
||||
|
const cancelRef = useRef<boolean>(false); |
||||
|
|
||||
|
/** |
||||
|
* 重置状态 |
||||
|
*/ |
||||
|
const reset = useCallback(() => { |
||||
|
setState(UnifiedScanState.IDLE); |
||||
|
setError(''); |
||||
|
setResult(null); |
||||
|
setIsLoading(false); |
||||
|
setScanType(ScanType.UNKNOWN); |
||||
|
cancelRef.current = false; |
||||
|
}, []); |
||||
|
|
||||
|
/** |
||||
|
* 检测二维码类型 |
||||
|
*/ |
||||
|
const detectScanType = useCallback((scanResult: string): ScanType => { |
||||
|
try { |
||||
|
// 1. 检查是否为JSON格式(核销二维码)
|
||||
|
if (isValidJSON(scanResult)) { |
||||
|
const json = JSON.parse(scanResult); |
||||
|
if (json.businessType === 'gift' && json.token && json.data) { |
||||
|
return ScanType.VERIFICATION; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 2. 检查是否为登录二维码
|
||||
|
const loginToken = parseQRContent(scanResult); |
||||
|
if (loginToken) { |
||||
|
return ScanType.LOGIN; |
||||
|
} |
||||
|
|
||||
|
// 3. 检查是否为纯文本核销码(6位数字)
|
||||
|
if (/^\d{6}$/.test(scanResult.trim())) { |
||||
|
return ScanType.VERIFICATION; |
||||
|
} |
||||
|
|
||||
|
return ScanType.UNKNOWN; |
||||
|
} catch (error) { |
||||
|
console.error('检测二维码类型失败:', error); |
||||
|
return ScanType.UNKNOWN; |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
/** |
||||
|
* 处理登录二维码 |
||||
|
*/ |
||||
|
const handleLoginQR = useCallback(async (scanResult: string): Promise<UnifiedScanResult> => { |
||||
|
const userId = Taro.getStorageSync('UserId'); |
||||
|
if (!userId) { |
||||
|
throw new Error('请先登录小程序'); |
||||
|
} |
||||
|
|
||||
|
const token = parseQRContent(scanResult); |
||||
|
if (!token) { |
||||
|
throw new Error('无效的登录二维码'); |
||||
|
} |
||||
|
|
||||
|
const confirmResult = await confirmWechatQRLogin(token, parseInt(userId)); |
||||
|
|
||||
|
if (confirmResult.success) { |
||||
|
return { |
||||
|
type: ScanType.LOGIN, |
||||
|
data: confirmResult, |
||||
|
message: '登录确认成功' |
||||
|
}; |
||||
|
} else { |
||||
|
throw new Error(confirmResult.message || '登录确认失败'); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
/** |
||||
|
* 处理核销二维码 |
||||
|
*/ |
||||
|
const handleVerificationQR = useCallback(async (scanResult: string): Promise<UnifiedScanResult> => { |
||||
|
if (!isAdmin()) { |
||||
|
throw new Error('您没有核销权限'); |
||||
|
} |
||||
|
|
||||
|
let code = ''; |
||||
|
|
||||
|
// 判断是否为加密的JSON格式
|
||||
|
if (isValidJSON(scanResult)) { |
||||
|
const json = JSON.parse(scanResult); |
||||
|
if (json.businessType === 'gift' && json.token && json.data) { |
||||
|
// 解密获取核销码
|
||||
|
const decryptedData = await decryptQrData({ |
||||
|
token: json.token, |
||||
|
encryptedData: json.data |
||||
|
}); |
||||
|
|
||||
|
if (decryptedData) { |
||||
|
code = decryptedData.toString(); |
||||
|
} else { |
||||
|
throw new Error('解密失败'); |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
// 直接使用扫码结果作为核销码
|
||||
|
code = scanResult.trim(); |
||||
|
} |
||||
|
|
||||
|
if (!code) { |
||||
|
throw new Error('无法获取有效的核销码'); |
||||
|
} |
||||
|
|
||||
|
// 验证核销码
|
||||
|
const gift = await getShopGiftByCode(code); |
||||
|
|
||||
|
if (!gift) { |
||||
|
throw new Error('核销码无效'); |
||||
|
} |
||||
|
|
||||
|
if (gift.status === 1) { |
||||
|
throw new Error('此礼品码已使用'); |
||||
|
} |
||||
|
|
||||
|
if (gift.status === 2) { |
||||
|
throw new Error('此礼品码已失效'); |
||||
|
} |
||||
|
|
||||
|
if (gift.userId === 0) { |
||||
|
throw new Error('此礼品码未认领'); |
||||
|
} |
||||
|
|
||||
|
// 执行核销
|
||||
|
await updateShopGift({ |
||||
|
...gift, |
||||
|
status: 1, |
||||
|
operatorUserId: Number(Taro.getStorageSync('UserId')) || 0, |
||||
|
takeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), |
||||
|
verificationTime: dayjs().format('YYYY-MM-DD HH:mm:ss') |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
type: ScanType.VERIFICATION, |
||||
|
data: gift, |
||||
|
message: '核销成功' |
||||
|
}; |
||||
|
}, [isAdmin]); |
||||
|
|
||||
|
/** |
||||
|
* 开始扫码 |
||||
|
*/ |
||||
|
const startScan = useCallback(async (): Promise<UnifiedScanResult | null> => { |
||||
|
try { |
||||
|
reset(); |
||||
|
setState(UnifiedScanState.SCANNING); |
||||
|
|
||||
|
// 调用扫码API
|
||||
|
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 || '扫码失败')); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// 检查是否被取消
|
||||
|
if (cancelRef.current) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
// 检测二维码类型
|
||||
|
const type = detectScanType(scanResult); |
||||
|
setScanType(type); |
||||
|
|
||||
|
if (type === ScanType.UNKNOWN) { |
||||
|
throw new Error('不支持的二维码类型'); |
||||
|
} |
||||
|
|
||||
|
// 开始处理
|
||||
|
setState(UnifiedScanState.PROCESSING); |
||||
|
setIsLoading(true); |
||||
|
|
||||
|
let result: UnifiedScanResult; |
||||
|
|
||||
|
switch (type) { |
||||
|
case ScanType.LOGIN: |
||||
|
result = await handleLoginQR(scanResult); |
||||
|
break; |
||||
|
case ScanType.VERIFICATION: |
||||
|
result = await handleVerificationQR(scanResult); |
||||
|
break; |
||||
|
default: |
||||
|
throw new Error('未知的扫码类型'); |
||||
|
} |
||||
|
|
||||
|
if (cancelRef.current) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
setState(UnifiedScanState.SUCCESS); |
||||
|
setResult(result); |
||||
|
|
||||
|
// 显示成功提示
|
||||
|
Taro.showToast({ |
||||
|
title: result.message, |
||||
|
icon: 'success', |
||||
|
duration: 2000 |
||||
|
}); |
||||
|
|
||||
|
return result; |
||||
|
|
||||
|
} catch (err: any) { |
||||
|
if (!cancelRef.current) { |
||||
|
setState(UnifiedScanState.ERROR); |
||||
|
const errorMessage = err.message || '处理失败'; |
||||
|
setError(errorMessage); |
||||
|
|
||||
|
// 显示错误提示
|
||||
|
Taro.showToast({ |
||||
|
title: errorMessage, |
||||
|
icon: 'error', |
||||
|
duration: 3000 |
||||
|
}); |
||||
|
} |
||||
|
return null; |
||||
|
} finally { |
||||
|
setIsLoading(false); |
||||
|
} |
||||
|
}, [reset, detectScanType, handleLoginQR, handleVerificationQR]); |
||||
|
|
||||
|
/** |
||||
|
* 取消扫码 |
||||
|
*/ |
||||
|
const cancel = useCallback(() => { |
||||
|
cancelRef.current = true; |
||||
|
reset(); |
||||
|
}, [reset]); |
||||
|
|
||||
|
/** |
||||
|
* 检查是否可以进行扫码 |
||||
|
*/ |
||||
|
const canScan = useCallback(() => { |
||||
|
const userId = Taro.getStorageSync('UserId'); |
||||
|
const accessToken = Taro.getStorageSync('access_token'); |
||||
|
return !!(userId && accessToken); |
||||
|
}, []); |
||||
|
|
||||
|
// 组件卸载时取消操作
|
||||
|
useEffect(() => { |
||||
|
return () => { |
||||
|
cancelRef.current = true; |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
return { |
||||
|
// 状态
|
||||
|
state, |
||||
|
error, |
||||
|
result, |
||||
|
isLoading, |
||||
|
scanType, |
||||
|
|
||||
|
// 方法
|
||||
|
startScan, |
||||
|
cancel, |
||||
|
reset, |
||||
|
canScan, |
||||
|
|
||||
|
// 便捷状态判断
|
||||
|
isIdle: state === UnifiedScanState.IDLE, |
||||
|
isScanning: state === UnifiedScanState.SCANNING, |
||||
|
isProcessing: state === UnifiedScanState.PROCESSING, |
||||
|
isSuccess: state === UnifiedScanState.SUCCESS, |
||||
|
isError: state === UnifiedScanState.ERROR |
||||
|
}; |
||||
|
} |
@ -1,5 +0,0 @@ |
|||||
export default { |
|
||||
navigationBarTitleText: '扫码登录测试', |
|
||||
navigationBarTextStyle: 'black', |
|
||||
navigationBarBackgroundColor: '#ffffff' |
|
||||
} |
|
@ -1,33 +0,0 @@ |
|||||
import { View, Text } from '@tarojs/components'; |
|
||||
import { Card } from '@nutui/nutui-react-taro'; |
|
||||
import QRLoginDemo from '@/components/QRLoginDemo'; |
|
||||
import QRLoginButton from "@/components/QRLoginButton"; |
|
||||
|
|
||||
/** |
|
||||
* 扫码登录测试页面 |
|
||||
*/ |
|
||||
const QRTestPage = () => { |
|
||||
return ( |
|
||||
<View className="qr-test-page min-h-screen bg-gray-50"> |
|
||||
<QRLoginButton /> |
|
||||
<View className="p-4"> |
|
||||
{/* 页面标题 */} |
|
||||
<Card className="mb-4"> |
|
||||
<View className="p-4 text-center"> |
|
||||
<Text className="text-xl font-bold text-gray-800 mb-2 block"> |
|
||||
扫码登录功能测试 |
|
||||
</Text> |
|
||||
<Text className="text-sm text-gray-600 block"> |
|
||||
测试各种扫码登录集成方式 |
|
||||
</Text> |
|
||||
</View> |
|
||||
</Card> |
|
||||
|
|
||||
{/* 演示组件 */} |
|
||||
<QRLoginDemo /> |
|
||||
</View> |
|
||||
</View> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
export default QRTestPage; |
|
@ -0,0 +1,4 @@ |
|||||
|
export default { |
||||
|
navigationBarTitleText: '统一扫码', |
||||
|
navigationBarTextStyle: 'black' |
||||
|
} |
@ -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; |
Loading…
Reference in new issue