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