feat(auth): 实现二维码登录功能
- 新增二维码登录相关接口和页面 - 实现二维码生成、状态检查、登录确认等逻辑 - 添加微信小程序登录支持- 优化用户信息展示和处理
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
export const ENV_CONFIG = {
|
||||
// 开发环境
|
||||
development: {
|
||||
API_BASE_URL: 'https://cms-api.websoft.top/api',
|
||||
API_BASE_URL: 'http://127.0.0.1:9200/api',
|
||||
APP_NAME: '开发环境',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
|
||||
182
docs/统一扫码功能说明.md
Normal file
182
docs/统一扫码功能说明.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 统一扫码功能说明
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
本项目实现了统一扫码入口,支持多种类型的二维码识别和处理:
|
||||
|
||||
1. **二维码登录** - 扫码确认后台管理系统登录
|
||||
2. **礼品卡核销** - 管理员扫码核销礼品卡
|
||||
3. **礼品卡兑换** - 用户扫码兑换礼品卡
|
||||
4. **车辆查询** - 扫码查询车辆信息
|
||||
5. **未知类型** - 提供选择处理方式
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### 1. API接口层 (`src/api/qrLogin/`)
|
||||
- `model/index.ts` - 类型定义
|
||||
- `index.ts` - API接口和扫码结果解析逻辑
|
||||
|
||||
#### 2. 统一扫码组件 (`src/components/UniversalScanner.tsx`)
|
||||
- `useUniversalScanner` Hook - 提供扫码功能
|
||||
- 智能识别不同类型的二维码
|
||||
- 权限控制和错误处理
|
||||
|
||||
#### 3. 用户界面集成
|
||||
- `UserCard.tsx` - 移除管理员权限限制,所有登录用户可见扫码按钮
|
||||
- `verification.tsx` - 支持从统一扫码传递参数
|
||||
|
||||
## 🎯 使用方法
|
||||
|
||||
### 在组件中使用统一扫码
|
||||
|
||||
```typescript
|
||||
import { useUniversalScanner } from '@/components/UniversalScanner';
|
||||
|
||||
function MyComponent() {
|
||||
const { startScan } = useUniversalScanner({
|
||||
onScanSuccess: (result) => {
|
||||
console.log('扫码成功:', result);
|
||||
},
|
||||
onScanError: (error) => {
|
||||
console.error('扫码失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Button onClick={startScan}>
|
||||
扫码
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 扫码结果类型
|
||||
|
||||
```typescript
|
||||
export type ScanResultType =
|
||||
| 'qr-login' // 二维码登录
|
||||
| 'gift-verification' // 礼品卡核销
|
||||
| 'gift-redeem' // 礼品卡兑换
|
||||
| 'vehicle-query' // 车辆查询
|
||||
| 'unknown'; // 未知类型
|
||||
```
|
||||
|
||||
## 🔍 二维码格式识别规则
|
||||
|
||||
### 1. 二维码登录
|
||||
- 格式:`qr-login:token` 或纯token(32位以上字符串)
|
||||
- 示例:`qr-login:abc123def456...` 或 `abc123def456ghi789...`
|
||||
|
||||
### 2. 礼品卡核销
|
||||
- 格式:JSON格式,包含 `businessType: 'gift'`
|
||||
- 示例:`{"businessType":"gift","token":"xxx","data":"yyy"}`
|
||||
|
||||
### 3. 礼品卡兑换
|
||||
- 格式:6位字母数字组合
|
||||
- 示例:`ABC123`、`XYZ789`
|
||||
|
||||
### 4. 车辆查询
|
||||
- 格式:以 `vehicle-` 或 `car-` 开头
|
||||
- 示例:`vehicle-12345`、`car-abc123`
|
||||
|
||||
### 5. URL格式
|
||||
- 支持包含特定参数的URL
|
||||
- 二维码登录:包含 `qr-login-token` 参数
|
||||
- 礼品卡:包含 `gift-code` 参数
|
||||
|
||||
## 🛡️ 权限控制
|
||||
|
||||
### 用户权限
|
||||
- **所有已登录用户**:可以看到扫码按钮
|
||||
- **二维码登录**:需要登录状态
|
||||
- **礼品卡兑换**:无需特殊权限
|
||||
- **车辆查询**:无需特殊权限
|
||||
|
||||
### 管理员权限
|
||||
- **礼品卡核销**:仅管理员可用
|
||||
|
||||
## 🔄 处理流程
|
||||
|
||||
### 二维码登录流程
|
||||
1. 扫码获取token
|
||||
2. 调用 `scanQrCode(token)` 更新状态为已扫码
|
||||
3. 调用 `wechatMiniProgramConfirm()` 确认登录
|
||||
4. 显示成功提示
|
||||
|
||||
### 礼品卡核销流程
|
||||
1. 检查管理员权限
|
||||
2. 跳转到核销页面并传递扫码数据
|
||||
3. 在核销页面处理解密和验证
|
||||
|
||||
### 礼品卡兑换流程
|
||||
1. 直接跳转到兑换页面
|
||||
2. 传递兑换码参数
|
||||
|
||||
## 🚨 错误处理
|
||||
|
||||
### 常见错误类型
|
||||
- **权限不足**:非管理员尝试核销
|
||||
- **未登录**:需要登录的功能
|
||||
- **二维码过期**:登录token过期
|
||||
- **无效二维码**:格式不正确
|
||||
- **网络错误**:API调用失败
|
||||
|
||||
### 错误提示
|
||||
- 自动显示Toast提示
|
||||
- 根据错误类型显示不同消息
|
||||
- 支持自定义错误处理回调
|
||||
|
||||
## 📱 用户体验
|
||||
|
||||
### 成功提示
|
||||
- **二维码登录**:显示确认弹窗,提示在电脑端查看
|
||||
- **其他功能**:显示成功Toast并跳转相应页面
|
||||
|
||||
### 未知类型处理
|
||||
- 显示操作选择弹窗
|
||||
- 提供复制、作为礼品卡码、作为车辆码等选项
|
||||
|
||||
## 🔧 配置选项
|
||||
|
||||
### useUniversalScanner 参数
|
||||
```typescript
|
||||
interface UniversalScannerProps {
|
||||
/** 扫码成功回调 */
|
||||
onScanSuccess?: (result: ScanResultParsed) => void;
|
||||
/** 扫码失败回调 */
|
||||
onScanError?: (error: string) => void;
|
||||
/** 是否显示处理结果提示 */
|
||||
showToast?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 测试场景
|
||||
1. **不同用户权限**:普通用户、管理员
|
||||
2. **不同二维码类型**:登录、核销、兑换、车辆查询
|
||||
3. **错误情况**:过期、无效、网络错误
|
||||
4. **边界情况**:未登录、权限不足
|
||||
|
||||
### 测试数据
|
||||
- 有效的登录token
|
||||
- 有效的礼品卡核销JSON
|
||||
- 有效的兑换码(6位)
|
||||
- 有效的车辆查询码
|
||||
- 无效/过期的各种码
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **安全性**:登录token应该有过期时间
|
||||
2. **性能**:扫码解析逻辑应该高效
|
||||
3. **用户体验**:错误提示应该清晰明确
|
||||
4. **扩展性**:新增扫码类型时只需修改解析逻辑
|
||||
|
||||
## 🔄 后续优化
|
||||
|
||||
1. **缓存机制**:缓存扫码结果避免重复处理
|
||||
2. **统计功能**:记录扫码使用情况
|
||||
3. **批量处理**:支持连续扫码
|
||||
4. **离线支持**:部分功能支持离线处理
|
||||
104
java/auto/controller/QrLoginController.java
Normal file
104
java/auto/controller/QrLoginController.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.gxwebsoft.auto.controller;
|
||||
|
||||
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
|
||||
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
|
||||
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
|
||||
import com.gxwebsoft.auto.service.QrLoginService;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
/**
|
||||
* 认证模块
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-03-06 22:50:25
|
||||
*/
|
||||
@Tag(name = "认证模块")
|
||||
@RestController
|
||||
@RequestMapping("/api/qr-login")
|
||||
public class QrLoginController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private QrLoginService qrLoginService;
|
||||
|
||||
/**
|
||||
* 生成扫码登录token
|
||||
*/
|
||||
@Operation(summary = "生成扫码登录token")
|
||||
@PostMapping("/generate")
|
||||
public ApiResult<?> generateQrLoginToken() {
|
||||
try {
|
||||
QrLoginGenerateResponse response = qrLoginService.generateQrLoginToken();
|
||||
return success("生成成功", response);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查扫码登录状态
|
||||
*/
|
||||
@Operation(summary = "检查扫码登录状态")
|
||||
@GetMapping("/status/{token}")
|
||||
public ApiResult<?> checkQrLoginStatus(
|
||||
@Parameter(description = "扫码登录token") @PathVariable String token) {
|
||||
try {
|
||||
QrLoginStatusResponse response = qrLoginService.checkQrLoginStatus(token);
|
||||
return success("查询成功", response);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认扫码登录
|
||||
*/
|
||||
@Operation(summary = "确认扫码登录")
|
||||
@PostMapping("/confirm")
|
||||
public ApiResult<?> confirmQrLogin(@Valid @RequestBody QrLoginConfirmRequest request) {
|
||||
try {
|
||||
QrLoginStatusResponse response = qrLoginService.confirmQrLogin(request);
|
||||
return success("确认成功", response);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码操作(可选接口,用于移动端扫码后更新状态)
|
||||
*/
|
||||
@Operation(summary = "扫码操作")
|
||||
@PostMapping("/scan/{token}")
|
||||
public ApiResult<?> scanQrCode(@Parameter(description = "扫码登录token") @PathVariable String token) {
|
||||
try {
|
||||
boolean result = qrLoginService.scanQrCode(token);
|
||||
return success("操作成功", result);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序扫码登录确认(便捷接口)
|
||||
*/
|
||||
@Operation(summary = "微信小程序扫码登录确认")
|
||||
@PostMapping("/wechat-confirm")
|
||||
public ApiResult<?> wechatMiniProgramConfirm(@Valid @RequestBody QrLoginConfirmRequest request) {
|
||||
try {
|
||||
// 设置平台为微信小程序
|
||||
request.setPlatform("miniprogram");
|
||||
QrLoginStatusResponse response = qrLoginService.confirmQrLogin(request);
|
||||
return success("微信小程序登录确认成功", response);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
50
java/auto/dto/QrLoginConfirmRequest.java
Normal file
50
java/auto/dto/QrLoginConfirmRequest.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.gxwebsoft.auto.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 扫码登录确认请求
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-08-31
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "扫码登录确认请求")
|
||||
public class QrLoginConfirmRequest {
|
||||
|
||||
@Schema(description = "扫码登录token")
|
||||
@NotBlank(message = "token不能为空")
|
||||
private String token;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Integer userId;
|
||||
|
||||
@Schema(description = "登录平台: web-网页端, app-移动应用, miniprogram-微信小程序")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "微信小程序相关信息")
|
||||
private WechatMiniProgramInfo wechatInfo;
|
||||
|
||||
/**
|
||||
* 微信小程序信息
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "微信小程序信息")
|
||||
public static class WechatMiniProgramInfo {
|
||||
@Schema(description = "微信openid")
|
||||
private String openid;
|
||||
|
||||
@Schema(description = "微信unionid")
|
||||
private String unionid;
|
||||
|
||||
@Schema(description = "微信昵称")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "微信头像")
|
||||
private String avatar;
|
||||
}
|
||||
|
||||
}
|
||||
55
java/auto/dto/QrLoginData.java
Normal file
55
java/auto/dto/QrLoginData.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package com.gxwebsoft.auto.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 扫码登录数据模型
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-08-31
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class QrLoginData {
|
||||
|
||||
/**
|
||||
* 扫码登录token
|
||||
*/
|
||||
private String token;
|
||||
|
||||
/**
|
||||
* 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 用户ID(扫码确认后设置)
|
||||
*/
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 用户名(扫码确认后设置)
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 过期时间
|
||||
*/
|
||||
private LocalDateTime expireTime;
|
||||
|
||||
/**
|
||||
* JWT访问令牌(确认后生成)
|
||||
*/
|
||||
private String accessToken;
|
||||
|
||||
}
|
||||
29
java/auto/dto/QrLoginGenerateResponse.java
Normal file
29
java/auto/dto/QrLoginGenerateResponse.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.auto.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 扫码登录生成响应
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-08-31
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "扫码登录生成响应")
|
||||
public class QrLoginGenerateResponse {
|
||||
|
||||
@Schema(description = "扫码登录token")
|
||||
private String token;
|
||||
|
||||
@Schema(description = "二维码内容")
|
||||
private String qrCode;
|
||||
|
||||
@Schema(description = "过期时间(秒)")
|
||||
private Long expiresIn;
|
||||
|
||||
}
|
||||
32
java/auto/dto/QrLoginStatusResponse.java
Normal file
32
java/auto/dto/QrLoginStatusResponse.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.gxwebsoft.auto.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 扫码登录状态响应
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-08-31
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "扫码登录状态响应")
|
||||
public class QrLoginStatusResponse {
|
||||
|
||||
@Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "JWT访问令牌(仅在confirmed状态时返回)")
|
||||
private String accessToken;
|
||||
|
||||
@Schema(description = "用户信息(仅在confirmed状态时返回)")
|
||||
private Object userInfo;
|
||||
|
||||
@Schema(description = "剩余过期时间(秒)")
|
||||
private Long expiresIn;
|
||||
|
||||
}
|
||||
46
java/auto/service/QrLoginService.java
Normal file
46
java/auto/service/QrLoginService.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.gxwebsoft.auto.service;
|
||||
|
||||
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
|
||||
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
|
||||
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
|
||||
|
||||
/**
|
||||
* 扫码登录服务接口
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-08-31
|
||||
*/
|
||||
public interface QrLoginService {
|
||||
|
||||
/**
|
||||
* 生成扫码登录token
|
||||
*
|
||||
* @return QrLoginGenerateResponse
|
||||
*/
|
||||
QrLoginGenerateResponse generateQrLoginToken();
|
||||
|
||||
/**
|
||||
* 检查扫码登录状态
|
||||
*
|
||||
* @param token 扫码登录token
|
||||
* @return QrLoginStatusResponse
|
||||
*/
|
||||
QrLoginStatusResponse checkQrLoginStatus(String token);
|
||||
|
||||
/**
|
||||
* 确认扫码登录
|
||||
*
|
||||
* @param request 确认请求
|
||||
* @return QrLoginStatusResponse
|
||||
*/
|
||||
QrLoginStatusResponse confirmQrLogin(QrLoginConfirmRequest request);
|
||||
|
||||
/**
|
||||
* 扫码操作(更新状态为已扫码)
|
||||
*
|
||||
* @param token 扫码登录token
|
||||
* @return boolean
|
||||
*/
|
||||
boolean scanQrCode(String token);
|
||||
|
||||
}
|
||||
239
java/auto/service/impl/QrLoginServiceImpl.java
Normal file
239
java/auto/service/impl/QrLoginServiceImpl.java
Normal file
@@ -0,0 +1,239 @@
|
||||
package com.gxwebsoft.auto.service.impl;
|
||||
|
||||
import cn.hutool.core.lang.UUID;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.auto.dto.*;
|
||||
import com.gxwebsoft.auto.service.QrLoginService;
|
||||
import com.gxwebsoft.common.core.security.JwtSubject;
|
||||
import com.gxwebsoft.common.core.security.JwtUtil;
|
||||
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
|
||||
|
||||
/**
|
||||
* 扫码登录服务实现
|
||||
*
|
||||
* @author 科技小王子
|
||||
* @since 2025-08-31
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class QrLoginServiceImpl implements QrLoginService {
|
||||
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Value("${config.jwt.secret:websoft-jwt-secret-key-2025}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${config.jwt.expire:86400}")
|
||||
private Long jwtExpire;
|
||||
|
||||
@Override
|
||||
public QrLoginGenerateResponse generateQrLoginToken() {
|
||||
// 生成唯一的扫码登录token
|
||||
String token = UUID.randomUUID().toString(true);
|
||||
|
||||
// 创建扫码登录数据
|
||||
QrLoginData qrLoginData = new QrLoginData();
|
||||
qrLoginData.setToken(token);
|
||||
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
|
||||
qrLoginData.setCreateTime(LocalDateTime.now());
|
||||
qrLoginData.setExpireTime(LocalDateTime.now().plusSeconds(QR_LOGIN_TOKEN_TTL));
|
||||
|
||||
// 存储到Redis,设置过期时间
|
||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||
redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS);
|
||||
|
||||
log.info("生成扫码登录token: {}", token);
|
||||
|
||||
// 构造二维码内容(这里可以是前端登录页面的URL + token参数)
|
||||
String qrCodeContent = "qr-login:" + token;
|
||||
|
||||
return new QrLoginGenerateResponse(token, qrCodeContent, QR_LOGIN_TOKEN_TTL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrLoginStatusResponse checkQrLoginStatus(String token) {
|
||||
if (StrUtil.isBlank(token)) {
|
||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
|
||||
}
|
||||
|
||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||
|
||||
if (qrLoginData == null) {
|
||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (LocalDateTime.now().isAfter(qrLoginData.getExpireTime())) {
|
||||
// 删除过期的token
|
||||
redisUtil.delete(redisKey);
|
||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
|
||||
}
|
||||
|
||||
// 计算剩余过期时间
|
||||
long expiresIn = ChronoUnit.SECONDS.between(LocalDateTime.now(), qrLoginData.getExpireTime());
|
||||
|
||||
QrLoginStatusResponse response = new QrLoginStatusResponse();
|
||||
response.setStatus(qrLoginData.getStatus());
|
||||
response.setExpiresIn(expiresIn);
|
||||
|
||||
// 如果已确认,返回token和用户信息
|
||||
if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())) {
|
||||
response.setAccessToken(qrLoginData.getAccessToken());
|
||||
|
||||
// 获取用户信息
|
||||
if (qrLoginData.getUserId() != null) {
|
||||
User user = userService.getByIdRel(qrLoginData.getUserId());
|
||||
if (user != null) {
|
||||
// 清除敏感信息
|
||||
user.setPassword(null);
|
||||
response.setUserInfo(user);
|
||||
}
|
||||
}
|
||||
|
||||
// 确认后删除token,防止重复使用
|
||||
redisUtil.delete(redisKey);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrLoginStatusResponse confirmQrLogin(QrLoginConfirmRequest request) {
|
||||
String token = request.getToken();
|
||||
Integer userId = request.getUserId();
|
||||
String platform = request.getPlatform();
|
||||
|
||||
if (StrUtil.isBlank(token) || userId == null) {
|
||||
throw new RuntimeException("参数不能为空");
|
||||
}
|
||||
|
||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||
|
||||
if (qrLoginData == null) {
|
||||
throw new RuntimeException("扫码登录token不存在或已过期");
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (LocalDateTime.now().isAfter(qrLoginData.getExpireTime())) {
|
||||
redisUtil.delete(redisKey);
|
||||
throw new RuntimeException("扫码登录token已过期");
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
User user = userService.getByIdRel(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (user.getStatus() != null && user.getStatus() != 0) {
|
||||
throw new RuntimeException("用户已被冻结");
|
||||
}
|
||||
|
||||
// 如果是微信小程序登录,处理微信相关信息
|
||||
if ("miniprogram".equals(platform) && request.getWechatInfo() != null) {
|
||||
handleWechatMiniProgramLogin(user, request.getWechatInfo());
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
|
||||
String accessToken = JwtUtil.buildToken(jwtSubject, jwtExpire, jwtSecret);
|
||||
|
||||
// 更新扫码登录数据
|
||||
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
|
||||
qrLoginData.setUserId(userId);
|
||||
qrLoginData.setUsername(user.getUsername());
|
||||
qrLoginData.setAccessToken(accessToken);
|
||||
|
||||
// 更新Redis中的数据
|
||||
redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); // 给前端60秒时间获取token
|
||||
|
||||
log.info("用户 {} 通过 {} 平台确认扫码登录,token: {}", user.getUsername(),
|
||||
platform != null ? platform : "unknown", token);
|
||||
|
||||
// 清除敏感信息
|
||||
user.setPassword(null);
|
||||
|
||||
return new QrLoginStatusResponse(QR_LOGIN_STATUS_CONFIRMED, accessToken, user, 60L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微信小程序登录相关逻辑
|
||||
*/
|
||||
private void handleWechatMiniProgramLogin(User user, QrLoginConfirmRequest.WechatMiniProgramInfo wechatInfo) {
|
||||
// 更新用户的微信信息
|
||||
if (StrUtil.isNotBlank(wechatInfo.getOpenid())) {
|
||||
user.setOpenid(wechatInfo.getOpenid());
|
||||
}
|
||||
if (StrUtil.isNotBlank(wechatInfo.getUnionid())) {
|
||||
user.setUnionid(wechatInfo.getUnionid());
|
||||
}
|
||||
if (StrUtil.isNotBlank(wechatInfo.getNickname()) && StrUtil.isBlank(user.getNickname())) {
|
||||
user.setNickname(wechatInfo.getNickname());
|
||||
}
|
||||
if (StrUtil.isNotBlank(wechatInfo.getAvatar()) && StrUtil.isBlank(user.getAvatar())) {
|
||||
user.setAvatar(wechatInfo.getAvatar());
|
||||
}
|
||||
|
||||
// 更新用户信息到数据库
|
||||
try {
|
||||
userService.updateById(user);
|
||||
log.info("更新用户 {} 的微信小程序信息成功", user.getUsername());
|
||||
} catch (Exception e) {
|
||||
log.warn("更新用户 {} 的微信小程序信息失败: {}", user.getUsername(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean scanQrCode(String token) {
|
||||
if (StrUtil.isBlank(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String redisKey = QR_LOGIN_TOKEN_KEY + token;
|
||||
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
|
||||
|
||||
if (qrLoginData == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (LocalDateTime.now().isAfter(qrLoginData.getExpireTime())) {
|
||||
redisUtil.delete(redisKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有pending状态才能更新为scanned
|
||||
if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) {
|
||||
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
|
||||
|
||||
// 计算剩余过期时间
|
||||
long remainingSeconds = ChronoUnit.SECONDS.between(LocalDateTime.now(), qrLoginData.getExpireTime());
|
||||
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
|
||||
|
||||
log.info("扫码登录token {} 状态更新为已扫码", token);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
197
src/api/qrLogin/index.ts
Normal file
197
src/api/qrLogin/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult } from '@/api';
|
||||
import type {
|
||||
QrLoginGenerateResponse,
|
||||
QrLoginStatusResponse,
|
||||
QrLoginConfirmRequest,
|
||||
ScanResultParsed,
|
||||
ScanResultType
|
||||
} from './model';
|
||||
|
||||
/**
|
||||
* 生成扫码登录token
|
||||
*/
|
||||
export async function generateQrLoginToken() {
|
||||
const res = await request.post<ApiResult<QrLoginGenerateResponse>>(
|
||||
'/api/qr-login/generate'
|
||||
);
|
||||
if (res.code === 0 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查扫码登录状态
|
||||
*/
|
||||
export async function checkQrLoginStatus(token: string) {
|
||||
const res = await request.get<ApiResult<QrLoginStatusResponse>>(
|
||||
`/api/qr-login/status/${token}`
|
||||
);
|
||||
if (res.code === 0 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认扫码登录
|
||||
*/
|
||||
export async function confirmQrLogin(data: QrLoginConfirmRequest) {
|
||||
const res = await request.post<ApiResult<QrLoginStatusResponse>>(
|
||||
'/api/qr-login/confirm',
|
||||
data
|
||||
);
|
||||
if (res.code === 0 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序扫码登录确认
|
||||
*/
|
||||
export async function wechatMiniProgramConfirm(data: QrLoginConfirmRequest) {
|
||||
const res = await request.post<ApiResult<QrLoginStatusResponse>>(
|
||||
'/api/qr-login/wechat-confirm',
|
||||
data
|
||||
);
|
||||
if (res.code === 0 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码操作(更新状态为已扫码)
|
||||
*/
|
||||
export async function scanQrCode(token: string) {
|
||||
const res = await request.post<ApiResult<boolean>>(
|
||||
`/api/qr-login/scan/${token}`
|
||||
);
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为有效的JSON
|
||||
*/
|
||||
export function isValidJSON(str: string): boolean {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析扫码结果,识别二维码类型
|
||||
*/
|
||||
export function parseScanResult(scanResult: string): ScanResultParsed {
|
||||
const rawContent = scanResult.trim();
|
||||
|
||||
try {
|
||||
// 1. 尝试解析JSON格式(礼品卡核销)
|
||||
if (isValidJSON(rawContent)) {
|
||||
const json = JSON.parse(rawContent);
|
||||
if (json.businessType === 'gift') {
|
||||
return {
|
||||
type: 'gift-verification',
|
||||
rawContent,
|
||||
data: json,
|
||||
requireAuth: true,
|
||||
requireAdmin: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否为二维码登录token格式
|
||||
// 假设二维码登录的格式为: qr-login:token 或者纯token(32位以上字符串)
|
||||
if (rawContent.startsWith('qr-login:')) {
|
||||
const token = rawContent.replace('qr-login:', '');
|
||||
return {
|
||||
type: 'qr-login',
|
||||
rawContent,
|
||||
data: { token },
|
||||
requireAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否为纯token格式(32位以上的字母数字组合)
|
||||
if (/^[a-zA-Z0-9-]{32,}$/.test(rawContent)) {
|
||||
return {
|
||||
type: 'qr-login',
|
||||
rawContent,
|
||||
data: { token: rawContent },
|
||||
requireAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 检查是否为礼品卡兑换码(6位字母数字组合)
|
||||
if (/^[A-Z0-9]{6}$/.test(rawContent)) {
|
||||
return {
|
||||
type: 'gift-redeem',
|
||||
rawContent,
|
||||
data: { code: rawContent },
|
||||
requireAuth: false
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 检查是否为车辆查询码
|
||||
if (rawContent.startsWith('vehicle-') || rawContent.startsWith('car-')) {
|
||||
return {
|
||||
type: 'vehicle-query' as ScanResultType,
|
||||
rawContent,
|
||||
data: { vehicleId: rawContent },
|
||||
requireAuth: false
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 检查URL格式的二维码
|
||||
if (rawContent.startsWith('http://') || rawContent.startsWith('https://')) {
|
||||
const url = new URL(rawContent);
|
||||
|
||||
// 检查是否包含二维码登录相关参数
|
||||
if (url.searchParams.has('qr-login-token') || url.pathname.includes('/qr-login/')) {
|
||||
const token = url.searchParams.get('qr-login-token') || url.pathname.split('/').pop();
|
||||
return {
|
||||
type: 'qr-login',
|
||||
rawContent,
|
||||
data: { token },
|
||||
requireAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否为礼品卡相关URL
|
||||
if (url.pathname.includes('/gift/') || url.searchParams.has('gift-code')) {
|
||||
const code = url.searchParams.get('gift-code') || url.pathname.split('/').pop();
|
||||
return {
|
||||
type: 'gift-redeem',
|
||||
rawContent,
|
||||
data: { code },
|
||||
requireAuth: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 默认返回未知类型
|
||||
return {
|
||||
type: 'unknown',
|
||||
rawContent,
|
||||
data: { content: rawContent },
|
||||
requireAuth: false
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('解析扫码结果失败:', error);
|
||||
return {
|
||||
type: 'unknown',
|
||||
rawContent,
|
||||
data: { content: rawContent, error: error.message },
|
||||
requireAuth: false
|
||||
};
|
||||
}
|
||||
}
|
||||
85
src/api/qrLogin/model/index.ts
Normal file
85
src/api/qrLogin/model/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 二维码登录相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 二维码登录生成响应
|
||||
*/
|
||||
export interface QrLoginGenerateResponse {
|
||||
/** 扫码登录token */
|
||||
token: string;
|
||||
/** 二维码内容 */
|
||||
qrCode: string;
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码登录状态响应
|
||||
*/
|
||||
export interface QrLoginStatusResponse {
|
||||
/** 登录状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期, cancelled-已取消 */
|
||||
status: 'pending' | 'scanned' | 'confirmed' | 'expired' | 'cancelled';
|
||||
/** 状态描述 */
|
||||
message?: string;
|
||||
/** 登录成功时返回的用户信息 */
|
||||
user?: any;
|
||||
/** 登录成功时返回的访问令牌 */
|
||||
accessToken?: string;
|
||||
/** 剩余过期时间(秒) */
|
||||
remainingTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码登录确认请求
|
||||
*/
|
||||
export interface QrLoginConfirmRequest {
|
||||
/** 扫码登录token */
|
||||
token: string;
|
||||
/** 用户ID */
|
||||
userId?: number;
|
||||
/** 登录平台: web-网页端, app-移动应用, miniprogram-微信小程序 */
|
||||
platform?: string;
|
||||
/** 微信小程序相关信息 */
|
||||
wechatInfo?: WechatMiniProgramInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序信息
|
||||
*/
|
||||
export interface WechatMiniProgramInfo {
|
||||
/** 微信openid */
|
||||
openid?: string;
|
||||
/** 微信unionid */
|
||||
unionid?: string;
|
||||
/** 微信昵称 */
|
||||
nickname?: string;
|
||||
/** 微信头像 */
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码结果类型
|
||||
*/
|
||||
export type ScanResultType =
|
||||
| 'qr-login' // 二维码登录
|
||||
| 'gift-verification' // 礼品卡核销
|
||||
| 'gift-redeem' // 礼品卡兑换
|
||||
| 'vehicle-query' // 车辆查询
|
||||
| 'unknown'; // 未知类型
|
||||
|
||||
/**
|
||||
* 扫码结果解析
|
||||
*/
|
||||
export interface ScanResultParsed {
|
||||
/** 扫码结果类型 */
|
||||
type: ScanResultType;
|
||||
/** 原始扫码内容 */
|
||||
rawContent: string;
|
||||
/** 解析后的数据 */
|
||||
data: any;
|
||||
/** 是否需要权限验证 */
|
||||
requireAuth?: boolean;
|
||||
/** 是否需要管理员权限 */
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
@@ -8,8 +8,18 @@ export interface ShopDealerReferee {
|
||||
id?: number;
|
||||
// 分销商用户ID
|
||||
dealerId?: number;
|
||||
// 分销商名称
|
||||
dealerName?: string;
|
||||
// 分销商手机号
|
||||
dealerPhone?: string;
|
||||
// 用户id(被推荐人)
|
||||
userId?: number;
|
||||
// 用户头像
|
||||
avatar?: string;
|
||||
// 用户昵称
|
||||
nickname?: string;
|
||||
// 用户手机号
|
||||
phone?: string;
|
||||
// 推荐关系层级(1,2,3)
|
||||
level?: number;
|
||||
// 商城ID
|
||||
|
||||
@@ -47,6 +47,12 @@ export default defineAppConfig({
|
||||
"theme/index"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages/test",
|
||||
"pages": [
|
||||
"scan"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "dealer",
|
||||
"pages": [
|
||||
|
||||
303
src/components/UniversalScanner.tsx
Normal file
303
src/components/UniversalScanner.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { parseScanResult, wechatMiniProgramConfirm, scanQrCode } from '@/api/qrLogin';
|
||||
import type { ScanResultParsed } from '@/api/qrLogin/model';
|
||||
import navTo from '@/utils/common';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
|
||||
/**
|
||||
* 统一扫码处理组件
|
||||
*/
|
||||
export interface UniversalScannerProps {
|
||||
/** 扫码成功回调 */
|
||||
onScanSuccess?: (result: ScanResultParsed) => void;
|
||||
/** 扫码失败回调 */
|
||||
onScanError?: (error: string) => void;
|
||||
/** 是否显示处理结果提示 */
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一扫码处理Hook
|
||||
*/
|
||||
export function useUniversalScanner(props: UniversalScannerProps = {}) {
|
||||
const {
|
||||
onScanSuccess,
|
||||
onScanError,
|
||||
showToast = true
|
||||
} = props;
|
||||
|
||||
const { user, isLoggedIn, isAdmin, loginUser } = useUser();
|
||||
|
||||
/**
|
||||
* 启动扫码
|
||||
*/
|
||||
const startScan = () => {
|
||||
Taro.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => {
|
||||
handleScanResult(res.result);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('扫码失败:', err);
|
||||
const errorMsg = '扫码失败,请重试';
|
||||
if (showToast) {
|
||||
Taro.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
onScanError?.(errorMsg);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理扫码结果
|
||||
*/
|
||||
const handleScanResult = async (scanResult: string) => {
|
||||
try {
|
||||
console.log('扫码结果:', scanResult);
|
||||
|
||||
// 解析扫码结果
|
||||
const parsed = parseScanResult(scanResult);
|
||||
console.log('解析结果:', parsed);
|
||||
|
||||
// 权限检查
|
||||
if (parsed.requireAuth && !isLoggedIn) {
|
||||
if (showToast) {
|
||||
Taro.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
onScanError?.('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.requireAdmin && !isAdmin()) {
|
||||
if (showToast) {
|
||||
Taro.showToast({
|
||||
title: '仅管理员可使用此功能',
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
onScanError?.('权限不足');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据类型处理
|
||||
await handleByType(parsed);
|
||||
|
||||
// 回调
|
||||
onScanSuccess?.(parsed);
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理扫码结果失败:', error);
|
||||
const errorMsg = error.message || '处理扫码结果失败';
|
||||
if (showToast) {
|
||||
Taro.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
onScanError?.(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据类型处理扫码结果
|
||||
*/
|
||||
const handleByType = async (parsed: ScanResultParsed) => {
|
||||
switch (parsed.type) {
|
||||
case 'qr-login':
|
||||
await handleQrLogin(parsed);
|
||||
break;
|
||||
|
||||
case 'gift-verification':
|
||||
handleGiftVerification(parsed);
|
||||
break;
|
||||
|
||||
case 'gift-redeem':
|
||||
handleGiftRedeem(parsed);
|
||||
break;
|
||||
|
||||
case 'vehicle-query':
|
||||
handleVehicleQuery(parsed);
|
||||
break;
|
||||
|
||||
case 'unknown':
|
||||
handleUnknownType(parsed);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`未支持的扫码类型: ${parsed.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理二维码登录
|
||||
*/
|
||||
const handleQrLogin = async (parsed: ScanResultParsed) => {
|
||||
const { token } = parsed.data;
|
||||
|
||||
try {
|
||||
if (showToast) {
|
||||
Taro.showLoading({ title: '正在处理登录...' });
|
||||
}
|
||||
|
||||
// 1. 先调用扫码接口,更新状态为已扫码
|
||||
await scanQrCode(token);
|
||||
|
||||
// 2. 确认登录
|
||||
const confirmData = {
|
||||
token,
|
||||
userId: user?.userId,
|
||||
platform: 'miniprogram',
|
||||
wechatInfo: {
|
||||
openid: user?.openid,
|
||||
unionid: user?.unionid,
|
||||
nickname: user?.nickname || user?.realName,
|
||||
avatar: user?.avatar
|
||||
}
|
||||
};
|
||||
|
||||
const result = await wechatMiniProgramConfirm(confirmData);
|
||||
|
||||
if (result.status === 'confirmed') {
|
||||
if (showToast) {
|
||||
Taro.hideLoading();
|
||||
Taro.showToast({
|
||||
title: '后台管理登录确认成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
|
||||
// 显示成功提示弹窗
|
||||
Taro.showModal({
|
||||
title: '登录成功',
|
||||
content: '您已成功确认后台管理系统登录,请在电脑端查看登录状态。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
});
|
||||
|
||||
} else {
|
||||
throw new Error(result.message || '登录确认失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (showToast) {
|
||||
Taro.hideLoading();
|
||||
}
|
||||
|
||||
// 根据错误类型显示不同的提示
|
||||
let errorMessage = '登录确认失败';
|
||||
if (error.message?.includes('过期')) {
|
||||
errorMessage = '二维码已过期,请重新生成';
|
||||
} else if (error.message?.includes('无效')) {
|
||||
errorMessage = '无效的登录二维码';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
Taro.showToast({
|
||||
title: errorMessage,
|
||||
icon: 'error',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理礼品卡核销
|
||||
*/
|
||||
const handleGiftVerification = (parsed: ScanResultParsed) => {
|
||||
// 跳转到核销页面,并传递扫码数据
|
||||
const encryptedData = encodeURIComponent(JSON.stringify(parsed.data));
|
||||
navTo(`/user/store/verification?scanData=${encryptedData}`, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理礼品卡兑换
|
||||
*/
|
||||
const handleGiftRedeem = (parsed: ScanResultParsed) => {
|
||||
const { code } = parsed.data;
|
||||
navTo(`/user/gift/redeem?code=${encodeURIComponent(code)}`, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理车辆查询
|
||||
*/
|
||||
const handleVehicleQuery = (parsed: ScanResultParsed) => {
|
||||
const { vehicleId } = parsed.data;
|
||||
navTo(`/hjm/query?id=${vehicleId}`, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理未知类型
|
||||
*/
|
||||
const handleUnknownType = (parsed: ScanResultParsed) => {
|
||||
// 显示选择弹窗,让用户选择如何处理
|
||||
Taro.showActionSheet({
|
||||
itemList: [
|
||||
'复制内容',
|
||||
'作为礼品卡兑换码',
|
||||
'作为车辆查询码',
|
||||
'取消'
|
||||
],
|
||||
success: (res) => {
|
||||
const { tapIndex } = res;
|
||||
switch (tapIndex) {
|
||||
case 0:
|
||||
// 复制内容
|
||||
Taro.setClipboardData({
|
||||
data: parsed.rawContent,
|
||||
success: () => {
|
||||
if (showToast) {
|
||||
Taro.showToast({
|
||||
title: '已复制到剪贴板',
|
||||
icon: 'success'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 1:
|
||||
// 作为礼品卡兑换码
|
||||
navTo(`/user/gift/redeem?code=${encodeURIComponent(parsed.rawContent)}`, true);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// 作为车辆查询码
|
||||
navTo(`/hjm/query?id=${parsed.rawContent}`, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
startScan,
|
||||
handleScanResult
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一扫码处理组件(如果需要作为组件使用)
|
||||
*/
|
||||
const UniversalScanner: React.FC<UniversalScannerProps> = (props) => {
|
||||
const { startScan } = useUniversalScanner(props);
|
||||
|
||||
// 这个组件主要提供Hook,不渲染UI
|
||||
// 如果需要可以返回一个扫码按钮
|
||||
return null;
|
||||
};
|
||||
|
||||
export default UniversalScanner;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {useState, useEffect, useCallback} from 'react'
|
||||
import {View, Text} from '@tarojs/components'
|
||||
import {Space,Empty, Avatar} from '@nutui/nutui-react-taro'
|
||||
import {User} from '@nutui/icons-react-taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
import {useDealerUser} from '@/hooks/useDealerUser'
|
||||
import {listShopDealerReferee} from '@/api/shop/shopDealerReferee'
|
||||
@@ -13,11 +12,16 @@ import navTo from "@/utils/common";
|
||||
interface TeamMemberWithStats extends ShopDealerReferee {
|
||||
name?: string
|
||||
avatar?: string
|
||||
nickname?: string;
|
||||
phone?: string;
|
||||
orderCount?: number
|
||||
commission?: string
|
||||
status?: 'active' | 'inactive'
|
||||
subMembers?: number
|
||||
joinTime?: string
|
||||
dealerAvatar?: string;
|
||||
dealerName?: string;
|
||||
dealerPhone?: string;
|
||||
}
|
||||
|
||||
const DealerTeam: React.FC = () => {
|
||||
@@ -40,8 +44,7 @@ const DealerTeam: React.FC = () => {
|
||||
// 处理团队成员数据
|
||||
const processedMembers: TeamMemberWithStats[] = refereeResult.map(member => ({
|
||||
...member,
|
||||
name: `用户${member.userId}`,
|
||||
avatar: '',
|
||||
name: `${member.userId}`,
|
||||
orderCount: 0,
|
||||
commission: '0.00',
|
||||
status: 'active' as const,
|
||||
@@ -121,13 +124,12 @@ const DealerTeam: React.FC = () => {
|
||||
<Avatar
|
||||
size="40"
|
||||
src={member.avatar}
|
||||
icon={<User/>}
|
||||
className="mr-3"
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<View className="flex items-center mb-1">
|
||||
<Text className="font-semibold text-gray-800 mr-2">
|
||||
{member.name}
|
||||
{member.nickname}
|
||||
</Text>
|
||||
{/*{getLevelIcon(Number(member.level))}*/}
|
||||
{/*<Text className="text-xs text-gray-500 ml-1">*/}
|
||||
|
||||
110
src/pages/test/scan.tsx
Normal file
110
src/pages/test/scan.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Button } from '@nutui/nutui-react-taro';
|
||||
import { Scan } from '@nutui/icons-react-taro';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useUniversalScanner } from '@/components/UniversalScanner';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
|
||||
const ScanTest: React.FC = () => {
|
||||
const { user, isLoggedIn } = useUser();
|
||||
|
||||
const { startScan } = useUniversalScanner({
|
||||
onScanSuccess: (result) => {
|
||||
console.log('测试页面 - 扫码成功:', result);
|
||||
Taro.showModal({
|
||||
title: '扫码成功',
|
||||
content: `类型: ${result.type}\n内容: ${result.rawContent}`,
|
||||
showCancel: false
|
||||
});
|
||||
},
|
||||
onScanError: (error) => {
|
||||
console.error('测试页面 - 扫码失败:', error);
|
||||
Taro.showModal({
|
||||
title: '扫码失败',
|
||||
content: error,
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleDirectScan = () => {
|
||||
console.log('直接调用 Taro.scanCode');
|
||||
Taro.scanCode({
|
||||
success: (res) => {
|
||||
console.log('直接扫码成功:', res.result);
|
||||
Taro.showModal({
|
||||
title: '直接扫码成功',
|
||||
content: res.result,
|
||||
showCancel: false
|
||||
});
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('直接扫码失败:', err);
|
||||
Taro.showModal({
|
||||
title: '直接扫码失败',
|
||||
content: JSON.stringify(err),
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="p-4">
|
||||
<Text className="text-lg font-bold mb-4">扫码功能测试</Text>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text>登录状态: {isLoggedIn ? '已登录' : '未登录'}</Text>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text>用户信息: {user ? JSON.stringify(user, null, 2) : '无'}</Text>
|
||||
</View>
|
||||
|
||||
<View className="space-y-4">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<Scan />}
|
||||
onClick={() => {
|
||||
console.log('点击了统一扫码按钮');
|
||||
startScan();
|
||||
}}
|
||||
>
|
||||
统一扫码测试
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
block
|
||||
icon={<Scan />}
|
||||
onClick={handleDirectScan}
|
||||
>
|
||||
直接扫码测试
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
onClick={() => {
|
||||
console.log('测试日志输出');
|
||||
console.log('startScan 函数:', startScan);
|
||||
console.log('startScan 类型:', typeof startScan);
|
||||
Taro.showToast({
|
||||
title: '查看控制台日志',
|
||||
icon: 'none'
|
||||
});
|
||||
}}
|
||||
>
|
||||
测试日志输出
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScanTest;
|
||||
@@ -9,6 +9,7 @@ import navTo from "@/utils/common";
|
||||
import {TenantId} from "@/config/app";
|
||||
import {useUser} from "@/hooks/useUser";
|
||||
import {useUserData} from "@/hooks/useUserData";
|
||||
import {useUniversalScanner} from "@/components/UniversalScanner";
|
||||
|
||||
function UserCard() {
|
||||
const {
|
||||
@@ -20,7 +21,17 @@ function UserCard() {
|
||||
getDisplayName,
|
||||
getRoleName
|
||||
} = useUser();
|
||||
const {data} = useUserData()
|
||||
const {data} = useUserData();
|
||||
|
||||
// 统一扫码处理
|
||||
const { startScan } = useUniversalScanner({
|
||||
onScanSuccess: (result) => {
|
||||
console.log('扫码成功:', result);
|
||||
},
|
||||
onScanError: (error) => {
|
||||
console.error('扫码失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Taro.getSetting:获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限。
|
||||
@@ -190,7 +201,48 @@ function UserCard() {
|
||||
) : ''}
|
||||
</View>
|
||||
</View>
|
||||
{isAdmin() && <Scan onClick={() => navTo('/user/store/verification', true)}/>}
|
||||
{isLoggedIn && (
|
||||
<View
|
||||
onClick={() => {
|
||||
console.log('扫码按钮被点击了');
|
||||
|
||||
// 检查 Taro.scanCode 是否存在
|
||||
if (typeof Taro.scanCode === 'function') {
|
||||
console.log('Taro.scanCode 函数存在');
|
||||
|
||||
// 直接测试 Taro.scanCode
|
||||
Taro.scanCode({
|
||||
success: (res) => {
|
||||
console.log('直接扫码成功:', res.result);
|
||||
Taro.showModal({
|
||||
title: '扫码成功',
|
||||
content: res.result,
|
||||
showCancel: false
|
||||
});
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('直接扫码失败:', err);
|
||||
Taro.showModal({
|
||||
title: '扫码失败',
|
||||
content: `错误信息: ${JSON.stringify(err)}`,
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Taro.scanCode 函数不存在');
|
||||
Taro.showModal({
|
||||
title: '错误',
|
||||
content: 'Taro.scanCode 函数不存在,请检查 Taro 版本',
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-blue-100 rounded cursor-pointer"
|
||||
>
|
||||
<Scan className="text-blue-500" />
|
||||
</View>
|
||||
)}
|
||||
<View className={'mr-4 text-sm px-3 py-1 text-black border-gray-400 border-solid border-2 rounded-3xl'}
|
||||
onClick={() => navTo('/user/profile/profile', true)}>
|
||||
{'个人资料'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useState} from 'react'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {View, Text, Image} from '@tarojs/components'
|
||||
import {Button, Input} from '@nutui/nutui-react-taro'
|
||||
import {Scan, Search} from '@nutui/icons-react-taro'
|
||||
@@ -8,6 +8,7 @@ import {getShopGiftByCode, updateShopGift, decryptQrData} from "@/api/shop/shopG
|
||||
import {useUser} from "@/hooks/useUser";
|
||||
import type {ShopGift} from "@/api/shop/shopGift/model";
|
||||
import {isValidJSON} from "@/utils/jsonUtils";
|
||||
import {useUniversalScanner} from "@/components/UniversalScanner";
|
||||
|
||||
const StoreVerification: React.FC = () => {
|
||||
const {
|
||||
@@ -18,8 +19,53 @@ const StoreVerification: React.FC = () => {
|
||||
const [giftInfo, setGiftInfo] = useState<ShopGift | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 扫码功能
|
||||
// 统一扫码处理(仅用于管理员权限检查后的扫码)
|
||||
const { startScan } = useUniversalScanner({
|
||||
onScanSuccess: (result) => {
|
||||
console.log('管理员扫码成功:', result);
|
||||
},
|
||||
onScanError: (error) => {
|
||||
console.error('管理员扫码失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 页面加载时检查是否有传递的扫码数据
|
||||
useEffect(() => {
|
||||
const handlePageLoad = async () => {
|
||||
try {
|
||||
// 获取页面参数
|
||||
const instance = Taro.getCurrentInstance();
|
||||
const params = instance.router?.params;
|
||||
|
||||
if (params?.scanData) {
|
||||
// 解析传递过来的扫码数据
|
||||
const scanData = JSON.parse(decodeURIComponent(params.scanData));
|
||||
console.log('接收到扫码数据:', scanData);
|
||||
|
||||
if (scanData.businessType === 'gift') {
|
||||
setLoading(true);
|
||||
await handleDecryptAndVerify(scanData.token, scanData.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理页面参数失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
handlePageLoad();
|
||||
}, []);
|
||||
|
||||
// 扫码功能(保留原有的直接扫码功能,用于管理员在此页面的直接操作)
|
||||
const handleScan = () => {
|
||||
// 检查管理员权限
|
||||
if (!isAdmin()) {
|
||||
Taro.showToast({
|
||||
title: '仅管理员可使用核销功能',
|
||||
icon: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Taro.scanCode({
|
||||
success: (res) => {
|
||||
if (res.result) {
|
||||
|
||||
Reference in New Issue
Block a user