From d12a0fbf11d3a927d13b7e9a2c40b447b63d05c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Tue, 23 Sep 2025 11:11:20 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(dealer):=20=E5=B0=86=E2=80=9C?= =?UTF-8?q?=E7=A1=AE=E5=AE=9A=E7=AD=BE=E7=BA=A6=E2=80=9D=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E4=BF=AE=E6=94=B9=E4=B8=BA=E2=80=9C=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E6=8F=90=E4=BA=A4=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将客户添加页面中的按钮文本从“确定签约”更改为“立即提交”,以更准确地反映用户操作意图。feat(config): 移除测试页面的路由配置 从 app.config.ts 中移除了 /pages/test/scan 路由配置,清理不再使用的测试页面路径。 feat(index): 添加统一扫码按钮并优化用户信息检查逻辑在首页头部添加了 UnifiedQRButton 组件,支持统一扫码入口,用于登录和核销功能。 同时优化了微信用户昵称判断条件的空格格式。 feat(api): 新增扫码登录相关接口及工具方法 新增 qr-login 模块,包含生成二维码 token、检查扫码状态、确认登录等接口。同时提供了解析二维码内容、获取设备信息等辅助函数。 feat(component): 新增统一扫码按钮组件 UnifiedQRButton 创建 UnifiedQRButton 组件,封装扫码逻辑,支持页面模式跳转与直接扫码两种方式, 并根据扫码结果展示不同反馈。 feat(hook): 新增 useUnifiedQRScan Hook 支持登录与核销扫码 实现 useUnifiedQRScan 自定义 Hook,统一处理登录二维码和礼品卡核销二维码的识别与处理流程,支持权限校验、解密、状态管理等功能。 ``` --- src/api/passport/qr-login/index.ts | 246 +++++++++++++++++++++ src/app.config.ts | 6 - src/components/UnifiedQRButton.tsx | 126 +++++++++++ src/dealer/customer/add.tsx | 2 +- src/hooks/useUnifiedQRScan.ts | 331 +++++++++++++++++++++++++++++ src/pages/index/Header.tsx | 30 ++- src/pages/test/scan.tsx | 110 ---------- 7 files changed, 732 insertions(+), 119 deletions(-) create mode 100644 src/api/passport/qr-login/index.ts create mode 100644 src/components/UnifiedQRButton.tsx create mode 100644 src/hooks/useUnifiedQRScan.ts delete mode 100644 src/pages/test/scan.tsx diff --git a/src/api/passport/qr-login/index.ts b/src/api/passport/qr-login/index.ts new file mode 100644 index 0000000..f76c47f --- /dev/null +++ b/src/api/passport/qr-login/index.ts @@ -0,0 +1,246 @@ +import request from '@/utils/request'; +import type { ApiResult } from '@/api'; +import Taro from '@tarojs/taro'; +import {SERVER_API_URL} from "@/utils/server"; +import {getUserInfo} from "@/api/layout"; + +/** + * 扫码登录相关接口 + */ + +// 生成扫码token请求参数 +export interface GenerateQRTokenParam { + // 客户端类型:web, app, wechat + clientType?: string; + // 设备信息 + deviceInfo?: string; + // 过期时间(分钟) + expireMinutes?: number; +} + +// 生成扫码token响应 +export interface GenerateQRTokenResult { + // 扫码token + token: string; + // 二维码内容(通常是包含token的URL或JSON) + qrContent: string; + // 过期时间戳 + expireTime: number; + // 二维码图片URL(可选) + qrImageUrl?: string; +} + +// 扫码状态枚举 +export enum QRLoginStatus { + PENDING = 'pending', // 等待扫码 + SCANNED = 'scanned', // 已扫码,等待确认 + CONFIRMED = 'confirmed', // 已确认登录 + EXPIRED = 'expired', // 已过期 + CANCELLED = 'cancelled' // 已取消 +} + +// 检查扫码状态响应 +export interface QRLoginStatusResult { + // 当前状态 + status: QRLoginStatus; + // 状态描述 + message?: string; + // 如果已确认登录,返回JWT token + accessToken?: string; + // 用户信息 + userInfo?: { + userId: number; + nickname?: string; + avatar?: string; + phone?: string; + }; + // 剩余有效时间(秒) + remainingTime?: number; +} + +// 确认登录请求参数 +export interface ConfirmLoginParam { + // 扫码token + token: string; + // 用户ID + userId: number; + // 登录平台:web, app, wechat + platform?: string; + // 微信用户信息(当platform为wechat时) + wechatInfo?: { + openid?: string; + unionid?: string; + nickname?: string; + avatar?: string; + gender?: string; + }; + // 设备信息 + deviceInfo?: string; +} + +// 确认登录响应 +export interface ConfirmLoginResult { + // 是否成功 + success: boolean; + // 消息 + message: string; + // 登录用户信息 + userInfo?: { + userId: number; + nickname?: string; + avatar?: string; + phone?: string; + }; +} + +/** + * 生成扫码登录token + */ +export async function generateQRToken(data?: GenerateQRTokenParam) { + const res = await request.post>( + SERVER_API_URL + '/qr-login/generate', + { + clientType: 'wechat', + expireMinutes: 5, + ...data + } + ); + 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>( + SERVER_API_URL + `/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: ConfirmLoginParam) { + const res = await request.post>( + SERVER_API_URL + '/qr-login/confirm', + data + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 微信小程序扫码登录确认(便捷接口) + */ +export async function confirmWechatQRLogin(token: string, userId: number) { + try { + // 获取微信用户信息 + const userInfo = await getUserInfo(); + + const data: ConfirmLoginParam = { + token, + userId, + platform: 'wechat', + wechatInfo: { + nickname: userInfo?.nickname, + avatar: userInfo?.avatar, + gender: userInfo?.sex + }, + deviceInfo: await getDeviceInfo() + }; + + const res = await request.post>( + SERVER_API_URL + '/qr-login/confirm', + data + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); + } catch (error: any) { + return Promise.reject(new Error(error.message || '确认登录失败')); + } +} + +/** + * 获取设备信息 + */ +async function getDeviceInfo() { + return new Promise((resolve) => { + Taro.getSystemInfo({ + success: (res) => { + const deviceInfo = { + platform: res.platform, + system: res.system, + version: res.version, + model: res.model, + brand: res.brand, + screenWidth: res.screenWidth, + screenHeight: res.screenHeight + }; + resolve(JSON.stringify(deviceInfo)); + }, + fail: () => { + resolve('unknown'); + } + }); + }); +} + +/** + * 解析二维码内容,提取token + */ +export function parseQRContent(qrContent: string): string | null { + try { + console.log('解析二维码内容1:', qrContent); + + // 尝试解析JSON格式 + if (qrContent.startsWith('{')) { + const parsed = JSON.parse(qrContent); + return parsed.token || parsed.qrCodeKey || null; + } + + // 尝试解析URL格式 + if (qrContent.includes('http')) { + const url = new URL(qrContent); + // 支持多种参数名 + return url.searchParams.get('token') || + url.searchParams.get('qrCodeKey') || + url.searchParams.get('qr_code_key') || + null; + } + + // 尝试解析简单的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 null; + } catch (error) { + console.error('解析二维码内容失败:', error); + return null; + } +} diff --git a/src/app.config.ts b/src/app.config.ts index 3f800f5..c246924 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -51,12 +51,6 @@ export default defineAppConfig({ "chat/message/detail" ] }, - { - "root": "pages/test", - "pages": [ - "scan" - ] - }, { "root": "dealer", "pages": [ diff --git a/src/components/UnifiedQRButton.tsx b/src/components/UnifiedQRButton.tsx new file mode 100644 index 0000000..52ba516 --- /dev/null +++ b/src/components/UnifiedQRButton.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Button } from '@nutui/nutui-react-taro'; +import { View } from '@tarojs/components'; +import { Scan } from '@nutui/icons-react-taro'; +import Taro from '@tarojs/taro'; +import { useUnifiedQRScan, ScanType, type UnifiedScanResult } from '@/hooks/useUnifiedQRScan'; + +export interface UnifiedQRButtonProps { + /** 按钮类型 */ + type?: 'primary' | 'success' | 'warning' | 'danger' | 'default'; + /** 按钮大小 */ + size?: 'large' | 'normal' | 'small'; + /** 按钮文本 */ + text?: string; + /** 是否显示图标 */ + showIcon?: boolean; + /** 自定义样式类名 */ + className?: string; + /** 扫码成功回调 */ + onSuccess?: (result: UnifiedScanResult) => void; + /** 扫码失败回调 */ + onError?: (error: string) => void; + /** 是否使用页面模式(跳转到专门页面) */ + usePageMode?: boolean; +} + +/** + * 统一扫码按钮组件 + * 支持登录和核销两种类型的二维码扫描 + */ +const UnifiedQRButton: React.FC = ({ + type = 'success', + size = 'small', + text = '', + showIcon = true, + onSuccess, + onError, + usePageMode = false +}) => { + const { startScan, isLoading, canScan, state, result } = useUnifiedQRScan(); + console.log(result,'useUnifiedQRScan>>result') + // 处理点击事件 + const handleClick = async () => { + if (usePageMode) { + // 跳转到专门的统一扫码页面 + if (canScan()) { + Taro.navigateTo({ + url: '/passport/unified-qr/index' + }); + } else { + Taro.showToast({ + title: '请先登录小程序', + icon: 'error' + }); + } + return; + } + + // 直接执行扫码 + try { + const scanResult = await startScan(); + if (scanResult) { + onSuccess?.(scanResult); + + // 根据扫码类型给出不同的后续提示 + if (scanResult.type === ScanType.VERIFICATION) { + // 核销成功后可以继续扫码 + setTimeout(() => { + Taro.showModal({ + title: '核销成功', + content: '是否继续扫码核销其他礼品卡?', + success: (res) => { + if (res.confirm) { + handleClick(); // 递归调用继续扫码 + } + } + }); + }, 2000); + } + } + } catch (error: any) { + onError?.(error.message || '扫码失败'); + } + }; + + const disabled = !canScan() || isLoading; + + // 根据当前状态动态显示文本 + const getButtonText = () => { + if (isLoading) { + switch (state) { + case 'scanning': + return '扫码中...'; + case 'processing': + return '处理中...'; + default: + return '扫码中...'; + } + } + + if (disabled && !canScan()) { + return '请先登录'; + } + + return text; + }; + + return ( + + ); +}; + +export default UnifiedQRButton; diff --git a/src/dealer/customer/add.tsx b/src/dealer/customer/add.tsx index 3c3d4a9..3fc1509 100644 --- a/src/dealer/customer/add.tsx +++ b/src/dealer/customer/add.tsx @@ -385,7 +385,7 @@ const AddShopDealerApply = () => { {(!isEditMode || FormData?.applyStatus === 10) && ( } - text={'确定签约'} + text={'立即提交'} onClick={handleFixedButtonClick} /> )} diff --git a/src/hooks/useUnifiedQRScan.ts b/src/hooks/useUnifiedQRScan.ts new file mode 100644 index 0000000..5468046 --- /dev/null +++ b/src/hooks/useUnifiedQRScan.ts @@ -0,0 +1,331 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import Taro from '@tarojs/taro'; +import { + confirmWechatQRLogin, + parseQRContent +} from '@/api/passport/qr-login'; +import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; +import { useUser } from "@/hooks/useUser"; +import { isValidJSON } from "@/utils/jsonUtils"; +import dayjs from 'dayjs'; + +/** + * 统一扫码状态 + */ +export enum UnifiedScanState { + IDLE = 'idle', // 空闲状态 + SCANNING = 'scanning', // 正在扫码 + PROCESSING = 'processing', // 正在处理 + SUCCESS = 'success', // 处理成功 + ERROR = 'error' // 处理失败 +} + +/** + * 扫码类型 + */ +export enum ScanType { + LOGIN = 'login', // 登录二维码 + VERIFICATION = 'verification', // 核销二维码 + UNKNOWN = 'unknown' // 未知类型 +} + +/** + * 统一扫码结果 + */ +export interface UnifiedScanResult { + type: ScanType; + data: any; + message: string; +} + +/** + * 统一扫码Hook + * 可以处理登录和核销两种类型的二维码 + */ +export function useUnifiedQRScan() { + const { isAdmin } = useUser(); + const [state, setState] = useState(UnifiedScanState.IDLE); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [scanType, setScanType] = useState(ScanType.UNKNOWN); + + // 用于取消操作的引用 + const cancelRef = useRef(false); + + /** + * 重置状态 + */ + const reset = useCallback(() => { + setState(UnifiedScanState.IDLE); + setError(''); + setResult(null); + setIsLoading(false); + setScanType(ScanType.UNKNOWN); + cancelRef.current = false; + }, []); + + /** + * 检测二维码类型 + */ + const detectScanType = useCallback((scanResult: string): ScanType => { + try { + // 1. 检查是否为JSON格式(核销二维码) + if (isValidJSON(scanResult)) { + const json = JSON.parse(scanResult); + if (json.businessType === 'gift' && json.token && json.data) { + return ScanType.VERIFICATION; + } + } + + // 2. 检查是否为登录二维码 + const loginToken = parseQRContent(scanResult); + if (loginToken) { + return ScanType.LOGIN; + } + + // 3. 检查是否为纯文本核销码(6位数字) + if (/^\d{6}$/.test(scanResult.trim())) { + return ScanType.VERIFICATION; + } + + return ScanType.UNKNOWN; + } catch (error) { + console.error('检测二维码类型失败:', error); + return ScanType.UNKNOWN; + } + }, []); + + /** + * 处理登录二维码 + */ + const handleLoginQR = useCallback(async (scanResult: string): Promise => { + const userId = Taro.getStorageSync('UserId'); + if (!userId) { + throw new Error('请先登录小程序'); + } + + const token = parseQRContent(scanResult); + if (!token) { + throw new Error('无效的登录二维码'); + } + + const confirmResult = await confirmWechatQRLogin(token, parseInt(userId)); + if (confirmResult.status === 'confirmed') { + return { + type: ScanType.LOGIN, + data: confirmResult, + message: '登录成功' + }; + } else { + throw new Error(confirmResult.message || '登录确认失败'); + } + }, []); + + /** + * 处理核销二维码 + */ + const handleVerificationQR = useCallback(async (scanResult: string): Promise => { + if (!isAdmin()) { + throw new Error('您没有核销权限'); + } + + let code = ''; + + // 判断是否为加密的JSON格式 + if (isValidJSON(scanResult)) { + const json = JSON.parse(scanResult); + if (json.businessType === 'gift' && json.token && json.data) { + // 解密获取核销码 + const decryptedData = await decryptQrData({ + token: json.token, + encryptedData: json.data + }); + + if (decryptedData) { + code = decryptedData.toString(); + } else { + throw new Error('解密失败'); + } + } + } else { + // 直接使用扫码结果作为核销码 + code = scanResult.trim(); + } + + if (!code) { + throw new Error('无法获取有效的核销码'); + } + + // 验证核销码 + const gift = await getShopGiftByCode(code); + + if (!gift) { + throw new Error('核销码无效'); + } + + if (gift.status === 1) { + throw new Error('此礼品码已使用'); + } + + if (gift.status === 2) { + throw new Error('此礼品码已失效'); + } + + if (gift.userId === 0) { + throw new Error('此礼品码未认领'); + } + + // 执行核销 + await updateShopGift({ + ...gift, + status: 1, + operatorUserId: Number(Taro.getStorageSync('UserId')) || 0, + takeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), + verificationTime: dayjs().format('YYYY-MM-DD HH:mm:ss') + }); + + return { + type: ScanType.VERIFICATION, + data: gift, + message: '核销成功' + }; + }, [isAdmin]); + + /** + * 开始扫码 + */ + const startScan = useCallback(async (): Promise => { + try { + reset(); + setState(UnifiedScanState.SCANNING); + + // 调用扫码API + const scanResult = await new Promise((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 || '扫码失败')); + } + }); + }); + + // 检查是否被取消 + if (cancelRef.current) { + return null; + } + + // 检测二维码类型 + const type = detectScanType(scanResult); + setScanType(type); + + if (type === ScanType.UNKNOWN) { + throw new Error('不支持的二维码类型'); + } + + // 开始处理 + setState(UnifiedScanState.PROCESSING); + setIsLoading(true); + + let result: UnifiedScanResult; + + switch (type) { + case ScanType.LOGIN: + result = await handleLoginQR(scanResult); + break; + case ScanType.VERIFICATION: + result = await handleVerificationQR(scanResult); + break; + default: + throw new Error('未知的扫码类型'); + } + + if (cancelRef.current) { + return null; + } + + setState(UnifiedScanState.SUCCESS); + setResult(result); + + // 显示成功提示 + Taro.showToast({ + title: result.message, + icon: 'success', + duration: 2000 + }); + + return result; + + } catch (err: any) { + if (!cancelRef.current) { + setState(UnifiedScanState.ERROR); + const errorMessage = err.message || '处理失败'; + setError(errorMessage); + + // 显示错误提示 + Taro.showToast({ + title: errorMessage, + icon: 'error', + duration: 3000 + }); + } + return null; + } finally { + setIsLoading(false); + } + }, [reset, detectScanType, handleLoginQR, handleVerificationQR]); + + /** + * 取消扫码 + */ + const cancel = useCallback(() => { + cancelRef.current = true; + reset(); + }, [reset]); + + /** + * 检查是否可以进行扫码 + */ + const canScan = useCallback(() => { + const userId = Taro.getStorageSync('UserId'); + const accessToken = Taro.getStorageSync('access_token'); + return !!(userId && accessToken); + }, []); + + // 组件卸载时取消操作 + useEffect(() => { + return () => { + cancelRef.current = true; + }; + }, []); + + return { + // 状态 + state, + error, + result, + isLoading, + scanType, + + // 方法 + startScan, + cancel, + reset, + canScan, + + // 便捷状态判断 + isIdle: state === UnifiedScanState.IDLE, + isScanning: state === UnifiedScanState.SCANNING, + isProcessing: state === UnifiedScanState.PROCESSING, + isSuccess: state === UnifiedScanState.SUCCESS, + isError: state === UnifiedScanState.ERROR + }; +} diff --git a/src/pages/index/Header.tsx b/src/pages/index/Header.tsx index d4fcdb7..66053b0 100644 --- a/src/pages/index/Header.tsx +++ b/src/pages/index/Header.tsx @@ -14,6 +14,7 @@ import {View, Text} from '@tarojs/components' import MySearch from "./MySearch"; import './Header.scss'; import navTo from "@/utils/common"; +import UnifiedQRButton from "@/components/UnifiedQRButton"; const Header = (props: any) => { // 使用新的useShopInfo Hook @@ -194,7 +195,7 @@ const Header = (props: any) => { if (isLoggedIn && user) { console.log('用户信息已更新:', user); // 检查是否设置头像和昵称 - if(user.nickname === '微信用户'){ + if (user.nickname === '微信用户') { Taro.showToast({ title: '请设置头像和昵称', icon: 'none' @@ -242,7 +243,32 @@ const Header = (props: any) => { - )}> + )} + right={ + + {/*统一扫码入口 - 支持登录和核销*/} + { + console.log('统一扫码成功:', result); + // 根据扫码类型给出不同的提示 + if (result.type === 'verification') { + // 核销成功,可以显示更多信息或跳转到详情页 + Taro.showModal({ + title: '核销成功', + content: `已成功核销的品类:${result.data.goodsName || '礼品卡'},面值¥${result.data.faceValue}` + }); + } + }} + onError={(error) => { + console.error('统一扫码失败:', error); + }} + /> + + } + > ) diff --git a/src/pages/test/scan.tsx b/src/pages/test/scan.tsx deleted file mode 100644 index 0b4e289..0000000 --- a/src/pages/test/scan.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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;