Browse Source

```

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

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

主要变更:
- 新增统一扫码 Hook (useUnifiedQRScan) 和按钮组件 (UnifiedQRButton)- 新增统一扫码页面 /passport/unified-qr/index
- 迁移二维码登录页面路径:/pages/qr-login → /passport/qr-login
- 更新管理员面板及用户卡片中的扫码入口为统一扫码- 移除旧的 QRLoginDemo 和 qr-test 测试页面- 补充统一扫码使用指南文档```
master
科技小王子 3 weeks ago
parent
commit
e47e34825a
  1. 8
      README_QR_LOGIN.md
  2. 4
      docs/QR_LOGIN_INTEGRATION.md
  3. 6
      docs/QR_LOGIN_USAGE.md
  4. 212
      docs/UNIFIED_QR_SCAN_GUIDE.md
  5. 30
      src/api/qr-login/index.ts
  6. 8
      src/app.config.ts
  7. 19
      src/components/AdminPanel.tsx
  8. 2
      src/components/QRLoginButton.tsx
  9. 184
      src/components/QRLoginDemo.tsx
  10. 126
      src/components/UnifiedQRButton.tsx
  11. 332
      src/hooks/useUnifiedQRScan.ts
  12. 5
      src/pages/qr-test/index.config.ts
  13. 33
      src/pages/qr-test/index.tsx
  14. 41
      src/pages/user/components/UserCard.tsx
  15. 0
      src/passport/qr-confirm/index.config.ts
  16. 4
      src/passport/qr-confirm/index.tsx
  17. 0
      src/passport/qr-login/index.config.ts
  18. 0
      src/passport/qr-login/index.tsx
  19. 4
      src/passport/unified-qr/index.config.ts
  20. 320
      src/passport/unified-qr/index.tsx

8
README_QR_LOGIN.md

@ -79,7 +79,7 @@ import QRScanModal from '@/components/QRScanModal';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
Taro.navigateTo({ Taro.navigateTo({
url: '/pages/qr-login/index'
url: '/passport/qr-login/index'
}); });
``` ```
@ -93,18 +93,18 @@ Taro.navigateTo({
## 📱 页面说明 ## 📱 页面说明
### 1. 扫码登录页面 (`/pages/qr-login/index`)
### 1. 扫码登录页面 (`/passport/qr-login/index`)
- 完整的扫码登录功能 - 完整的扫码登录功能
- 用户信息显示 - 用户信息显示
- 登录历史记录 - 登录历史记录
- 使用说明和安全提示 - 使用说明和安全提示
### 2. 登录确认页面 (`/pages/qr-confirm/index`)
### 2. 登录确认页面 (`/passport/qr-confirm/index`)
- 处理二维码跳转确认 - 处理二维码跳转确认
- 支持URL参数:`qrCodeKey` 或 `token` - 支持URL参数:`qrCodeKey` 或 `token`
- 用户确认界面 - 用户确认界面
### 3. 功能测试页面 (`/pages/qr-test/index`)
### 3. 功能测试页面 (`/passport/qr-test/index`)
- 演示各种集成方式 - 演示各种集成方式
- 功能测试和调试 - 功能测试和调试

4
docs/QR_LOGIN_INTEGRATION.md

@ -98,7 +98,7 @@ const {
``` ```
### 4. 页面层 ### 4. 页面层
文件:`src/pages/qr-login/index.tsx`
文件:`src/passport/qr-login/index.tsx`
专门的扫码登录页面,提供完整的用户体验: 专门的扫码登录页面,提供完整的用户体验:
- 用户信息展示 - 用户信息展示
@ -155,7 +155,7 @@ import Taro from '@tarojs/taro';
const handleQRLogin = () => { const handleQRLogin = () => {
Taro.navigateTo({ Taro.navigateTo({
url: '/pages/qr-login/index'
url: '/passport/qr-login/index'
}); });
}; };
``` ```

6
docs/QR_LOGIN_USAGE.md

@ -105,7 +105,7 @@ import QRLoginButton from '@/components/QRLoginButton';
// 或者自定义跳转 // 或者自定义跳转
<Button onClick={() => { <Button onClick={() => {
Taro.navigateTo({ Taro.navigateTo({
url: '/pages/qr-login/index'
url: '/passport/qr-login/index'
}); });
}}> }}>
扫码登录 扫码登录
@ -174,8 +174,8 @@ qr-login:02278c578d3e4aad87dece6aab2f0296
export default { export default {
pages: [ pages: [
// ... 其他页面 // ... 其他页面
'pages/qr-login/index', // 扫码登录页面
'pages/qr-confirm/index', // 登录确认页面
'passport/qr-login/index', // 扫码登录页面
'passport/qr-confirm/index', // 登录确认页面
], ],
// ... // ...
} }

212
docs/UNIFIED_QR_SCAN_GUIDE.md

@ -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'
]
}
```
### 权限要求
- **扫码权限**: 所有用户都可以扫码
- **登录功能**: 需要用户已登录小程序
- **核销功能**: 需要管理员权限
## 🎯 未来规划
### 扩展可能性
- 支持更多类型的二维码(商品码、活动码等)
- 增加扫码统计和分析功能
- 支持批量扫码操作
- 增加扫码记录的云端同步
### 性能优化
- 扫码响应速度优化
- 二维码识别准确率提升
- 网络请求优化和缓存机制

30
src/api/qr-login/index.ts

@ -145,7 +145,6 @@ export async function confirmWechatQRLogin(token: string, userId: number) {
try { try {
// 获取微信用户信息 // 获取微信用户信息
const userInfo = await getUserInfo(); const userInfo = await getUserInfo();
console.log('获取微信用户信息3:', userInfo)
const data: ConfirmLoginParam = { const data: ConfirmLoginParam = {
token, token,
@ -169,37 +168,10 @@ export async function confirmWechatQRLogin(token: string, userId: number) {
} }
return Promise.reject(new Error(res.message)); return Promise.reject(new Error(res.message));
} catch (error: any) { } catch (error: any) {
return Promise.reject(new Error(error.message || '确认登录失败'));
return Promise.reject(new Error(error.message || '22确认登录失败'));
} }
} }
/**
*
*/
async function getUserProfile() {
return new Promise<any>((resolve, reject) => {
Taro.getUserProfile({
desc: '用于扫码登录身份确认',
success: (res) => {
resolve(res.userInfo);
},
fail: (err) => {
// 如果获取失败,尝试使用已存储的用户信息
const storedUser = Taro.getStorageSync('User');
if (storedUser) {
resolve({
nickName: storedUser.nickname,
avatarUrl: storedUser.avatar,
gender: storedUser.gender
});
} else {
reject(err);
}
}
});
});
}
/** /**
* *
*/ */

8
src/app.config.ts

@ -4,9 +4,6 @@ export default {
'pages/cart/cart', 'pages/cart/cart',
'pages/find/find', 'pages/find/find',
'pages/user/user', 'pages/user/user',
'pages/qr-login/index',
'pages/qr-confirm/index',
'pages/qr-test/index',
'pages/cms/category/index' 'pages/cms/category/index'
], ],
"subpackages": [ "subpackages": [
@ -18,7 +15,10 @@ export default {
"forget", "forget",
"setting", "setting",
"agreement", "agreement",
"sms-login"
"sms-login",
'qr-login/index',
'qr-confirm/index',
'unified-qr/index'
] ]
}, },
{ {

19
src/components/AdminPanel.tsx

@ -26,24 +26,13 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
// 管理员功能列表 // 管理员功能列表
const adminFeatures = [ const adminFeatures = [
{ {
id: 'store-verification',
title: '门店核销',
description: '扫码核销用户优惠券',
id: 'unified-qr',
title: '统一扫码',
description: '扫码登录和核销一体化功能',
icon: <Scan className="text-blue-500" size="24" />, icon: <Scan className="text-blue-500" size="24" />,
color: 'bg-blue-50 border-blue-200', color: 'bg-blue-50 border-blue-200',
onClick: () => { onClick: () => {
navTo('/user/store/verification', true);
onClose?.();
}
},
{
id: 'qr-login',
title: '扫码登录',
description: '扫码快速登录网页端',
icon: <Scan className="text-green-500" size="24" />,
color: 'bg-green-50 border-green-200',
onClick: () => {
navTo('/pages/qr-login/index', true);
navTo('/passport/unified-qr/index', true);
onClose?.(); onClose?.();
} }
}, },

2
src/components/QRLoginButton.tsx

@ -45,7 +45,7 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
// 跳转到专门的扫码登录页面 // 跳转到专门的扫码登录页面
if (canScan()) { if (canScan()) {
Taro.navigateTo({ Taro.navigateTo({
url: '/pages/qr-login/index'
url: '/passport/qr-login/index'
}); });
} else { } else {
Taro.showToast({ Taro.showToast({

184
src/components/QRLoginDemo.tsx

@ -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;

126
src/components/UnifiedQRButton.tsx

@ -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;

332
src/hooks/useUnifiedQRScan.ts

@ -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
};
}

5
src/pages/qr-test/index.config.ts

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

33
src/pages/qr-test/index.tsx

@ -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;

41
src/pages/user/components/UserCard.tsx

@ -1,8 +1,6 @@
import {Button} from '@nutui/nutui-react-taro'
import {Avatar, Tag, Space} from '@nutui/nutui-react-taro'
import {Avatar, Tag, Space, Button} from '@nutui/nutui-react-taro'
import {View, Text, Image} from '@tarojs/components' import {View, Text, Image} from '@tarojs/components'
import {getUserInfo, getWxOpenId} from '@/api/layout'; import {getUserInfo, getWxOpenId} from '@/api/layout';
import {Scan} from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import {useEffect, useState, forwardRef, useImperativeHandle} from "react"; import {useEffect, useState, forwardRef, useImperativeHandle} from "react";
import {User} from "@/api/system/user/model"; import {User} from "@/api/system/user/model";
@ -11,12 +9,9 @@ import {TenantId} from "@/config/app";
import {useUser} from "@/hooks/useUser"; import {useUser} from "@/hooks/useUser";
import {useUserData} from "@/hooks/useUserData"; import {useUserData} from "@/hooks/useUserData";
import {getStoredInviteParams} from "@/utils/invite"; import {getStoredInviteParams} from "@/utils/invite";
import QRLoginButton from "@/components/QRLoginButton";
import UnifiedQRButton from "@/components/UnifiedQRButton";
const UserCard = forwardRef<any, any>((_, ref) => { const UserCard = forwardRef<any, any>((_, ref) => {
const {
isAdmin
} = useUser();
const {data, refresh} = useUserData() const {data, refresh} = useUserData()
const {getDisplayName, getRoleName} = useUser(); const {getDisplayName, getRoleName} = useUser();
const [IsLogin, setIsLogin] = useState<boolean>(false) const [IsLogin, setIsLogin] = useState<boolean>(false)
@ -213,19 +208,25 @@ const UserCard = forwardRef<any, any>((_, ref) => {
marginTop: '30px', marginTop: '30px',
marginRight: '10px' marginRight: '10px'
}}> }}>
{/*扫码登录*/}
<QRLoginButton/>
{!isAdmin() &&
<Button
size={'small'}
onClick={() => navTo('/user/store/verification', true)}
>
<View className="flex items-center justify-center">
<Scan className="mr-1"/>
</View>
</Button>
}
{/*统一扫码入口 - 支持登录和核销*/}
<UnifiedQRButton
text="扫码"
size="small"
onSuccess={(result) => {
console.log('统一扫码成功:', result);
// 根据扫码类型给出不同的提示
if (result.type === 'verification') {
// 核销成功,可以显示更多信息或跳转到详情页
Taro.showModal({
title: '核销成功',
content: `已成功核销的品类:${result.data.goodsName || '礼品卡'},面值¥${result.data.faceValue}`
});
}
}}
onError={(error) => {
console.error('统一扫码失败:', error);
}}
/>
</Space> </Space>
</View> </View>
<View className={'flex justify-around mt-1'}> <View className={'flex justify-around mt-1'}>

0
src/pages/qr-confirm/index.config.ts → src/passport/qr-confirm/index.config.ts

4
src/pages/qr-confirm/index.tsx → src/passport/qr-confirm/index.tsx

@ -191,12 +191,12 @@ const QRConfirmPage: React.FC = () => {
</Button> </Button>
</View> </View>
) : ( ) : (
<View className="space-y-2">
<View className="mt-3">
<Button <Button
type="primary" type="primary"
size="large" size="large"
onClick={handleConfirmLogin} onClick={handleConfirmLogin}
className="w-full"
className="w-full mb-2"
disabled={!token || !user?.userId} disabled={!token || !user?.userId}
> >

0
src/pages/qr-login/index.config.ts → src/passport/qr-login/index.config.ts

0
src/pages/qr-login/index.tsx → src/passport/qr-login/index.tsx

4
src/passport/unified-qr/index.config.ts

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

320
src/passport/unified-qr/index.tsx

@ -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…
Cancel
Save