feat(passport): 实现微信小程序手机号授权登录和扫码功能
- 新增微信小程序手机号一键授权登录功能 - 添加扫码登录确认页面实现二维码登录流程 - 集成统一扫码功能支持登录和核销场景 - 更新协议页面从CMS动态加载内容并添加加载状态 - 修改登录注册页面UI和导航逻辑 - 配置文件添加新的路由页面入口 - 更新API基础URL配置 - 实现邀请参数解析和推广跟踪功能 - 添加微信OpenID自动获取和存储机制 - 优化页面跳转和重定向逻辑处理
This commit is contained in:
@@ -10,11 +10,14 @@ export default defineAppConfig({
|
||||
"root": "passport",
|
||||
"pages": [
|
||||
"login",
|
||||
// "register",
|
||||
// "forget",
|
||||
// "setting",
|
||||
"register",
|
||||
"forget",
|
||||
"setting",
|
||||
"agreement",
|
||||
"sms-login"
|
||||
"sms-login",
|
||||
'qr-login/index',
|
||||
'qr-confirm/index',
|
||||
'unified-qr/index'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import {View, RichText} from '@tarojs/components'
|
||||
import { Loading } from '@nutui/nutui-react-taro'
|
||||
import { RichText, View } from '@tarojs/components'
|
||||
import { getByCode } from '@/api/cms/cmsArticle'
|
||||
import { wxParse } from '@/utils/common'
|
||||
|
||||
const Agreement = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [content, setContent] = useState<string>('')
|
||||
|
||||
const [content, setContent] = useState<any>('')
|
||||
const reload = () => {
|
||||
Taro.hideTabBar()
|
||||
setContent('<p>' +
|
||||
'<span style="font-size: 14px;">欢迎使用</span>' +
|
||||
'<span style="font-size: 14px;"> </span>' +
|
||||
'<span style="font-size: 14px;"><strong><span style="color: rgb(255, 0, 0);">【WebSoft】</span></strong></span>' +
|
||||
'<span style="font-size: 14px;">服务协议 </span>' +
|
||||
'</p>')
|
||||
const reload = async () => {
|
||||
try {
|
||||
Taro.hideTabBar()
|
||||
} catch (_) {
|
||||
// ignore (e.g. H5 / unsupported env)
|
||||
}
|
||||
|
||||
try {
|
||||
const article = await getByCode('xieyi')
|
||||
setContent(article?.content ? wxParse(article.content) : '<p>暂无协议内容</p>')
|
||||
} catch (e) {
|
||||
// Keep UI usable even if CMS/API fails.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('load agreement failed', e)
|
||||
setContent('<p>协议内容加载失败</p>')
|
||||
Taro.showToast({ title: '协议加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <Loading className={'px-2'}>加载中</Loading>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className={'content text-gray-700 text-sm p-4'}>
|
||||
<RichText nodes={content}/>
|
||||
<RichText nodes={content} />
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,83 +1,13 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import {Input, Radio, Button} from '@nutui/nutui-react-taro'
|
||||
import {loginBySms} from '@/api/passport/login'
|
||||
|
||||
const Login = () => {
|
||||
const [isAgree, setIsAgree] = useState(false)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const reload = () => {
|
||||
Taro.hideTabBar()
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!isAgree) {
|
||||
Taro.showToast({
|
||||
title: '请先同意服务协议',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!phone || phone.trim() === '') {
|
||||
Taro.showToast({
|
||||
title: '请输入手机号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.trim() === '') {
|
||||
Taro.showToast({
|
||||
title: '请输入密码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(phone)) {
|
||||
Taro.showToast({
|
||||
title: '请输入正确的手机号',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await loginBySms({
|
||||
phone: phone,
|
||||
code: password
|
||||
})
|
||||
|
||||
Taro.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
Taro.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}, 1500)
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
Taro.showToast({
|
||||
title: error.message || '登录失败,请重试',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
@@ -89,45 +19,24 @@ const Login = () => {
|
||||
|
||||
<>
|
||||
<div className={'flex flex-col justify-between items-center my-2'}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="手机号"
|
||||
maxLength={11}
|
||||
value={phone}
|
||||
onChange={(val) => setPhone(val)}
|
||||
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<Input type="text" placeholder="手机号" maxLength={11}
|
||||
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
|
||||
</div>
|
||||
<div className={'flex flex-col justify-between items-center my-2'}>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(val) => setPassword(val)}
|
||||
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<Input type="password" placeholder="密码" style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
|
||||
</div>
|
||||
<div className={'flex justify-between my-2 text-left px-1'}>
|
||||
<a href={'#'} className={'text-blue-600 text-sm'}
|
||||
onClick={() => Taro.navigateTo({url: '/passport/forget'})}>忘记密码</a>
|
||||
<a href={'#'} className={'text-blue-600 text-sm'}
|
||||
onClick={() => Taro.navigateTo({url: '/passport/register'})}>立即注册</a>
|
||||
</div>
|
||||
{/*<div className={'flex justify-between my-2 text-left px-1'}>*/}
|
||||
{/* <a href={'#'} className={'text-blue-600 text-sm'}*/}
|
||||
{/* onClick={() => Taro.navigateTo({url: '/passport/forget'})}>忘记密码</a>*/}
|
||||
{/* <a href={'#'} className={'text-blue-600 text-sm'}*/}
|
||||
{/* onClick={() => Taro.navigateTo({url: '/passport/register'})}>立即注册</a>*/}
|
||||
{/*</div>*/}
|
||||
<div className={'flex justify-center my-5'}>
|
||||
<Button
|
||||
type="info"
|
||||
size={'large'}
|
||||
className={'w-full rounded-lg p-2'}
|
||||
disabled={!isAgree}
|
||||
loading={loading}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
<Button type="info" size={'large'} className={'w-full rounded-lg p-2'} disabled={!isAgree}>登录</Button>
|
||||
</div>
|
||||
<div className={'my-2 flex fixed justify-center bottom-20 left-0 text-sm items-center text-center w-full'}>
|
||||
<Button onClick={() => Taro.navigateTo({url: '/passport/setting'})}>服务配置</Button>
|
||||
</div>
|
||||
{/*<div className={'my-2 flex fixed justify-center bottom-20 left-0 text-sm items-center text-center w-full'}>*/}
|
||||
{/* <Button onClick={() => Taro.navigateTo({url: '/passport/setting'})}>服务配置</Button>*/}
|
||||
{/*</div>*/}
|
||||
{/*<div className={'w-full fixed bottom-20 my-2 flex justify-center text-sm items-center text-center'}>*/}
|
||||
{/* 没有账号?<a href={''} onClick={() => Taro.navigateTo({url: '/passport/register'})}*/}
|
||||
{/* className={'text-blue-600'}>立即注册</a>*/}
|
||||
|
||||
5
src/passport/qr-confirm/index.config.ts
Normal file
5
src/passport/qr-confirm/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
navigationBarTitleText: '确认登录',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
}
|
||||
239
src/passport/qr-confirm/index.tsx
Normal file
239
src/passport/qr-confirm/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Button, Loading, Card } from '@nutui/nutui-react-taro';
|
||||
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { confirmQRLogin } from '@/api/passport/qr-login';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
|
||||
/**
|
||||
* 扫码登录确认页面
|
||||
* 用于处理从二维码跳转过来的登录确认
|
||||
*/
|
||||
const QRConfirmPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { user, getDisplayName } = useUser();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [token, setToken] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// 从URL参数中获取token
|
||||
const { qrCodeKey, token: urlToken } = router.params;
|
||||
const loginToken = qrCodeKey || urlToken;
|
||||
|
||||
if (loginToken) {
|
||||
setToken(loginToken);
|
||||
} else {
|
||||
setError('无效的登录链接');
|
||||
}
|
||||
}, [router.params]);
|
||||
|
||||
// 确认登录
|
||||
const handleConfirmLogin = async () => {
|
||||
if (!token) {
|
||||
setError('缺少登录token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user?.userId) {
|
||||
setError('请先登录小程序');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const result = await confirmQRLogin({
|
||||
token,
|
||||
userId: user.userId,
|
||||
platform: 'wechat',
|
||||
wechatInfo: {
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setConfirmed(true);
|
||||
Taro.showToast({
|
||||
title: '登录确认成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
|
||||
// 3秒后自动返回
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack();
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(result.message || '登录确认失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '登录确认失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消登录
|
||||
const handleCancel = () => {
|
||||
Taro.navigateBack();
|
||||
};
|
||||
|
||||
// 重试
|
||||
const handleRetry = () => {
|
||||
setError('');
|
||||
setConfirmed(false);
|
||||
handleConfirmLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="qr-confirm-page min-h-screen bg-gray-50">
|
||||
<View className="p-4">
|
||||
{/* 主要内容卡片 */}
|
||||
<Card className="bg-white rounded-lg shadow-sm">
|
||||
<View className="p-6 text-center">
|
||||
{/* 图标 */}
|
||||
<View className="mb-6">
|
||||
{loading ? (
|
||||
<View className="w-16 h-16 mx-auto flex items-center justify-center">
|
||||
<Loading className="text-blue-500" />
|
||||
</View>
|
||||
) : confirmed ? (
|
||||
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Success className="text-green-500" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
|
||||
<Failure className="text-red-500" />
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-16 h-16 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User size="32" className="text-blue-500" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text className="text-xl font-bold text-gray-800 mb-2 block">
|
||||
{loading ? '正在确认登录...' :
|
||||
confirmed ? '登录确认成功' :
|
||||
error ? '登录确认失败' : '确认登录'}
|
||||
</Text>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text className="text-gray-600 mb-6 block">
|
||||
{loading ? '请稍候,正在为您确认登录' :
|
||||
confirmed ? '您已成功确认登录,网页端将自动登录' :
|
||||
error ? error :
|
||||
`确认使用 ${getDisplayName()} 登录网页端?`}
|
||||
</Text>
|
||||
|
||||
{/* 用户信息 */}
|
||||
{!loading && !confirmed && !error && user && (
|
||||
<View className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<View className="flex items-center justify-center">
|
||||
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
||||
<User className="text-blue-500" size="20" />
|
||||
</View>
|
||||
<View className="text-left">
|
||||
<Text className="text-sm font-medium text-gray-800 block">
|
||||
{user.nickname || user.username || '用户'}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500 block">
|
||||
ID: {user.userId}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className="space-y-3">
|
||||
{loading ? (
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
disabled
|
||||
className="w-full"
|
||||
>
|
||||
确认中...
|
||||
</Button>
|
||||
) : confirmed ? (
|
||||
<Button
|
||||
type="success"
|
||||
size="large"
|
||||
onClick={handleCancel}
|
||||
className="w-full"
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
) : error ? (
|
||||
<View className="space-y-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleRetry}
|
||||
className="w-full"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={handleCancel}
|
||||
className="w-full"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View className="mt-3">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleConfirmLogin}
|
||||
className="w-full mb-2"
|
||||
disabled={!token || !user?.userId}
|
||||
>
|
||||
确认登录
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={handleCancel}
|
||||
className="w-full"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<Card className="bg-yellow-50 border border-yellow-200 rounded-lg mt-4">
|
||||
<View className="p-4">
|
||||
<View className="flex items-start">
|
||||
<Tips className="text-yellow-600 mr-2 mt-1" size="16" />
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-yellow-800 mb-1 block">
|
||||
安全提示
|
||||
</Text>
|
||||
<Text className="text-xs text-yellow-700 block">
|
||||
请确认这是您本人的登录操作。如果不是,请点击取消并检查账户安全。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRConfirmPage;
|
||||
5
src/passport/qr-login/index.config.ts
Normal file
5
src/passport/qr-login/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
navigationBarTitleText: '扫码登录',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
}
|
||||
193
src/passport/qr-login/index.tsx
Normal file
193
src/passport/qr-login/index.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Card, Divider, Button } from '@nutui/nutui-react-taro';
|
||||
import { Scan, Success, Failure, Tips } from '@nutui/icons-react-taro';
|
||||
import Taro from '@tarojs/taro';
|
||||
import QRLoginScanner from '@/components/QRLoginScanner';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
|
||||
/**
|
||||
* 扫码登录页面
|
||||
*/
|
||||
const QRLoginPage: React.FC = () => {
|
||||
const [loginHistory, setLoginHistory] = useState<any[]>([]);
|
||||
const { getDisplayName } = useUser();
|
||||
|
||||
// 处理扫码成功
|
||||
const handleScanSuccess = (result: any) => {
|
||||
console.log('扫码登录成功:', result);
|
||||
|
||||
// 添加到登录历史
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
userInfo: result.userInfo,
|
||||
success: true
|
||||
};
|
||||
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
|
||||
|
||||
// 显示成功提示
|
||||
Taro.showToast({
|
||||
title: '登录确认成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
};
|
||||
|
||||
// 处理扫码失败
|
||||
const handleScanError = (error: string) => {
|
||||
console.error('扫码登录失败:', error);
|
||||
|
||||
// 添加到登录历史
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
error,
|
||||
success: false
|
||||
};
|
||||
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]);
|
||||
};
|
||||
|
||||
// 返回上一页
|
||||
// const handleBack = () => {
|
||||
// Taro.navigateBack();
|
||||
// };
|
||||
|
||||
// 清除历史记录
|
||||
const clearHistory = () => {
|
||||
setLoginHistory([]);
|
||||
Taro.showToast({
|
||||
title: '已清除历史记录',
|
||||
icon: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="qr-login-page min-h-screen bg-gray-50">
|
||||
{/* 导航栏 */}
|
||||
{/*<NavBar*/}
|
||||
{/* title="扫码登录"*/}
|
||||
{/* leftShow*/}
|
||||
{/* onBackClick={handleBack}*/}
|
||||
{/* leftText={<ArrowLeft />}*/}
|
||||
{/* className="bg-white"*/}
|
||||
{/*/>*/}
|
||||
|
||||
{/* 主要内容 */}
|
||||
<View className="p-4">
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className="bg-white rounded-lg shadow-sm">
|
||||
<View className="p-4">
|
||||
<View className="flex items-center mb-4">
|
||||
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
||||
<Scan className="text-blue-500" size="24" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-lg font-bold text-gray-800">
|
||||
{getDisplayName()}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500">
|
||||
使用小程序扫码快速登录网页端
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 扫码登录组件 */}
|
||||
<QRLoginScanner
|
||||
onSuccess={handleScanSuccess}
|
||||
onError={handleScanError}
|
||||
buttonText="开始扫码登录"
|
||||
showStatus={true}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className="bg-white rounded-lg shadow-sm">
|
||||
<View className="p-4">
|
||||
<View className="flex items-center mb-3">
|
||||
<Tips className="text-orange-500 mr-2" />
|
||||
<Text className="font-medium text-gray-800">使用说明</Text>
|
||||
</View>
|
||||
<View className="text-sm text-gray-600">
|
||||
<Text className="block">1. 在电脑或其他设备上打开网页端登录页面</Text>
|
||||
<Text className="block">2. 点击"扫码登录"按钮,显示登录二维码</Text>
|
||||
<Text className="block">3. 使用此功能扫描二维码即可快速登录</Text>
|
||||
<Text className="block">4. 扫码成功后,网页端将自动完成登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 登录历史 */}
|
||||
{loginHistory.length > 0 && (
|
||||
<Card className="bg-white rounded-lg shadow-sm">
|
||||
<View className="p-4">
|
||||
<View className="flex items-center justify-between mb-3">
|
||||
<Text className="font-medium text-gray-800">最近登录记录</Text>
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
onClick={clearHistory}
|
||||
className="text-xs"
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
{loginHistory.map((record, index) => (
|
||||
<View key={record.id}>
|
||||
<View className="flex items-center justify-between">
|
||||
<View className="flex items-center">
|
||||
{record.success ? (
|
||||
<Success className="text-green-500 mr-2" size="16" />
|
||||
) : (
|
||||
<Failure className="text-red-500 mr-2" size="16" />
|
||||
)}
|
||||
<View>
|
||||
<Text className="text-sm text-gray-800">
|
||||
{record.success ? '登录成功' : '登录失败'}
|
||||
</Text>
|
||||
{record.error && (
|
||||
<Text className="text-xs text-red-500">
|
||||
{record.error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-xs text-gray-500">
|
||||
{record.time}
|
||||
</Text>
|
||||
</View>
|
||||
{index < loginHistory.length - 1 && (
|
||||
<Divider className="my-2" />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 安全提示 */}
|
||||
<Card className="bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<View className="p-4">
|
||||
<View className="flex items-start">
|
||||
<Tips className="text-yellow-600 mr-2 mt-1" size="16" />
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-yellow-800 mb-1">
|
||||
安全提示
|
||||
</Text>
|
||||
<Text className="text-xs text-yellow-700">
|
||||
请确保只扫描来自官方网站的登录二维码,避免扫描来源不明的二维码,保护账户安全。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRLoginPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '注册账号',
|
||||
navigationBarTitleText: '注册/登录',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,47 +1,295 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import {Input, Radio, Button} from '@nutui/nutui-react-taro'
|
||||
import { Button, Radio } from '@nutui/nutui-react-taro'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import {
|
||||
getStoredInviteParams,
|
||||
parseInviteParams,
|
||||
saveInviteParams,
|
||||
trackInviteSource,
|
||||
checkAndHandleInviteRelation,
|
||||
} from '@/utils/invite'
|
||||
|
||||
interface GetPhoneNumberDetail {
|
||||
code?: string
|
||||
encryptedData?: string
|
||||
iv?: string
|
||||
errMsg?: string
|
||||
}
|
||||
|
||||
interface GetPhoneNumberEvent {
|
||||
detail: GetPhoneNumberDetail
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
data: {
|
||||
code?: number
|
||||
message?: string
|
||||
data?: {
|
||||
access_token: string
|
||||
user: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getWeappLoginCode(): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
||||
Taro.login({
|
||||
success: (r) => resolve(r as any),
|
||||
fail: (e) => reject(e),
|
||||
})
|
||||
})
|
||||
return res?.code
|
||||
} catch (_e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWxOpenIdSaved(opts: { user?: any; wxLoginCode?: string }) {
|
||||
// JSAPI 微信支付必须有 openid;注册/登录后立刻补齐,避免后续创建支付单失败。
|
||||
try {
|
||||
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return
|
||||
} catch (_e) {
|
||||
if (process.env.TARO_ENV !== 'weapp') return
|
||||
}
|
||||
|
||||
if (opts.user?.openid) return
|
||||
|
||||
const code = opts.wxLoginCode || (await getWeappLoginCode())
|
||||
if (!code) return
|
||||
|
||||
// 该接口一般会在服务端把 openid 绑定到当前登录用户;返回值并不一定包含 openid。
|
||||
await getWxOpenId({ code })
|
||||
|
||||
// 同步本地 User(让后续页面/逻辑能直接读到 openid)
|
||||
try {
|
||||
const fresh = await getUserInfo()
|
||||
if (fresh) Taro.setStorageSync('User', fresh)
|
||||
} catch (_e) {
|
||||
// ignore: openid 已在服务端绑定,本地不同步也不影响后端创建支付订单
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecodeMaybeEncoded(input?: string): string {
|
||||
if (!input) return ''
|
||||
try {
|
||||
// Taro 路由参数通常是 URL 编码过的字符串
|
||||
return decodeURIComponent(input)
|
||||
} catch (_e) {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
function isTabBarUrl(url: string) {
|
||||
const pure = url.split('?')[0]
|
||||
return (
|
||||
pure === '/pages/index/index' ||
|
||||
pure === '/pages/cart/cart' ||
|
||||
pure === '/pages/user/user' ||
|
||||
pure === '/pages/category/index'
|
||||
)
|
||||
}
|
||||
|
||||
const Register = () => {
|
||||
const [isAgree, setIsAgree] = useState(false)
|
||||
const reload = () => {
|
||||
Taro.hideTabBar()
|
||||
}
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 短信验证码登录仅在非微信小程序端展示
|
||||
const isWeapp = useMemo(() => {
|
||||
try {
|
||||
return Taro.getEnv() === Taro.ENV_TYPE.WEAPP
|
||||
} catch (_e) {
|
||||
return process.env.TARO_ENV === 'weapp'
|
||||
}
|
||||
}, [])
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
// 注册/登录页不需要展示 tabBar
|
||||
Taro.hideTabBar()
|
||||
}, [])
|
||||
|
||||
const redirectUrl = useMemo(() => {
|
||||
const raw = (router?.params as any)?.redirect as string | undefined
|
||||
const decoded = safeDecodeMaybeEncoded(raw)
|
||||
if (!decoded) return ''
|
||||
return decoded.startsWith('/') ? decoded : `/${decoded}`
|
||||
}, [router?.params])
|
||||
|
||||
// 如果从分享/二维码直接进入注册页(携带 inviter/source/t),先暂存邀请信息
|
||||
useEffect(() => {
|
||||
try {
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
if (inviteParams?.inviter) {
|
||||
saveInviteParams(inviteParams)
|
||||
trackInviteSource(inviteParams.source || 'qrcode', parseInt(inviteParams.inviter, 10))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('注册页处理邀请参数失败:', e)
|
||||
}
|
||||
}, [router?.params])
|
||||
|
||||
const navigateAfterLogin = async () => {
|
||||
if (!redirectUrl) {
|
||||
await Taro.reLaunch({ url: '/pages/index/index' })
|
||||
return
|
||||
}
|
||||
|
||||
if (isTabBarUrl(redirectUrl)) {
|
||||
// switchTab 不支持携带 query,这里按纯路径跳转
|
||||
await Taro.switchTab({ url: redirectUrl.split('?')[0] })
|
||||
return
|
||||
}
|
||||
|
||||
// 替换当前注册页,避免返回栈里再回到注册页
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading) return
|
||||
|
||||
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
|
||||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||||
Taro.showToast({ title: '未授权手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 获取存储的邀请参数(推荐人ID)
|
||||
const inviteParams = getStoredInviteParams()
|
||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||
|
||||
// 获取小程序登录 code(用于后续绑定 openid)
|
||||
const wxLoginCode = await getWeappLoginCode()
|
||||
|
||||
const res = (await Taro.request({
|
||||
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: phoneCode,
|
||||
encryptedData,
|
||||
iv,
|
||||
notVerifyPhone: true,
|
||||
refereeId: refereeId,
|
||||
sceneType: 'save_referee',
|
||||
tenantId: TenantId,
|
||||
},
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
TenantId,
|
||||
},
|
||||
})) as unknown as LoginResponse
|
||||
|
||||
if ((res as any)?.data?.code === 1) {
|
||||
Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = res?.data?.data?.access_token
|
||||
const user = res?.data?.data?.user
|
||||
if (!token || !user?.userId) {
|
||||
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
|
||||
// 注册/登录成功后,立即补齐 openid(JSAPI 支付必需)
|
||||
try {
|
||||
await ensureWxOpenIdSaved({ user, wxLoginCode })
|
||||
} catch (e) {
|
||||
console.error('注册页绑定 openid 失败:', e)
|
||||
}
|
||||
|
||||
// 登录成功后尝试绑定推荐关系(如果有待处理 inviter,会自动处理并清理参数)
|
||||
try {
|
||||
await checkAndHandleInviteRelation()
|
||||
} catch (e) {
|
||||
console.error('注册页登录后处理邀请关系失败:', e)
|
||||
}
|
||||
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigateAfterLogin().catch((e) => console.error('登录后跳转失败:', e))
|
||||
}, 800)
|
||||
} catch (e: any) {
|
||||
console.error('注册/登录失败:', e)
|
||||
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goSmsLogin = () => {
|
||||
const inviteParams = getStoredInviteParams()
|
||||
const inviter = inviteParams?.inviter
|
||||
const source = inviteParams?.source
|
||||
const t = inviteParams?.t
|
||||
|
||||
const params: Record<string, any> = {}
|
||||
if (redirectUrl) params.redirect = redirectUrl
|
||||
// 兜底:把 inviter 带过去,避免“先点注册再进入”时丢失
|
||||
if (inviter) params.inviter = inviter
|
||||
if (source) params.source = source
|
||||
if (t) params.t = t
|
||||
|
||||
const qs = Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&')
|
||||
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col justify-center px-5 pt-3'}>
|
||||
<div className={'text-xl font-bold py-2'}>免费试用14天,快速上手独立站</div>
|
||||
<div className={'text-sm py-1 font-normal text-gray-500'}>建站、选品、营销、支付、物流,全部搞定</div>
|
||||
<div className={'text-sm pb-4 font-normal text-gray-500'}>
|
||||
WebSoft为您提供独立站的解决方案,提供专业、高效、安全的运营服务。
|
||||
<div className={'flex flex-col justify-center px-5 pt-5'}>
|
||||
<div className={'text-3xl text-center py-5 font-normal my-6'}>注册/登录</div>
|
||||
|
||||
<div className={'flex flex-col gap-3 bg-green-600 py-1'} style={{ borderRadius: '100px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
fill="solid"
|
||||
color="#07c160"
|
||||
block
|
||||
loading={loading}
|
||||
disabled={!isAgree || loading}
|
||||
open-type="getPhoneNumber"
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
>
|
||||
手机号一键注册/登录
|
||||
</Button>
|
||||
|
||||
{!isWeapp && (
|
||||
<Button type="default" block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||||
短信验证码注册/登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex flex-col justify-between items-center my-2'}>
|
||||
<Input type="text" placeholder="手机号" maxLength={11} style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
|
||||
|
||||
<div className={'mt-6 flex text-sm items-center px-1'}>
|
||||
<Radio style={{ color: '#333333' }} checked={isAgree} onClick={() => setIsAgree(!isAgree)} />
|
||||
<span className={'text-gray-400'} onClick={() => setIsAgree(!isAgree)}>
|
||||
勾选表示您已阅读并同意
|
||||
</span>
|
||||
<a
|
||||
onClick={() => Taro.navigateTo({ url: '/passport/agreement' })}
|
||||
className={'text-blue-600'}
|
||||
>
|
||||
《服务协议及隐私政策》
|
||||
</a>
|
||||
</div>
|
||||
<div className={'flex flex-col justify-between items-center my-2'}>
|
||||
<Input type="password" placeholder="密码" style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
|
||||
</div>
|
||||
<div className={'flex flex-col justify-between items-center my-2'}>
|
||||
<Input type="password" placeholder="再次输入密码" style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
|
||||
</div>
|
||||
<div className={'flex justify-center my-5'}>
|
||||
<Button type="info" size={'large'} className={'w-full rounded-lg p-2'} disabled={!isAgree}>免费试用</Button>
|
||||
</div>
|
||||
<div className={'my-2 flex text-sm items-center px-1'}>
|
||||
<Radio style={{color: '#333333'}} checked={isAgree} onClick={() => setIsAgree(!isAgree)}></Radio>
|
||||
<span className={'text-gray-400'} onClick={() => setIsAgree(!isAgree)}>勾选表示您已阅读并同意</span>
|
||||
<a onClick={() => Taro.navigateTo({url: '/passport/agreement'})} className={'text-blue-600'}>《服务协议及隐私政策》</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'w-full fixed bottom-20 my-2 flex justify-center text-sm items-center text-center'}>
|
||||
已有账号?<a className={'text-blue-600'} onClick={() => Taro.navigateBack()}>返回登录</a>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
|
||||
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro'
|
||||
import {Input, Button} from '@nutui/nutui-react-taro'
|
||||
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
|
||||
import {LoginParam} from "@/api/passport/login/model";
|
||||
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
|
||||
|
||||
const SmsLogin = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
@@ -13,6 +14,46 @@ const SmsLogin = () => {
|
||||
code: ''
|
||||
})
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
const redirectParam = (router?.params as any)?.redirect as string | undefined
|
||||
|
||||
const safeDecodeMaybeEncoded = (input?: string) => {
|
||||
if (!input) return ''
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch (_e) {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUrl = (() => {
|
||||
const decoded = safeDecodeMaybeEncoded(redirectParam)
|
||||
if (!decoded) return ''
|
||||
return decoded.startsWith('/') ? decoded : `/${decoded}`
|
||||
})()
|
||||
|
||||
const isTabBarUrl = (url: string) => {
|
||||
const pure = url.split('?')[0]
|
||||
return (
|
||||
pure === '/pages/index/index' ||
|
||||
pure === '/pages/cart/cart' ||
|
||||
pure === '/pages/user/user' ||
|
||||
pure === '/pages/category/index'
|
||||
)
|
||||
}
|
||||
|
||||
const navigateAfterLogin = async () => {
|
||||
if (!redirectUrl) {
|
||||
await Taro.reLaunch({ url: '/pages/index/index' })
|
||||
return
|
||||
}
|
||||
if (isTabBarUrl(redirectUrl)) {
|
||||
await Taro.switchTab({ url: redirectUrl.split('?')[0] })
|
||||
return
|
||||
}
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
Taro.hideTabBar()
|
||||
}
|
||||
@@ -21,6 +62,19 @@ const SmsLogin = () => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
|
||||
useEffect(() => {
|
||||
try {
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
if (inviteParams?.inviter) {
|
||||
saveInviteParams(inviteParams)
|
||||
trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter, 10))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('短信登录页处理邀请参数失败:', e)
|
||||
}
|
||||
}, [router?.params])
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
@@ -131,6 +185,15 @@ const SmsLogin = () => {
|
||||
code: formData.code
|
||||
})
|
||||
|
||||
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
|
||||
if (hasPendingInvite()) {
|
||||
try {
|
||||
await checkAndHandleInviteRelation()
|
||||
} catch (e) {
|
||||
console.error('短信登录后处理邀请关系失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
Taro.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success'
|
||||
@@ -138,8 +201,9 @@ const SmsLogin = () => {
|
||||
|
||||
// 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
Taro.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
navigateAfterLogin().catch((e) => {
|
||||
console.error('短信登录后跳转失败:', e)
|
||||
Taro.reLaunch({ url: '/pages/index/index' })
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
|
||||
4
src/passport/unified-qr/index.config.ts
Normal file
4
src/passport/unified-qr/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
navigationBarTitleText: '统一扫码',
|
||||
navigationBarTextStyle: 'black'
|
||||
}
|
||||
342
src/passport/unified-qr/index.tsx
Normal file
342
src/passport/unified-qr/index.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Card, Button, Tag } from '@nutui/nutui-react-taro';
|
||||
import { Scan, Success, Failure, Tips, ArrowLeft } from '@nutui/icons-react-taro';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useUnifiedQRScan, ScanType, type UnifiedScanResult } from '@/hooks/useUnifiedQRScan';
|
||||
|
||||
/**
|
||||
* 统一扫码页面
|
||||
* 支持登录和核销两种类型的二维码扫描
|
||||
*/
|
||||
const UnifiedQRPage: React.FC = () => {
|
||||
const [scanHistory, setScanHistory] = useState<any[]>([]);
|
||||
const {
|
||||
startScan,
|
||||
isLoading,
|
||||
canScan,
|
||||
state,
|
||||
result,
|
||||
error,
|
||||
scanType,
|
||||
reset
|
||||
} = useUnifiedQRScan();
|
||||
|
||||
// 处理扫码成功
|
||||
const handleScanSuccess = (result: UnifiedScanResult) => {
|
||||
console.log('扫码成功:', result);
|
||||
|
||||
// 添加到扫码历史
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
type: result.type,
|
||||
data: result.data,
|
||||
message: result.message,
|
||||
success: true
|
||||
};
|
||||
setScanHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
|
||||
|
||||
// 根据类型给出不同提示
|
||||
if (result.type === ScanType.VERIFICATION) {
|
||||
// 核销成功后询问是否继续扫码
|
||||
setTimeout(() => {
|
||||
Taro.showModal({
|
||||
title: '核销成功',
|
||||
content: '是否继续扫码核销其他水票/礼品卡?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
handleStartScan();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理扫码失败
|
||||
const handleScanError = (error: string) => {
|
||||
console.error('扫码失败:', error);
|
||||
|
||||
// 添加到扫码历史
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
error,
|
||||
success: false
|
||||
};
|
||||
setScanHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
|
||||
};
|
||||
|
||||
// 开始扫码
|
||||
const handleStartScan = async () => {
|
||||
try {
|
||||
const scanResult = await startScan();
|
||||
if (scanResult) {
|
||||
handleScanSuccess(scanResult);
|
||||
}
|
||||
} catch (error: any) {
|
||||
handleScanError(error.message || '扫码失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 返回上一页
|
||||
const handleGoBack = () => {
|
||||
Taro.navigateBack();
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (success: boolean, type?: ScanType) => {
|
||||
console.log(type,'获取状态图标')
|
||||
if (success) {
|
||||
return <Success className="text-green-500" size="16" />;
|
||||
} else {
|
||||
return <Failure className="text-red-500" size="16" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取类型标签
|
||||
const getTypeTag = (type: ScanType) => {
|
||||
switch (type) {
|
||||
case ScanType.LOGIN:
|
||||
return <Tag type="success">登录</Tag>;
|
||||
case ScanType.VERIFICATION:
|
||||
return <Tag type="warning">核销</Tag>;
|
||||
default:
|
||||
return <Tag type="default">未知</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="unified-qr-page min-h-screen bg-gray-50">
|
||||
{/* 页面头部 */}
|
||||
<View className="bg-white px-4 py-3 border-b border-gray-100 flex items-center">
|
||||
<ArrowLeft
|
||||
className="text-gray-600 mr-3"
|
||||
size="20"
|
||||
onClick={handleGoBack}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold">统一扫码</Text>
|
||||
<Text className="text-sm text-gray-600 block">
|
||||
支持登录和核销功能
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 主要扫码区域 */}
|
||||
<Card className="m-4">
|
||||
<View className="text-center py-6">
|
||||
{/* 状态显示 */}
|
||||
{state === 'idle' && (
|
||||
<>
|
||||
<Scan className="text-blue-500 mx-auto mb-4" size="48" />
|
||||
<Text className="text-lg font-medium text-gray-800 mb-2 block">
|
||||
智能扫码
|
||||
</Text>
|
||||
<Text className="text-gray-600 mb-6 block">
|
||||
自动识别登录和核销二维码
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleStartScan}
|
||||
disabled={!canScan() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{canScan() ? '开始扫码' : '请先登录'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'scanning' && (
|
||||
<>
|
||||
<Scan className="text-blue-500 mx-auto mb-4" size="48" />
|
||||
<Text className="text-lg font-medium text-blue-600 mb-2 block">
|
||||
扫码中...
|
||||
</Text>
|
||||
<Text className="text-gray-600 mb-6 block">
|
||||
请对准二维码
|
||||
</Text>
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={reset}
|
||||
className="w-full"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<>
|
||||
<View className="text-orange-500 mx-auto mb-4">
|
||||
<Tips size="48" />
|
||||
</View>
|
||||
<Text className="text-lg font-medium text-orange-600 mb-2 block">
|
||||
处理中...
|
||||
</Text>
|
||||
<Text className="text-gray-600 mb-6 block">
|
||||
{scanType === ScanType.LOGIN ? '正在确认登录' :
|
||||
scanType === ScanType.VERIFICATION ? '正在核销' : '正在处理'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'success' && result && (
|
||||
<>
|
||||
<Success className="text-green-500 mx-auto mb-4" size="48" />
|
||||
<Text className="text-lg font-medium text-green-600 mb-2 block">
|
||||
{result.message}
|
||||
</Text>
|
||||
{result.type === ScanType.VERIFICATION && result.data && (
|
||||
<View className="bg-green-50 rounded-lg p-3 mb-4">
|
||||
{result.data.businessType === 'gift' && result.data.gift && (
|
||||
<>
|
||||
<Text className="text-sm text-green-800 block">
|
||||
礼品:{result.data.gift.goodsName || result.data.gift.name || '未知'}
|
||||
</Text>
|
||||
<Text className="text-sm text-green-800 block">
|
||||
面值:¥{result.data.gift.faceValue}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{result.data.businessType === 'ticket' && result.data.ticket && (
|
||||
<>
|
||||
<Text className="text-sm text-green-800 block">
|
||||
水票:{result.data.ticket.templateName || '水票'}
|
||||
</Text>
|
||||
<Text className="text-sm text-green-800 block">
|
||||
本次核销:{result.data.qty || 1} 次
|
||||
</Text>
|
||||
<Text className="text-sm text-green-800 block">
|
||||
剩余可用:{result.data.ticket.availableQty ?? 0} 次
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleStartScan}
|
||||
className="w-full mb-2"
|
||||
>
|
||||
继续扫码
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="normal"
|
||||
onClick={reset}
|
||||
className="w-full"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<>
|
||||
<Failure className="text-red-500 mx-auto mb-4" size="48" />
|
||||
<Text className="text-lg font-medium text-red-600 mb-2 block">
|
||||
操作失败
|
||||
</Text>
|
||||
<Text className="text-gray-600 mb-6 block">
|
||||
{error || '未知错误'}
|
||||
</Text>
|
||||
<View className="mt-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleStartScan}
|
||||
className="w-full mb-2"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="normal"
|
||||
onClick={reset}
|
||||
className="w-full"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 扫码历史 */}
|
||||
{scanHistory.length > 0 && (
|
||||
<Card className="m-4">
|
||||
<View className="pb-4">
|
||||
<Text className="text-lg font-medium text-gray-800 mb-3 block">
|
||||
最近扫码记录
|
||||
</Text>
|
||||
|
||||
{scanHistory.map((record, index) => (
|
||||
<View
|
||||
key={record.id}
|
||||
className={`flex items-center justify-between p-3 bg-gray-50 rounded-lg ${index < scanHistory.length - 1 ? 'mb-2' : ''}`}
|
||||
>
|
||||
<View className="flex items-center flex-1">
|
||||
{getStatusIcon(record.success, record.type)}
|
||||
<View className="ml-3 flex-1">
|
||||
<View className="flex items-center mb-1">
|
||||
{record.type && getTypeTag(record.type)}
|
||||
<Text className="text-sm text-gray-600 ml-2">
|
||||
{record.time}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-sm text-gray-800">
|
||||
{record.success ? record.message : record.error}
|
||||
</Text>
|
||||
{record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'gift' && record.data?.gift && (
|
||||
<Text className="text-xs text-gray-500">
|
||||
{record.data.gift.goodsName || record.data.gift.name} - ¥{record.data.gift.faceValue}
|
||||
</Text>
|
||||
)}
|
||||
{record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'ticket' && record.data?.ticket && (
|
||||
<Text className="text-xs text-gray-500">
|
||||
{record.data.ticket.templateName || '水票'} - 本次核销 {record.data.qty || 1} 次
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 功能说明 */}
|
||||
<Card className="m-4 bg-blue-50 border border-blue-200">
|
||||
<View className="p-4">
|
||||
<View className="flex items-start">
|
||||
<Tips className="text-blue-600 mr-2 mt-1" size="16" />
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-blue-800 mb-1 block">
|
||||
功能说明
|
||||
</Text>
|
||||
<Text className="text-xs text-blue-700 block mb-1">
|
||||
• 登录二维码:自动确认网页端登录
|
||||
</Text>
|
||||
<Text className="text-xs text-blue-700 block mb-1">
|
||||
• 核销二维码:核销用户水票/礼品卡
|
||||
</Text>
|
||||
<Text className="text-xs text-blue-700 block">
|
||||
• 系统会自动识别二维码类型并执行相应操作
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedQRPage;
|
||||
Reference in New Issue
Block a user