Browse Source

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

- 新增邀请统计页面,包含统计概览、邀请记录和排行榜三个标签页
- 实现邀请统计数据的获取和展示,包括总邀请数、成功注册数、转化率等
- 添加邀请记录的查询和展示功能
- 实现邀请排行榜的查询和展示功能
- 新增生成小程序码和处理邀请场景值的接口
dev
科技小王子 6 days ago
parent
commit
f928264e2c
  1. 2
      config/env.ts
  2. 239
      src/api/invite/index.ts
  3. 279
      src/api/invite/model/index.ts
  4. 4
      src/api/shop/shopDealerOrder/model/index.ts
  5. 3
      src/api/shop/shopDealerReferee/model/index.ts
  6. 1
      src/api/shop/shopDealerWithdraw/model/index.ts
  7. 1
      src/app.config.ts
  8. 21
      src/app.scss
  9. 48
      src/app.ts
  10. 8
      src/dealer/index.scss
  11. 69
      src/dealer/index.tsx
  12. 7
      src/dealer/invite-stats/index.config.ts
  13. 343
      src/dealer/invite-stats/index.tsx
  14. 371
      src/dealer/orders/index.tsx
  15. 382
      src/dealer/qrcode/index.tsx
  16. 224
      src/dealer/team/index.tsx
  17. 336
      src/dealer/withdraw/index.tsx
  18. 20
      src/pages/index/Header.tsx
  19. 51
      src/pages/index/HeaderWithHook.tsx
  20. 20
      src/pages/index/Login.tsx
  21. 2
      src/shop/orderConfirmCart/index.tsx
  22. 2
      src/styles/gradients.ts
  23. 228
      src/utils/invite.ts

2
config/env.ts

@ -2,7 +2,7 @@
export const ENV_CONFIG = {
// 开发环境
development: {
API_BASE_URL: 'http://127.0.0.1:9200/api',
API_BASE_URL: 'https://cms-api.websoft.top/api',
APP_NAME: '开发环境',
DEBUG: 'true',
},

239
src/api/invite/index.ts

@ -0,0 +1,239 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api/index';
import { SERVER_API_URL } from '@/utils/server';
/**
*
*/
export interface MiniProgramCodeParam {
// 小程序页面路径
page?: string;
// 场景值,最大32个可见字符
scene: string;
// 二维码宽度,单位 px,最小 280px,最大 1280px
width?: number;
// 是否检查页面是否存在
checkPath?: boolean;
// 环境版本
envVersion?: 'release' | 'trial' | 'develop';
}
/**
*
*/
export interface InviteRelationParam {
// 邀请人ID
inviterId: number;
// 被邀请人ID
inviteeId: number;
// 邀请来源
source: string;
// 场景值
scene?: string;
// 邀请时间
inviteTime?: string;
}
/**
*
*/
export interface InviteStats {
// 总邀请数
totalInvites: number;
// 成功注册数
successfulRegistrations: number;
// 转化率
conversionRate: number;
// 今日邀请数
todayInvites: number;
// 本月邀请数
monthlyInvites: number;
// 邀请来源统计
sourceStats: InviteSourceStat[];
}
/**
*
*/
export interface InviteSourceStat {
source: string;
count: number;
successCount: number;
conversionRate: number;
}
/**
*
*/
export interface InviteRecord {
id?: number;
inviterId?: number;
inviteeId?: number;
inviterName?: string;
inviteeName?: string;
source?: string;
scene?: string;
status?: 'pending' | 'registered' | 'activated';
inviteTime?: string;
registerTime?: string;
activateTime?: string;
}
/**
*
*/
export interface InviteRecordParam {
page?: number;
limit?: number;
inviterId?: number;
status?: string;
source?: string;
startTime?: string;
endTime?: string;
}
/**
*
*/
export async function generateMiniProgramCode(data: MiniProgramCodeParam) {
const res = await request.post<ApiResult<string>>(
SERVER_API_URL + '/invite/generate-miniprogram-code',
data
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function generateInviteCode(inviterId: number, source: string = 'qrcode') {
const scene = `inviter=${inviterId}&source=${source}&t=${Date.now()}`;
return generateMiniProgramCode({
page: 'pages/index/index',
scene: scene,
width: 430,
checkPath: true,
envVersion: 'release'
});
}
/**
*
*/
export async function createInviteRelation(data: InviteRelationParam) {
const res = await request.post<ApiResult<unknown>>(
SERVER_API_URL + '/invite/create-relation',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function processInviteScene(scene: string, userId: number) {
const res = await request.post<ApiResult<unknown>>(
SERVER_API_URL + '/invite/process-scene',
{ scene, userId }
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function getInviteStats(inviterId: number) {
const res = await request.get<ApiResult<InviteStats>>(
SERVER_API_URL + `/invite/stats/${inviterId}`
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function pageInviteRecords(params: InviteRecordParam) {
const res = await request.get<ApiResult<PageResult<InviteRecord>>>(
SERVER_API_URL + '/invite/records/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function getMyInviteRecords(params: InviteRecordParam) {
const res = await request.get<ApiResult<PageResult<InviteRecord>>>(
SERVER_API_URL + '/invite/my-records',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function validateInviteCode(scene: string) {
const res = await request.post<ApiResult<{ valid: boolean; inviterId?: number; source?: string }>>(
SERVER_API_URL + '/invite/validate-code',
{ scene }
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function updateInviteStatus(inviteId: number, status: 'registered' | 'activated') {
const res = await request.put<ApiResult<unknown>>(
SERVER_API_URL + `/invite/update-status/${inviteId}`,
{ status }
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
*
*/
export async function getInviteRanking(params?: { limit?: number; period?: 'day' | 'week' | 'month' }) {
const res = await request.get<ApiResult<Array<{
inviterId: number;
inviterName: string;
inviteCount: number;
successCount: number;
conversionRate: number;
}>>>(
SERVER_API_URL + '/invite/ranking',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

279
src/api/invite/model/index.ts

@ -0,0 +1,279 @@
import type { PageParam } from '@/api/index';
/**
*
*/
export interface InviteRecord {
// 主键ID
id?: number;
// 邀请人ID
inviterId?: number;
// 被邀请人ID
inviteeId?: number;
// 邀请人姓名
inviterName?: string;
// 被邀请人姓名
inviteeName?: string;
// 邀请来源 (qrcode, link, share等)
source?: string;
// 场景值
scene?: string;
// 邀请状态: pending-待注册, registered-已注册, activated-已激活
status?: 'pending' | 'registered' | 'activated';
// 邀请时间
inviteTime?: string;
// 注册时间
registerTime?: string;
// 激活时间
activateTime?: string;
// 备注
comments?: string;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
*
*/
export interface InviteStats {
// 主键ID
id?: number;
// 邀请人ID
inviterId?: number;
// 统计日期
statDate?: string;
// 总邀请数
totalInvites?: number;
// 成功注册数
successfulRegistrations?: number;
// 激活用户数
activatedUsers?: number;
// 转化率
conversionRate?: number;
// 今日邀请数
todayInvites?: number;
// 本周邀请数
weeklyInvites?: number;
// 本月邀请数
monthlyInvites?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
*
*/
export interface InviteSourceStats {
// 主键ID
id?: number;
// 邀请人ID
inviterId?: number;
// 来源类型
source?: string;
// 来源名称
sourceName?: string;
// 邀请数量
inviteCount?: number;
// 成功数量
successCount?: number;
// 转化率
conversionRate?: number;
// 统计日期
statDate?: string;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
*
*/
export interface MiniProgramCode {
// 主键ID
id?: number;
// 邀请人ID
inviterId?: number;
// 场景值
scene?: string;
// 小程序码URL
codeUrl?: string;
// 页面路径
pagePath?: string;
// 二维码宽度
width?: number;
// 环境版本
envVersion?: string;
// 过期时间
expireTime?: string;
// 使用次数
useCount?: number;
// 最后使用时间
lastUseTime?: string;
// 状态: active-有效, expired-过期, disabled-禁用
status?: 'active' | 'expired' | 'disabled';
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
*
*/
export interface InviteRecordParam extends PageParam {
// 邀请人ID
inviterId?: number;
// 被邀请人ID
inviteeId?: number;
// 邀请状态
status?: string;
// 邀请来源
source?: string;
// 开始时间
startTime?: string;
// 结束时间
endTime?: string;
// 关键词搜索
keywords?: string;
}
/**
*
*/
export interface InviteStatsParam extends PageParam {
// 邀请人ID
inviterId?: number;
// 统计开始日期
startDate?: string;
// 统计结束日期
endDate?: string;
}
/**
*
*/
export interface InviteSourceStatsParam extends PageParam {
// 邀请人ID
inviterId?: number;
// 来源类型
source?: string;
// 统计开始日期
startDate?: string;
// 统计结束日期
endDate?: string;
}
/**
*
*/
export interface MiniProgramCodeParam extends PageParam {
// 邀请人ID
inviterId?: number;
// 状态
status?: string;
// 场景值
scene?: string;
}
/**
*
*/
export interface InviteRanking {
// 邀请人ID
inviterId?: number;
// 邀请人姓名
inviterName?: string;
// 邀请人头像
inviterAvatar?: string;
// 邀请数量
inviteCount?: number;
// 成功数量
successCount?: number;
// 转化率
conversionRate?: number;
// 排名
rank?: number;
// 奖励金额
rewardAmount?: number;
}
/**
*
*/
export interface InviteRewardConfig {
// 主键ID
id?: number;
// 奖励类型: register-注册奖励, activate-激活奖励, order-订单奖励
rewardType?: string;
// 奖励名称
rewardName?: string;
// 奖励金额
rewardAmount?: number;
// 奖励积分
rewardPoints?: number;
// 奖励优惠券ID
couponId?: number;
// 是否启用
enabled?: boolean;
// 生效时间
effectTime?: string;
// 失效时间
expireTime?: string;
// 备注
comments?: string;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
*
*/
export interface InviteRewardRecord {
// 主键ID
id?: number;
// 邀请记录ID
inviteRecordId?: number;
// 邀请人ID
inviterId?: number;
// 被邀请人ID
inviteeId?: number;
// 奖励类型
rewardType?: string;
// 奖励金额
rewardAmount?: number;
// 奖励积分
rewardPoints?: number;
// 优惠券ID
couponId?: number;
// 发放状态: pending-待发放, issued-已发放, failed-发放失败
status?: 'pending' | 'issued' | 'failed';
// 发放时间
issueTime?: string;
// 失败原因
failReason?: string;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}

4
src/api/shop/shopDealerOrder/model/index.ts

@ -43,5 +43,9 @@ export interface ShopDealerOrder {
*/
export interface ShopDealerOrderParam extends PageParam {
id?: number;
firstUserId?: number;
secondUserId?: number;
thirdUserId?: number;
userId?: number;
keywords?: string;
}

3
src/api/shop/shopDealerReferee/model/index.ts

@ -1,4 +1,4 @@
import type { PageParam } from '@/api/index';
import type { PageParam } from '@/api';
/**
*
@ -25,5 +25,6 @@ export interface ShopDealerReferee {
*/
export interface ShopDealerRefereeParam extends PageParam {
id?: number;
dealerId?: number;
keywords?: string;
}

1
src/api/shop/shopDealerWithdraw/model/index.ts

@ -43,5 +43,6 @@ export interface ShopDealerWithdraw {
*/
export interface ShopDealerWithdrawParam extends PageParam {
id?: number;
userId?: number;
keywords?: string;
}

1
src/app.config.ts

@ -56,6 +56,7 @@ export default defineAppConfig({
"orders/index",
"team/index",
"qrcode/index",
"invite-stats/index",
"info"
]
},

21
src/app.scss

@ -17,6 +17,27 @@ button {
}
}
// 去掉 Grid 组件的边框
.no-border-grid {
.nut-grid-item {
border: none !important;
border-right: none !important;
border-bottom: none !important;
&::after {
border: none !important;
}
}
.nut-grid {
border: none !important;
&::after {
border: none !important;
}
}
}
// 微信授权按钮的特殊样式
button[open-type="getPhoneNumber"] {
background: none !important;

48
src/app.ts

@ -6,6 +6,7 @@ import './app.scss'
import {loginByOpenId} from "@/api/layout";
import {TenantId} from "@/config/app";
import {saveStorageByLoginUser} from "@/utils/server";
import {parseInviteParams, saveInviteParams, trackInviteSource, handleInviteRelation} from "@/utils/invite";
function App(props: { children: any; }) {
const reload = () => {
@ -14,9 +15,21 @@ function App(props: { children: any; }) {
loginByOpenId({
code: res.code,
tenantId: TenantId
}).then(data => {
}).then(async data => {
if (data) {
saveStorageByLoginUser(data.access_token, data.user)
// 处理邀请关系
if (data.user?.userId) {
try {
const inviteSuccess = await handleInviteRelation(data.user.userId)
if (inviteSuccess) {
console.log('自动登录时邀请关系建立成功')
}
} catch (error) {
console.error('自动登录时处理邀请关系失败:', error)
}
}
}
})
}
@ -36,8 +49,41 @@ function App(props: { children: any; }) {
// 对应 onShow
useDidShow(() => {
// 处理小程序启动参数中的邀请信息
const options = Taro.getLaunchOptionsSync()
handleLaunchOptions(options)
})
// 处理启动参数
const handleLaunchOptions = (options: any) => {
try {
console.log('小程序启动参数:', options)
// 解析邀请参数
const inviteParams = parseInviteParams(options)
if (inviteParams) {
console.log('检测到邀请参数:', inviteParams)
// 保存邀请参数到本地存储
saveInviteParams(inviteParams)
// 统计邀请来源
trackInviteSource(inviteParams.source || 'unknown', parseInt(inviteParams.inviter || '0'))
// 显示邀请提示
setTimeout(() => {
Taro.showToast({
title: '检测到邀请信息',
icon: 'success',
duration: 2000
})
}, 1000)
}
} catch (error) {
console.error('处理启动参数失败:', error)
}
}
// 对应 onHide
useDidHide(() => {
})

8
src/dealer/index.scss

@ -1,8 +0,0 @@
/* 添加这段样式后,Primary Button 会变成绿色 */
:root {
--nutui-color-primary: green;
--nutui-color-primary-stop1: green;
--nutui-color-primary-stop2: green;
// 间隔线/容错线用于结构或信息分割
--nutui-black-2: rgba(255, 0, 0, 0.08);
}

69
src/dealer/index.tsx

@ -7,7 +7,8 @@ import {
Dongdong,
ArrowRight,
Purse,
People
People,
Chart
} from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import { useThemeStyles } from '@/hooks/useTheme'
@ -69,7 +70,7 @@ const DealerIndex: React.FC = () => {
}
return (
<View className="bg-gray-50 min-h-screen">
<View className="bg-gray-100 min-h-screen">
<View>
{/*头部信息*/}
{dealerUser && (
@ -162,9 +163,9 @@ const DealerIndex: React.FC = () => {
{/* 团队统计 */}
{dealerUser && (
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
<View className="bg-white mx-4 mt-4 rounded-xl p-4 hidden">
<View className="flex items-center justify-between mb-4">
<Text className="font-semibold text-gray-800"></Text>
<Text className="font-semibold text-gray-800"></Text>
<View
className="text-gray-400 text-sm flex items-center"
onClick={() => navigateToPage('/dealer/team/index')}
@ -200,7 +201,15 @@ const DealerIndex: React.FC = () => {
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
<View className="font-semibold mb-4 text-gray-800"></View>
<ConfigProvider>
<Grid columns={4}>
<Grid
columns={4}
className="no-border-grid"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text="分销订单" onClick={() => navigateToPage('/dealer/orders/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
@ -209,30 +218,68 @@ const DealerIndex: React.FC = () => {
</View>
</Grid.Item>
<Grid.Item onClick={() => navigateToPage('/dealer/withdraw/index')}>
<Grid.Item text={'提现申请'} onClick={() => navigateToPage('/dealer/withdraw/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Purse color="#10b981" size="20"/>
</View>
<Text className="text-xs text-gray-600"></Text>
</View>
</Grid.Item>
<Grid.Item onClick={() => navigateToPage('/dealer/team/index')}>
<Grid.Item text={'我的邀请'} onClick={() => navigateToPage('/dealer/team/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<People color="#8b5cf6" size="20"/>
</View>
<Text className="text-xs text-gray-600"></Text>
</View>
</Grid.Item>
<Grid.Item onClick={() => navigateToPage('/dealer/qrcode/index')}>
<Grid.Item text={'我的邀请码'} onClick={() => navigateToPage('/dealer/qrcode/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Dongdong color="#f59e0b" size="20"/>
</View>
<Text className="text-xs text-gray-600">广</Text>
</View>
</Grid.Item>
</Grid>
{/* 第二行功能 */}
<Grid
columns={4}
className="no-border-grid mt-4"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text={'邀请统计'} onClick={() => navigateToPage('/dealer/invite-stats/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Chart color="#6366f1" size="20"/>
</View>
</View>
</Grid.Item>
{/* 预留其他功能位置 */}
<Grid.Item text={''}>
<View className="text-center">
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
</View>
</View>
</Grid.Item>
<Grid.Item text={''}>
<View className="text-center">
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
</View>
</View>
</Grid.Item>
<Grid.Item text={''}>
<View className="text-center">
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
</View>
</View>
</Grid.Item>
</Grid>

7
src/dealer/invite-stats/index.config.ts

@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '邀请统计',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: true
})

343
src/dealer/invite-stats/index.tsx

@ -0,0 +1,343 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import {
Empty,
Tabs,
Progress,
Loading,
PullToRefresh,
Card,
Button,
DatePicker
} from '@nutui/nutui-react-taro'
import {
User,
Star,
TrendingUp,
Calendar,
Share,
Target,
Award
} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import {
getInviteStats,
getMyInviteRecords,
getInviteRanking
} from '@/api/invite'
import type {
InviteStats,
InviteRecord,
InviteRanking
} from '@/api/invite'
import { businessGradients } from '@/styles/gradients'
const InviteStatsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<string>('stats')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [inviteStats, setInviteStats] = useState<InviteStats | null>(null)
const [inviteRecords, setInviteRecords] = useState<InviteRecord[]>([])
const [ranking, setRanking] = useState<InviteRanking[]>([])
const [dateRange, setDateRange] = useState<string>('month')
const { dealerUser } = useDealerUser()
// 获取邀请统计数据
const fetchInviteStats = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
const stats = await getInviteStats(dealerUser.userId)
setInviteStats(stats)
} catch (error) {
console.error('获取邀请统计失败:', error)
Taro.showToast({
title: '获取统计数据失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 获取邀请记录
const fetchInviteRecords = useCallback(async () => {
if (!dealerUser?.userId) return
try {
const result = await getMyInviteRecords({
page: 1,
limit: 50,
inviterId: dealerUser.userId
})
setInviteRecords(result?.list || [])
} catch (error) {
console.error('获取邀请记录失败:', error)
}
}, [dealerUser?.userId])
// 获取邀请排行榜
const fetchRanking = useCallback(async () => {
try {
const result = await getInviteRanking({
limit: 20,
period: dateRange as 'day' | 'week' | 'month'
})
setRanking(result || [])
} catch (error) {
console.error('获取排行榜失败:', error)
}
}, [dateRange])
// 刷新数据
const handleRefresh = async () => {
setRefreshing(true)
await Promise.all([
fetchInviteStats(),
fetchInviteRecords(),
fetchRanking()
])
setRefreshing(false)
}
// 初始化数据
useEffect(() => {
if (dealerUser?.userId) {
fetchInviteStats()
fetchInviteRecords()
fetchRanking()
}
}, [fetchInviteStats, fetchInviteRecords, fetchRanking])
// 获取状态显示文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'pending': '待注册',
'registered': '已注册',
'activated': '已激活'
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
'pending': 'text-orange-500',
'registered': 'text-blue-500',
'activated': 'text-green-500'
}
return colorMap[status] || 'text-gray-500'
}
// 渲染统计概览
const renderStatsOverview = () => (
<View className="px-4 space-y-4">
{/* 核心数据卡片 */}
<Card className="bg-white rounded-2xl shadow-sm">
<View className="p-4">
<Text className="text-lg font-semibold text-gray-800 mb-4"></Text>
{loading ? (
<View className="flex items-center justify-center py-8">
<Loading />
</View>
) : inviteStats ? (
<View className="grid grid-cols-2 gap-4">
<View className="text-center p-4 bg-blue-50 rounded-xl">
<TrendingUp size="24" className="text-blue-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-blue-600">
{inviteStats.totalInvites || 0}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
<View className="text-center p-4 bg-green-50 rounded-xl">
<User size="24" className="text-green-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-green-600">
{inviteStats.successfulRegistrations || 0}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
<View className="text-center p-4 bg-purple-50 rounded-xl">
<Target size="24" className="text-purple-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-purple-600">
{inviteStats.conversionRate ? `${(inviteStats.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
<View className="text-center p-4 bg-orange-50 rounded-xl">
<Calendar size="24" className="text-orange-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-orange-600">
{inviteStats.todayInvites || 0}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
</View>
) : (
<View className="text-center py-8">
<Text className="text-gray-500"></Text>
</View>
)}
</View>
</Card>
{/* 邀请来源分析 */}
{inviteStats?.sourceStats && inviteStats.sourceStats.length > 0 && (
<Card className="bg-white rounded-2xl shadow-sm">
<View className="p-4">
<Text className="text-lg font-semibold text-gray-800 mb-4"></Text>
<View className="space-y-3">
{inviteStats.sourceStats.map((source, index) => (
<View key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<View className="flex items-center">
<Share size="16" className="text-blue-500 mr-2" />
<Text className="font-medium text-gray-800">{source.source}</Text>
</View>
<View className="text-right">
<Text className="text-lg font-bold text-gray-800">{source.count}</Text>
<Text className="text-sm text-gray-500">
{source.conversionRate ? `${(source.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
</View>
</View>
))}
</View>
</View>
</Card>
)}
</View>
)
// 渲染邀请记录
const renderInviteRecords = () => (
<View className="px-4">
{inviteRecords.length > 0 ? (
<View className="space-y-3">
{inviteRecords.map((record, index) => (
<Card key={record.id || index} className="bg-white rounded-xl shadow-sm">
<View className="p-4">
<View className="flex items-center justify-between mb-2">
<Text className="font-medium text-gray-800">
{record.inviteeName || `用户${record.inviteeId}`}
</Text>
<Text className={`text-sm font-medium ${getStatusColor(record.status || 'pending')}`}>
{getStatusText(record.status || 'pending')}
</Text>
</View>
<View className="flex items-center justify-between text-sm text-gray-500">
<Text>: {record.source || '未知'}</Text>
<Text>{record.inviteTime ? new Date(record.inviteTime).toLocaleDateString() : ''}</Text>
</View>
{record.registerTime && (
<Text className="text-xs text-green-600 mt-1">
: {new Date(record.registerTime).toLocaleString()}
</Text>
)}
</View>
</Card>
))}
</View>
) : (
<Empty description="暂无邀请记录" />
)}
</View>
)
// 渲染排行榜
const renderRanking = () => (
<View className="px-4">
<View className="mb-4">
<Tabs value={dateRange} onChange={setDateRange}>
<Tabs.TabPane title="日榜" value="day" />
<Tabs.TabPane title="周榜" value="week" />
<Tabs.TabPane title="月榜" value="month" />
</Tabs>
</View>
{ranking.length > 0 ? (
<View className="space-y-3">
{ranking.map((item, index) => (
<Card key={item.inviterId} className="bg-white rounded-xl shadow-sm">
<View className="p-4 flex items-center">
<View className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 mr-3">
{index < 3 ? (
<Award size="16" className={index === 0 ? 'text-yellow-500' : index === 1 ? 'text-gray-400' : 'text-orange-400'} />
) : (
<Text className="text-sm font-bold text-gray-600">{index + 1}</Text>
)}
</View>
<View className="flex-1">
<Text className="font-medium text-gray-800">{item.inviterName}</Text>
<Text className="text-sm text-gray-500">
{item.inviteCount} · {item.conversionRate ? `${(item.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
</View>
<Text className="text-lg font-bold text-blue-600">{item.successCount}</Text>
</View>
</Card>
))}
</View>
) : (
<Empty description="暂无排行数据" />
)}
</View>
)
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="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 mb-4">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.TabPane title="统计概览" value="stats" />
<Tabs.TabPane title="邀请记录" value="records" />
<Tabs.TabPane title="排行榜" value="ranking" />
</Tabs>
</View>
{/* 内容区域 */}
<PullToRefresh onRefresh={handleRefresh} loading={refreshing}>
<View className="pb-6">
{activeTab === 'stats' && renderStatsOverview()}
{activeTab === 'records' && renderInviteRecords()}
{activeTab === 'ranking' && renderRanking()}
</View>
</PullToRefresh>
</View>
)
}
export default InviteStatsPage

371
src/dealer/orders/index.tsx

@ -1,60 +1,197 @@
import React, { useState } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Empty, Tabs, Tag, PullToRefresh } from '@nutui/nutui-react-taro'
import { Empty, Tabs, Tag, PullToRefresh, Loading } from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import { pageShopDealerOrder } from '@/api/shop/shopDealerOrder'
import { useDealerUser } from '@/hooks/useDealerUser'
import type { ShopDealerOrder } from '@/api/shop/shopDealerOrder/model'
interface OrderWithDetails extends ShopDealerOrder {
orderNo?: string
customerName?: string
totalCommission?: string
// 当前用户在此订单中的层级和佣金
userLevel?: 1 | 2 | 3
userCommission?: string
}
const DealerOrders: React.FC = () => {
const [activeTab, setActiveTab] = useState<string>('0')
const [refreshing, setRefreshing] = useState<boolean>(false)
// 模拟订单数据
const mockOrders = [
{
id: '1',
orderNo: 'DD202412180001',
customerName: '张三',
amount: '299.00',
commission: '29.90',
status: 'completed',
createTime: '2024-12-18 10:30:00'
},
{
id: '2',
orderNo: 'DD202412180002',
customerName: '李四',
amount: '599.00',
commission: '59.90',
status: 'pending',
createTime: '2024-12-18 14:20:00'
}
]
const getStatusText = (status: string) => {
switch (status) {
case 'completed': return '已完成'
case 'pending': return '待结算'
case 'cancelled': return '已取消'
default: return '未知'
const [loading, setLoading] = useState<boolean>(false)
const [orders, setOrders] = useState<OrderWithDetails[]>([])
const [statistics, setStatistics] = useState({
totalOrders: 0,
totalCommission: '0.00',
pendingCommission: '0.00',
// 分层统计
level1: { orders: 0, commission: '0.00' },
level2: { orders: 0, commission: '0.00' },
level3: { orders: 0, commission: '0.00' }
})
const { dealerUser } = useDealerUser()
// 获取订单数据 - 查询当前用户作为各层级分销商的所有订单
const fetchOrders = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
// 并行查询三个层级的订单
const [level1Result, level2Result, level3Result] = await Promise.all([
// 一级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
firstUserId: dealerUser.userId
}),
// 二级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
secondUserId: dealerUser.userId
}),
// 三级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
thirdUserId: dealerUser.userId
})
])
const allOrders: OrderWithDetails[] = []
const stats = {
totalOrders: 0,
totalCommission: '0.00',
pendingCommission: '0.00',
level1: { orders: 0, commission: '0.00' },
level2: { orders: 0, commission: '0.00' },
level3: { orders: 0, commission: '0.00' }
}
// 处理一级分销订单
if (level1Result?.list) {
const level1Orders = level1Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 1 as const,
userCommission: order.firstMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level1Orders)
stats.level1.orders = level1Orders.length
stats.level1.commission = level1Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 处理二级分销订单
if (level2Result?.list) {
const level2Orders = level2Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 2 as const,
userCommission: order.secondMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level2Orders)
stats.level2.orders = level2Orders.length
stats.level2.commission = level2Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 处理三级分销订单
if (level3Result?.list) {
const level3Orders = level3Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 3 as const,
userCommission: order.thirdMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level3Orders)
stats.level3.orders = level3Orders.length
stats.level3.commission = level3Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 去重(同一个订单可能在多个层级中出现)
const uniqueOrders = allOrders.filter((order, index, self) =>
index === self.findIndex(o => o.orderId === order.orderId)
)
// 计算总统计
stats.totalOrders = uniqueOrders.length
stats.totalCommission = (
parseFloat(stats.level1.commission) +
parseFloat(stats.level2.commission) +
parseFloat(stats.level3.commission)
).toFixed(2)
stats.pendingCommission = allOrders
.filter(order => order.isSettled === 0)
.reduce((sum, order) => sum + parseFloat(order.userCommission || '0'), 0)
.toFixed(2)
setOrders(uniqueOrders)
setStatistics(stats)
} catch (error) {
console.error('获取分销订单失败:', error)
Taro.showToast({
title: '获取订单失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 刷新数据
const handleRefresh = async () => {
await fetchOrders()
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'success'
case 'pending': return 'warning'
case 'cancelled': return 'danger'
default: return 'default'
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchOrders().then()
}
}, [fetchOrders])
const getStatusText = (isSettled?: number, isInvalid?: number) => {
if (isInvalid === 1) return '已失效'
if (isSettled === 1) return '已结算'
return '待结算'
}
const handleRefresh = async () => {
setRefreshing(true)
// 模拟刷新
setTimeout(() => {
setRefreshing(false)
}, 1000)
const getStatusColor = (isSettled?: number, isInvalid?: number) => {
if (isInvalid === 1) return 'danger'
if (isSettled === 1) return 'success'
return 'warning'
}
const renderOrderItem = (order: any) => (
const renderOrderItem = (order: OrderWithDetails) => (
<View key={order.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
@ -64,19 +201,28 @@ const DealerOrders: React.FC = () => {
<Text className="text-sm text-gray-500">
{order.customerName}
</Text>
{/* 显示用户在此订单中的层级 */}
<Text className="text-xs text-blue-500">
{order.userLevel === 1 && '一级分销'}
{order.userLevel === 2 && '二级分销'}
{order.userLevel === 3 && '三级分销'}
</Text>
</View>
<Tag type={getStatusColor(order.status)}>
{getStatusText(order.status)}
<Tag type={getStatusColor(order.isSettled, order.isInvalid)}>
{getStatusText(order.isSettled, order.isInvalid)}
</Tag>
</View>
<View className="flex justify-between items-center">
<View>
<Text className="text-sm text-gray-600">
¥{order.amount}
¥{order.orderPrice || '0.00'}
</Text>
<Text className="text-sm text-orange-500 font-semibold">
¥{order.commission}
¥{order.userCommission}
</Text>
<Text className="text-xs text-gray-400">
¥{order.totalCommission}
</Text>
</View>
<Text className="text-xs text-gray-400">
@ -86,37 +232,92 @@ const DealerOrders: React.FC = () => {
</View>
)
// 根据状态和层级过滤订单
const getFilteredOrders = (filter: string) => {
switch (filter) {
case '1': // 一级分销
return orders.filter(order => order.userLevel === 1)
case '2': // 二级分销
return orders.filter(order => order.userLevel === 2)
case '3': // 三级分销
return orders.filter(order => order.userLevel === 3)
case '4': // 待结算
return orders.filter(order => order.isSettled === 0 && order.isInvalid === 0)
case '5': // 已结算
return orders.filter(order => order.isSettled === 1)
case '6': // 已失效
return orders.filter(order => order.isInvalid === 1)
default: // 全部
return orders
}
}
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="bg-gray-50 min-h-screen">
{/* 统计卡片 */}
<View className="bg-white p-4 mb-4">
<View className="grid grid-cols-3 gap-4">
{/* 总体统计 */}
<View className="grid grid-cols-3 gap-4 mb-4">
<View className="text-center">
<Text className="text-lg font-bold text-blue-500">2</Text>
<Text className="text-lg font-bold text-blue-500">{statistics.totalOrders}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-green-500">¥89.80</Text>
<Text className="text-lg font-bold text-green-500">¥{statistics.totalCommission}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-orange-500">¥29.90</Text>
<Text className="text-lg font-bold text-orange-500">¥{statistics.pendingCommission}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
{/* 分层统计 */}
<View className="border-t pt-3">
<Text className="text-sm text-gray-600 mb-2"></Text>
<View className="grid grid-cols-3 gap-2">
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-red-500">{statistics.level1.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-red-500">¥{statistics.level1.commission}</Text>
</View>
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-blue-500">{statistics.level2.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-blue-500">¥{statistics.level2.commission}</Text>
</View>
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-purple-500">{statistics.level3.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-purple-500">¥{statistics.level3.commission}</Text>
</View>
</View>
</View>
</View>
{/* 订单列表 */}
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="全部" value="0">
<PullToRefresh
// @ts-ignore
loading={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{mockOrders.length > 0 ? (
mockOrders.map(renderOrderItem)
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : getFilteredOrders('0').length > 0 ? (
getFilteredOrders('0').map(renderOrderItem)
) : (
<Empty description="暂无分销订单" />
)}
@ -124,15 +325,63 @@ const DealerOrders: React.FC = () => {
</PullToRefresh>
</Tabs.TabPane>
<Tabs.TabPane title="待结算" value="1">
<Tabs.TabPane title="一级分销" value="1">
<View className="p-4">
{getFilteredOrders('1').length > 0 ? (
getFilteredOrders('1').map(renderOrderItem)
) : (
<Empty description="暂无一级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="二级分销" value="2">
<View className="p-4">
{getFilteredOrders('2').length > 0 ? (
getFilteredOrders('2').map(renderOrderItem)
) : (
<Empty description="暂无二级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="三级分销" value="3">
<View className="p-4">
{getFilteredOrders('3').length > 0 ? (
getFilteredOrders('3').map(renderOrderItem)
) : (
<Empty description="暂无三级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="待结算" value="4">
<View className="p-4">
{getFilteredOrders('4').length > 0 ? (
getFilteredOrders('4').map(renderOrderItem)
) : (
<Empty description="暂无待结算订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="已结算" value="5">
<View className="p-4">
{mockOrders.filter(o => o.status === 'pending').map(renderOrderItem)}
{getFilteredOrders('5').length > 0 ? (
getFilteredOrders('5').map(renderOrderItem)
) : (
<Empty description="暂无已结算订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="已完成" value="2">
<Tabs.TabPane title="已失效" value="6">
<View className="p-4">
{mockOrders.filter(o => o.status === 'completed').map(renderOrderItem)}
{getFilteredOrders('6').length > 0 ? (
getFilteredOrders('6').map(renderOrderItem)
) : (
<Empty description="暂无失效订单" />
)}
</View>
</Tabs.TabPane>
</Tabs>

382
src/dealer/qrcode/index.tsx

@ -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>
)

224
src/dealer/team/index.tsx

@ -1,55 +1,144 @@
import React, { useState } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Empty, Tabs, Avatar, Tag, Progress } from '@nutui/nutui-react-taro'
import { Empty, Tabs, Avatar, Tag, Progress, Loading, PullToRefresh } from '@nutui/nutui-react-taro'
import { User, Star, StarFill } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { listShopDealerReferee } from '@/api/shop/shopDealerReferee'
import { pageShopDealerOrder } from '@/api/shop/shopDealerOrder'
import type { ShopDealerReferee } from '@/api/shop/shopDealerReferee/model'
interface TeamMemberWithStats extends ShopDealerReferee {
name?: string
avatar?: string
orderCount?: number
commission?: string
status?: 'active' | 'inactive'
subMembers?: number
joinTime?: string
}
const DealerTeam: React.FC = () => {
const [activeTab, setActiveTab] = useState('0')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [teamMembers, setTeamMembers] = useState<TeamMemberWithStats[]>([])
const [teamStats, setTeamStats] = useState({
total: 0,
firstLevel: 0,
secondLevel: 0,
thirdLevel: 0,
monthlyCommission: '0.00'
})
const { dealerUser } = useDealerUser()
// 获取团队数据
const fetchTeamData = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
// 获取团队成员关系
const refereeResult = await listShopDealerReferee({
dealerId: dealerUser.userId
})
if (refereeResult) {
// 处理团队成员数据
const processedMembers: TeamMemberWithStats[] = refereeResult.map(member => ({
...member,
name: `用户${member.userId}`,
avatar: '',
orderCount: 0,
commission: '0.00',
status: 'active' as const,
subMembers: 0,
joinTime: member.createTime
}))
// 并行获取每个成员的订单统计
const memberStats = await Promise.all(
processedMembers.map(async (member) => {
try {
const orderResult = await pageShopDealerOrder({
page: 1,
limit: 100,
userId: member.userId
})
if (orderResult?.list) {
const orders = orderResult.list
const orderCount = orders.length
const commission = orders.reduce((sum, order) => {
const levelCommission = member.level === 1 ? order.firstMoney :
member.level === 2 ? order.secondMoney :
order.thirdMoney
return sum + parseFloat(levelCommission || '0')
}, 0).toFixed(2)
// 判断活跃状态(30天内有订单为活跃)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const hasRecentOrder = orders.some(order =>
new Date(order.createTime || '') > thirtyDaysAgo
)
return {
...member,
orderCount,
commission,
status: hasRecentOrder ? 'active' as const : 'inactive' as const
}
}
return member
} catch (error) {
console.error(`获取成员${member.userId}订单失败:`, error)
return member
}
})
)
setTeamMembers(memberStats)
// 模拟团队数据
const teamStats = {
total: 28,
firstLevel: 12,
secondLevel: 10,
thirdLevel: 6,
monthlyCommission: '2,580.50'
// 计算统计数据
const stats = {
total: memberStats.length,
firstLevel: memberStats.filter(m => m.level === 1).length,
secondLevel: memberStats.filter(m => m.level === 2).length,
thirdLevel: memberStats.filter(m => m.level === 3).length,
monthlyCommission: memberStats.reduce((sum, member) =>
sum + parseFloat(member.commission || '0'), 0
).toFixed(2)
}
setTeamStats(stats)
}
} catch (error) {
console.error('获取团队数据失败:', error)
Taro.showToast({
title: '获取团队数据失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 刷新数据
const handleRefresh = async () => {
setRefreshing(true)
await fetchTeamData()
setRefreshing(false)
}
const teamMembers = [
{
id: '1',
name: '张小明',
level: 1,
joinTime: '2024-11-15',
orderCount: 15,
commission: '580.50',
status: 'active',
avatar: '',
subMembers: 3
},
{
id: '2',
name: '李小红',
level: 1,
joinTime: '2024-12-01',
orderCount: 8,
commission: '320.00',
status: 'active',
avatar: '',
subMembers: 2
},
{
id: '3',
name: '王小华',
level: 2,
joinTime: '2024-12-10',
orderCount: 5,
commission: '150.00',
status: 'inactive',
avatar: '',
subMembers: 0
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchTeamData().then()
}
]
}, [fetchTeamData])
const getLevelColor = (level: number) => {
switch (level) {
@ -69,7 +158,7 @@ const DealerTeam: React.FC = () => {
}
}
const renderMemberItem = (member: any) => (
const renderMemberItem = (member: TeamMemberWithStats) => (
<View key={member.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex items-center mb-3">
<Avatar
@ -83,7 +172,7 @@ const DealerTeam: React.FC = () => {
<Text className="font-semibold text-gray-800 mr-2">
{member.name}
</Text>
{getLevelIcon(member.level)}
{getLevelIcon(Number(member.level))}
<Text className="text-xs text-gray-500 ml-1">
{member.level}
</Text>
@ -171,7 +260,7 @@ const DealerTeam: React.FC = () => {
<Progress
percent={(teamStats.firstLevel / teamStats.total) * 100}
strokeWidth="6"
background="#f59e0b"
background={'#f59e0b'}
className="w-20"
/>
</View>
@ -187,7 +276,7 @@ const DealerTeam: React.FC = () => {
<Progress
percent={(teamStats.secondLevel / teamStats.total) * 100}
strokeWidth="6"
background="#8b5cf6"
background={'#8b5cf6'}
className="w-20"
/>
</View>
@ -203,7 +292,7 @@ const DealerTeam: React.FC = () => {
<Progress
percent={(teamStats.thirdLevel / teamStats.total) * 100}
strokeWidth="6"
background="#ec4899"
background={'#ec4899'}
className="w-20"
/>
</View>
@ -220,17 +309,38 @@ const DealerTeam: React.FC = () => {
)
const renderMemberList = (level?: number) => (
<View className="p-4">
{teamMembers
.filter(member => !level || member.level === level)
.map(renderMemberItem)}
{teamMembers.filter(member => !level || member.level === level).length === 0 && (
<Empty description={`暂无${level ? level + '级' : ''}团队成员`} />
)}
</View>
<PullToRefresh
loading={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : teamMembers
.filter(member => !level || member.level === level)
.length > 0 ? (
teamMembers
.filter(member => !level || member.level === level)
.map(renderMemberItem)
) : (
<Empty description={`暂无${level ? level + '级' : ''}团队成员`} />
)}
</View>
</PullToRefresh>
)
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="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={() => setActiveTab}>

336
src/dealer/withdraw/index.tsx

@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import {
Cell,
@ -9,63 +9,196 @@ import {
Radio,
Tabs,
Tag,
Empty
Empty,
Loading,
PullToRefresh
} from '@nutui/nutui-react-taro'
import { Wallet } from '@nutui/icons-react-taro'
import { businessGradients } from '@/styles/gradients'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { pageShopDealerWithdraw, addShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw'
import type { ShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw/model'
interface WithdrawRecordWithDetails extends ShopDealerWithdraw {
accountDisplay?: string
}
const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState('0')
const [selectedAccount, setSelectedAccount] = useState('')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [submitting, setSubmitting] = useState<boolean>(false)
const [availableAmount, setAvailableAmount] = useState<string>('0.00')
const [withdrawRecords, setWithdrawRecords] = useState<WithdrawRecordWithDetails[]>([])
const formRef = useRef<any>(null)
// 模拟可提现金额
const availableAmount = '1,288.50'
// 模拟提现记录
const withdrawRecords = [
{
id: '1',
amount: '500.00',
account: '尾号1234',
status: 'completed',
createTime: '2024-12-15 10:30:00',
completeTime: '2024-12-15 16:20:00'
},
{
id: '2',
amount: '300.00',
account: '尾号1234',
status: 'pending',
createTime: '2024-12-18 09:15:00'
const { dealerUser } = useDealerUser()
// 获取可提现余额
const fetchBalance = useCallback(async () => {
try {
setAvailableAmount(dealerUser?.money || '0.00')
} catch (error) {
console.error('获取余额失败:', error)
}
}, [])
// 获取提现记录
const fetchWithdrawRecords = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
const result = await pageShopDealerWithdraw({
page: 1,
limit: 100,
userId: dealerUser.userId
})
if (result?.list) {
const processedRecords = result.list.map(record => ({
...record,
accountDisplay: getAccountDisplay(record)
}))
setWithdrawRecords(processedRecords)
}
} catch (error) {
console.error('获取提现记录失败:', error)
Taro.showToast({
title: '获取提现记录失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 格式化账户显示
const getAccountDisplay = (record: ShopDealerWithdraw) => {
if (record.payType === 10) {
return '微信钱包'
} else if (record.payType === 20 && record.alipayAccount) {
return `支付宝(${record.alipayAccount.slice(-4)})`
} else if (record.payType === 30 && record.bankCard) {
return `${record.bankName || '银行卡'}(尾号${record.bankCard.slice(-4)})`
}
return '未知账户'
}
// 刷新数据
const handleRefresh = async () => {
setRefreshing(true)
await Promise.all([fetchBalance(), fetchWithdrawRecords()])
setRefreshing(false)
}
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchBalance().then()
fetchWithdrawRecords().then()
}
]
}, [fetchBalance, fetchWithdrawRecords])
const getStatusText = (status: string) => {
const getStatusText = (status?: number) => {
switch (status) {
case 'completed': return '已到账'
case 'pending': return '处理中'
case 'rejected': return '已拒绝'
case 40: return '已到账'
case 20: return '审核通过'
case 10: return '待审核'
case 30: return '已驳回'
default: return '未知'
}
}
const getStatusColor = (status: string) => {
const getStatusColor = (status?: number) => {
switch (status) {
case 'completed': return 'success'
case 'pending': return 'warning'
case 'rejected': return 'danger'
case 40: return 'success'
case 20: return 'success'
case 10: return 'warning'
case 30: return 'danger'
default: return 'default'
}
}
const handleSubmit = (values: any) => {
console.log('提现申请:', values)
Taro.showToast({
title: '提现申请已提交',
icon: 'success'
})
const handleSubmit = async (values: any) => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息获取失败',
icon: 'error'
})
return
}
// 验证提现金额
const amount = parseFloat(values.amount)
const available = parseFloat(availableAmount.replace(',', ''))
if (amount < 100) {
Taro.showToast({
title: '最低提现金额为100元',
icon: 'error'
})
return
}
if (amount > available) {
Taro.showToast({
title: '提现金额超过可用余额',
icon: 'error'
})
return
}
try {
setSubmitting(true)
const withdrawData: ShopDealerWithdraw = {
userId: dealerUser.userId,
money: values.amount,
payType: values.accountType === 'wechat' ? 10 :
values.accountType === 'alipay' ? 20 : 30,
applyStatus: 10, // 待审核
platform: 'MiniProgram'
}
// 根据提现方式设置账户信息
if (values.accountType === 'alipay') {
withdrawData.alipayAccount = values.account
withdrawData.alipayName = values.accountName
} else if (values.accountType === 'bank') {
withdrawData.bankCard = values.account
withdrawData.bankAccount = values.accountName
withdrawData.bankName = values.bankName || '银行卡'
}
await addShopDealerWithdraw(withdrawData)
Taro.showToast({
title: '提现申请已提交',
icon: 'success'
})
// 重置表单
formRef.current?.resetFields()
setSelectedAccount('')
// 刷新数据
await handleRefresh()
// 切换到提现记录页面
setActiveTab('1')
} catch (error: any) {
console.error('提现申请失败:', error)
Taro.showToast({
title: error.message || '提现申请失败',
icon: 'error'
})
} finally {
setSubmitting(false)
}
}
const quickAmounts = ['100', '300', '500', '1000']
@ -165,22 +298,49 @@ const DealerWithdraw: React.FC = () => {
</Radio.Group>
</Form.Item>
<Form.Item name="account" label="账户信息" required>
<Input placeholder="请输入账户号码" />
</Form.Item>
{selectedAccount === 'alipay' && (
<>
<Form.Item name="account" label="支付宝账号" required>
<Input placeholder="请输入支付宝账号" />
</Form.Item>
<Form.Item name="accountName" label="支付宝姓名" required>
<Input placeholder="请输入支付宝实名姓名" />
</Form.Item>
</>
)}
<Form.Item name="accountName" label="账户姓名" required>
<Input placeholder="请输入账户姓名" />
</Form.Item>
{selectedAccount === 'bank' && (
<>
<Form.Item name="bankName" label="开户银行" required>
<Input placeholder="请输入开户银行名称" />
</Form.Item>
<Form.Item name="account" label="银行卡号" required>
<Input placeholder="请输入银行卡号" />
</Form.Item>
<Form.Item name="accountName" label="开户姓名" required>
<Input placeholder="请输入银行卡开户姓名" />
</Form.Item>
</>
)}
<Form.Item name="remark" label="备注">
<Input placeholder="请输入备注信息(可选)" />
</Form.Item>
{selectedAccount === 'wechat' && (
<View className="px-4 py-2">
<Text className="text-sm text-gray-500">
</Text>
</View>
)}
</CellGroup>
<View className="mt-6 px-4">
<Button block type="primary" nativeType="submit">
<Button
block
type="primary"
nativeType="submit"
loading={submitting}
disabled={submitting || !selectedAccount}
>
{submitting ? '提交中...' : '申请提现'}
</Button>
</View>
</Form>
@ -188,40 +348,64 @@ const DealerWithdraw: React.FC = () => {
)
const renderWithdrawRecords = () => (
<View className="p-4">
{withdrawRecords.length > 0 ? (
withdrawRecords.map(record => (
<View key={record.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.amount}
</Text>
<Text className="text-sm text-gray-500">
{record.account}
</Text>
<PullToRefresh
loading={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : withdrawRecords.length > 0 ? (
withdrawRecords.map(record => (
<View key={record.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.money}
</Text>
<Text className="text-sm text-gray-500">
{record.accountDisplay}
</Text>
</View>
<Tag type={getStatusColor(record.applyStatus)}>
{getStatusText(record.applyStatus)}
</Tag>
</View>
<Tag type={getStatusColor(record.status)}>
{getStatusText(record.status)}
</Tag>
</View>
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.completeTime && (
<Text className="block mt-1">
{record.completeTime}
</Text>
)}
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.auditTime && (
<Text className="block mt-1">
{new Date(record.auditTime).toLocaleString()}
</Text>
)}
{record.rejectReason && (
<Text className="block mt-1 text-red-500">
{record.rejectReason}
</Text>
)}
</View>
</View>
</View>
))
) : (
<Empty description="暂无提现记录" />
)}
</View>
))
) : (
<Empty description="暂无提现记录" />
)}
</View>
</PullToRefresh>
)
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="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={() => setActiveTab}>

20
src/pages/index/Header.tsx

@ -8,6 +8,7 @@ import {TenantId} from "@/config/app";
import {getOrganization} from "@/api/system/organization";
import {myUserVerify} from "@/api/system/userVerify";
import { useShopInfo } from '@/hooks/useShopInfo';
import {handleInviteRelation} from "@/utils/invite";
import MySearch from "./MySearch";
import './Header.scss';
@ -105,7 +106,7 @@ const Header = (props: any) => {
'content-type': 'application/json',
TenantId
},
success: function (res) {
success: async function (res) {
if (res.data.code == 1) {
Taro.showToast({
title: res.data.message,
@ -118,6 +119,23 @@ const Header = (props: any) => {
Taro.setStorageSync('access_token', res.data.data.access_token)
Taro.setStorageSync('UserId', res.data.data.user.userId)
setIsLogin(true)
// 处理邀请关系
if (res.data.data.user?.userId) {
try {
const inviteSuccess = await handleInviteRelation(res.data.data.user.userId)
if (inviteSuccess) {
Taro.showToast({
title: '邀请关系建立成功',
icon: 'success',
duration: 2000
})
}
} catch (error) {
console.error('处理邀请关系失败:', error)
}
}
// 重新加载小程序
Taro.reLaunch({
url: '/pages/index/index'

51
src/pages/index/HeaderWithHook.tsx

@ -10,22 +10,23 @@ import {myUserVerify} from "@/api/system/userVerify";
import {User} from "@/api/system/user/model";
import { useShopInfo } from '@/hooks/useShopInfo';
import { useUser } from '@/hooks/useUser';
import {handleInviteRelation} from "@/utils/invite";
import MySearch from "./MySearch";
import './Header.scss';
const Header = (props: any) => {
// 使用新的hooks
const {
shopInfo,
loading: shopLoading,
getWebsiteName,
getWebsiteLogo
const {
shopInfo,
loading: shopLoading,
getWebsiteName,
getWebsiteLogo
} = useShopInfo();
const {
user,
isLoggedIn,
loading: userLoading
const {
user,
isLoggedIn,
loading: userLoading
} = useUser();
const [showBasic, setShowBasic] = useState(false)
@ -37,10 +38,10 @@ const Header = (props: any) => {
setStatusBarHeight(res.statusBarHeight)
},
})
// 注意:商店信息现在通过useShopInfo自动管理,不需要手动获取
// 用户信息现在通过useUser自动管理,不需要手动获取
// 如果需要获取openId,可以在用户登录后处理
if (user && !user.openid) {
Taro.login({
@ -88,7 +89,7 @@ const Header = (props: any) => {
sceneType: 'save_referee',
tenantId: TenantId
},
success: function (res) {
success: async function (res) {
if (res.data.code == 1) {
Taro.showToast({
title: res.data.message,
@ -100,7 +101,23 @@ const Header = (props: any) => {
// 登录成功
Taro.setStorageSync('access_token', res.data.data.access_token)
Taro.setStorageSync('UserId', res.data.data.user.userId)
// 处理邀请关系
if (res.data.data.user?.userId) {
try {
const inviteSuccess = await handleInviteRelation(res.data.data.user.userId)
if (inviteSuccess) {
Taro.showToast({
title: '邀请关系建立成功',
icon: 'success',
duration: 2000
})
}
} catch (error) {
console.error('处理邀请关系失败:', error)
}
}
// 重新加载小程序
Taro.reLaunch({
url: '/pages/index/index'
@ -179,7 +196,7 @@ const Header = (props: any) => {
<h3></h3>
<div>: {getWebsiteName()}</div>
<div>Logo: <img src={getWebsiteLogo()} alt="logo" style={{width: '50px', height: '50px'}} /></div>
<h3></h3>
<div>: {isLoggedIn ? '已登录' : '未登录'}</div>
{user && (
@ -189,8 +206,8 @@ const Header = (props: any) => {
<div>: {user.nickname}</div>
</>
)}
<button
<button
onClick={() => setShowBasic(false)}
style={{marginTop: '20px', padding: '10px 20px'}}
>

20
src/pages/index/Login.tsx

@ -4,6 +4,7 @@ import {Input, Radio, Button} from '@nutui/nutui-react-taro'
import {TenantId} from "@/config/app";
import './login.scss';
import {saveStorageByLoginUser} from "@/utils/server";
import {handleInviteRelation} from "@/utils/invite";
// 微信获取手机号回调参数类型
interface GetPhoneNumberDetail {
@ -58,8 +59,25 @@ const Login = (props: LoginProps) => {
'content-type': 'application/json',
TenantId
},
success: function (res: LoginResponse) {
success: async function (res: LoginResponse) {
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user)
// 处理邀请关系
if (res.data.data.user?.userId) {
try {
const inviteSuccess = await handleInviteRelation(res.data.data.user.userId)
if (inviteSuccess) {
Taro.showToast({
title: '邀请关系建立成功',
icon: 'success',
duration: 2000
})
}
} catch (error) {
console.error('处理邀请关系失败:', error)
}
}
props.done?.(res.data.data.user);
}
})

2
src/shop/orderConfirmCart/index.tsx

@ -10,8 +10,6 @@ import {ShopUserAddress} from "@/api/shop/shopUserAddress/model";
import './index.scss'
import {useCart, CartItem} from "@/hooks/useCart";
import Gap from "@/components/Gap";
import {createOrder} from "@/api/shop/shopOrder";
import {OrderCreateRequest} from "@/api/shop/shopOrder/model";
import {Payment} from "@/api/system/payment/model";
import {PaymentHandler, PaymentType, buildCartOrder} from "@/utils/payment";

2
src/styles/gradients.ts

@ -123,7 +123,7 @@ export const cardGradients = {
},
elevated: {
background: 'linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%)',
background: 'linear-gradient(135deg, #ffffff 0%, #ffffff 100%)',
border: '1px solid rgba(255, 255, 255, 0.9)'
// 注意:小程序不支持 boxShadow,使用边框和背景替代
}

228
src/utils/invite.ts

@ -0,0 +1,228 @@
import Taro from '@tarojs/taro'
import { processInviteScene, createInviteRelation } from '@/api/invite'
/**
*
*/
export interface InviteParams {
inviter?: string;
source?: string;
t?: string;
}
/**
*
*/
export function parseInviteParams(options: any): InviteParams | null {
try {
// 从 scene 参数中解析邀请信息
if (options.scene) {
const params: InviteParams = {}
const pairs = options.scene.split('&')
pairs.forEach((pair: string) => {
const [key, value] = pair.split('=')
if (key && value) {
switch (key) {
case 'inviter':
params.inviter = decodeURIComponent(value)
break
case 'source':
params.source = decodeURIComponent(value)
break
case 't':
params.t = decodeURIComponent(value)
break
}
}
})
return params.inviter ? params : null
}
// 从 query 参数中解析邀请信息(兼容旧版本)
if (options.referrer) {
return {
inviter: options.referrer,
source: 'link'
}
}
return null
} catch (error) {
console.error('解析邀请参数失败:', error)
return null
}
}
/**
*
*/
export function saveInviteParams(params: InviteParams) {
try {
Taro.setStorageSync('invite_params', {
...params,
timestamp: Date.now()
})
console.log('邀请参数已保存:', params)
} catch (error) {
console.error('保存邀请参数失败:', error)
}
}
/**
*
*/
export function getStoredInviteParams(): InviteParams | null {
try {
const stored = Taro.getStorageSync('invite_params')
if (stored && stored.inviter) {
// 检查是否过期(24小时)
const now = Date.now()
const expireTime = 24 * 60 * 60 * 1000 // 24小时
if (now - stored.timestamp < expireTime) {
return {
inviter: stored.inviter,
source: stored.source || 'unknown',
t: stored.t
}
} else {
// 过期则清除
clearInviteParams()
}
}
return null
} catch (error) {
console.error('获取邀请参数失败:', error)
return null
}
}
/**
*
*/
export function clearInviteParams() {
try {
Taro.removeStorageSync('invite_params')
console.log('邀请参数已清除')
} catch (error) {
console.error('清除邀请参数失败:', error)
}
}
/**
*
*/
export async function handleInviteRelation(userId: number): Promise<boolean> {
try {
const inviteParams = getStoredInviteParams()
if (!inviteParams || !inviteParams.inviter) {
return false
}
const inviterId = parseInt(inviteParams.inviter)
if (isNaN(inviterId) || inviterId === userId) {
// 邀请人ID无效或自己邀请自己
clearInviteParams()
return false
}
// 建立邀请关系
await createInviteRelation({
inviterId: inviterId,
inviteeId: userId,
source: inviteParams.source || 'unknown',
scene: `inviter=${inviterId}&source=${inviteParams.source}&t=${inviteParams.t}`,
inviteTime: new Date().toISOString()
})
// 清除本地存储的邀请参数
clearInviteParams()
console.log(`邀请关系建立成功: ${inviterId} -> ${userId}`)
return true
} catch (error) {
console.error('建立邀请关系失败:', error)
return false
}
}
/**
*
*/
export function hasPendingInvite(): boolean {
const params = getStoredInviteParams()
return !!(params && params.inviter)
}
/**
*
*/
export function getSourceDisplayName(source: string): string {
const sourceMap: Record<string, string> = {
'qrcode': '小程序码',
'link': '分享链接',
'share': '好友分享',
'poster': '海报分享',
'unknown': '未知来源'
}
return sourceMap[source] || source
}
/**
*
*/
export function validateInviteCode(scene: string): boolean {
try {
if (!scene) return false
// 检查是否包含必要的参数
const hasInviter = scene.includes('inviter=')
const hasSource = scene.includes('source=')
return hasInviter && hasSource
} catch (error) {
return false
}
}
/**
*
*/
export function generateInviteScene(inviterId: number, source: string): string {
const timestamp = Date.now()
return `inviter=${inviterId}&source=${source}&t=${timestamp}`
}
/**
*
*/
export function trackInviteSource(source: string, inviterId?: number) {
try {
// 记录邀请来源统计
const trackData = {
source,
inviterId,
timestamp: Date.now(),
userAgent: Taro.getSystemInfoSync()
}
// 可以发送到统计服务
console.log('邀请来源统计:', trackData)
// 暂存到本地,后续可批量上报
const existingTracks = Taro.getStorageSync('invite_tracks') || []
existingTracks.push(trackData)
// 只保留最近100条记录
if (existingTracks.length > 100) {
existingTracks.splice(0, existingTracks.length - 100)
}
Taro.setStorageSync('invite_tracks', existingTracks)
} catch (error) {
console.error('统计邀请来源失败:', error)
}
}
Loading…
Cancel
Save