Files
template-10584/src/dealer/qrcode/index.tsx
赵忠林 78ac461ef9 feat(share): 添加分享功能并限制水票商品加入购物车
- 在二维码页面启用分享给朋友和分享到朋友圈功能
- 实现分享菜单显示和分享内容自定义逻辑
- 移除原有的复制邀请信息和分享给好友按钮
- 新增水票套票模板查询接口和类型定义
- 阻止水票套票商品加入购物车并提示用户立即购买
- 添加组件卸载时的清理逻辑防止内存泄漏
- 优化商品详情页异步操作的状态管理
2026-02-26 12:11:30 +08:00

523 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {useState, useEffect} from 'react'
import {View, Text, Image} from '@tarojs/components'
import {Button, Loading} from '@nutui/nutui-react-taro'
import {Download, QrCode} from '@nutui/icons-react-taro'
import Taro, {useShareAppMessage} from '@tarojs/taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import {generateInviteCode} from '@/api/invite'
// import type {InviteStats} from '@/api/invite'
import {businessGradients} from '@/styles/gradients'
const DealerQrcode: React.FC = () => {
const [miniProgramCodeUrl, setMiniProgramCodeUrl] = useState<string>('')
const [codeLoading, setCodeLoading] = useState<boolean>(false)
const [saving, setSaving] = useState<boolean>(false)
// const [inviteStats, setInviteStats] = useState<InviteStats | null>(null)
// const [statsLoading, setStatsLoading] = useState<boolean>(false)
const {dealerUser, loading: dealerLoading, error, refresh} = useDealerUser()
// Enable "转发给朋友" + "分享到朋友圈" items in the share panel/menu.
useEffect(() => {
// Some clients require explicit call to show both share entries.
Taro.showShareMenu({
withShareTicket: true,
showShareItems: ['shareAppMessage', 'shareTimeline']
}).catch(() => {})
}, [])
// 转发给朋友(分享小程序链接)
useShareAppMessage(() => {
const inviterRaw = dealerUser?.userId ?? Taro.getStorageSync('UserId')
const inviter = Number(inviterRaw)
const hasInviter = Number.isFinite(inviter) && inviter > 0
const user = Taro.getStorageSync('User') || {}
const nickname = (user && (user.nickname || user.realName || user.username)) || ''
const title = hasInviter ? `${nickname || '我'}邀请你加入桂乐淘伙伴计划` : '桂乐淘伙伴计划'
return {
title,
path: hasInviter
? `/pages/index/index?inviter=${inviter}&source=dealer_qrcode&t=${Date.now()}`
: `/pages/index/index`,
success: function () {
Taro.showToast({title: '分享成功', icon: 'success', duration: 2000})
},
fail: function () {
Taro.showToast({title: '分享失败', icon: 'none', duration: 2000})
}
}
})
// 生成小程序码
const generateMiniProgramCode = async () => {
if (!dealerUser?.userId) {
return
}
try {
setCodeLoading(true)
// 生成邀请小程序码
const codeUrl = await generateInviteCode(dealerUser.userId)
if (codeUrl) {
setMiniProgramCodeUrl(codeUrl)
} else {
throw new Error('返回的小程序码URL为空')
}
} catch (error: any) {
Taro.showToast({
title: error.message || '生成小程序码失败',
icon: 'error'
})
// 清空之前的二维码
setMiniProgramCodeUrl('')
} finally {
setCodeLoading(false)
}
}
// 获取邀请统计数据
// const fetchInviteStats = async () => {
// if (!dealerUser?.userId) return
//
// try {
// setStatsLoading(true)
// const stats = await getInviteStats(dealerUser.userId)
// stats && setInviteStats(stats)
// } catch (error) {
// // 静默处理错误,不影响用户体验
// } finally {
// setStatsLoading(false)
// }
// }
// 初始化生成小程序码和获取统计数据
useEffect(() => {
if (dealerUser?.userId) {
generateMiniProgramCode()
// fetchInviteStats()
}
}, [dealerUser?.userId])
const isAlbumAuthError = (errMsg?: string) => {
if (!errMsg) return false
// WeChat uses variants like: "saveImageToPhotosAlbum:fail auth deny",
// "saveImageToPhotosAlbum:fail auth denied", "authorize:fail auth deny"
return (
errMsg.includes('auth deny') ||
errMsg.includes('auth denied') ||
errMsg.includes('authorize') ||
errMsg.includes('scope.writePhotosAlbum')
)
}
const ensureWriteAlbumPermission = async (): Promise<boolean> => {
try {
const setting = await Taro.getSetting()
if (setting?.authSetting?.['scope.writePhotosAlbum']) return true
await Taro.authorize({scope: 'scope.writePhotosAlbum'})
return true
} catch (error: any) {
const modal = await Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册,请在设置中开启相册权限',
confirmText: '去设置'
})
if (modal.confirm) {
await Taro.openSetting()
}
return false
}
}
const downloadImageToLocalPath = async (url: string): Promise<string> => {
// saveImageToPhotosAlbum must receive a local temp path (e.g. `http://tmp/...` or `wxfile://...`).
// Some environments may return a non-existing temp path from getImageInfo, so we verify.
if (url.startsWith('http://tmp/') || url.startsWith('wxfile://')) {
return url
}
const token = Taro.getStorageSync('access_token')
const tenantId = Taro.getStorageSync('TenantId')
const header: Record<string, string> = {}
if (token) header.Authorization = token
if (tenantId) header.TenantId = tenantId
// 先下载到本地临时文件再保存到相册
const res = await Taro.downloadFile({url, header})
if (res.statusCode !== 200 || !res.tempFilePath) {
throw new Error(`图片下载失败(${res.statusCode || 'unknown'})`)
}
// Double-check file exists to avoid: saveImageToPhotosAlbum:fail no such file or directory
try {
await Taro.getFileInfo({filePath: res.tempFilePath})
} catch (_) {
throw new Error('图片临时文件不存在,请重试')
}
return res.tempFilePath
}
// 保存小程序码到相册
const saveMiniProgramCode = async () => {
if (!miniProgramCodeUrl) {
Taro.showToast({
title: '小程序码未生成',
icon: 'error'
})
return
}
try {
if (saving) return
setSaving(true)
Taro.showLoading({title: '保存中...'})
const hasPermission = await ensureWriteAlbumPermission()
if (!hasPermission) return
let filePath = await downloadImageToLocalPath(miniProgramCodeUrl)
try {
await Taro.saveImageToPhotosAlbum({filePath})
} catch (e: any) {
const msg = e?.errMsg || e?.message || ''
// Fallback: some devices/clients may fail to save directly from a temp path.
if (
msg.includes('no such file or directory') &&
(filePath.startsWith('http://tmp/') || filePath.startsWith('wxfile://'))
) {
const saved = (await Taro.saveFile({tempFilePath: filePath})) as unknown as { savedFilePath?: string }
if (saved?.savedFilePath) {
filePath = saved.savedFilePath
}
await Taro.saveImageToPhotosAlbum({filePath})
} else {
throw e
}
}
Taro.showToast({
title: '保存成功',
icon: 'success'
})
} catch (error: any) {
const errMsg = error?.errMsg || error?.message
if (errMsg?.includes('cancel')) {
Taro.showToast({title: '已取消', icon: 'none'})
return
}
if (isAlbumAuthError(errMsg)) {
const modal = await Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
confirmText: '去设置'
})
if (modal.confirm) {
await Taro.openSetting()
}
} else {
// Prefer a modal so we can show the real reason (e.g. domain whitelist / network error).
await Taro.showModal({
title: '保存失败',
content: errMsg || '保存失败,请稍后重试',
showCancel: false
})
}
} finally {
Taro.hideLoading()
setSaving(false)
}
}
// 复制邀请信息
// 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,
// showShareItems: ['shareAppMessage']
// })
// }
if (dealerLoading) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
if (error) {
return (
<View className="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6">
<Text className="text-gray-800 font-semibold"></Text>
<Text className="text-gray-500 text-sm mt-2">{error}</Text>
<Button className="mt-6" type="primary" onClick={refresh}></Button>
</View>
)
}
// 未成为分销商时给出明确引导,避免一直停留在“加载中”
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6">
<Text className="text-gray-800 font-semibold"></Text>
<Text className="text-gray-500 text-sm mt-2 text-center"></Text>
<Button
className="mt-6"
type="primary"
onClick={() => Taro.navigateTo({url: '/dealer/apply/add'})}
>
</Button>
</View>
)
}
return (
<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 flex flex-col">
<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">
{codeLoading ? (
<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"
onError={() => {
Taro.showModal({
title: '二维码加载失败',
content: '请检查网络连接或联系管理员',
showCancel: true,
confirmText: '重新生成',
success: (res) => {
if (res.confirm) {
generateMiniProgramCode();
}
}
});
}}
/>
</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>
)}
<View className="text-lg font-semibold text-gray-800 mb-2">
</View>
<View className="text-sm text-gray-500 mb-4">
| |
</View>
</View>
</View>
{/* 操作按钮 */}
<View className={'gap-2'}>
<View className={'my-2'}>
<Button
type="primary"
size="large"
block
icon={<Download/>}
onClick={saveMiniProgramCode}
disabled={!miniProgramCodeUrl || codeLoading || saving}
>
</Button>
</View>
</View>
{/* 推广说明 */}
<View className="bg-white rounded-2xl p-4 mt-6 hidden">
<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">*/}
{/* <View className="text-gray-500">暂无邀请数据</View>*/}
{/* <Button*/}
{/* size="small"*/}
{/* type="primary"*/}
{/* className="mt-2"*/}
{/* onClick={fetchInviteStats}*/}
{/* >*/}
{/* 刷新数据*/}
{/* </Button>*/}
{/* </View>*/}
{/* )}*/}
{/*</View>*/}
</View>
</View>
)
}
export default DealerQrcode