feat(invite): 增加邀请加入确认页面功能
- 在 app.config.ts 中添加 invite 模块路径 - 在 project.private.config.json 中配置小程序的邀请确认页面入口 - 新增邀请加入页面配置文件,设置页面标题和样式 - 实现邀请加入页面核心逻辑,支持获取邀请信息和微信手机号授权 - 支持用户同意协议后快速加入应用,加入成功后跳转首页 - 实现拒绝加入功能,支持返回上一页或首页 - 页面UI设计带渐变背景、邀请信息展示和协议勾选交互 - 提供服务协议和隐私政策跳转查看链接 - 添加加载和错误状态的用户友好提示界面
This commit is contained in:
@@ -1,7 +1,26 @@
|
|||||||
{
|
{
|
||||||
"libVersion": "3.15.2",
|
"libVersion": "3.15.2",
|
||||||
"projectname": "websopy-mp",
|
"projectname": "websopy-mp",
|
||||||
"condition": {},
|
"condition": {
|
||||||
|
"miniprogram": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "pages/passport/qr-confirm/index",
|
||||||
|
"pathName": "pages/passport/qr-confirm/index",
|
||||||
|
"query": "",
|
||||||
|
"scene": null,
|
||||||
|
"launchMode": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "passport/qr-confirm/index",
|
||||||
|
"pathName": "passport/qr-confirm/index",
|
||||||
|
"query": "",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true,
|
"urlCheck": true,
|
||||||
"coverView": true,
|
"coverView": true,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default {
|
|||||||
"phone-auth/index",
|
"phone-auth/index",
|
||||||
'qr-login/index',
|
'qr-login/index',
|
||||||
'qr-confirm/index',
|
'qr-confirm/index',
|
||||||
|
'invite/index',
|
||||||
'unified-qr/index',
|
'unified-qr/index',
|
||||||
'webview/index'
|
'webview/index'
|
||||||
]
|
]
|
||||||
|
|||||||
5
src/passport/invite/index.config.ts
Normal file
5
src/passport/invite/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '邀请加入',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
backgroundColor: '#0a0a1a',
|
||||||
|
})
|
||||||
338
src/passport/invite/index.tsx
Normal file
338
src/passport/invite/index.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, Button, Image } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import { SERVER_API_URL } from "@/utils/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请加入确认页面
|
||||||
|
*
|
||||||
|
* 用户扫描邀请二维码后,打开此小程序页面确认加入应用
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 微信获取手机号回调参数类型
|
||||||
|
interface GetPhoneNumberDetail {
|
||||||
|
code?: string;
|
||||||
|
encryptedData?: string;
|
||||||
|
iv?: string;
|
||||||
|
errMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetPhoneNumberEvent {
|
||||||
|
detail: GetPhoneNumberDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请信息类型
|
||||||
|
interface InviteInfo {
|
||||||
|
appId: string;
|
||||||
|
appName: string;
|
||||||
|
appLogo: string;
|
||||||
|
inviterName: string;
|
||||||
|
roleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 协议类型
|
||||||
|
type AgreementType = 'service' | 'privacy';
|
||||||
|
|
||||||
|
const InvitePage: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null);
|
||||||
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
const [agreementChecked, setAgreementChecked] = useState(false);
|
||||||
|
const [token, setToken] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从 URL 参数中获取 token
|
||||||
|
const params = router.params;
|
||||||
|
let inviteToken = params.scene || params.token || params.qrCodeKey || '';
|
||||||
|
|
||||||
|
// 兼容 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) {
|
||||||
|
inviteToken = decodeURIComponent(params.q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(inviteToken);
|
||||||
|
|
||||||
|
// 获取邀请信息
|
||||||
|
if (inviteToken) {
|
||||||
|
fetchInviteInfo(inviteToken);
|
||||||
|
} else {
|
||||||
|
setError('无效的邀请链接');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [router.params]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取邀请信息
|
||||||
|
*/
|
||||||
|
const fetchInviteInfo = async (inviteToken: string) => {
|
||||||
|
try {
|
||||||
|
const res = await Taro.request({
|
||||||
|
url: `${SERVER_API_URL}/api/_app/developer/invite/info`,
|
||||||
|
method: 'GET',
|
||||||
|
data: { token: inviteToken },
|
||||||
|
header: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.code === 200 || res.data.code === 0) {
|
||||||
|
setInviteInfo(res.data.data);
|
||||||
|
} else {
|
||||||
|
setError(res.data.message || '邀请信息获取失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '网络请求失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理微信手机号授权
|
||||||
|
*/
|
||||||
|
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 handleJoinApp(code, encryptedData, iv);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入应用
|
||||||
|
*/
|
||||||
|
const handleJoinApp = async (phoneCode: string, encryptedData?: string, iv?: string) => {
|
||||||
|
try {
|
||||||
|
setAuthLoading(true);
|
||||||
|
|
||||||
|
const res = await Taro.request({
|
||||||
|
url: `${SERVER_API_URL}/api/_app/invite/accept`,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
code: phoneCode,
|
||||||
|
encryptedData,
|
||||||
|
iv
|
||||||
|
},
|
||||||
|
header: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.code === 200 || res.data.code === 0) {
|
||||||
|
Taro.showToast({ title: '加入成功', icon: 'success', duration: 1500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
// 跳转到应用页面或首页
|
||||||
|
Taro.switchTab({ url: '/pages/index/index' });
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: res.data.message || '加入失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Taro.showToast({ title: err.message || '加入失败', icon: 'error' });
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝邀请
|
||||||
|
*/
|
||||||
|
const handleReject = () => {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
if (pages.length > 1) {
|
||||||
|
Taro.navigateBack();
|
||||||
|
} else {
|
||||||
|
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}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
if (loading) {
|
||||||
|
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">
|
||||||
|
<View style={{
|
||||||
|
width: '40px', height: '40px', border: '3px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
borderTopColor: '#3b82f6', borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<Text style={{ color: 'rgba(255, 255, 255, 0.6)', marginTop: '16px', fontSize: '14px' }}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
if (error) {
|
||||||
|
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: '64px', marginBottom: '20px' }}>❌</Text>
|
||||||
|
<Text style={{ color: '#ef4444', fontSize: '16px', textAlign: 'center', marginBottom: '24px' }}>{error}</Text>
|
||||||
|
<Button onClick={handleReject} style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)', color: '#fff',
|
||||||
|
borderRadius: '20px', fontSize: '14px', padding: '8px 24px'
|
||||||
|
}}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请确认页面
|
||||||
|
return (
|
||||||
|
<View className="min-h-screen relative overflow-hidden" style={{ background: 'linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #0a1520 100%)' }}>
|
||||||
|
|
||||||
|
{/* 背景效果 */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '50px 50px', opacity: 0.5,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 渐变光晕 */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: '-30%', left: '-20%', width: '60%', height: '60%',
|
||||||
|
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.25) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(40px)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<View className="relative z-10 flex flex-col items-center justify-center min-h-screen px-8">
|
||||||
|
|
||||||
|
{/* 邀请卡片 */}
|
||||||
|
<View style={{
|
||||||
|
width: '100%', maxWidth: '360px', background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
borderRadius: '24px', border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
padding: '32px 24px', backdropFilter: 'blur(20px)',
|
||||||
|
}}>
|
||||||
|
{/* 应用信息 */}
|
||||||
|
<View className="flex flex-col items-center mb-8">
|
||||||
|
<View style={{
|
||||||
|
width: '80px', height: '80px', borderRadius: '20px',
|
||||||
|
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: '16px', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{inviteInfo?.appLogo ? (
|
||||||
|
<Image src={inviteInfo.appLogo} style={{ width: '60px', height: '60px', borderRadius: '12px' }} />
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: '36px' }}>🚀</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: '22px', fontWeight: '700', color: '#fff', marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
{inviteInfo?.appName || 'Websopy'}
|
||||||
|
</Text>
|
||||||
|
<View style={{
|
||||||
|
padding: '4px 12px', borderRadius: '12px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.2)', border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#3b82f6', fontSize: '12px' }}>
|
||||||
|
邀请你加入应用
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 邀请人信息 */}
|
||||||
|
<View className="flex items-center justify-center mb-6">
|
||||||
|
<Text style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: '13px' }}>
|
||||||
|
邀请人:{inviteInfo?.inviterName || '某位用户'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<View style={{
|
||||||
|
height: '1px', background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 协议勾选 */}
|
||||||
|
<View className="flex items-center justify-center mb-4">
|
||||||
|
<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={{
|
||||||
|
width: '100%', background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 50%, #6366f1 100%)',
|
||||||
|
borderRadius: '24px', padding: '2px',
|
||||||
|
boxShadow: '0 0 30px rgba(59, 130, 246, 0.4)',
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
open-type="getPhoneNumber"
|
||||||
|
onGetPhoneNumber={handleGetPhoneNumber}
|
||||||
|
disabled={authLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: '48px', fontSize: '16px', fontWeight: '600',
|
||||||
|
color: '#fff', background: 'transparent', borderRadius: '22px',
|
||||||
|
border: 'none', padding: '0', boxSizing: 'border-box', lineHeight: '48px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authLoading ? '处理中...' : '微信手机号快速加入'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 拒绝按钮 */}
|
||||||
|
<View style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: '16px' }} onClick={handleReject}>
|
||||||
|
<Text style={{ color: 'rgba(255, 255, 255, 0.4)', fontSize: '14px' }}>暂不加入</Text>
|
||||||
|
</View>
|
||||||
|
</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 InvitePage;
|
||||||
Reference in New Issue
Block a user