feat(qr-login): 实现扫码登录功能模块
This commit is contained in:
209
README_QR_LOGIN.md
Normal file
209
README_QR_LOGIN.md
Normal file
@@ -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
|
||||||
284
docs/QR_LOGIN_USAGE.md
Normal file
284
docs/QR_LOGIN_USAGE.md
Normal file
@@ -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 | - | 失败回调 |
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
import type { ApiResult } from '@/api';
|
import type { ApiResult } from '@/api';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
import {SERVER_API_URL} from "@/utils/server";
|
||||||
|
import {getUserInfo} from "@/api/layout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫码登录相关接口
|
* 扫码登录相关接口
|
||||||
@@ -70,7 +72,7 @@ export interface ConfirmLoginParam {
|
|||||||
unionid?: string;
|
unionid?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
gender?: number;
|
gender?: string;
|
||||||
};
|
};
|
||||||
// 设备信息
|
// 设备信息
|
||||||
deviceInfo?: string;
|
deviceInfo?: string;
|
||||||
@@ -96,7 +98,7 @@ export interface ConfirmLoginResult {
|
|||||||
*/
|
*/
|
||||||
export async function generateQRToken(data?: GenerateQRTokenParam) {
|
export async function generateQRToken(data?: GenerateQRTokenParam) {
|
||||||
const res = await request.post<ApiResult<GenerateQRTokenResult>>(
|
const res = await request.post<ApiResult<GenerateQRTokenResult>>(
|
||||||
'http://127.0.0.1:9200/api/qr-login/generate',
|
SERVER_API_URL + '/qr-login/generate',
|
||||||
{
|
{
|
||||||
clientType: 'wechat',
|
clientType: 'wechat',
|
||||||
expireMinutes: 5,
|
expireMinutes: 5,
|
||||||
@@ -114,7 +116,7 @@ export async function generateQRToken(data?: GenerateQRTokenParam) {
|
|||||||
*/
|
*/
|
||||||
export async function checkQRLoginStatus(token: string) {
|
export async function checkQRLoginStatus(token: string) {
|
||||||
const res = await request.get<ApiResult<QRLoginStatusResult>>(
|
const res = await request.get<ApiResult<QRLoginStatusResult>>(
|
||||||
`http://127.0.0.1:9200/api/qr-login/status/${token}`
|
SERVER_API_URL + `/qr-login/status/${token}`
|
||||||
);
|
);
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -127,7 +129,7 @@ export async function checkQRLoginStatus(token: string) {
|
|||||||
*/
|
*/
|
||||||
export async function confirmQRLogin(data: ConfirmLoginParam) {
|
export async function confirmQRLogin(data: ConfirmLoginParam) {
|
||||||
const res = await request.post<ApiResult<ConfirmLoginResult>>(
|
const res = await request.post<ApiResult<ConfirmLoginResult>>(
|
||||||
'http://127.0.0.1:9200/api/qr-login/confirm',
|
SERVER_API_URL + '/qr-login/confirm',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
@@ -142,25 +144,26 @@ export async function confirmQRLogin(data: ConfirmLoginParam) {
|
|||||||
export async function confirmWechatQRLogin(token: string, userId: number) {
|
export async function confirmWechatQRLogin(token: string, userId: number) {
|
||||||
try {
|
try {
|
||||||
// 获取微信用户信息
|
// 获取微信用户信息
|
||||||
const userInfo = await getUserProfile();
|
const userInfo = await getUserInfo();
|
||||||
|
console.log('获取微信用户信息3:', userInfo)
|
||||||
|
|
||||||
const data: ConfirmLoginParam = {
|
const data: ConfirmLoginParam = {
|
||||||
token,
|
token,
|
||||||
userId,
|
userId,
|
||||||
platform: 'wechat',
|
platform: 'wechat',
|
||||||
wechatInfo: {
|
wechatInfo: {
|
||||||
nickname: userInfo?.nickName,
|
nickname: userInfo?.nickname,
|
||||||
avatar: userInfo?.avatarUrl,
|
avatar: userInfo?.avatar,
|
||||||
gender: userInfo?.gender
|
gender: userInfo?.sex
|
||||||
},
|
},
|
||||||
deviceInfo: await getDeviceInfo()
|
deviceInfo: await getDeviceInfo()
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await request.post<ApiResult<ConfirmLoginResult>>(
|
const res = await request.post<ApiResult<ConfirmLoginResult>>(
|
||||||
'http://127.0.0.1:9200/api/qr-login/wechat-confirm',
|
SERVER_API_URL + '/qr-login/confirm',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
|
console.log(res,'ConfirmLoginParamResult>')
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
@@ -227,20 +230,44 @@ async function getDeviceInfo() {
|
|||||||
*/
|
*/
|
||||||
export function parseQRContent(qrContent: string): string | null {
|
export function parseQRContent(qrContent: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
console.log('解析二维码内容1:', qrContent);
|
||||||
|
|
||||||
// 尝试解析JSON格式
|
// 尝试解析JSON格式
|
||||||
if (qrContent.startsWith('{')) {
|
if (qrContent.startsWith('{')) {
|
||||||
const parsed = JSON.parse(qrContent);
|
const parsed = JSON.parse(qrContent);
|
||||||
return parsed.token || null;
|
return parsed.token || parsed.qrCodeKey || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试解析URL格式
|
// 尝试解析URL格式
|
||||||
if (qrContent.includes('token=')) {
|
if (qrContent.includes('http')) {
|
||||||
const url = new URL(qrContent);
|
const url = new URL(qrContent);
|
||||||
return url.searchParams.get('token');
|
// 支持多种参数名
|
||||||
|
return url.searchParams.get('token') ||
|
||||||
|
url.searchParams.get('qrCodeKey') ||
|
||||||
|
url.searchParams.get('qr_code_key') ||
|
||||||
|
null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接返回内容作为token
|
// 尝试解析简单的key=value格式
|
||||||
|
if (qrContent.includes('=')) {
|
||||||
|
const params = new URLSearchParams(qrContent);
|
||||||
|
return params.get('token') ||
|
||||||
|
params.get('qrCodeKey') ||
|
||||||
|
params.get('qr_code_key') ||
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是以qr-login:开头的格式
|
||||||
|
if (qrContent.startsWith('qr-login:')) {
|
||||||
|
return qrContent.replace('qr-login:', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接返回内容作为token(如果是32位以上的字符串)
|
||||||
|
if (qrContent.length >= 32) {
|
||||||
return qrContent;
|
return qrContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析二维码内容失败:', error);
|
console.error('解析二维码内容失败:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export default {
|
|||||||
'pages/find/find',
|
'pages/find/find',
|
||||||
'pages/user/user',
|
'pages/user/user',
|
||||||
'pages/qr-login/index',
|
'pages/qr-login/index',
|
||||||
|
'pages/qr-confirm/index',
|
||||||
|
'pages/qr-test/index',
|
||||||
'pages/cms/category/index'
|
'pages/cms/category/index'
|
||||||
],
|
],
|
||||||
"subpackages": [
|
"subpackages": [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@nutui/nutui-react-taro';
|
import { Button } from '@nutui/nutui-react-taro';
|
||||||
|
import {View} from '@tarojs/components'
|
||||||
import { Scan } from '@nutui/icons-react-taro';
|
import { Scan } from '@nutui/icons-react-taro';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { useQRLogin } from '@/hooks/useQRLogin';
|
import { useQRLogin } from '@/hooks/useQRLogin';
|
||||||
@@ -31,7 +32,6 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
|
|||||||
size = 'normal',
|
size = 'normal',
|
||||||
text = '扫码登录',
|
text = '扫码登录',
|
||||||
showIcon = true,
|
showIcon = true,
|
||||||
className = '',
|
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
usePageMode = false
|
usePageMode = false
|
||||||
@@ -40,6 +40,7 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
|
|||||||
|
|
||||||
// 处理点击事件
|
// 处理点击事件
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
|
console.log('处理点击事件handleClick', usePageMode)
|
||||||
if (usePageMode) {
|
if (usePageMode) {
|
||||||
// 跳转到专门的扫码登录页面
|
// 跳转到专门的扫码登录页面
|
||||||
if (canScan()) {
|
if (canScan()) {
|
||||||
@@ -74,12 +75,13 @@ const QRLoginButton: React.FC<QRLoginButtonProps> = ({
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={className}
|
|
||||||
>
|
>
|
||||||
|
<View className="flex items-center justify-center">
|
||||||
{showIcon && !isLoading && (
|
{showIcon && !isLoading && (
|
||||||
<Scan className="mr-1" />
|
<Scan className="mr-1" />
|
||||||
)}
|
)}
|
||||||
{isLoading ? '扫码中...' : (disabled && !canScan() ? '请先登录' : text)}
|
{isLoading ? '扫码中...' : (disabled && !canScan() ? '请先登录' : text)}
|
||||||
|
</View>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
184
src/components/QRLoginDemo.tsx
Normal file
184
src/components/QRLoginDemo.tsx
Normal file
@@ -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;
|
||||||
272
src/components/QRScanModal.tsx
Normal file
272
src/components/QRScanModal.tsx
Normal file
@@ -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;
|
||||||
@@ -79,6 +79,7 @@ export function useQRLogin() {
|
|||||||
|
|
||||||
// 解析二维码内容
|
// 解析二维码内容
|
||||||
const token = parseQRContent(scanResult);
|
const token = parseQRContent(scanResult);
|
||||||
|
console.log('解析二维码内容2:',token)
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('无效的登录二维码');
|
throw new Error('无效的登录二维码');
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,7 @@ export function useQRLogin() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const confirmResult = await confirmWechatQRLogin(token, parseInt(userId));
|
const confirmResult = await confirmWechatQRLogin(token, parseInt(userId));
|
||||||
|
console.log(confirmResult,'confirmResult>>>>')
|
||||||
if (cancelRef.current) {
|
if (cancelRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/pages/qr-confirm/index.config.ts
Normal file
5
src/pages/qr-confirm/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '确认登录',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
|
}
|
||||||
239
src/pages/qr-confirm/index.tsx
Normal file
239
src/pages/qr-confirm/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import { Button, Loading, Card } from '@nutui/nutui-react-taro';
|
||||||
|
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import { confirmQRLogin } from '@/api/qr-login';
|
||||||
|
import { useUser } from '@/hooks/useUser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码登录确认页面
|
||||||
|
* 用于处理从二维码跳转过来的登录确认
|
||||||
|
*/
|
||||||
|
const QRConfirmPage: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, getDisplayName } = useUser();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [token, setToken] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从URL参数中获取token
|
||||||
|
const { qrCodeKey, token: urlToken } = router.params;
|
||||||
|
const loginToken = qrCodeKey || urlToken;
|
||||||
|
|
||||||
|
if (loginToken) {
|
||||||
|
setToken(loginToken);
|
||||||
|
} else {
|
||||||
|
setError('无效的登录链接');
|
||||||
|
}
|
||||||
|
}, [router.params]);
|
||||||
|
|
||||||
|
// 确认登录
|
||||||
|
const handleConfirmLogin = async () => {
|
||||||
|
if (!token) {
|
||||||
|
setError('缺少登录token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.userId) {
|
||||||
|
setError('请先登录小程序');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const result = await confirmQRLogin({
|
||||||
|
token,
|
||||||
|
userId: user.userId,
|
||||||
|
platform: 'wechat',
|
||||||
|
wechatInfo: {
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatar: user.avatar
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setConfirmed(true);
|
||||||
|
Taro.showToast({
|
||||||
|
title: '登录确认成功',
|
||||||
|
icon: 'success',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3秒后自动返回
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.navigateBack();
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
setError(result.message || '登录确认失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '登录确认失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消登录
|
||||||
|
const handleCancel = () => {
|
||||||
|
Taro.navigateBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重试
|
||||||
|
const handleRetry = () => {
|
||||||
|
setError('');
|
||||||
|
setConfirmed(false);
|
||||||
|
handleConfirmLogin();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="qr-confirm-page min-h-screen bg-gray-50">
|
||||||
|
<View className="p-4">
|
||||||
|
{/* 主要内容卡片 */}
|
||||||
|
<Card className="bg-white rounded-lg shadow-sm">
|
||||||
|
<View className="p-6 text-center">
|
||||||
|
{/* 图标 */}
|
||||||
|
<View className="mb-6">
|
||||||
|
{loading ? (
|
||||||
|
<View className="w-16 h-16 mx-auto flex items-center justify-center">
|
||||||
|
<Loading className="text-blue-500" />
|
||||||
|
</View>
|
||||||
|
) : confirmed ? (
|
||||||
|
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<Success className="text-green-500" />
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<Failure className="text-red-500" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="w-16 h-16 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<User size="32" className="text-blue-500" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<Text className="text-xl font-bold text-gray-800 mb-2 block">
|
||||||
|
{loading ? '正在确认登录...' :
|
||||||
|
confirmed ? '登录确认成功' :
|
||||||
|
error ? '登录确认失败' : '确认登录'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<Text className="text-gray-600 mb-6 block">
|
||||||
|
{loading ? '请稍候,正在为您确认登录' :
|
||||||
|
confirmed ? '您已成功确认登录,网页端将自动登录' :
|
||||||
|
error ? error :
|
||||||
|
`确认使用 ${getDisplayName()} 登录网页端?`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 用户信息 */}
|
||||||
|
{!loading && !confirmed && !error && user && (
|
||||||
|
<View className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||||
|
<View className="flex items-center justify-center">
|
||||||
|
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
||||||
|
<User className="text-blue-500" size="20" />
|
||||||
|
</View>
|
||||||
|
<View className="text-left">
|
||||||
|
<Text className="text-sm font-medium text-gray-800 block">
|
||||||
|
{user.nickname || user.username || '用户'}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-500 block">
|
||||||
|
ID: {user.userId}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<View className="space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="large"
|
||||||
|
disabled
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
确认中...
|
||||||
|
</Button>
|
||||||
|
) : confirmed ? (
|
||||||
|
<Button
|
||||||
|
type="success"
|
||||||
|
size="large"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
) : error ? (
|
||||||
|
<View className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="large"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="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;
|
||||||
5
src/pages/qr-test/index.config.ts
Normal file
5
src/pages/qr-test/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '扫码登录测试',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
|
}
|
||||||
33
src/pages/qr-test/index.tsx
Normal file
33
src/pages/qr-test/index.tsx
Normal file
@@ -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;
|
||||||
@@ -11,6 +11,7 @@ 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";
|
||||||
|
|
||||||
const UserCard = forwardRef<any, any>((_, ref) => {
|
const UserCard = forwardRef<any, any>((_, ref) => {
|
||||||
const {
|
const {
|
||||||
@@ -204,6 +205,9 @@ const UserCard = forwardRef<any, any>((_, ref) => {
|
|||||||
) : ''}
|
) : ''}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{userInfo?.userId === 33738 &&
|
||||||
|
<QRLoginButton />
|
||||||
|
}
|
||||||
{isAdmin() &&
|
{isAdmin() &&
|
||||||
<Scan className={'text-gray-900'} size={24} onClick={() => navTo('/user/store/verification', true)}/>}
|
<Scan className={'text-gray-900'} size={24} onClick={() => navTo('/user/store/verification', true)}/>}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const GoodsItem = ({ goods }: GoodsItemProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
|
<View className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
|
||||||
<Image
|
<Image
|
||||||
src={goods.image || ''}
|
src={goods.image || ''}
|
||||||
mode={'aspectFit'}
|
mode={'aspectFit'}
|
||||||
@@ -27,31 +27,31 @@ const GoodsItem = ({ goods }: GoodsItemProps) => {
|
|||||||
height="180"
|
height="180"
|
||||||
onClick={goToDetail}
|
onClick={goToDetail}
|
||||||
/>
|
/>
|
||||||
<div className={'flex flex-col p-2 rounded-lg'}>
|
<View className={'flex flex-col p-2 rounded-lg'}>
|
||||||
<div>
|
<View>
|
||||||
<div className={'car-no text-sm'}>{goods.name || goods.goodsName}</div>
|
<View className={'car-no text-sm'}>{goods.name || goods.goodsName}</View>
|
||||||
<div className={'flex justify-between text-xs py-1'}>
|
<View className={'flex justify-between text-xs py-1'}>
|
||||||
<span className={'text-orange-500'}>{goods.comments || ''}</span>
|
<span className={'text-orange-500'}>{goods.comments || ''}</span>
|
||||||
<span className={'text-gray-400'}>已售 {goods.sales || 0}</span>
|
<span className={'text-gray-400'}>已售 {goods.sales || 0}</span>
|
||||||
</div>
|
</View>
|
||||||
<div className={'flex justify-between items-center py-2'}>
|
<View className={'flex justify-between items-center py-2'}>
|
||||||
<div className={'flex text-red-500 text-xl items-baseline'}>
|
<View className={'flex text-red-500 text-xl items-baseline'}>
|
||||||
<span className={'text-xs'}>¥</span>
|
<span className={'text-xs'}>¥</span>
|
||||||
<span className={'font-bold text-2xl'}>{goods.price || '0.00'}</span>
|
<span className={'font-bold text-2xl'}>{goods.price || '0.00'}</span>
|
||||||
</div>
|
</View>
|
||||||
<div className={'buy-btn'}>
|
<View className={'buy-btn'}>
|
||||||
<div className={'cart-icon'}>
|
<View className={'cart-icon'}>
|
||||||
<Share size={20} className={'mx-4 mt-2'}
|
<Share size={20} className={'mx-4 mt-2'}
|
||||||
onClick={goToDetail}/>
|
onClick={goToDetail}/>
|
||||||
</div>
|
</View>
|
||||||
<div className={'text-white pl-4 pr-5'}
|
<View className={'text-white pl-4 pr-5'}
|
||||||
onClick={goToDetail}>购买
|
onClick={goToDetail}>购买
|
||||||
</div>
|
</View>
|
||||||
</div>
|
</View>
|
||||||
</div>
|
</View>
|
||||||
</div>
|
</View>
|
||||||
</div>
|
</View>
|
||||||
</div>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,7 +365,15 @@ const AddUserAddress = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 底部浮动按钮 */}
|
{/* 底部浮动按钮 */}
|
||||||
<FixedButton text={isEditMode ? '更新地址' : '保存并使用'} onClick={() => submitSucceed} />
|
<FixedButton
|
||||||
|
text={isEditMode ? '更新地址' : '保存并使用'}
|
||||||
|
onClick={() => {
|
||||||
|
// 触发表单提交
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user