diff --git a/config/env.ts b/config/env.ts index de8e281..c3f95e2 100644 --- a/config/env.ts +++ b/config/env.ts @@ -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', }, diff --git a/docs/统一扫码功能说明.md b/docs/统一扫码功能说明.md new file mode 100644 index 0000000..c4036e5 --- /dev/null +++ b/docs/统一扫码功能说明.md @@ -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 ( + + ); +} +``` + +### 扫码结果类型 + +```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. **离线支持**:部分功能支持离线处理 diff --git a/java/auto/controller/QrLoginController.java b/java/auto/controller/QrLoginController.java new file mode 100644 index 0000000..d296d82 --- /dev/null +++ b/java/auto/controller/QrLoginController.java @@ -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()); + } + } + +} diff --git a/java/auto/dto/QrLoginConfirmRequest.java b/java/auto/dto/QrLoginConfirmRequest.java new file mode 100644 index 0000000..f3b423e --- /dev/null +++ b/java/auto/dto/QrLoginConfirmRequest.java @@ -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; + } + +} diff --git a/java/auto/dto/QrLoginData.java b/java/auto/dto/QrLoginData.java new file mode 100644 index 0000000..563bf1d --- /dev/null +++ b/java/auto/dto/QrLoginData.java @@ -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; + +} diff --git a/java/auto/dto/QrLoginGenerateResponse.java b/java/auto/dto/QrLoginGenerateResponse.java new file mode 100644 index 0000000..f0b69e5 --- /dev/null +++ b/java/auto/dto/QrLoginGenerateResponse.java @@ -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; + +} diff --git a/java/auto/dto/QrLoginStatusResponse.java b/java/auto/dto/QrLoginStatusResponse.java new file mode 100644 index 0000000..1eb0d4a --- /dev/null +++ b/java/auto/dto/QrLoginStatusResponse.java @@ -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; + +} diff --git a/java/auto/service/QrLoginService.java b/java/auto/service/QrLoginService.java new file mode 100644 index 0000000..85ed28f --- /dev/null +++ b/java/auto/service/QrLoginService.java @@ -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); + +} diff --git a/java/auto/service/impl/QrLoginServiceImpl.java b/java/auto/service/impl/QrLoginServiceImpl.java new file mode 100644 index 0000000..34658e8 --- /dev/null +++ b/java/auto/service/impl/QrLoginServiceImpl.java @@ -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; + } +} diff --git a/src/api/qrLogin/index.ts b/src/api/qrLogin/index.ts new file mode 100644 index 0000000..a2807bb --- /dev/null +++ b/src/api/qrLogin/index.ts @@ -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>( + '/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>( + `/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>( + '/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>( + '/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>( + `/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 + }; + } +} diff --git a/src/api/qrLogin/model/index.ts b/src/api/qrLogin/model/index.ts new file mode 100644 index 0000000..c831119 --- /dev/null +++ b/src/api/qrLogin/model/index.ts @@ -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; +} diff --git a/src/api/shop/shopDealerReferee/model/index.ts b/src/api/shop/shopDealerReferee/model/index.ts index 61c1e49..ab12fa5 100644 --- a/src/api/shop/shopDealerReferee/model/index.ts +++ b/src/api/shop/shopDealerReferee/model/index.ts @@ -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 diff --git a/src/app.config.ts b/src/app.config.ts index bd3fe80..39b34e4 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -47,6 +47,12 @@ export default defineAppConfig({ "theme/index" ] }, + { + "root": "pages/test", + "pages": [ + "scan" + ] + }, { "root": "dealer", "pages": [ diff --git a/src/components/UniversalScanner.tsx b/src/components/UniversalScanner.tsx new file mode 100644 index 0000000..3ae4b0d --- /dev/null +++ b/src/components/UniversalScanner.tsx @@ -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 = (props) => { + const { startScan } = useUniversalScanner(props); + + // 这个组件主要提供Hook,不渲染UI + // 如果需要可以返回一个扫码按钮 + return null; +}; + +export default UniversalScanner; diff --git a/src/dealer/team/index.tsx b/src/dealer/team/index.tsx index 68130d8..a62b934 100644 --- a/src/dealer/team/index.tsx +++ b/src/dealer/team/index.tsx @@ -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 = () => { } className="mr-3" /> - {member.name} + {member.nickname} {/*{getLevelIcon(Number(member.level))}*/} {/**/} diff --git a/src/pages/test/scan.tsx b/src/pages/test/scan.tsx new file mode 100644 index 0000000..0b4e289 --- /dev/null +++ b/src/pages/test/scan.tsx @@ -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 ( + + 扫码功能测试 + + + 登录状态: {isLoggedIn ? '已登录' : '未登录'} + + + + 用户信息: {user ? JSON.stringify(user, null, 2) : '无'} + + + + + + + + + + + ); +}; + +export default ScanTest; diff --git a/src/pages/user/components/UserCard.tsx b/src/pages/user/components/UserCard.tsx index 3f74407..4d55cdd 100644 --- a/src/pages/user/components/UserCard.tsx +++ b/src/pages/user/components/UserCard.tsx @@ -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() { ) : ''} - {isAdmin() && navTo('/user/store/verification', true)}/>} + {isLoggedIn && ( + { + 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" + > + + + )} navTo('/user/profile/profile', true)}> {'个人资料'} diff --git a/src/user/store/verification.tsx b/src/user/store/verification.tsx index cb2cdb6..6ecc6a5 100644 --- a/src/user/store/verification.tsx +++ b/src/user/store/verification.tsx @@ -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(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) {