diff --git a/.workbuddy/expert-history.json b/.workbuddy/expert-history.json index 2c5ec6c..a718924 100644 --- a/.workbuddy/expert-history.json +++ b/.workbuddy/expert-history.json @@ -35,5 +35,5 @@ } ] }, - "lastUpdated": 1775921440281 + "lastUpdated": 1775923978885 } \ No newline at end of file diff --git a/.workbuddy/memory/2026-04-11.md b/.workbuddy/memory/2026-04-11.md index 5f4a066..6f33f1f 100644 --- a/.workbuddy/memory/2026-04-11.md +++ b/.workbuddy/memory/2026-04-11.md @@ -42,3 +42,212 @@ ### 文件修改 - `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` diff --git a/project.private.config.json b/project.private.config.json index ab188a4..5b48130 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -5,8 +5,8 @@ "miniprogram": { "list": [ { - "name": "passport/invite/index", - "pathName": "passport/invite/index", + "name": "passport/login", + "pathName": "passport/login", "query": "", "scene": null, "launchMode": "default" @@ -18,6 +18,13 @@ "launchMode": "default", "scene": null }, + { + "name": "passport/invite/index", + "pathName": "passport/invite/index", + "query": "", + "launchMode": "default", + "scene": null + }, { "name": "passport/invite", "pathName": "passport/invite", diff --git a/src/passport/invite/index.tsx b/src/passport/invite/index.tsx index 9d1d083..564bcf4 100644 --- a/src/passport/invite/index.tsx +++ b/src/passport/invite/index.tsx @@ -1,39 +1,24 @@ import React, { useState, useEffect } from 'react'; import { View, Text, Button, Image } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; +import { loginByOpenId } from '@/api/passport/wx-login'; +import { TenantId } from "@/config/app"; // 邀请相关接口使用独立的 API 域名 -// 注意:使用 /api/_app 前缀表示小程序专用接口(免登录) const INVITE_API_URL = 'https://websopy-api.websoft.top'; -import { TenantId } from "@/config/app"; -import { getStoredInviteParams, saveInviteParams, clearInviteParams } from "@/utils/invite"; /** * 邀请加入确认页面 * - * 用户扫描邀请二维码后,打开此小程序页面确认加入应用 - * * 流程: - * 1. 用户扫码进入页面,解析 token 参数 - * 2. 获取邀请信息展示给用户 - * 3. 用户点击"微信手机号快速加入" - * 4. 如果用户未登录,保存邀请信息并引导到登录页 - * 5. 登录成功后返回此页面,自动执行加入操作 - * 6. 已登录用户直接执行加入操作 + * 1. 扫码进入页面 + * 2. 调用 wx.login() 获取 code + * 3. 调用 loginByOpenId 判断用户是否已注册 + * 4. 已注册:显示邀请页面,用户点击加入 + * 5. 未注册:跳转到 passport/login 页面完成登录/注册 + * 6. 登录成功后返回,自动执行加入操作 */ -// 微信获取手机号回调参数类型 -interface GetPhoneNumberDetail { - code?: string; - encryptedData?: string; - iv?: string; - errMsg: string; -} - -interface GetPhoneNumberEvent { - detail: GetPhoneNumberDetail; -} - // 邀请信息类型 interface InviteInfo { appId: string; @@ -46,9 +31,12 @@ interface InviteInfo { // 协议类型 type AgreementType = 'service' | 'privacy'; +// 页面状态 +type PageStatus = 'loading' | 'checking' | 'invite' | 'login' | 'error'; + const InvitePage: React.FC = () => { const router = useRouter(); - const [loading, setLoading] = useState(true); + const [pageStatus, setPageStatus] = useState('loading'); const [inviteInfo, setInviteInfo] = useState(null); const [authLoading, setAuthLoading] = useState(false); const [agreementChecked, setAgreementChecked] = useState(false); @@ -57,14 +45,15 @@ const InvitePage: React.FC = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); useEffect(() => { - // 检查用户是否已登录 - const accessToken = Taro.getStorageSync('access_token'); - setIsLoggedIn(!!accessToken); + initPage().then(); + }, [router.params]); + // 页面初始化 + const initPage = async () => { // 从 URL 参数中获取 token const params = router.params; let inviteToken = params.scene || params.token || params.qrCodeKey || ''; - + console.log(params,'URL参数') // 兼容 q 参数(URL 编码的完整 URL) if (params.q && !inviteToken) { try { @@ -76,58 +65,82 @@ const InvitePage: React.FC = () => { } } - setToken(inviteToken); - - // 获取邀请信息 - if (inviteToken) { - // 保存邀请 token 到本地存储,供登录后使用 - saveInviteParams({ - inviter: inviteToken, - source: 'app_invite', - t: Date.now().toString() - }); - fetchInviteInfo(inviteToken); - } else { + if (!inviteToken) { setError('无效的邀请链接'); - setLoading(false); + setPageStatus('error'); + return; } - }, [router.params]); - // 页面显示时检查是否需要自动执行加入操作(从登录页返回) - useEffect(() => { - const handleShow = () => { - // 检查是否有 pending 的邀请数据且用户已登录 - const pendingToken = Taro.getStorageSync('pending_invite_token'); - const accessToken = Taro.getStorageSync('access_token'); - - if (pendingToken && accessToken) { - console.log('检测到登录后返回,自动执行加入应用操作'); - // 更新登录状态 - setIsLoggedIn(true); - // 自动执行加入操作 - handleJoinApp(); + setToken(inviteToken); + // 保存邀请 token,供登录后使用 + Taro.setStorageSync('invite_token', inviteToken); + + // 检查用户登录状态 + await checkLoginStatus(inviteToken); + console.log('检查登录状态完成', inviteToken) + }; + + // 检查用户登录状态 + const checkLoginStatus = async (inviteToken: string) => { + setPageStatus('checking'); + + try { + // 调用 wx.login 获取 code + const wxLoginRes = await Taro.login(); + console.log('wx.login 结果:', wxLoginRes); + + if (!wxLoginRes.code) { + throw new Error('获取微信登录凭证失败'); } - }; - // 监听页面显示事件 - Taro.eventCenter.on('AppShow', handleShow); - - // 立即检查一次(处理页面首次加载时已经是登录状态的情况) - handleShow(); + // 调用 loginByOpenId 判断用户是否已注册 + const loginRes = await loginByOpenId({ + code: wxLoginRes.code, + tenantId: parseInt(TenantId) || 1 + }); - return () => { - Taro.eventCenter.off('AppShow', handleShow); - }; - }, []); + console.log('loginByOpenId 结果:', loginRes); - /** - * 获取邀请信息 - */ + 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) => { try { 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({ 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.data); if (res.data.code === 200 || res.data.code === 0) { setInviteInfo(res.data.data); } else { - console.error('接口返回错误:', res.data.message, 'code:', res.data.code); + console.error('接口返回错误:', res.data.message); setError(res.data.message || '邀请信息获取失败'); + setPageStatus('error'); } } catch (err: any) { console.error('获取邀请信息异常:', err); setError(err.message || '网络请求失败'); - } finally { - setLoading(false); + setPageStatus('error'); } }; - /** - * 处理微信手机号授权 - * - * 如果用户未登录,先引导到登录页面完成注册/登录 - * 登录成功后返回此页面自动执行加入操作 - */ - const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => { - const { code, encryptedData, iv, errMsg } = detail; + // 处理微信手机号授权 + const handleGetPhoneNumber = async (e: any) => { + const { code, encryptedData, iv, errMsg } = e.detail; + + console.log('handleGetPhoneNumber:', { code, errMsg }); // 检查协议是否勾选 if (!agreementChecked) { @@ -181,57 +190,27 @@ const InvitePage: React.FC = () => { 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); }; - /** - * 加入应用 - * - * 支持两种调用方式: - * 1. 新用户:通过手机号授权码完成注册并加入(phoneCode 必传) - * 2. 已登录用户:直接加入应用(使用存储的 token) - */ - const handleJoinApp = async (phoneCode?: string, encryptedData?: string, iv?: string) => { + // 加入应用 + const handleJoinApp = async (phoneCode: string, encryptedData?: string, iv?: string) => { try { setAuthLoading(true); - // 获取 pending 的邀请信息(如果是从登录页返回) - 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'); + const inviteToken = Taro.getStorageSync('invite_token') || token; - console.log('开始接受邀请, token:', pendingToken); - console.log('请求URL:', `${INVITE_API_URL}/api/_app/developer/invite/accept`); - console.log('请求参数:', { token: pendingToken, code: pendingPhoneCode ? '***' : null }); + console.log('开始接受邀请, token:', inviteToken); const res = await Taro.request({ url: `${INVITE_API_URL}/api/_app/developer/invite/accept`, method: 'POST', data: { - token: pendingToken, - code: pendingPhoneCode, - encryptedData: pendingEncryptedData, - iv: pendingIv + token: inviteToken, + code: phoneCode, + encryptedData, + iv }, header: { 'content-type': 'application/json', @@ -240,47 +219,18 @@ const InvitePage: React.FC = () => { }); console.log('接受邀请接口响应:', res); - console.log('响应数据:', res.data); if (res.data.code === 200 || res.data.code === 0) { - // 清除 pending 的邀请信息 - clearPendingInviteData(); - clearInviteParams(); - + // 清理邀请信息 + Taro.removeStorageSync('invite_token'); + Taro.showToast({ title: '加入成功', icon: 'success', duration: 1500 }); setTimeout(() => { - // 跳转到应用页面或首页 Taro.switchTab({ url: '/pages/index/index' }); }, 1500); } else { - console.error('接受邀请失败:', res.data.message, 'code:', res.data.code); - const errorMsg = res.data.message || ''; - - // 用户不存在,引导去登录注册 - 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' }); - } + console.error('接受邀请失败:', res.data.message); + Taro.showToast({ title: res.data.message || '加入失败', icon: 'none' }); } } catch (err: any) { 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 pages = Taro.getCurrentPages(); - if (pages.length > 1) { - Taro.navigateBack(); - } else { - Taro.switchTab({ url: '/pages/index/index' }); - } + Taro.switchTab({ url: '/pages/index/index' }); }; // 打开协议页面 @@ -322,8 +255,8 @@ const InvitePage: React.FC = () => { Taro.navigateTo({ url: `/passport/webview/index?url=${targetUrl}` }); }; - // 加载中 - if (loading) { + // 加载中状态 + if (pageStatus === 'loading' || pageStatus === 'checking') { return ( @@ -332,14 +265,33 @@ const InvitePage: React.FC = () => { borderTopColor: '#3b82f6', borderRadius: '50%', animation: 'spin 1s linear infinite', }} /> - 加载中... + + {pageStatus === 'checking' ? '检查登录状态...' : '加载中...'} + + + + ); + } + + // 跳转到登录中状态 + if (pageStatus === 'login') { + return ( + + + 🔐 + + 请先登录 + + + 正在跳转到登录页面... + ); } // 错误状态 - if (error) { + if (pageStatus === 'error') { return ( diff --git a/src/passport/login.tsx b/src/passport/login.tsx index bec45c8..cf55689 100644 --- a/src/passport/login.tsx +++ b/src/passport/login.tsx @@ -1,56 +1,359 @@ -import {useEffect, useState} from "react"; -import Taro from '@tarojs/taro' -import {Input, Radio, Button} from '@nutui/nutui-react-taro' +import React, { useState, useEffect } from 'react'; +import { View, Text, Button } from '@tarojs/components'; +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(''); // 登录 token 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({ + 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 ( - <> -
-
账号登录
+ - <> -
- -
-
- -
- -
- -
-
- -
- {/**/} - + {/* 背景科技元素 */} + {/* 网格背景 */} + -
- setIsAgree(!isAgree)}> - setIsAgree(!isAgree)}>勾选表示您已阅读并同意 Taro.navigateTo({url: '/passport/agreement'})} - className={'text-blue-600'}>《服务协议及隐私政策》 -
-
- - ) -} -export default Login + {/* 渐变光晕 - 左上 */} + + + {/* 渐变光晕 - 右下 */} + + + {/* 动态粒子光点 */} + {[ + { 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) => ( + + ))} + + {/* 扫描线效果 */} + + + {/* 主内容区域 */} + + + {/* Logo 区域 */} + + + {/* Logo 内光效 */} + + 🔐 + + + websopy + + + + + {/* 主按钮 - 渐变发光按钮 */} + + + + + {/* 取消按钮 */} + + 取消 + + + {/* 协议勾选 */} + + setAgreementChecked(!agreementChecked)}> + + {agreementChecked && } + + + 我已阅读并同意 + openAgreement('service')}>《服务协议》 + + openAgreement('privacy')}>《隐私政策》 + + + {/* 底部装饰 */} + + + 安全加密连接 + + + + + ); +}; + +export default QRConfirmPage;