feat(passport): 改造扫码登录确认页面授权登录界面及协议功能

- 重新设计授权登录页面UI,参考权大师风格,包含顶部标题、Logo与品牌名
- 添加手机号授权登录按钮及取消按钮,优化用户交互体验
- 新增服务协议和隐私政策弹窗,支持点击查看详细内容
- 增加协议勾选状态管理,未勾选协议时授权提示用户
- 修改登录确认流程,根据用户注册状态分别处理自动确认与手机号授权
- 优化按钮、状态提示和页面布局,提升整体界面一致性和可用性
This commit is contained in:
2026-04-08 02:03:10 +08:00
parent 54fa9e772c
commit 28102fb0bd
3 changed files with 288 additions and 92 deletions

View File

@@ -13,5 +13,5 @@
} }
] ]
}, },
"lastUpdated": 1775583343042 "lastUpdated": 1775584636978
} }

View File

@@ -0,0 +1,42 @@
# 2026-04-08 工作记录
## passport/qr-confirm/index.tsx 页面改造
### 改造内容
将扫码登录确认页面的授权登录界面改造成参考权大师的设计风格:
1. **UI 改造**
- 顶部标题"请登录"
- 中间 Logo + 品牌名 + 标语(权大师风格)
- 橙色主按钮"手机号授权登录"
- 灰色"取消"按钮
- 底部协议勾选(带链接可点击查看详情)
2. **新增功能**
- 协议勾选状态管理
- 服务协议和隐私政策弹窗
- 未勾选协议时点击授权按钮会提示
3. **保留功能**
- 已注册用户的自动登录确认流程
- 未注册用户的手机号授权登录
- 扫码登录成功后的跳转逻辑
### 页面流程
```
用户扫码 → qr-confirm 页面
检测用户状态
┌──────────────┬──────────────┐
已注册 未注册
↓ ↓
自动确认登录 显示授权登录界面
↓ ↓
登录成功 一键授权登录
```
### 技术要点
- 使用 Taro 的 Button 组件 openType="getPhoneNumber" 获取微信手机号授权
- 授权前校验协议勾选状态
- 调用后端 loginByMpWxPhone 接口(带 notVerifyPhone: true 自动注册)

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, Button as TaroButton } from '@tarojs/components';
import { Button, Loading, Card } from '@nutui/nutui-react-taro'; import { Loading, Card } from '@nutui/nutui-react-taro';
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro'; import { Success, Failure, Tips, User, Checklist } from '@nutui/icons-react-taro';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { useRouter } from '@tarojs/taro';
import { confirmQRLogin } from '@/api/passport/qr-login'; import { confirmQRLogin } from '@/api/passport/qr-login';
import { loginByOpenId } from '@/api/passport/wx-login'; import { loginByOpenId } from '@/api/passport/wx-login';
@@ -51,6 +51,9 @@ interface LoginResponse {
message: string; message: string;
} }
// 协议弹窗类型
type AgreementType = 'service' | 'privacy' | null;
const QRConfirmPage: React.FC = () => { const QRConfirmPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -61,6 +64,8 @@ const QRConfirmPage: React.FC = () => {
const [userInfo, setUserInfo] = useState<any>(null); const [userInfo, setUserInfo] = useState<any>(null);
const [needAuth, setNeedAuth] = useState(false); // 是否需要手机号授权 const [needAuth, setNeedAuth] = useState(false); // 是否需要手机号授权
const [authLoading, setAuthLoading] = useState(false); // 授权中状态 const [authLoading, setAuthLoading] = useState(false); // 授权中状态
const [agreementChecked, setAgreementChecked] = useState(false); // 协议勾选状态
const [showAgreementPopup, setShowAgreementPopup] = useState<AgreementType>(null); // 协议弹窗
useEffect(() => { useEffect(() => {
// 从 URL 参数中获取 token // 从 URL 参数中获取 token
@@ -158,6 +163,15 @@ const QRConfirmPage: React.FC = () => {
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => { const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
const { code, encryptedData, iv, errMsg } = detail; const { code, encryptedData, iv, errMsg } = detail;
// 检查协议是否勾选
if (!agreementChecked) {
Taro.showToast({
title: '请先同意服务协议和隐私政策',
icon: 'none'
});
return;
}
// 用户拒绝授权 // 用户拒绝授权
if (errMsg && errMsg.includes('fail')) { if (errMsg && errMsg.includes('fail')) {
Taro.showToast({ Taro.showToast({
@@ -411,6 +425,184 @@ const QRConfirmPage: React.FC = () => {
}); });
}; };
// 打开协议弹窗
const openAgreement = (type: AgreementType) => {
setShowAgreementPopup(type);
};
// 关闭协议弹窗
const closeAgreement = () => {
setShowAgreementPopup(null);
};
// 渲染协议弹窗内容
const renderAgreementContent = () => {
if (showAgreementPopup === 'service') {
return (
<View className="p-4">
<View className="text-center mb-4">
<Text className="text-lg font-bold"></Text>
</View>
<View className="text-sm text-gray-600 leading-relaxed max-h-64 overflow-y-auto">
<Text className="block mb-2">使</Text>
<Text className="block mb-2">1. </Text>
<Text className="block mb-2">使</Text>
<Text className="block mb-2">2. </Text>
<Text className="block mb-2">使</Text>
<Text className="block mb-2">3. 使</Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">4. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">5. </Text>
<Text className="block mb-2"></Text>
</View>
<TaroButton
className="mt-4 bg-orange-500 text-white rounded-full"
onClick={closeAgreement}
>
</TaroButton>
</View>
);
}
if (showAgreementPopup === 'privacy') {
return (
<View className="p-4">
<View className="text-center mb-4">
<Text className="text-lg font-bold"></Text>
</View>
<View className="text-sm text-gray-600 leading-relaxed max-h-64 overflow-y-auto">
<Text className="block mb-2"></Text>
<Text className="block mb-2">1. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">2. 使</Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">3. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">4. </Text>
<Text className="block mb-2"></Text>
<Text className="block mb-2">5. </Text>
<Text className="block mb-2"></Text>
</View>
<TaroButton
className="mt-4 bg-orange-500 text-white rounded-full"
onClick={closeAgreement}
>
</TaroButton>
</View>
);
}
return null;
};
// 授权登录页面(参考权大师风格)
const renderAuthPage = () => {
return (
<View className="min-h-screen bg-white flex flex-col">
{/* 顶部导航 */}
<View className="flex items-center justify-between px-4 py-3">
<View className="w-8" />
<Text className="text-lg font-medium text-gray-800"></Text>
<View className="w-8" />
</View>
{/* Logo 区域 */}
<View className="flex-1 flex flex-col items-center justify-center px-8 -mt-20">
{/* Logo */}
<View className="w-20 h-20 mb-4">
<svg viewBox="0 0 100 100" className="w-full h-full">
<circle cx="50" cy="50" r="45" fill="#FF6B00" />
<path
d="M35 35 L50 65 L65 35"
stroke="white"
strokeWidth="6"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</View>
{/* 品牌名 */}
<Text className="text-2xl font-bold text-gray-900 mb-2"></Text>
{/* 标语 */}
<Text className="text-sm text-gray-500 tracking-widest"></Text>
</View>
{/* 底部操作区域 */}
<View className="px-6 pb-12">
{/* 主按钮 - 手机号授权登录 */}
<TaroButton
className="w-full h-12 bg-orange-500 text-white text-base font-medium rounded-lg flex items-center justify-center mb-4"
openType="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
disabled={authLoading}
style={{ backgroundColor: '#FF6B00', border: 'none' }}
>
{authLoading ? '授权中...' : '手机号授权登录'}
</TaroButton>
{/* 取消按钮 */}
<View
className="w-full h-12 flex items-center justify-center text-gray-500 text-base cursor-pointer"
onClick={handleCancel}
>
</View>
{/* 协议勾选 */}
<View className="flex items-center justify-center mt-6">
<View
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center mr-2 ${
agreementChecked ? 'bg-orange-500 border-orange-500' : 'border-gray-300'
}`}
onClick={() => setAgreementChecked(!agreementChecked)}
>
{agreementChecked && (
<Checklist size="12" color="#fff" />
)}
</View>
<Text className="text-xs text-gray-500">
</Text>
<Text
className="text-xs text-orange-500 ml-1"
onClick={(e) => {
e.stopPropagation();
openAgreement('service');
}}
>
</Text>
<Text className="text-xs text-gray-500"></Text>
<Text
className="text-xs text-orange-500 ml-1"
onClick={(e) => {
e.stopPropagation();
openAgreement('privacy');
}}
>
</Text>
</View>
</View>
{/* 协议弹窗 */}
{showAgreementPopup && (
<View className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 px-6">
<View className="bg-white rounded-xl w-full max-w-sm">
{renderAgreementContent()}
</View>
</View>
)}
</View>
);
};
// 渲染状态图标 // 渲染状态图标
const renderStatusIcon = () => { const renderStatusIcon = () => {
if (loading || authLoading) return ( if (loading || authLoading) return (
@@ -437,15 +629,10 @@ const QRConfirmPage: React.FC = () => {
</View> </View>
); );
if (needAuth) return ( if (needAuth) {
<View className="mb-6"> // 需要授权时显示授权页面
<View className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center"> return renderAuthPage();
<svg width="32" height="32" viewBox="0 0 24 24" fill="#22c55e"> }
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>
</View>
</View>
);
return ( return (
<View className="mb-6"> <View className="mb-6">
@@ -460,7 +647,7 @@ const QRConfirmPage: React.FC = () => {
const getTitle = () => { const getTitle = () => {
if (loading || authLoading) return '正在处理...'; if (loading || authLoading) return '正在处理...';
if (confirmed) return '登录确认成功'; if (confirmed) return '登录确认成功';
if (needAuth) return '首次登录授权'; if (needAuth) return ''; // 授权页面有自己的标题
if (error) return '登录确认失败'; if (error) return '登录确认失败';
return loginMethod === 'url' ? '扫码登录确认' : '确认登录'; return loginMethod === 'url' ? '扫码登录确认' : '确认登录';
}; };
@@ -470,7 +657,7 @@ const QRConfirmPage: React.FC = () => {
if (loading) return '请稍候,正在为您确认登录'; if (loading) return '请稍候,正在为您确认登录';
if (authLoading) return '正在授权登录...'; if (authLoading) return '正在授权登录...';
if (confirmed) return '您已成功确认登录,网页端将自动登录'; if (confirmed) return '您已成功确认登录,网页端将自动登录';
if (needAuth) return '检测到您是首次使用,请授权手机号完成注册并登录'; if (needAuth) return ''; // 授权页面有自己的描述
if (error) return error; if (error) return error;
if (loginMethod === 'url') { if (loginMethod === 'url') {
return '检测到登录请求,是否确认登录?'; return '检测到登录请求,是否确认登录?';
@@ -481,130 +668,93 @@ const QRConfirmPage: React.FC = () => {
// 渲染操作按钮 // 渲染操作按钮
const renderActions = () => { const renderActions = () => {
// 需要授权登录 // 需要授权登录时,按钮在授权页面内部渲染
if (needAuth) { if (needAuth) {
return ( return null;
<View className="space-y-3">
<Button
type="success"
size="large"
className="w-full rounded-xl"
open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
disabled={authLoading}
>
{authLoading ? '授权中...' : '微信手机号一键授权'}
</Button>
<Button
type="default"
size="large"
onClick={handleCancel}
className="w-full rounded-xl"
fill="none"
>
</Button>
</View>
);
} }
if (loading) { if (loading) {
return ( return (
<Button <View
type="default" className="w-full h-12 bg-gray-300 text-white text-base font-medium rounded-lg flex items-center justify-center"
size="large"
disabled
className="w-full rounded-xl"
> >
... ...
</Button> </View>
); );
} }
if (confirmed) { if (confirmed) {
return ( return (
<Button <View
type="success" className="w-full h-12 bg-green-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
size="large"
onClick={handleCancel} onClick={handleCancel}
className="w-full rounded-xl"
> >
</Button> </View>
); );
} }
if (error) { if (error) {
return ( return (
<View className="space-y-2"> <View className="space-y-3">
<Button <View
type="primary" className="w-full h-12 bg-blue-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
size="large"
onClick={handleRetry} onClick={handleRetry}
className="w-full rounded-xl"
> >
</Button> </View>
<Button <View
type="default" className="w-full h-12 bg-gray-100 text-gray-700 text-base font-medium rounded-lg flex items-center justify-center"
size="large"
onClick={handleScan} onClick={handleScan}
className="w-full rounded-xl"
> >
</Button> </View>
<Button <View
type="default" className="w-full h-10 text-gray-500 text-sm flex items-center justify-center"
size="small"
onClick={handleCancel} onClick={handleCancel}
className="w-full rounded-xl"
fill="none"
> >
</Button> </View>
</View> </View>
); );
} }
if (loginMethod === 'scan') { if (loginMethod === 'scan') {
return ( return (
<View> <View className="space-y-3">
<Button <View
type="primary" className="w-full h-12 bg-blue-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
size="large"
onClick={handleManualConfirm} onClick={handleManualConfirm}
className="w-full mb-2 rounded-xl"
disabled={!token}
> >
</Button> </View>
<Button <View
type="default" className="w-full h-12 text-gray-500 text-base flex items-center justify-center"
size="large"
onClick={handleCancel} onClick={handleCancel}
className="w-full rounded-xl"
fill="none"
> >
</Button> </View>
</View> </View>
); );
} }
return ( return (
<Button <View
type="primary" className="w-full h-12 bg-blue-500 text-white text-base font-medium rounded-lg flex items-center justify-center"
size="large"
onClick={handleManualConfirm} onClick={handleManualConfirm}
className="w-full rounded-xl"
> >
</Button> </View>
); );
}; };
// 如果是授权页面,直接返回授权页面
if (needAuth) {
return renderAuthPage();
}
return ( return (
<View className="qr-confirm-page min-h-screen bg-gradient-to-b from-blue-50 to-white"> <View className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
<View className="p-4"> <View className="p-4">
{/* Logo/品牌区域 */} {/* Logo/品牌区域 */}
<View className="text-center pt-8 pb-6"> <View className="text-center pt-8 pb-6">
@@ -621,14 +771,18 @@ const QRConfirmPage: React.FC = () => {
{renderStatusIcon()} {renderStatusIcon()}
{/* 标题 */} {/* 标题 */}
{getTitle() && (
<Text className="text-xl font-bold text-gray-800 mb-2 block"> <Text className="text-xl font-bold text-gray-800 mb-2 block">
{getTitle()} {getTitle()}
</Text> </Text>
)}
{/* 描述 */} {/* 描述 */}
{getDescription() && (
<Text className="text-gray-600 mb-6 block text-sm"> <Text className="text-gray-600 mb-6 block text-sm">
{getDescription()} {getDescription()}
</Text> </Text>
)}
{/* 用户信息 */} {/* 用户信息 */}
{!loading && !confirmed && !error && !needAuth && userInfo && ( {!loading && !confirmed && !error && !needAuth && userInfo && (