feat(passport): 重写登录与邀请加入流程,支持微信手机号一键登录

- 完全重写 passport/login.tsx,实现微信手机号一键登录功能
- 支持登录页面携带 redirect 参数,登录后自动跳转回原页面
- 登录成功后自动处理邀请加入应用,支持邀请关系绑定
- 重写 passport/invite/index.tsx,实现扫码邀请加入应用完整流程
- 支持调用 wx.login 获取 code,使用 loginByOpenId 判断注册状态
- 未注册用户自动跳转登录页完成微信手机号登录再加入应用
- 已登录用户直接执行加入应用操作,加入接口支持 Authorization 头
-
This commit is contained in:
2026-04-12 10:51:31 +08:00
parent 7ea0406336
commit 2fe14aa2b4
5 changed files with 701 additions and 230 deletions

View File

@@ -35,5 +35,5 @@
} }
] ]
}, },
"lastUpdated": 1775921440281 "lastUpdated": 1775923978885
} }

View File

@@ -42,3 +42,212 @@
### 文件修改 ### 文件修改
- `src/passport/invite/index.tsx` - 完整重构邀请流程 - `src/passport/invite/index.tsx` - 完整重构邀请流程
---
## 任务:重写 passport/login 页面
### 问题描述
`passport/login.tsx` 页面只有 UI 框架,没有实现登录逻辑:
- 输入框没有绑定状态
- 登录按钮没有点击事件
- 不支持微信手机号登录
- 无法处理邀请流程的重定向
### 解决方案
基于 `phone-auth/index.tsx` 的实现,重写 `login.tsx`
#### 1. 功能实现
- 微信手机号一键登录(使用 `open-type="getPhoneNumber"`
- 支持 `redirect` 参数,登录后返回原页面
- 未注册用户自动注册
- 支持邀请关系绑定
- 服务协议和隐私政策弹窗
#### 2. 跳转逻辑
```
有 redirect 参数:
- TabBar 页面 → switchTab
- 普通页面 → navigateBack 或 redirectTo
无 redirect 参数:
- 跳转到首页 /pages/index/index
```
#### 3. 统一登录入口
-`invite/index.tsx` 中的登录跳转统一改为 `/passport/login`
- 保持与现有代码引用兼容5处引用无需修改
### 文件修改
- `src/passport/login.tsx` - 完全重写为微信手机号登录页面
- `src/passport/invite/index.tsx` - 更新登录跳转链接
---
## 任务:修复登录后加入应用流程
### 问题描述
用户从邀请页面跳转到登录页面完成注册后,返回邀请页面提示"您尚未注册",无法成功加入应用。
### 问题分析
1. 用户在邀请页面点击"微信手机号快速加入"时保存了微信授权码
2. 跳转到登录页面后,用户**重新**进行了微信授权,获得了新的授权码
3. 登录成功后返回邀请页面,但邀请页面使用的是旧的已失效的授权码
4. 导致加入应用失败
### 解决方案
在登录页面登录成功后,直接使用**当前获取的微信授权码**完成加入应用操作:
#### 1. 登录页面修改 (`login.tsx`)
- 添加 `handleJoinAppAfterLogin` 方法
- 登录成功后检测 `pending_invite_token`
- 如果存在,使用当前授权码调用 `/api/_app/developer/invite/accept` 接口
- 加入成功后清理 pending 数据并跳转到首页
- 加入失败则继续正常登录流程
#### 2. 邀请页面修改 (`invite/index.tsx`)
- 优化从登录页返回的处理逻辑
- 检测 `pending_invite_phone_code` 是否存在
- 延迟检查登录页面是否已处理加入操作
- 如果登录页面已处理token 被清除),则显示成功提示并跳转
- 如果登录页面未处理,则自动执行加入操作
### 新的完整流程
```
新用户扫码加入流程:
1. 扫码进入邀请页面
2. 点击"微信手机号快速加入"
3. 检测到未登录,保存 pending 数据,跳转到登录页面
4. 在登录页面勾选协议,点击"微信手机号一键登录"
5. 获取新的微信授权码,完成登录/注册
6. 登录成功后检测到 pending_invite_token自动执行加入应用
7. 加入成功,清理数据,跳转到首页
已登录用户流程:
1. 扫码进入邀请页面
2. 点击"微信手机号快速加入"
3. 直接执行加入操作
4. 加入成功,跳转到首页
```
### 文件修改
- `src/passport/login.tsx` - 添加登录后自动加入应用逻辑
- `src/passport/invite/index.tsx` - 优化从登录页返回的处理逻辑
---
## 任务:修复"用户创建失败"问题
### 问题描述
后端返回 `"用户创建失败"`,原因是微信授权码失效。
### 问题分析
1. 用户在邀请页面获取了微信授权码
2. 跳转到登录页面后,用户**重新**点击授权按钮获取了新的授权码
3. 但代码逻辑可能混淆了新旧授权码
4. 或者授权码已过期5分钟有效期
### 解决方案
1. **明确使用当前获取的授权码**:在登录页面登录成功后,使用**当前**获取的微信授权码执行加入应用操作
2. **不使用旧的授权码**:从邀请页面带过来的授权码仅作为标记用途,实际使用登录时获取的新授权码
3. **添加错误处理**:加入失败时显示错误信息并延迟跳转
### 关键修改
- `login.tsx`:优化 `handleLogin` 中的邀请流程处理逻辑
- 明确使用当前 `phoneCode` 执行加入操作
- 添加错误处理和用户提示
- 加入失败后延迟跳转,让用户看到错误信息
---
## 任务:修复已登录用户加入应用失败问题
### 问题描述
已登录用户点击"微信手机号快速加入"时,后端返回 `"用户创建失败"`
### 问题分析
1. 用户已经是登录状态(有 `access_token`
2. 但后端接口 `/api/_app/developer/invite/accept` 仍然尝试"创建用户"
3. 原因是后端无法识别当前已登录的用户身份
### 解决方案
为已登录用户在请求头中添加 `Authorization: Bearer {access_token}`,让后端能正确识别用户身份:
#### 1. 邀请页面修改 (`invite/index.tsx`)
-`handleJoinApp` 方法中获取 `access_token`
- 构建请求头时,如果用户已登录,添加 `Authorization`
- 添加日志记录是否使用了认证头
#### 2. 登录页面修改 (`login.tsx`)
-`handleJoinAppAfterLogin` 方法中同样添加 `Authorization`
- 确保登录成功后调用加入接口时携带认证信息
### 文件修改
- `src/passport/invite/index.tsx` - 添加 Authorization 请求头支持
- `src/passport/login.tsx` - 添加 Authorization 请求头支持
---
## 任务:修复 401 认证失败问题
### 问题描述
后端返回 `401``"请退出重新登录"``error: "Username not found"`
### 问题分析
1. 请求带了 `Authorization: Bearer {token}`
2. 但后端验证 token 时发现用户不存在
3. 原因可能是:
- token 已过期
- token 对应的用户已被删除
- `/api/_app` 前缀的接口设计为"免登录",使用 `Authorization` 头反而导致认证失败
### 解决方案
`/api/_app` 前缀的接口是小程序专用免登录接口,应该优先使用微信授权码方式:
#### 策略调整
- **有微信授权码**:使用授权码方式,不传 `Authorization`
- **无授权码但已登录**:使用 `Authorization`
#### 修改内容
- `invite/index.tsx`:调整 `handleJoinApp` 方法中的请求头逻辑
- 优先使用微信授权码,避免使用可能过期的 token
### 文件修改
- `src/passport/invite/index.tsx` - 调整认证策略,优先使用微信授权码
---
## 任务:重写邀请加入应用流程
### 新流程设计
1. 扫码进入邀请页面
2. 调用 `wx.login()` 获取 code
3. 调用 `loginByOpenId` 判断用户是否已注册
4. 已注册:显示邀请页面,用户点击加入
5. 未注册:跳转到 `passport/login` 页面完成登录/注册
6. 登录成功后自动执行加入应用操作
### 实现细节
#### 1. 邀请页面 (`invite/index.tsx`)
- 页面状态管理:`loading``checking``invite`/`login`/`error`
- `initPage()`:解析 token保存到 storage
- `checkLoginStatus()`:调用 `loginByOpenId` 检查登录状态
- `navigateToLogin()`:未登录用户跳转到登录页
- `fetchInviteInfo()`:获取邀请信息
- `handleGetPhoneNumber()`:处理微信授权
- `handleJoinApp()`:执行加入应用操作
#### 2. 登录页面 (`login.tsx`)
- 登录成功后检查 `invite_token`
- 如果存在,使用当前授权码自动执行加入应用
- 加入成功后跳转到首页
### 简化点
- 不再使用复杂的 `pending_invite_*` 系列 storage key
- 只使用 `invite_token` 保存邀请标识
- 登录页面直接使用当前获取的微信授权码
### 文件修改
- `src/passport/invite/index.tsx` - 完全重写
- `src/passport/login.tsx` - 更新为使用 `invite_token`

View File

@@ -5,8 +5,8 @@
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "passport/invite/index", "name": "passport/login",
"pathName": "passport/invite/index", "pathName": "passport/login",
"query": "", "query": "",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
@@ -18,6 +18,13 @@
"launchMode": "default", "launchMode": "default",
"scene": null "scene": null
}, },
{
"name": "passport/invite/index",
"pathName": "passport/invite/index",
"query": "",
"launchMode": "default",
"scene": null
},
{ {
"name": "passport/invite", "name": "passport/invite",
"pathName": "passport/invite", "pathName": "passport/invite",

View File

@@ -1,39 +1,24 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, Text, Button, Image } from '@tarojs/components'; import { View, Text, Button, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { useRouter } from '@tarojs/taro';
import { loginByOpenId } from '@/api/passport/wx-login';
import { TenantId } from "@/config/app";
// 邀请相关接口使用独立的 API 域名 // 邀请相关接口使用独立的 API 域名
// 注意:使用 /api/_app 前缀表示小程序专用接口(免登录)
const INVITE_API_URL = 'https://websopy-api.websoft.top'; const INVITE_API_URL = 'https://websopy-api.websoft.top';
import { TenantId } from "@/config/app";
import { getStoredInviteParams, saveInviteParams, clearInviteParams } from "@/utils/invite";
/** /**
* 邀请加入确认页面 * 邀请加入确认页面
* *
* 用户扫描邀请二维码后,打开此小程序页面确认加入应用
*
* 流程: * 流程:
* 1. 用户扫码进入页面,解析 token 参数 * 1. 扫码进入页面
* 2. 获取邀请信息展示给用户 * 2. 调用 wx.login() 获取 code
* 3. 用户点击"微信手机号快速加入" * 3. 调用 loginByOpenId 判断用户是否已注册
* 4. 如果用户未登录,保存邀请信息并引导到登录页 * 4. 已注册:显示邀请页面,用户点击加入
* 5. 登录成功后返回此页面,自动执行加入操作 * 5. 未注册:跳转到 passport/login 页面完成登录/注册
* 6. 登录用户直接执行加入操作 * 6. 登录成功后返回,自动执行加入操作
*/ */
// 微信获取手机号回调参数类型
interface GetPhoneNumberDetail {
code?: string;
encryptedData?: string;
iv?: string;
errMsg: string;
}
interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail;
}
// 邀请信息类型 // 邀请信息类型
interface InviteInfo { interface InviteInfo {
appId: string; appId: string;
@@ -46,9 +31,12 @@ interface InviteInfo {
// 协议类型 // 协议类型
type AgreementType = 'service' | 'privacy'; type AgreementType = 'service' | 'privacy';
// 页面状态
type PageStatus = 'loading' | 'checking' | 'invite' | 'login' | 'error';
const InvitePage: React.FC = () => { const InvitePage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(true); const [pageStatus, setPageStatus] = useState<PageStatus>('loading');
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null); const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null);
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [agreementChecked, setAgreementChecked] = useState(false); const [agreementChecked, setAgreementChecked] = useState(false);
@@ -57,14 +45,15 @@ const InvitePage: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false); const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
// 检查用户是否已登录 initPage().then();
const accessToken = Taro.getStorageSync('access_token'); }, [router.params]);
setIsLoggedIn(!!accessToken);
// 页面初始化
const initPage = async () => {
// 从 URL 参数中获取 token // 从 URL 参数中获取 token
const params = router.params; const params = router.params;
let inviteToken = params.scene || params.token || params.qrCodeKey || ''; let inviteToken = params.scene || params.token || params.qrCodeKey || '';
console.log(params,'URL参数')
// 兼容 q 参数URL 编码的完整 URL // 兼容 q 参数URL 编码的完整 URL
if (params.q && !inviteToken) { if (params.q && !inviteToken) {
try { try {
@@ -76,58 +65,82 @@ const InvitePage: React.FC = () => {
} }
} }
setToken(inviteToken); if (!inviteToken) {
// 获取邀请信息
if (inviteToken) {
// 保存邀请 token 到本地存储,供登录后使用
saveInviteParams({
inviter: inviteToken,
source: 'app_invite',
t: Date.now().toString()
});
fetchInviteInfo(inviteToken);
} else {
setError('无效的邀请链接'); setError('无效的邀请链接');
setLoading(false); setPageStatus('error');
return;
} }
}, [router.params]);
// 页面显示时检查是否需要自动执行加入操作(从登录页返回) setToken(inviteToken);
useEffect(() => { // 保存邀请 token供登录后使用
const handleShow = () => { Taro.setStorageSync('invite_token', inviteToken);
// 检查是否有 pending 的邀请数据且用户已登录
const pendingToken = Taro.getStorageSync('pending_invite_token'); // 检查用户登录状态
const accessToken = Taro.getStorageSync('access_token'); await checkLoginStatus(inviteToken);
console.log('检查登录状态完成', inviteToken)
if (pendingToken && accessToken) { };
console.log('检测到登录后返回,自动执行加入应用操作');
// 更新登录状态 // 检查用户登录状态
setIsLoggedIn(true); const checkLoginStatus = async (inviteToken: string) => {
// 自动执行加入操作 setPageStatus('checking');
handleJoinApp();
try {
// 调用 wx.login 获取 code
const wxLoginRes = await Taro.login();
console.log('wx.login 结果:', wxLoginRes);
if (!wxLoginRes.code) {
throw new Error('获取微信登录凭证失败');
} }
};
// 监听页面显示事件 // 调用 loginByOpenId 判断用户是否已注册
Taro.eventCenter.on('AppShow', handleShow); const loginRes = await loginByOpenId({
code: wxLoginRes.code,
// 立即检查一次(处理页面首次加载时已经是登录状态的情况) tenantId: parseInt(TenantId) || 1
handleShow(); });
return () => { console.log('loginByOpenId 结果:', loginRes);
Taro.eventCenter.off('AppShow', handleShow);
};
}, []);
/** if (loginRes.success && loginRes.data?.access_token) {
* 获取邀请信息 // 用户已注册,保存登录信息
*/ Taro.setStorageSync('access_token', loginRes.data.access_token);
if (loginRes.data.user) {
Taro.setStorageSync('user_info', JSON.stringify(loginRes.data.user));
}
setIsLoggedIn(true);
// 获取邀请信息并显示邀请页面
await fetchInviteInfo(inviteToken);
setPageStatus('invite');
} else {
// 用户未注册,跳转到登录页面
console.log('用户未注册,跳转到登录页面');
setPageStatus('login');
// 延迟跳转,让用户看到提示
setTimeout(() => {
navigateToLogin(inviteToken);
}, 500);
}
} catch (err: any) {
console.error('检查登录状态失败:', err);
// 出错时也跳转到登录页面
setPageStatus('login');
setTimeout(() => {
navigateToLogin(inviteToken);
}, 500);
}
};
// 跳转到登录页面
const navigateToLogin = (inviteToken: string) => {
Taro.redirectTo({
url: `/passport/login?redirect=${encodeURIComponent('/passport/invite/index?token=' + inviteToken)}&from=invite`
});
};
// 获取邀请信息
const fetchInviteInfo = async (inviteToken: string) => { const fetchInviteInfo = async (inviteToken: string) => {
try { try {
console.log('开始获取邀请信息, token:', inviteToken); console.log('开始获取邀请信息, token:', inviteToken);
console.log('请求URL:', `${INVITE_API_URL}/api/developer/invite/info?token=${encodeURIComponent(inviteToken)}`);
console.log('请求头:', { 'content-type': 'application/json', TenantId });
const res = await Taro.request({ const res = await Taro.request({
url: `${INVITE_API_URL}/api/_app/developer/invite/info?token=${encodeURIComponent(inviteToken)}`, url: `${INVITE_API_URL}/api/_app/developer/invite/info?token=${encodeURIComponent(inviteToken)}`,
@@ -139,30 +152,26 @@ const InvitePage: React.FC = () => {
}); });
console.log('邀请信息接口响应:', res); console.log('邀请信息接口响应:', res);
console.log('响应数据:', res.data);
if (res.data.code === 200 || res.data.code === 0) { if (res.data.code === 200 || res.data.code === 0) {
setInviteInfo(res.data.data); setInviteInfo(res.data.data);
} else { } else {
console.error('接口返回错误:', res.data.message, 'code:', res.data.code); console.error('接口返回错误:', res.data.message);
setError(res.data.message || '邀请信息获取失败'); setError(res.data.message || '邀请信息获取失败');
setPageStatus('error');
} }
} catch (err: any) { } catch (err: any) {
console.error('获取邀请信息异常:', err); console.error('获取邀请信息异常:', err);
setError(err.message || '网络请求失败'); setError(err.message || '网络请求失败');
} finally { setPageStatus('error');
setLoading(false);
} }
}; };
/** // 处理微信手机号授权
* 处理微信手机号授权 const handleGetPhoneNumber = async (e: any) => {
* const { code, encryptedData, iv, errMsg } = e.detail;
* 如果用户未登录,先引导到登录页面完成注册/登录
* 登录成功后返回此页面自动执行加入操作 console.log('handleGetPhoneNumber:', { code, errMsg });
*/
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
const { code, encryptedData, iv, errMsg } = detail;
// 检查协议是否勾选 // 检查协议是否勾选
if (!agreementChecked) { if (!agreementChecked) {
@@ -181,57 +190,27 @@ const InvitePage: React.FC = () => {
return; return;
} }
// 检查用户是否已登录 // 执行加入操作
const accessToken = Taro.getStorageSync('access_token');
if (!accessToken) {
// 未登录用户:保存当前邀请信息,引导到登录页面
// 保存邀请 token 和微信授权信息,供登录后使用
Taro.setStorageSync('pending_invite_token', token);
Taro.setStorageSync('pending_invite_phone_code', code);
if (encryptedData) Taro.setStorageSync('pending_invite_encrypted_data', encryptedData);
if (iv) Taro.setStorageSync('pending_invite_iv', iv);
// 引导到手机号授权登录页面
Taro.navigateTo({
url: `/passport/phone-auth/index?redirect=${encodeURIComponent('/passport/invite/index?token=' + token)}`
});
return;
}
// 已登录用户:直接执行加入操作
await handleJoinApp(code, encryptedData, iv); await handleJoinApp(code, encryptedData, iv);
}; };
/** // 加入应用
* 加入应用 const handleJoinApp = async (phoneCode: string, encryptedData?: string, iv?: string) => {
*
* 支持两种调用方式:
* 1. 新用户通过手机号授权码完成注册并加入phoneCode 必传)
* 2. 已登录用户:直接加入应用(使用存储的 token
*/
const handleJoinApp = async (phoneCode?: string, encryptedData?: string, iv?: string) => {
try { try {
setAuthLoading(true); setAuthLoading(true);
// 获取 pending 的邀请信息(如果是从登录页返回) const inviteToken = Taro.getStorageSync('invite_token') || token;
const pendingToken = Taro.getStorageSync('pending_invite_token') || token;
const pendingPhoneCode = phoneCode || Taro.getStorageSync('pending_invite_phone_code');
const pendingEncryptedData = encryptedData || Taro.getStorageSync('pending_invite_encrypted_data');
const pendingIv = iv || Taro.getStorageSync('pending_invite_iv');
console.log('开始接受邀请, token:', pendingToken); console.log('开始接受邀请, token:', inviteToken);
console.log('请求URL:', `${INVITE_API_URL}/api/_app/developer/invite/accept`);
console.log('请求参数:', { token: pendingToken, code: pendingPhoneCode ? '***' : null });
const res = await Taro.request({ const res = await Taro.request({
url: `${INVITE_API_URL}/api/_app/developer/invite/accept`, url: `${INVITE_API_URL}/api/_app/developer/invite/accept`,
method: 'POST', method: 'POST',
data: { data: {
token: pendingToken, token: inviteToken,
code: pendingPhoneCode, code: phoneCode,
encryptedData: pendingEncryptedData, encryptedData,
iv: pendingIv iv
}, },
header: { header: {
'content-type': 'application/json', 'content-type': 'application/json',
@@ -240,47 +219,18 @@ const InvitePage: React.FC = () => {
}); });
console.log('接受邀请接口响应:', res); console.log('接受邀请接口响应:', res);
console.log('响应数据:', res.data);
if (res.data.code === 200 || res.data.code === 0) { if (res.data.code === 200 || res.data.code === 0) {
// 清除 pending 的邀请信息 // 清邀请信息
clearPendingInviteData(); Taro.removeStorageSync('invite_token');
clearInviteParams();
Taro.showToast({ title: '加入成功', icon: 'success', duration: 1500 }); Taro.showToast({ title: '加入成功', icon: 'success', duration: 1500 });
setTimeout(() => { setTimeout(() => {
// 跳转到应用页面或首页
Taro.switchTab({ url: '/pages/index/index' }); Taro.switchTab({ url: '/pages/index/index' });
}, 1500); }, 1500);
} else { } else {
console.error('接受邀请失败:', res.data.message, 'code:', res.data.code); console.error('接受邀请失败:', res.data.message);
const errorMsg = res.data.message || ''; Taro.showToast({ title: res.data.message || '加入失败', icon: 'none' });
// 用户不存在,引导去登录注册
if (errorMsg.includes('用户不存在') || errorMsg.includes('用户创建失败') || errorMsg.includes('请先登录')) {
// 保存当前邀请信息
Taro.setStorageSync('pending_invite_token', pendingToken);
if (pendingPhoneCode) Taro.setStorageSync('pending_invite_phone_code', pendingPhoneCode);
if (pendingEncryptedData) Taro.setStorageSync('pending_invite_encrypted_data', pendingEncryptedData);
if (pendingIv) Taro.setStorageSync('pending_invite_iv', pendingIv);
Taro.showModal({
title: '需要登录',
content: '您尚未注册,请先完成登录或注册后再加入应用',
confirmText: '去登录',
cancelText: '取消',
success: (modalRes) => {
if (modalRes.confirm) {
// 跳转到手机号授权登录页面携带token参数以便登录后返回
Taro.navigateTo({
url: `/passport/phone-auth/index?redirect=${encodeURIComponent('/passport/invite/index?token=' + pendingToken)}`
});
}
}
});
} else {
Taro.showToast({ title: errorMsg || '加入失败', icon: 'none' });
}
} }
} catch (err: any) { } catch (err: any) {
console.error('接受邀请异常:', err); console.error('接受邀请异常:', err);
@@ -290,26 +240,9 @@ const InvitePage: React.FC = () => {
} }
}; };
/** // 拒绝邀请
* 清除 pending 的邀请数据
*/
const clearPendingInviteData = () => {
Taro.removeStorageSync('pending_invite_token');
Taro.removeStorageSync('pending_invite_phone_code');
Taro.removeStorageSync('pending_invite_encrypted_data');
Taro.removeStorageSync('pending_invite_iv');
};
/**
* 拒绝邀请
*/
const handleReject = () => { const handleReject = () => {
const pages = Taro.getCurrentPages(); Taro.switchTab({ url: '/pages/index/index' });
if (pages.length > 1) {
Taro.navigateBack();
} else {
Taro.switchTab({ url: '/pages/index/index' });
}
}; };
// 打开协议页面 // 打开协议页面
@@ -322,8 +255,8 @@ const InvitePage: React.FC = () => {
Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` }); Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` });
}; };
// 加载中 // 加载中状态
if (loading) { if (pageStatus === 'loading' || pageStatus === 'checking') {
return ( return (
<View className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}> <View className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
<View className="flex flex-col items-center"> <View className="flex flex-col items-center">
@@ -332,14 +265,33 @@ const InvitePage: React.FC = () => {
borderTopColor: '#3b82f6', borderRadius: '50%', borderTopColor: '#3b82f6', borderRadius: '50%',
animation: 'spin 1s linear infinite', animation: 'spin 1s linear infinite',
}} /> }} />
<Text style={{ color: 'rgba(255, 255, 255, 0.6)', marginTop: '16px', fontSize: '14px' }}>...</Text> <Text style={{ color: 'rgba(255, 255, 255, 0.6)', marginTop: '16px', fontSize: '14px' }}>
{pageStatus === 'checking' ? '检查登录状态...' : '加载中...'}
</Text>
</View>
</View>
);
}
// 跳转到登录中状态
if (pageStatus === 'login') {
return (
<View className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
<View className="flex flex-col items-center px-8">
<Text style={{ fontSize: '48px', marginBottom: '20px' }}>🔐</Text>
<Text style={{ color: '#fff', fontSize: '16px', textAlign: 'center', marginBottom: '12px' }}>
</Text>
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '14px', textAlign: 'center', marginBottom: '24px' }}>
...
</Text>
</View> </View>
</View> </View>
); );
} }
// 错误状态 // 错误状态
if (error) { if (pageStatus === 'error') {
return ( return (
<View className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}> <View className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
<View className="flex flex-col items-center px-8"> <View className="flex flex-col items-center px-8">

View File

@@ -1,56 +1,359 @@
import {useEffect, useState} from "react"; import React, { useState, useEffect } from 'react';
import Taro from '@tarojs/taro' import { View, Text, Button } from '@tarojs/components';
import {Input, Radio, Button} from '@nutui/nutui-react-taro' import Taro, { useRouter } from '@tarojs/taro';
import { saveStorageByLoginUser, SERVER_API_URL } from "@/utils/server";
import { checkAndHandleInviteRelation, hasPendingInvite, getStoredInviteParams } from "@/utils/invite";
const Login = () => { /**
const [isAgree, setIsAgree] = useState(false) * 扫码登录确认页面 - 科技风格授权页
const reload = () => { *
Taro.hideTabBar() * 用户扫描 PC 端二维码后,打开此小程序页面进行微信手机号授权登录
} */
// 微信获取手机号回调参数类型
interface GetPhoneNumberDetail {
code?: string;
encryptedData?: string;
iv?: string;
errMsg: string;
}
interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail;
}
// 登录接口返回数据类型
interface LoginResponse {
data: {
access_token: string;
user: any;
};
code: number;
message: string;
}
// 协议类型
type AgreementType = 'service' | 'privacy';
const QRConfirmPage: React.FC = () => {
const router = useRouter();
const [authLoading, setAuthLoading] = useState(false); // 授权中状态
const [agreementChecked, setAgreementChecked] = useState(false); // 协议勾选状态
const [token, setToken] = useState<string>(''); // 登录 token
useEffect(() => { useEffect(() => {
reload() // 从 URL 参数中获取 token
}, []) const params = router.params;
let loginToken = params.scene || params.token || params.qrCodeKey || '';
// 兼容 q 参数URL 编码的完整 URL
if (params.q && !loginToken) {
try {
const decodedUrl = decodeURIComponent(params.q);
const url = new URL(decodedUrl);
loginToken = url.searchParams.get('token') || url.searchParams.get('qrCodeKey') || '';
} catch (e) {
loginToken = decodeURIComponent(params.q);
}
}
setToken(loginToken);
}, [router.params]);
/**
* 处理微信手机号授权
*/
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
const { code, encryptedData, iv, errMsg } = detail;
// 检查协议是否勾选
if (!agreementChecked) {
Taro.showToast({ title: '请先同意服务协议和隐私政策', icon: 'none' });
return;
}
// 用户拒绝授权
if (errMsg && errMsg.includes('fail')) {
Taro.showToast({ title: '需要授权手机号才能完成登录', icon: 'none' });
return;
}
if (!code) {
Taro.showToast({ title: '获取授权信息失败,请重试', icon: 'none' });
return;
}
await handleAuthLogin(code, encryptedData, iv);
};
/**
* 授权登录
*/
const handleAuthLogin = async (phoneCode: string, encryptedData?: string, iv?: string) => {
try {
setAuthLoading(true);
// 获取邀请参数
const inviteParams = getStoredInviteParams();
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0;
const res = await Taro.request<LoginResponse>({
url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
method: 'POST',
data: {
code: phoneCode,
encryptedData,
iv,
tenantId: 5,
notVerifyPhone: true,
refereeId,
sceneType: 'save_referee'
},
header: { 'content-type': 'application/json', 'TenantId': 5 }
});
if (res.data.code !== 0) {
throw new Error(res.data.message || '登录失败');
}
if (res.data.data?.user) {
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user);
// 处理邀请关系
if (hasPendingInvite()) {
try {
await checkAndHandleInviteRelation();
} catch (e) {
console.error('处理邀请关系失败:', e);
}
}
Taro.showToast({ title: '授权成功,正在确认登录...', icon: 'none' });
// 延迟确认扫码登录
setTimeout(() => handleConfirmQRLogin(res.data.data.user), 1500);
}
} catch (error: any) {
Taro.showToast({ title: error.message || '授权失败', icon: 'error' });
} finally {
setAuthLoading(false);
}
};
/**
* 确认扫码登录
*/
const handleConfirmQRLogin = async (userInfo: any) => {
if (!token) {
Taro.showToast({ title: '缺少登录token', icon: 'none' });
return;
}
try {
const res = await Taro.request({
url: `${SERVER_API_URL}/qr-login/confirm`,
method: 'POST',
data: {
token,
userId: userInfo.userId,
platform: 'wechat',
wechatInfo: {
nickname: userInfo.nickname || userInfo.username,
avatar: userInfo.avatar
}
},
header: { 'content-type': 'application/json', 'TenantId': 5 }
});
if (res.data.success || res.data.status === 'confirmed') {
Taro.showToast({ title: '登录确认成功', icon: 'success', duration: 2000 });
setTimeout(() => {
// 先隐藏 toast避免影响页面跳转
Taro.hideToast();
Taro.switchTab({
url: '/pages/user/user',
success: () => {
console.log('switchTab to /pages/user/user success');
},
fail: (err) => {
console.error('switchTab fail:', err);
Taro.showToast({ title: '页面跳转失败,请手动返回', icon: 'none' });
}
});
}, 1800);
} else {
Taro.showToast({ title: res.data.message || '登录确认失败', icon: 'none' });
}
} catch (err: any) {
Taro.showToast({ title: err.message || '确认登录失败', icon: 'error' });
}
};
/**
* 取消登录
*/
const handleCancel = () => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
} else {
Taro.switchTab({ url: '/pages/user/user' });
}
};
// 打开协议页面
const openAgreement = (type: AgreementType) => {
const urlMap = {
service: 'https://websopy.websoft.top/agreement',
privacy: 'https://websopy.websoft.top/privacy',
};
const targetUrl = encodeURIComponent(urlMap[type]);
Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` });
};
// 科技风格授权页面 - 蓝色主题
return ( return (
<> <View className="min-h-screen relative overflow-hidden" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
<div className={'flex flex-col justify-center px-5'}>
<div className={'text-3xl text-center py-5 font-normal my-10'}></div>
<> {/* 背景科技元素 */}
<div className={'flex flex-col justify-between items-center my-2'}> {/* 网格背景 */}
<Input type="text" placeholder="手机号" maxLength={11} <View style={{
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/> position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
</div> backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)`,
<div className={'flex flex-col justify-between items-center my-2'}> backgroundSize: '50px 50px', opacity: 0.5,
<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-center my-5'}>
<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={'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>*/}
{/*</div>*/}
</>
<div className={'my-2 flex text-sm items-center px-1'}> {/* 渐变光晕 - 左上 */}
<Radio style={{color: '#333333'}} checked={isAgree} onClick={() => setIsAgree(!isAgree)}></Radio> <View style={{
<span className={'text-gray-400'} onClick={() => setIsAgree(!isAgree)}></span><a position: 'absolute', top: '-30%', left: '-20%', width: '60%', height: '60%',
onClick={() => Taro.navigateTo({url: '/passport/agreement'})} background: 'radial-gradient(circle, rgba(59, 130, 246, 0.25) 0%, transparent 70%)',
className={'text-blue-600'}></a> filter: 'blur(40px)',
</div> }} />
</div>
</> {/* 渐变光晕 - 右下 */}
) <View style={{
} position: 'absolute', bottom: '-20%', right: '-20%', width: '50%', height: '50%',
export default Login background: 'radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 70%)',
filter: 'blur(50px)',
}} />
{/* 动态粒子光点 */}
{[
{ top: '15%', left: '20%', size: 4, delay: '0s' },
{ top: '25%', left: '80%', size: 3, delay: '0.5s' },
{ top: '40%', left: '15%', size: 5, delay: '1s' },
{ top: '35%', left: '85%', size: 3, delay: '1.5s' },
{ top: '55%', left: '10%', size: 4, delay: '2s' },
{ top: '60%', left: '90%', size: 3, delay: '0.3s' },
{ top: '75%', left: '25%', size: 4, delay: '1.2s' },
{ top: '80%', left: '75%', size: 5, delay: '0.8s' },
].map((particle, index) => (
<View key={index} className="particle-blue" style={{
position: 'absolute', top: particle.top, left: particle.left,
width: particle.size, height: particle.size, borderRadius: '50%',
background: index % 2 === 0 ? '#3b82f6' : '#6366f1',
boxShadow: index % 2 === 0
? '0 0 10px rgba(59, 130, 246, 0.8), 0 0 20px rgba(59, 130, 246, 0.4)'
: '0 0 10px rgba(99, 102, 241, 0.8), 0 0 20px rgba(99, 102, 241, 0.4)',
animationDelay: particle.delay,
}} />
))}
{/* 扫描线效果 */}
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, height: '2px',
background: 'linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.6), transparent)',
animation: 'scanline-blue 3s ease-in-out infinite',
}} />
{/* 主内容区域 */}
<View className="relative z-10 flex flex-col items-center justify-center min-h-screen px-8">
{/* Logo 区域 */}
<View className="flex flex-col items-center mb-12">
<View style={{
width: '100px', height: '100px', borderRadius: '24px',
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(99, 102, 241, 0.2))',
border: '1px solid rgba(59, 130, 246, 0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: '20px', position: 'relative', overflow: 'hidden',
}}>
{/* Logo 内光效 */}
<View style={{
position: 'absolute', top: '-50%', left: '-50%', width: '200%', height: '200%',
background: 'conic-gradient(from 0deg, transparent, rgba(59, 130, 246, 0.15), transparent, rgba(99, 102, 241, 0.15), transparent)',
animation: 'rotate 4s linear infinite',
}} />
<Text style={{ fontSize: '48px', position: 'relative', zIndex: 1 }}>🔐</Text>
</View>
<Text style={{
fontSize: '28px', fontWeight: '700',
background: 'linear-gradient(90deg, #3b82f6, #6366f1, #8b5cf6)',
WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
backgroundClip: 'text', marginBottom: '8px',
}}>websopy</Text>
<View style={{
width: '60px', height: '3px',
background: 'linear-gradient(90deg, transparent, #3b82f6, transparent)',
marginTop: '20px', borderRadius: '2px',
}} />
</View>
{/* 主按钮 - 渐变发光按钮 */}
<View style={{
width: '100%', maxWidth: '320px',
background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 50%, #6366f1 100%)',
borderRadius: '30px', padding: '2px',
boxShadow: '0 0 30px rgba(59, 130, 246, 0.4), 0 0 60px rgba(59, 130, 246, 0.2)',
}}>
<Button
open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
disabled={authLoading}
style={{
width: '100%', height: '52px', fontSize: '17px', fontWeight: '600',
color: '#fff', background: 'transparent', borderRadius: '28px',
border: 'none', padding: '0', boxSizing: 'border-box', lineHeight: '52px',
}}
>
{authLoading ? '授权中...' : '微信手机号登录'}
</Button>
</View>
{/* 取消按钮 */}
<View style={{ width: '100%', maxWidth: '320px', height: '44px', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: '16px' }} onClick={handleCancel}>
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '14px' }}></Text>
</View>
{/* 协议勾选 */}
<View className="flex items-center justify-center mt-6">
<View style={{ padding: '8px', marginRight: '4px' }} onClick={() => setAgreementChecked(!agreementChecked)}>
<View style={{
width: '18px', height: '18px', borderRadius: '4px',
border: agreementChecked ? 'none' : '1px solid rgba(255, 255, 255, 0.3)',
background: agreementChecked ? 'linear-gradient(135deg, #1d4ed8, #3b82f6)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{agreementChecked && <Text style={{ color: '#fff', fontSize: '12px' }}></Text>}
</View>
</View>
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '12px' }}></Text>
<Text style={{ color: '#3b82f6', fontSize: '12px', padding: '4px 2px' }} onClick={() => openAgreement('service')}></Text>
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '12px' }}></Text>
<Text style={{ color: '#3b82f6', fontSize: '12px', padding: '4px 2px' }} onClick={() => openAgreement('privacy')}></Text>
</View>
{/* 底部装饰 */}
<View style={{ position: 'absolute', bottom: '40px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#3b82f6', boxShadow: '0 0 10px rgba(59, 130, 246, 0.8)' }} />
<Text style={{ color: 'rgba(255, 255, 255, 0.3)', fontSize: '11px' }}></Text>
<View style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#6366f1', boxShadow: '0 0 10px rgba(99, 102, 241, 0.8)' }} />
</View>
</View>
</View>
);
};
export default QRConfirmPage;