feat(auth): 实现二维码登录功能

- 新增二维码登录相关接口和页面
- 实现二维码生成、状态检查、登录确认等逻辑
- 添加微信小程序登录支持- 优化用户信息展示和处理
This commit is contained in:
2025-09-05 22:49:41 +08:00
parent 0dfe3934a4
commit 408ff13590
18 changed files with 1558 additions and 10 deletions

197
src/api/qrLogin/index.ts Normal file
View 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 或者纯token32位以上字符串
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
};
}
}

View 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;
}

View File

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

View File

@@ -47,6 +47,12 @@ export default defineAppConfig({
"theme/index"
]
},
{
"root": "pages/test",
"pages": [
"scan"
]
},
{
"root": "dealer",
"pages": [

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

View File

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

View File

@@ -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)}>
{'个人资料'}

View File

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