feat(invite): 添加邀请统计功能

- 新增邀请统计页面,包含统计概览、邀请记录和排行榜三个标签页
- 实现邀请统计数据的获取和展示,包括总邀请数、成功注册数、转化率等
- 添加邀请记录的查询和展示功能
- 实现邀请排行榜的查询和展示功能
- 新增生成小程序码和处理邀请场景值的接口
This commit is contained in:
2025-08-19 01:38:37 +08:00
parent 9d9762ef17
commit f928264e2c
23 changed files with 2406 additions and 261 deletions

View File

@@ -1,28 +1,370 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { View, Text, Image } from '@tarojs/components'
import { Cell, Button } from '@nutui/nutui-react-taro'
import { Button, Loading } from '@nutui/nutui-react-taro'
import { Share, Download, Copy, QrCode } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { generateInviteCode, getInviteStats } from '@/api/invite'
import type { InviteStats } from '@/api/invite'
import { businessGradients } from '@/styles/gradients'
const DealerQrcode: React.FC = () => {
const [miniProgramCodeUrl, setMiniProgramCodeUrl] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [inviteStats, setInviteStats] = useState<InviteStats | null>(null)
const [statsLoading, setStatsLoading] = useState<boolean>(false)
const { dealerUser } = useDealerUser()
// 生成小程序码
const generateMiniProgramCode = async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
// 生成邀请小程序码
const codeUrl = await generateInviteCode(dealerUser.userId, 'qrcode')
if (codeUrl) {
setMiniProgramCodeUrl(codeUrl)
}
} catch (error) {
console.error('生成小程序码失败:', error)
Taro.showToast({
title: '生成小程序码失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
// 获取邀请统计数据
const fetchInviteStats = async () => {
if (!dealerUser?.userId) return
try {
setStatsLoading(true)
const stats = await getInviteStats(dealerUser.userId)
stats && setInviteStats(stats)
} catch (error) {
console.error('获取邀请统计失败:', error)
} finally {
setStatsLoading(false)
}
}
// 初始化生成小程序码和获取统计数据
useEffect(() => {
if (dealerUser?.userId) {
generateMiniProgramCode()
fetchInviteStats()
}
}, [dealerUser?.userId])
// 保存小程序码到相册
const saveMiniProgramCode = async () => {
if (!miniProgramCodeUrl) {
Taro.showToast({
title: '小程序码未生成',
icon: 'error'
})
return
}
try {
// 先下载图片到本地
const res = await Taro.downloadFile({
url: miniProgramCodeUrl
})
if (res.statusCode === 200) {
// 保存到相册
await Taro.saveImageToPhotosAlbum({
filePath: res.tempFilePath
})
Taro.showToast({
title: '保存成功',
icon: 'success'
})
}
} catch (error: any) {
if (error.errMsg?.includes('auth deny')) {
Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
success: (res) => {
if (res.confirm) {
Taro.openSetting()
}
}
})
} else {
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
}
// 复制邀请信息
const copyInviteInfo = () => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息未加载',
icon: 'error'
})
return
}
const inviteText = `🎉 邀请您加入我的团队!
扫描小程序码或搜索"网宿小店"小程序,即可享受优质商品和服务!
💰 成为我的下级分销商,一起赚取丰厚佣金
🎁 新用户专享优惠等你来拿
邀请码:${dealerUser.userId}
快来加入我们吧!`
Taro.setClipboardData({
data: inviteText,
success: () => {
Taro.showToast({
title: '邀请信息已复制',
icon: 'success'
})
}
})
}
// 分享小程序码
const shareMiniProgramCode = () => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息未加载',
icon: 'error'
})
return
}
// 小程序分享
Taro.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
}
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="p-4">
<Text className="text-lg font-bold mb-4">广</Text>
<View className="text-center">
<View className="bg-gray-100 w-48 h-48 mx-auto mb-4 flex items-center justify-center">
<Text className="text-gray-500"></Text>
<View className="bg-gray-50 min-h-screen">
{/* 头部卡片 */}
<View className="rounded-b-3xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: businessGradients.dealer.header
}}>
{/* 装饰背景 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="relative z-10">
<Text className="text-2xl font-bold mb-2 text-white"></Text>
<Text className="text-white text-opacity-80">
</Text>
</View>
</View>
<View className="px-4">
{/* 小程序码展示区 */}
<View className="bg-white rounded-2xl p-6 mb-6 shadow-sm">
<View className="text-center">
{loading ? (
<View className="w-48 h-48 mx-auto mb-4 flex items-center justify-center bg-gray-50 rounded-xl">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : miniProgramCodeUrl ? (
<View className="w-48 h-48 mx-auto mb-4 bg-white rounded-xl shadow-sm p-4">
<Image
src={miniProgramCodeUrl}
className="w-full h-full"
mode="aspectFit"
/>
</View>
) : (
<View className="w-48 h-48 mx-auto mb-4 flex items-center justify-center bg-gray-50 rounded-xl">
<QrCode size="48" className="text-gray-400 mb-2"/>
<Text className="text-gray-500"></Text>
<Button
size="small"
type="primary"
className="mt-2"
onClick={generateMiniProgramCode}
>
</Button>
</View>
)}
<Text className="text-lg font-semibold text-gray-800 mb-2">
</Text>
<Text className="text-sm text-gray-500 mb-6">
</Text>
</View>
</View>
{/* 操作按钮 */}
<View className="space-y-3">
<Button
type="primary"
size="large"
block
icon={<Download />}
onClick={saveMiniProgramCode}
disabled={!miniProgramCodeUrl || loading}
>
</Button>
<Button
size="large"
block
icon={<Copy />}
onClick={copyInviteInfo}
disabled={!dealerUser?.userId || loading}
>
</Button>
<Button
size="large"
block
fill="outline"
icon={<Share />}
onClick={shareMiniProgramCode}
disabled={!dealerUser?.userId || loading}
>
</Button>
</View>
{/* 推广说明 */}
<View className="bg-white rounded-2xl p-4 mt-6">
<Text className="font-semibold text-gray-800 mb-3">广</Text>
<View className="space-y-2">
<View className="flex items-start">
<View className="w-2 h-2 bg-blue-500 rounded-full mt-2 mr-3 flex-shrink-0"></View>
<Text className="text-sm text-gray-600">
</Text>
</View>
<View className="flex items-start">
<View className="w-2 h-2 bg-green-500 rounded-full mt-2 mr-3 flex-shrink-0"></View>
<Text className="text-sm text-gray-600">
</Text>
</View>
<View className="flex items-start">
<View className="w-2 h-2 bg-purple-500 rounded-full mt-2 mr-3 flex-shrink-0"></View>
<Text className="text-sm text-gray-600">
</Text>
</View>
</View>
</View>
{/* 邀请统计数据 */}
<View className="bg-white rounded-2xl p-4 mt-4 mb-6">
<Text className="font-semibold text-gray-800 mb-3"></Text>
{statsLoading ? (
<View className="flex items-center justify-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : inviteStats ? (
<View className="space-y-4">
<View className="grid grid-cols-2 gap-4">
<View className="text-center">
<Text className="text-2xl font-bold text-blue-500">
{inviteStats.totalInvites || 0}
</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-2xl font-bold text-green-500">
{inviteStats.successfulRegistrations || 0}
</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
</View>
<View className="grid grid-cols-2 gap-4">
<View className="text-center">
<Text className="text-2xl font-bold text-purple-500">
{inviteStats.conversionRate ? `${(inviteStats.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-2xl font-bold text-orange-500">
{inviteStats.todayInvites || 0}
</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
</View>
{/* 邀请来源统计 */}
{inviteStats.sourceStats && inviteStats.sourceStats.length > 0 && (
<View className="mt-4">
<Text className="text-sm font-medium text-gray-700 mb-2"></Text>
<View className="space-y-2">
{inviteStats.sourceStats.map((source, index) => (
<View key={index} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<View className="flex items-center">
<View className="w-3 h-3 rounded-full bg-blue-500 mr-2"></View>
<Text className="text-sm text-gray-700">{source.source}</Text>
</View>
<View className="text-right">
<Text className="text-sm font-medium text-gray-800">{source.count}</Text>
<Text className="text-xs text-gray-500">
{source.conversionRate ? `${(source.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
</View>
</View>
))}
</View>
</View>
)}
</View>
) : (
<View className="text-center py-8">
<Text className="text-gray-500"></Text>
<Button
size="small"
type="primary"
className="mt-2"
onClick={fetchInviteStats}
>
</Button>
</View>
)}
</View>
<Text className="text-sm text-gray-600 mb-4">
</Text>
<Button type="primary" className="mb-2">
</Button>
<Button>
</Button>
</View>
</View>
)