Browse Source

feat(qr-login): 实现扫码登录功能模块

master
科技小王子 1 week ago
parent
commit
16559c76ed
  1. 209
      README_QR_LOGIN.md
  2. 284
      docs/QR_LOGIN_USAGE.md
  3. 55
      src/api/qr-login/index.ts
  4. 2
      src/app.config.ts
  5. 6
      src/components/QRLoginButton.tsx
  6. 184
      src/components/QRLoginDemo.tsx
  7. 272
      src/components/QRScanModal.tsx
  8. 3
      src/hooks/useQRLogin.ts
  9. 5
      src/pages/qr-confirm/index.config.ts
  10. 239
      src/pages/qr-confirm/index.tsx
  11. 5
      src/pages/qr-test/index.config.ts
  12. 33
      src/pages/qr-test/index.tsx
  13. 4
      src/pages/user/components/UserCard.tsx
  14. 38
      src/shop/search/components/GoodsItem.tsx
  15. 10
      src/user/address/add.tsx

209
README_QR_LOGIN.md

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

@ -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 | - | 失败回调 |

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

@ -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,
avatar: userInfo?.avatarUrl,
gender: userInfo?.gender
nickname: userInfo?.nickname,
avatar: userInfo?.avatar,
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;

2
src/app.config.ts

@ -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": [

6
src/components/QRLoginButton.tsx

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

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

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

3
src/hooks/useQRLogin.ts

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

@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '确认登录',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
}

239
src/pages/qr-confirm/index.tsx

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

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

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

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

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

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

38
src/shop/search/components/GoodsItem.tsx

@ -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'}>
<div>
<div className={'car-no text-sm'}>{goods.name || goods.goodsName}</div>
<div className={'flex justify-between text-xs py-1'}>
<View className={'flex flex-col p-2 rounded-lg'}>
<View>
<View className={'car-no text-sm'}>{goods.name || goods.goodsName}</View>
<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>
<div className={'flex justify-between items-center py-2'}>
<div className={'flex text-red-500 text-xl items-baseline'}>
</View>
<View className={'flex justify-between items-center py-2'}>
<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>
<div className={'buy-btn'}>
<div className={'cart-icon'}>
</View>
<View className={'buy-btn'}>
<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>
<div className={'text-white pl-4 pr-5'}
</View>
<View className={'text-white pl-4 pr-5'}
onClick={goToDetail}> onClick={goToDetail}>
</div>
</div>
</div>
</div>
</div>
</div>
</View>
</View>
</View>
</View>
</View>
</View>
) )
} }

10
src/user/address/add.tsx

@ -365,7 +365,15 @@ const AddUserAddress = () => {
/> />
{/* 底部浮动按钮 */} {/* 底部浮动按钮 */}
<FixedButton text={isEditMode ? '更新地址' : '保存并使用'} onClick={() => submitSucceed} />
<FixedButton
text={isEditMode ? '更新地址' : '保存并使用'}
onClick={() => {
// 触发表单提交
if (formRef.current) {
formRef.current.submit();
}
}}
/>
</> </>
); );
}; };

Loading…
Cancel
Save