From 60836da3c2f03339631f6d56150c19f620f9ea80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Sun, 12 Apr 2026 11:42:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(invite):=20=E9=87=8D=E6=9E=84=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E5=8A=A0=E5=85=A5=E6=B5=81=E7=A8=8B=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=B7=B2=E7=99=BB=E5=BD=95=E7=94=A8=E6=88=B7=E5=85=8D?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=9B=B4=E6=8E=A5=E5=8A=A0=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完全重写邀请页逻辑,区分已登录与未注册用户按钮显示 - 已登录用户显示“确认加入”按钮,点击用 wx.login 获取 code 加入 - 未注册用户显示“微信手机号快速加入”按钮,授权手机号后完成注册登录再加入 - 移除跳转登录页面逻辑,所有流程在邀请页内完成 - 加入接口统一使用 Authorization 头携带 token,确保身份双重验证 - 优化背景、粒子和扫描线效果,提升界面视觉体验 - 增加错误处理提示,加入失败时显示详细原因并可返回首页 - 协议勾选仅在未注册用户显示,确保合规授权流程 --- .workbuddy/expert-history.json | 2 +- .workbuddy/memory/2026-04-12.md | 84 ++++++ src/passport/invite/index.tsx | 496 +++++++++++++++++--------------- 3 files changed, 357 insertions(+), 225 deletions(-) create mode 100644 .workbuddy/memory/2026-04-12.md diff --git a/.workbuddy/expert-history.json b/.workbuddy/expert-history.json index a718924..df259eb 100644 --- a/.workbuddy/expert-history.json +++ b/.workbuddy/expert-history.json @@ -35,5 +35,5 @@ } ] }, - "lastUpdated": 1775923978885 + "lastUpdated": 1775965240928 } \ No newline at end of file diff --git a/.workbuddy/memory/2026-04-12.md b/.workbuddy/memory/2026-04-12.md new file mode 100644 index 0000000..5bcf90a --- /dev/null +++ b/.workbuddy/memory/2026-04-12.md @@ -0,0 +1,84 @@ +# 2026-04-12 工作记录 + +## 任务:优化邀请加入应用按钮逻辑 + +### 需求描述 +loginByOpenId 返回有用户数据(已登录)时,不显示手机号授权按钮,直接显示「确认加入」普通按钮; +loginByOpenId 返回未注册时才走 getPhoneNumber 授权分支。 + +### 解决方案 +完全重写 `invite/index.tsx`,核心逻辑: + +#### 按钮渲染逻辑 +```tsx +{isLoggedIn ? ( + // 已登录:普通按钮,直接加入,携带 Authorization 头 + +) : ( + // 未注册:手机号授权按钮(兜底,实际大多已被重定向到 login 页) + +)} +``` + +#### handleJoinApp 统一入口 +- `useToken` 参数:已登录用户,请求头加 `Authorization: Bearer xxx` +- `phoneCode` 参数:未注册用户,请求体加 code/encryptedData/iv + +### 文件修改 +- `src/passport/invite/index.tsx` - 完整重写,区分已登录/未注册两种按钮状态 + +--- + +## 任务:未注册用户在邀请页内完成授权注册,不跳登录页 + +### 需求 +- loginByOpenId 未注册 → 在页面内显示「微信手机号授权」按钮 +- 授权成功 → 调用 `loginByMpWxPhone` 注册/登录 → 自动执行加入应用 +- 不再跳转 passport/login 页面 + +### 关键逻辑 +1. `checkLoginStatus`:已注册 isLoggedIn=true,未注册 isLoggedIn=false,**两种情况都显示邀请页** +2. 未注册按钮:`open-type="getPhoneNumber"` → `handleGetPhoneNumber` + - 授权码调 `SERVER_API_URL/wx-login/loginByMpWxPhone` 完成注册登录 + - 保存 token → isLoggedIn=true → 立即调 `doJoinApp` +3. 已注册按钮:普通 `onClick` → `handleConfirmJoin` → `doJoinApp(access_token)` +4. `doJoinApp`:统一加入接口,请求头带 `Authorization: Bearer {access_token}` + +### 文件修改 +- `src/passport/invite/index.tsx` - 完整重写(彻底移除跳登录页逻辑) + +--- + +## 修复:「授权码不能为空」报错 + +### 问题 +已登录用户点「确认加入」时,后端报 `授权码不能为空`。 +后端 `/api/_app/developer/invite/accept` 接口不管是否登录,都要求传 `code`(微信授权码)。 + +### 解决 +统一用一个 `getPhoneNumber` 按钮处理两种场景: +- **已注册**:文字「确认加入」→ 触发 getPhoneNumber → `doJoinApp(code, accessToken)` +- **未注册**:文字「微信手机号快速加入」→ 触发 getPhoneNumber → 先 `loginByMpWxPhone` 注册 → 再 `wx.login()` 获取新 code → `doJoinApp(newCode, access_token)` + +### doJoinApp 参数 +```ts +doJoinApp(wxCode: string, accessToken: string) +// 请求体带 code,请求头带 Authorization: Bearer xxx +``` + +--- + +## 优化:已登录用户不弹手机号授权 + +### 改动 +- 已登录按钮:普通 `onClick`,文字「确认加入」 +- 未注册按钮:`getPhoneNumber` 授权,文字「微信手机号快速加入」 + +### 逻辑差异 +| 用户状态 | 按钮类型 | 获取 code 方式 | +|------|------|------| +| 已登录 | 普通 onClick | `wx.login()` | +| 未注册 | getPhoneNumber | 授权回调的 `code` | + +### 文件修改 +- `src/passport/invite/index.tsx` - 按钮区分两种类型,已登录用普通 onClick diff --git a/src/passport/invite/index.tsx b/src/passport/invite/index.tsx index 564bcf4..cf082c9 100644 --- a/src/passport/invite/index.tsx +++ b/src/passport/invite/index.tsx @@ -2,7 +2,8 @@ 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"; +import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'; +import { TenantId } from '@/config/app'; // 邀请相关接口使用独立的 API 域名 const INVITE_API_URL = 'https://websopy-api.websoft.top'; @@ -11,15 +12,12 @@ const INVITE_API_URL = 'https://websopy-api.websoft.top'; * 邀请加入确认页面 * * 流程: - * 1. 扫码进入页面 - * 2. 调用 wx.login() 获取 code - * 3. 调用 loginByOpenId 判断用户是否已注册 - * 4. 已注册:显示邀请页面,用户点击加入 - * 5. 未注册:跳转到 passport/login 页面完成登录/注册 - * 6. 登录成功后返回,自动执行加入操作 + * 1. 扫码进入 → 调用 loginByOpenId 判断登录状态 + * 2. 已注册 → isLoggedIn=true → 显示「确认加入」按钮 → 点击直接加入(带 access_token) + * 3. 未注册 → isLoggedIn=false → 显示「微信手机号授权」按钮 + * → 授权成功 → 注册/登录 → isLoggedIn=true → 自动执行加入 */ -// 邀请信息类型 interface InviteInfo { appId: string; appName: string; @@ -28,39 +26,39 @@ interface InviteInfo { roleName: string; } -// 协议类型 type AgreementType = 'service' | 'privacy'; - -// 页面状态 -type PageStatus = 'loading' | 'checking' | 'invite' | 'login' | 'error'; +type PageStatus = 'loading' | 'checking' | 'invite' | 'error'; const InvitePage: React.FC = () => { const router = useRouter(); + const [pageStatus, setPageStatus] = useState('loading'); const [inviteInfo, setInviteInfo] = useState(null); const [authLoading, setAuthLoading] = useState(false); const [agreementChecked, setAgreementChecked] = useState(false); const [token, setToken] = useState(''); const [error, setError] = useState(''); + /** + * true = 已注册已登录,按钮文字「确认加入」(仍走 getPhoneNumber 获取 code 传给后端) + * false = 未注册,按钮文字「微信手机号快速加入」,授权后先注册登录再加入 + */ const [isLoggedIn, setIsLoggedIn] = useState(false); useEffect(() => { - initPage().then(); + initPage(); }, [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 { const decodedUrl = decodeURIComponent(params.q); const url = new URL(decodedUrl); inviteToken = url.searchParams.get('token') || url.searchParams.get('qrCodeKey') || ''; - } catch (e) { + } catch { inviteToken = decodeURIComponent(params.q); } } @@ -72,196 +70,211 @@ const InvitePage: React.FC = () => { } setToken(inviteToken); - // 保存邀请 token,供登录后使用 Taro.setStorageSync('invite_token', inviteToken); - // 检查用户登录状态 + // 先获取邀请信息,再检查登录状态(并行更好,但 inviteInfo 失败要 error) await checkLoginStatus(inviteToken); - console.log('检查登录状态完成', inviteToken) }; - // 检查用户登录状态 + // ── loginByOpenId 检查登录状态 ────────────────────── const checkLoginStatus = async (inviteToken: string) => { setPageStatus('checking'); - try { - // 调用 wx.login 获取 code - const wxLoginRes = await Taro.login(); - console.log('wx.login 结果:', wxLoginRes); + const wxRes = await Taro.login(); + if (!wxRes.code) throw new Error('获取微信登录凭证失败'); - if (!wxLoginRes.code) { - throw new Error('获取微信登录凭证失败'); - } - - // 调用 loginByOpenId 判断用户是否已注册 const loginRes = await loginByOpenId({ - code: wxLoginRes.code, - tenantId: parseInt(TenantId) || 1 + code: wxRes.code, + tenantId: parseInt(TenantId) || 1, }); 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)); - } + // ✅ 已注册:保存登录信息,显示「确认加入」 + saveStorageByLoginUser(loginRes.data.access_token, loginRes.data.user as any); setIsLoggedIn(true); - // 获取邀请信息并显示邀请页面 - await fetchInviteInfo(inviteToken); - setPageStatus('invite'); } else { - // 用户未注册,跳转到登录页面 - console.log('用户未注册,跳转到登录页面'); - setPageStatus('login'); - // 延迟跳转,让用户看到提示 - setTimeout(() => { - navigateToLogin(inviteToken); - }, 500); + // ❌ 未注册:显示手机号授权按钮 + console.log('用户未注册,显示手机号授权按钮'); + setIsLoggedIn(false); } + + // 两种情况都需要获取邀请信息 + await fetchInviteInfo(inviteToken); + setPageStatus('invite'); } 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); - - const res = await Taro.request({ - url: `${INVITE_API_URL}/api/_app/developer/invite/info?token=${encodeURIComponent(inviteToken)}`, - method: 'GET', - header: { - 'content-type': 'application/json', - TenantId - } - }); - - console.log('邀请信息接口响应:', res); - - if (res.data.code === 200 || res.data.code === 0) { - setInviteInfo(res.data.data); - } else { - console.error('接口返回错误:', res.data.message); - setError(res.data.message || '邀请信息获取失败'); - setPageStatus('error'); - } - } catch (err: any) { - console.error('获取邀请信息异常:', err); - setError(err.message || '网络请求失败'); + setError(err.message || '初始化失败,请重试'); setPageStatus('error'); } }; - // 处理微信手机号授权 - const handleGetPhoneNumber = async (e: any) => { - const { code, encryptedData, iv, errMsg } = e.detail; + // ── 获取邀请信息 ───────────────────────────────────── + const fetchInviteInfo = async (inviteToken: string) => { + const res = await Taro.request({ + url: `${INVITE_API_URL}/api/_app/developer/invite/info?token=${encodeURIComponent(inviteToken)}`, + method: 'GET', + header: { 'content-type': 'application/json', TenantId }, + }); - console.log('handleGetPhoneNumber:', { code, errMsg }); + console.log('邀请信息接口响应:', res); - // 检查协议是否勾选 + if (res.data.code === 200 || res.data.code === 0) { + setInviteInfo(res.data.data); + } else { + throw new Error(res.data.message || '邀请信息获取失败'); + } + }; + + /** + * 已登录用户:点击「确认加入」 + * 不弹手机号授权,直接用 wx.login() 获取 code 调加入接口 + */ + const handleConfirmJoin = async () => { if (!agreementChecked) { Taro.showToast({ title: '请先同意服务协议和隐私政策', icon: 'none' }); return; } - - // 用户拒绝授权 - if (errMsg && errMsg.includes('fail')) { - Taro.showToast({ title: '需要授权手机号才能加入', icon: 'none' }); + const accessToken = Taro.getStorageSync('access_token'); + if (!accessToken) { + Taro.showToast({ title: '登录状态异常,请刷新重试', icon: 'none' }); return; } - if (!code) { - Taro.showToast({ title: '获取授权信息失败,请重试', icon: 'none' }); - return; - } - - // 执行加入操作 - await handleJoinApp(code, encryptedData, iv); - }; - - // 加入应用 - const handleJoinApp = async (phoneCode: string, encryptedData?: string, iv?: string) => { try { setAuthLoading(true); - - const inviteToken = Taro.getStorageSync('invite_token') || token; - - console.log('开始接受邀请, token:', inviteToken); - - const res = await Taro.request({ - url: `${INVITE_API_URL}/api/_app/developer/invite/accept`, - method: 'POST', - data: { - token: inviteToken, - code: phoneCode, - encryptedData, - iv - }, - header: { - 'content-type': 'application/json', - TenantId - } - }); - - console.log('接受邀请接口响应:', res); - - if (res.data.code === 200 || res.data.code === 0) { - // 清理邀请信息 - 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); - Taro.showToast({ title: res.data.message || '加入失败', icon: 'none' }); - } + // 用 wx.login() 获取 code(后端需要 code 识别用户身份) + const wxRes = await Taro.login(); + if (!wxRes.code) throw new Error('获取微信登录凭证失败'); + await doJoinApp(wxRes.code, accessToken); } catch (err: any) { - console.error('接受邀请异常:', err); - Taro.showToast({ title: err.message || '加入失败', icon: 'error' }); + console.error('确认加入失败:', err); + Taro.showToast({ title: err.message || '加入失败,请重试', icon: 'none' }); } finally { setAuthLoading(false); } }; - // 拒绝邀请 - const handleReject = () => { - Taro.switchTab({ url: '/pages/index/index' }); + /** + * 未注册用户:手机号授权回调 + * 先用 code 注册/登录,再用新的 wx.login code 调加入接口 + */ + const handleGetPhoneNumber = async (e: any) => { + const { code, encryptedData, iv, errMsg } = e.detail; + + if (!agreementChecked) { + Taro.showToast({ title: '请先同意服务协议和隐私政策', icon: 'none' }); + return; + } + if (errMsg?.includes('fail')) { + Taro.showToast({ title: '需要授权手机号才能加入', icon: 'none' }); + return; + } + if (!code) { + Taro.showToast({ title: '获取授权信息失败,请重试', icon: 'none' }); + return; + } + + try { + setAuthLoading(true); + + // 1. 用手机号授权码完成注册/登录 + console.log('未注册用户,先注册/登录再加入'); + const loginRes = await Taro.request({ + url: `${SERVER_API_URL}/wx-login/loginByMpWxPhone`, + method: 'POST', + data: { + code, + encryptedData, + iv, + tenantId: parseInt(TenantId) || 1, + notVerifyPhone: true, + }, + header: { 'content-type': 'application/json', TenantId }, + }); + + console.log('手机号注册/登录结果:', loginRes); + + if (loginRes.data.code !== 0 && loginRes.data.code !== 200) { + throw new Error(loginRes.data.message || '注册/登录失败'); + } + + const { access_token, user } = loginRes.data.data || {}; + if (!access_token) throw new Error('登录失败,未获取到 token'); + + // 2. 保存登录信息 + saveStorageByLoginUser(access_token, user); + setIsLoggedIn(true); + + // 3. 手机号授权码已被消耗,重新获取 wx.login code 调加入接口 + console.log('注册成功,重新获取 wx.login code 调加入接口'); + const wxRes = await Taro.login(); + if (!wxRes.code) throw new Error('获取微信登录凭证失败'); + + await doJoinApp(wxRes.code, access_token); + } catch (err: any) { + console.error('手机号授权登录失败:', err); + Taro.showToast({ title: err.message || '授权失败,请重试', icon: 'none' }); + } finally { + setAuthLoading(false); + } }; - // 打开协议页面 + /** + * 调用加入接口(统一入口) + * @param wxCode wx.login() 或 getPhoneNumber 获取的 code(后端用于识别用户) + * @param accessToken 登录 token(加到 Authorization 头,双重验证) + */ + const doJoinApp = async (wxCode: string, accessToken: string) => { + const inviteToken = Taro.getStorageSync('invite_token') || token; + console.log('doJoinApp, inviteToken:', inviteToken, 'wxCode:', wxCode ? '有' : '无', 'accessToken:', accessToken ? '有' : '无'); + + const res = await Taro.request({ + url: `${INVITE_API_URL}/api/_app/developer/invite/accept`, + method: 'POST', + data: { + token: inviteToken, + code: wxCode, + }, + header: { + 'content-type': 'application/json', + TenantId, + Authorization: `Bearer ${accessToken}`, + }, + }); + + console.log('加入应用接口响应:', res); + + if (res.data.code === 200 || res.data.code === 0) { + Taro.removeStorageSync('invite_token'); + Taro.showToast({ title: '加入成功', icon: 'success', duration: 1500 }); + setTimeout(() => Taro.switchTab({ url: '/pages/index/index' }), 1500); + } else { + throw new Error(res.data.message || '加入失败'); + } + }; + + // ── 拒绝 / 打开协议 ───────────────────────────────── + const handleReject = () => Taro.switchTab({ url: '/pages/index/index' }); + 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}` }); + Taro.navigateTo({ url: `/passport/webview/index?url=${encodeURIComponent(urlMap[type])}` }); }; - // 加载中状态 + // ── Loading / Checking 状态 ────────────────────────── if (pageStatus === 'loading' || pageStatus === 'checking') { return ( - + @@ -273,33 +286,17 @@ const InvitePage: React.FC = () => { ); } - // 跳转到登录中状态 - if (pageStatus === 'login') { - return ( - - - 🔐 - - 请先登录 - - - 正在跳转到登录页面... - - - - ); - } - - // 错误状态 + // ── 错误状态 ───────────────────────────────────────── if (pageStatus === 'error') { return ( - + {error} @@ -308,34 +305,69 @@ const InvitePage: React.FC = () => { ); } - // 邀请确认页面 + // ── 邀请确认页面 ────────────────────────────────────── return ( - + - {/* 背景效果 */} + {/* 背景网格 */} - {/* 渐变光晕 */} + {/* 渐变光晕 - 左上 */} - {/* 主内容区域 */} + {/* 渐变光晕 - 右下 */} + + + {/* 动态粒子光点 */} + {[ + { top: '15%', left: '10%', size: 4, delay: '0s' }, + { top: '20%', left: '85%', size: 3, delay: '0.5s' }, + { top: '70%', left: '8%', size: 4, delay: '1s' }, + { top: '75%', left: '88%', size: 3, delay: '1.5s' }, + ].map((p, i) => ( + + ))} + + {/* 扫描线 */} + + + {/* 主内容 */} {/* 邀请卡片 */} - {/* 应用信息 */} + + {/* 应用 Logo + 名称 */} { display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '16px', overflow: 'hidden', }}> - {inviteInfo?.appLogo ? ( - - ) : ( - 🚀 - )} + {inviteInfo?.appLogo + ? + : 🚀 + } - + {inviteInfo?.appName || 'Websopy'} - - 邀请你加入应用 - + 邀请你加入应用 - {/* 邀请人信息 */} + {/* 邀请人 */} 邀请人:{inviteInfo?.inviterName || '某位用户'} @@ -374,50 +401,71 @@ const InvitePage: React.FC = () => { {/* 分隔线 */} {/* 协议勾选 */} - - setAgreementChecked(!agreementChecked)}> - - {agreementChecked && } + {!isLoggedIn && ( + + setAgreementChecked(!agreementChecked)}> + + {agreementChecked && } + + 我已阅读并同意 + openAgreement('service')}>《服务协议》 + + openAgreement('privacy')}>《隐私政策》 - 我已阅读并同意 - openAgreement('service')}>《服务协议》 - - openAgreement('privacy')}>《隐私政策》 - + )} - {/* 主按钮 */} + {/* ===== 按钮区:根据登录状态切换按钮类型 ===== */} - + {isLoggedIn ? ( + /* 已登录:普通按钮,不弹手机号授权,用 wx.login() 获取 code */ + + ) : ( + /* 未注册:手机号授权按钮 */ + + )} - {/* 拒绝按钮 */} - + {/* 拒绝 */} + 暂不加入