15 changed files with 1329 additions and 54 deletions
@ -0,0 +1,209 @@ |
|||
# 微信小程序扫码登录功能实现 |
|||
|
|||
## 项目概述 |
|||
|
|||
本项目已完整实现微信小程序扫码登录功能,支持用户通过小程序扫描网页端二维码快速登录。 |
|||
|
|||
## 🎯 功能特性 |
|||
|
|||
- ✅ **完整的后端API** - Java Spring Boot实现 |
|||
- ✅ **多种前端集成方式** - 按钮、弹窗、页面 |
|||
- ✅ **智能二维码解析** - 支持URL、JSON、纯token格式 |
|||
- ✅ **安全可靠** - Token有效期控制,防重复使用 |
|||
- ✅ **用户体验优秀** - 实时状态反馈,错误处理完善 |
|||
- ✅ **微信深度集成** - 自动获取用户信息 |
|||
|
|||
## 📁 项目结构 |
|||
|
|||
### 后端 (Java) |
|||
``` |
|||
auto/ |
|||
├── controller/QrLoginController.java # REST API控制器 |
|||
├── service/QrLoginService.java # 业务接口 |
|||
├── service/impl/QrLoginServiceImpl.java # 业务实现 |
|||
└── dto/ # 数据传输对象 |
|||
├── QrLoginData.java |
|||
├── QrLoginConfirmRequest.java |
|||
├── QrLoginStatusResponse.java |
|||
└── QrLoginGenerateResponse.java |
|||
``` |
|||
|
|||
### 前端 (小程序) |
|||
``` |
|||
src/ |
|||
├── api/qr-login/index.ts # API接口层 |
|||
├── hooks/useQRLogin.ts # 业务逻辑Hook |
|||
├── components/ # 组件层 |
|||
│ ├── QRLoginButton.tsx # 扫码按钮组件 |
|||
│ ├── QRLoginScanner.tsx # 扫码器组件 |
|||
│ ├── QRScanModal.tsx # 扫码弹窗组件 |
|||
│ └── QRLoginDemo.tsx # 演示组件 |
|||
└── pages/ # 页面层 |
|||
├── qr-login/index.tsx # 扫码登录页面 |
|||
├── qr-confirm/index.tsx # 登录确认页面 |
|||
└── qr-test/index.tsx # 功能测试页面 |
|||
``` |
|||
|
|||
## 🚀 快速开始 |
|||
|
|||
### 1. 后端配置 |
|||
|
|||
确保Java后端服务正常运行,API接口可访问: |
|||
- `POST /api/qr-login/generate` - 生成扫码token |
|||
- `GET /api/qr-login/status/{token}` - 检查登录状态 |
|||
- `POST /api/qr-login/confirm` - 确认登录 |
|||
- `POST /api/qr-login/scan/{token}` - 扫码操作 |
|||
|
|||
### 2. 前端使用 |
|||
|
|||
#### 最简单的使用方式: |
|||
```tsx |
|||
import QRLoginButton from '@/components/QRLoginButton'; |
|||
|
|||
<QRLoginButton /> |
|||
``` |
|||
|
|||
#### 弹窗方式: |
|||
```tsx |
|||
import QRScanModal from '@/components/QRScanModal'; |
|||
|
|||
<QRScanModal |
|||
visible={showModal} |
|||
onClose={() => setShowModal(false)} |
|||
onSuccess={(result) => console.log('登录成功', result)} |
|||
/> |
|||
``` |
|||
|
|||
#### 页面跳转方式: |
|||
```tsx |
|||
import Taro from '@tarojs/taro'; |
|||
|
|||
Taro.navigateTo({ |
|||
url: '/pages/qr-login/index' |
|||
}); |
|||
``` |
|||
|
|||
## 🔧 支持的二维码格式 |
|||
|
|||
系统智能识别多种二维码格式: |
|||
|
|||
1. **URL格式**:`https://mp.websoft.top/qr-confirm?qrCodeKey=token123` |
|||
2. **JSON格式**:`{"token": "token123", "type": "qr-login"}` |
|||
3. **简单格式**:`qr-login:token123` 或直接 `token123` |
|||
|
|||
## 📱 页面说明 |
|||
|
|||
### 1. 扫码登录页面 (`/pages/qr-login/index`) |
|||
- 完整的扫码登录功能 |
|||
- 用户信息显示 |
|||
- 登录历史记录 |
|||
- 使用说明和安全提示 |
|||
|
|||
### 2. 登录确认页面 (`/pages/qr-confirm/index`) |
|||
- 处理二维码跳转确认 |
|||
- 支持URL参数:`qrCodeKey` 或 `token` |
|||
- 用户确认界面 |
|||
|
|||
### 3. 功能测试页面 (`/pages/qr-test/index`) |
|||
- 演示各种集成方式 |
|||
- 功能测试和调试 |
|||
|
|||
## 🛠️ 开发指南 |
|||
|
|||
### 1. 添加扫码按钮到现有页面 |
|||
|
|||
```tsx |
|||
import QRLoginButton from '@/components/QRLoginButton'; |
|||
|
|||
const MyPage = () => { |
|||
return ( |
|||
<View> |
|||
<QRLoginButton |
|||
text="扫码登录" |
|||
onSuccess={(result) => { |
|||
// 处理登录成功 |
|||
console.log('用户登录成功:', result); |
|||
}} |
|||
onError={(error) => { |
|||
// 处理登录失败 |
|||
console.error('登录失败:', error); |
|||
}} |
|||
/> |
|||
</View> |
|||
); |
|||
}; |
|||
``` |
|||
|
|||
### 2. 自定义扫码逻辑 |
|||
|
|||
```tsx |
|||
import { useQRLogin } from '@/hooks/useQRLogin'; |
|||
|
|||
const MyComponent = () => { |
|||
const { |
|||
startScan, |
|||
isLoading, |
|||
isSuccess, |
|||
result, |
|||
error |
|||
} = useQRLogin(); |
|||
|
|||
return ( |
|||
<Button |
|||
loading={isLoading} |
|||
onClick={startScan} |
|||
> |
|||
{isLoading ? '扫码中...' : '扫码登录'} |
|||
</Button> |
|||
); |
|||
}; |
|||
``` |
|||
|
|||
## 🔒 安全注意事项 |
|||
|
|||
1. **用户登录验证**:使用前确保用户已在小程序中登录 |
|||
2. **Token有效期**:二维码5分钟有效期,过期自动失效 |
|||
3. **权限申请**:确保小程序已申请摄像头权限 |
|||
4. **来源验证**:只扫描来自官方网站的登录二维码 |
|||
|
|||
## 🐛 常见问题 |
|||
|
|||
### Q: 提示"请先登录小程序" |
|||
A: 用户需要先在小程序中完成登录,获取用户ID和访问令牌。 |
|||
|
|||
### Q: 提示"无效的登录二维码" |
|||
A: 检查二维码格式是否正确,或者二维码是否已过期。 |
|||
|
|||
### Q: 扫码失败 |
|||
A: 检查摄像头权限,确保二维码清晰可见。 |
|||
|
|||
### Q: 网络请求失败 |
|||
A: 检查网络连接和API接口地址配置。 |
|||
|
|||
## 📚 相关文档 |
|||
|
|||
- [详细使用指南](docs/QR_LOGIN_USAGE.md) |
|||
- [API接口文档](src/api/qr-login/index.ts) |
|||
- [组件API文档](docs/QR_LOGIN_USAGE.md#组件api) |
|||
|
|||
## 🎉 测试功能 |
|||
|
|||
访问测试页面验证功能: |
|||
``` |
|||
/pages/qr-test/index |
|||
``` |
|||
|
|||
该页面包含所有集成方式的演示和测试功能。 |
|||
|
|||
## 📞 技术支持 |
|||
|
|||
如有问题,请检查: |
|||
1. 后端API服务是否正常运行 |
|||
2. 小程序用户是否已登录 |
|||
3. 网络连接是否正常 |
|||
4. 二维码格式是否正确 |
|||
|
|||
--- |
|||
|
|||
**开发者**: 科技小王子 |
|||
**更新时间**: 2025-09-20 |
@ -0,0 +1,284 @@ |
|||
# 微信小程序扫码登录使用指南 |
|||
|
|||
## 概述 |
|||
|
|||
本项目已完整实现微信小程序扫码登录功能,支持用户通过小程序扫描网页端二维码快速登录。 |
|||
|
|||
## 功能特性 |
|||
|
|||
- ✅ **多种集成方式** - 按钮组件、弹窗组件、专门页面 |
|||
- ✅ **自动解析二维码** - 支持多种二维码格式(URL、JSON、纯token) |
|||
- ✅ **安全可靠** - Token有效期控制,防重复使用 |
|||
- ✅ **用户体验好** - 实时状态反馈,错误处理完善 |
|||
- ✅ **微信集成** - 自动获取微信用户信息 |
|||
|
|||
## 后端接口 |
|||
|
|||
### 1. 生成扫码token |
|||
```http |
|||
POST /api/qr-login/generate |
|||
``` |
|||
|
|||
### 2. 检查登录状态 |
|||
```http |
|||
GET /api/qr-login/status/{token} |
|||
``` |
|||
|
|||
### 3. 确认登录 |
|||
```http |
|||
POST /api/qr-login/confirm |
|||
``` |
|||
|
|||
### 4. 扫码操作(可选) |
|||
```http |
|||
POST /api/qr-login/scan/{token} |
|||
``` |
|||
|
|||
## 前端使用方式 |
|||
|
|||
### 方式1: 直接扫码按钮 |
|||
|
|||
最简单的使用方式,点击按钮直接调用扫码API: |
|||
|
|||
```tsx |
|||
import QRLoginButton from '@/components/QRLoginButton'; |
|||
|
|||
// 基础使用 |
|||
<QRLoginButton /> |
|||
|
|||
// 自定义配置 |
|||
<QRLoginButton |
|||
text="扫码登录" |
|||
type="primary" |
|||
size="large" |
|||
onSuccess={(result) => console.log('登录成功', result)} |
|||
onError={(error) => console.log('登录失败', error)} |
|||
/> |
|||
``` |
|||
|
|||
### 方式2: 弹窗扫码 |
|||
|
|||
在当前页面弹出扫码弹窗: |
|||
|
|||
```tsx |
|||
import { useState } from 'react'; |
|||
import QRScanModal from '@/components/QRScanModal'; |
|||
|
|||
const MyComponent = () => { |
|||
const [showScan, setShowScan] = useState(false); |
|||
|
|||
return ( |
|||
<> |
|||
<Button onClick={() => setShowScan(true)}> |
|||
扫码登录 |
|||
</Button> |
|||
|
|||
<QRScanModal |
|||
visible={showScan} |
|||
onClose={() => setShowScan(false)} |
|||
onSuccess={(result) => { |
|||
console.log('登录成功', result); |
|||
setShowScan(false); |
|||
}} |
|||
onError={(error) => { |
|||
console.log('登录失败', error); |
|||
}} |
|||
/> |
|||
</> |
|||
); |
|||
}; |
|||
``` |
|||
|
|||
### 方式3: 跳转到专门页面 |
|||
|
|||
跳转到专门的扫码登录页面: |
|||
|
|||
```tsx |
|||
import QRLoginButton from '@/components/QRLoginButton'; |
|||
|
|||
// 使用页面模式 |
|||
<QRLoginButton |
|||
text="进入扫码页面" |
|||
usePageMode={true} |
|||
/> |
|||
|
|||
// 或者自定义跳转 |
|||
<Button onClick={() => { |
|||
Taro.navigateTo({ |
|||
url: '/pages/qr-login/index' |
|||
}); |
|||
}}> |
|||
扫码登录 |
|||
</Button> |
|||
``` |
|||
|
|||
### 方式4: 使用Hook |
|||
|
|||
直接使用Hook进行更灵活的控制: |
|||
|
|||
```tsx |
|||
import { useQRLogin } from '@/hooks/useQRLogin'; |
|||
|
|||
const MyComponent = () => { |
|||
const { |
|||
startScan, |
|||
isLoading, |
|||
isSuccess, |
|||
isError, |
|||
error, |
|||
result, |
|||
canScan |
|||
} = useQRLogin(); |
|||
|
|||
return ( |
|||
<Button |
|||
loading={isLoading} |
|||
disabled={!canScan()} |
|||
onClick={startScan} |
|||
> |
|||
{isLoading ? '扫码中...' : '扫码登录'} |
|||
</Button> |
|||
); |
|||
}; |
|||
``` |
|||
|
|||
## 二维码格式支持 |
|||
|
|||
系统支持多种二维码格式: |
|||
|
|||
### 1. URL格式 |
|||
``` |
|||
https://mp.websoft.top/qr-confirm?qrCodeKey=02278c578d3e4aad87dece6aab2f0296 |
|||
https://example.com/login?token=abc123 |
|||
``` |
|||
|
|||
### 2. JSON格式 |
|||
```json |
|||
{ |
|||
"token": "02278c578d3e4aad87dece6aab2f0296", |
|||
"type": "qr-login" |
|||
} |
|||
``` |
|||
|
|||
### 3. 简单格式 |
|||
``` |
|||
qr-login:02278c578d3e4aad87dece6aab2f0296 |
|||
02278c578d3e4aad87dece6aab2f0296 |
|||
``` |
|||
|
|||
## 页面配置 |
|||
|
|||
确保在 `app.config.ts` 中添加了相关页面: |
|||
|
|||
```typescript |
|||
export default { |
|||
pages: [ |
|||
// ... 其他页面 |
|||
'pages/qr-login/index', // 扫码登录页面 |
|||
'pages/qr-confirm/index', // 登录确认页面 |
|||
], |
|||
// ... |
|||
} |
|||
``` |
|||
|
|||
## 使用示例 |
|||
|
|||
### 完整示例组件 |
|||
|
|||
```tsx |
|||
import React, { useState } from 'react'; |
|||
import { View } from '@tarojs/components'; |
|||
import { Button } from '@nutui/nutui-react-taro'; |
|||
import QRLoginButton from '@/components/QRLoginButton'; |
|||
import QRScanModal from '@/components/QRScanModal'; |
|||
|
|||
const LoginPage = () => { |
|||
const [showModal, setShowModal] = useState(false); |
|||
|
|||
const handleSuccess = (result) => { |
|||
console.log('登录成功:', result); |
|||
// 处理登录成功逻辑 |
|||
}; |
|||
|
|||
const handleError = (error) => { |
|||
console.error('登录失败:', error); |
|||
// 处理登录失败逻辑 |
|||
}; |
|||
|
|||
return ( |
|||
<View className="p-4"> |
|||
{/* 方式1: 直接扫码 */} |
|||
<QRLoginButton |
|||
text="扫码登录" |
|||
onSuccess={handleSuccess} |
|||
onError={handleError} |
|||
/> |
|||
|
|||
{/* 方式2: 弹窗扫码 */} |
|||
<Button onClick={() => setShowModal(true)}> |
|||
弹窗扫码 |
|||
</Button> |
|||
|
|||
{/* 方式3: 跳转页面 */} |
|||
<QRLoginButton |
|||
text="进入扫码页面" |
|||
usePageMode={true} |
|||
/> |
|||
|
|||
<QRScanModal |
|||
visible={showModal} |
|||
onClose={() => setShowModal(false)} |
|||
onSuccess={handleSuccess} |
|||
onError={handleError} |
|||
/> |
|||
</View> |
|||
); |
|||
}; |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **用户登录状态**: 使用扫码登录功能前,用户必须已在小程序中登录 |
|||
2. **权限申请**: 确保小程序已申请摄像头权限 |
|||
3. **网络环境**: 确保网络连接正常,API接口可访问 |
|||
4. **二维码有效期**: 二维码有5分钟有效期,过期需重新生成 |
|||
5. **安全性**: 只扫描来自官方网站的登录二维码 |
|||
|
|||
## 错误处理 |
|||
|
|||
常见错误及解决方案: |
|||
|
|||
- `请先登录小程序`: 用户未登录,需要先完成小程序登录 |
|||
- `无效的登录二维码`: 二维码格式不正确或已过期 |
|||
- `登录确认失败`: 网络问题或服务器错误,可重试 |
|||
- `扫码失败`: 摄像头权限问题或二维码不清晰 |
|||
|
|||
## API接口详情 |
|||
|
|||
详细的API接口文档请参考:`src/api/qr-login/index.ts` |
|||
|
|||
## 组件API |
|||
|
|||
### QRLoginButton Props |
|||
|
|||
| 属性 | 类型 | 默认值 | 说明 | |
|||
|------|------|--------|------| |
|||
| type | string | 'primary' | 按钮类型 | |
|||
| size | string | 'normal' | 按钮大小 | |
|||
| text | string | '扫码登录' | 按钮文本 | |
|||
| showIcon | boolean | true | 是否显示图标 | |
|||
| usePageMode | boolean | false | 是否使用页面模式 | |
|||
| onSuccess | function | - | 成功回调 | |
|||
| onError | function | - | 失败回调 | |
|||
|
|||
### QRScanModal Props |
|||
|
|||
| 属性 | 类型 | 默认值 | 说明 | |
|||
|------|------|--------|------| |
|||
| visible | boolean | false | 是否显示弹窗 | |
|||
| title | string | '扫描登录二维码' | 弹窗标题 | |
|||
| description | string | '扫描网页端显示的登录二维码' | 描述文本 | |
|||
| autoConfirm | boolean | true | 是否自动确认登录 | |
|||
| onClose | function | - | 关闭回调 | |
|||
| onSuccess | function | - | 成功回调 | |
|||
| onError | function | - | 失败回调 | |
@ -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; |
@ -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; |
@ -0,0 +1,5 @@ |
|||
export default { |
|||
navigationBarTitleText: '确认登录', |
|||
navigationBarTextStyle: 'black', |
|||
navigationBarBackgroundColor: '#ffffff' |
|||
} |
@ -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="space-y-2"> |
|||
<Button |
|||
type="primary" |
|||
size="large" |
|||
onClick={handleConfirmLogin} |
|||
className="w-full" |
|||
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; |
@ -0,0 +1,5 @@ |
|||
export default { |
|||
navigationBarTitleText: '扫码登录测试', |
|||
navigationBarTextStyle: 'black', |
|||
navigationBarBackgroundColor: '#ffffff' |
|||
} |
@ -0,0 +1,33 @@ |
|||
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; |
Loading…
Reference in new issue