小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s

This commit is contained in:
jeremygan2021
2026-02-17 11:14:58 +08:00
parent 321c57bee2
commit ac61a127ae
15 changed files with 464 additions and 21 deletions

View File

@@ -147,6 +147,12 @@ class ServiceOrderSerializer(serializers.ModelSerializer):
validated_data['salesperson'] = salesperson
except Salesperson.DoesNotExist:
pass
try:
distributor = Distributor.objects.get(invite_code=ref_code)
validated_data['distributor'] = distributor
except Distributor.DoesNotExist:
pass
return super().create(validated_data)

View File

@@ -1463,10 +1463,36 @@ class DistributorViewSet(viewsets.GenericViewSet):
if distributor.qr_code_url:
return Response({'qr_code_url': distributor.qr_code_url})
# 调用微信接口生成小程序码 (wxacode.getUnlimited)
# 这里简化处理返回模拟URL或需要实现具体逻辑
# 实际逻辑需要获取 AccessToken 然后调用 API
return Response({'qr_code_url': 'https://placeholder.com/qrcode.png'})
access_token = get_access_token()
if not access_token:
return Response({'error': 'Failed to get access token'}, status=500)
# 微信小程序码接口 B适用于需要的码数量极多的业务场景
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}"
data = {
"scene": distributor.invite_code,
"page": "pages/index/index", # 扫码落地页
"width": 430
}
try:
res = requests.post(url, json=data)
# 微信返回图片时 Content-Type 包含 image/jpeg 或 image/png
if res.status_code == 200 and 'image' in res.headers.get('Content-Type', ''):
file_name = f"distributor_qr_{distributor.invite_code}_{uuid.uuid4().hex[:6]}.png"
# 保存到 media/qr_codes 目录
path = default_storage.save(f"qr_codes/{file_name}", ContentFile(res.content))
qr_url = default_storage.url(path)
distributor.qr_code_url = qr_url
distributor.save()
return Response({'qr_code_url': qr_url})
else:
# 如果是 JSON 错误信息
return Response({'error': 'WeChat API error', 'detail': res.json()}, status=500)
except Exception as e:
return Response({'error': str(e)}, status=500)
@action(detail=False, methods=['post'])
def withdraw(self, request):

View File

@@ -4,8 +4,14 @@ import { request } from '../utils/request'
export const getConfigs = () => request({ url: '/configs/' })
export const getConfigDetail = (id: number) => request({ url: `/configs/${id}/` })
const getInviteCode = () => Taro.getStorageSync('invite_code') || ''
// Orders
export const createOrder = (data: any) => request({ url: '/orders/', method: 'POST', data })
export const createOrder = (data: any) => {
const code = getInviteCode()
if (code) data.ref_code = code
return request({ url: '/orders/', method: 'POST', data })
}
export const getOrder = (id: number) => request({ url: `/orders/${id}/` })
export const getMyOrders = () => request({ url: '/orders/' })
export const prepayMiniprogram = (orderId: number) => request({ url: `/orders/${orderId}/prepay_miniprogram/`, method: 'POST' })
@@ -14,20 +20,28 @@ export const queryOrderStatus = (orderId: number) => request({ url: `/orders/${o
// AI Services
export const getServices = () => request({ url: '/services/' })
export const getServiceDetail = (id: number) => request({ url: `/services/${id}/` })
export const createServiceOrder = (data: any) => request({ url: '/service-orders/', method: 'POST', data })
export const createServiceOrder = (data: any) => {
const code = getInviteCode()
if (code) data.ref_code = code
return request({ url: '/service-orders/', method: 'POST', data })
}
// VB Courses
export const getVBCourses = () => request({ url: '/courses/' })
export const getVBCourseDetail = (id: number) => request({ url: `/courses/${id}/` })
// Distributor
export const distributorRegister = (data: any) => request({ url: '/distributor/register/', method: 'POST', data })
export const distributorRegister = (data: any) => {
const code = getInviteCode()
if (code && !data.invite_code) data.invite_code = code
return request({ url: '/distributor/register/', method: 'POST', data })
}
export const distributorInfo = () => request({ url: '/distributor/info/' })
export const distributorInvite = () => request({ url: '/distributor/invite/', method: 'POST' })
export const distributorWithdraw = (amount: number) => request({ url: '/distributor/withdraw/', method: 'POST', data: { amount } })
// TODO: Verify if these exist in the API docs
// export const distributorTeam = () => request({ url: '/distributor/team/' })
// export const distributorHistory = () => request({ url: '/distributor/history/' })
export const distributorTeam = () => request({ url: '/distributor/team/' })
export const distributorEarnings = (params?: any) => request({ url: '/distributor/earnings/', data: params })
export const distributorOrders = (params?: any) => request({ url: '/distributor/orders/', data: params })
// User
export const updateUserInfo = (data: any) => request({ url: '/wechat/update/', method: 'POST', data })

View File

@@ -21,7 +21,10 @@ export default defineAppConfig({
'index',
'register',
'invite',
'withdraw'
'withdraw',
'team',
'earnings',
'orders'
]
},
{

View File

@@ -1,12 +1,31 @@
import { PropsWithChildren } from 'react'
import { useLaunch } from '@tarojs/taro'
import Taro, { useLaunch } from '@tarojs/taro'
import { login } from './utils/request'
import './app.scss'
function App({ children }: PropsWithChildren<any>) {
useLaunch(() => {
console.log('App launched.')
useLaunch((options) => {
console.log('App launched.', options)
// 捕获邀请码 (场景值或直接参数)
const { query } = options
if (query) {
let inviteCode = ''
if (query.scene) {
// 扫码进入scene 需要解码
inviteCode = decodeURIComponent(query.scene)
} else if (query.invite_code) {
// 链接分享进入
inviteCode = query.invite_code
}
if (inviteCode && inviteCode !== 'undefined') {
console.log('Captured invite code:', inviteCode)
Taro.setStorageSync('invite_code', inviteCode)
}
}
// Auto login
login().then(res => {
console.log('Logged in as:', res?.nickname)

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '收益明细'
})

View File

@@ -0,0 +1,48 @@
.page-container {
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.item {
background: #fff;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
.row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
.type {
font-size: 30px;
color: #333;
font-weight: bold;
}
.amount {
font-size: 32px;
color: #ff9800;
font-weight: bold;
}
.source {
font-size: 24px;
color: #666;
}
.status {
font-size: 24px;
color: #00b96b;
}
}
.time {
font-size: 22px;
color: #999;
display: block;
margin-top: 10px;
border-top: 1px solid #f5f5f5;
padding-top: 10px;
}
}
.empty {
padding: 100px 0;
text-align: center;
color: #999;
font-size: 28px;
}

View File

@@ -0,0 +1,55 @@
import { View, Text } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { distributorEarnings } from '../../api'
import './earnings.scss'
export default function Earnings() {
const [list, setList] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useLoad(() => {
fetchData()
})
const fetchData = async () => {
try {
const res: any = await distributorEarnings()
// Pagination support check? The backend returns { count, next, previous, results } or just list if no pagination
if (res.results) {
setList(res.results)
} else if (Array.isArray(res)) {
setList(res)
}
} catch (err) {
console.error(err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
return (
<View className='page-container'>
{list.length > 0 ? (
list.map((item: any) => (
<View className='item' key={item.id}>
<View className='row'>
<Text className='type'>{item.level === 1 ? '直接推广' : '团队奖励'}</Text>
<Text className='amount'>+{item.amount}</Text>
</View>
<View className='row'>
<Text className='source'>
{item.order_info?.customer_name} - ¥{item.order_info?.total_price}
</Text>
<Text className='status'>{item.status === 'settled' ? '已结算' : '待结算'}</Text>
</View>
<Text className='time'>{item.created_at?.replace('T', ' ').substring(0, 19)}</Text>
</View>
))
) : (
<View className='empty'></View>
)}
</View>
)
}

View File

@@ -30,7 +30,9 @@ export default function DistributorIndex() {
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
const showComingSoon = () => Taro.showToast({ title: '功能开发中', icon: 'none' })
const goTeam = () => Taro.navigateTo({ url: '/subpackages/distributor/team' })
const goEarnings = () => Taro.navigateTo({ url: '/subpackages/distributor/earnings' })
const goOrders = () => Taro.navigateTo({ url: '/subpackages/distributor/orders' })
if (loading) return <View>Loading...</View>
if (!info) return <View>Error</View>
@@ -61,15 +63,19 @@ export default function DistributorIndex() {
<View className='menu-list'>
<View className='menu-item' onClick={goInvite}>
<Text>广</Text>
<Text className='arrow'>></Text>
<Text className='arrow'>{'>'}</Text>
</View>
<View className='menu-item' onClick={showComingSoon}>
<View className='menu-item' onClick={goTeam}>
<Text></Text>
<Text className='arrow'>></Text>
<Text className='arrow'>{'>'}</Text>
</View>
<View className='menu-item' onClick={showComingSoon}>
<Text></Text>
<Text className='arrow'>></Text>
<View className='menu-item' onClick={goEarnings}>
<Text></Text>
<Text className='arrow'>{'>'}</Text>
</View>
<View className='menu-item' onClick={goOrders}>
<Text></Text>
<Text className='arrow'>{'>'}</Text>
</View>
</View>
</View>

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '分销订单'
})

View File

@@ -0,0 +1,65 @@
.page-container {
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.item {
background: #fff;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
.row {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #f5f5f5;
padding-bottom: 16px;
margin-bottom: 16px;
.order-no {
font-size: 24px;
color: #666;
}
.status {
font-size: 24px;
color: #00b96b;
}
}
.content {
display: flex;
margin-bottom: 16px;
.img {
width: 120px;
height: 120px;
border-radius: 8px;
background: #eee;
margin-right: 20px;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.title {
font-size: 28px;
color: #333;
line-height: 1.4;
}
.price {
font-size: 32px;
color: #ff9800;
font-weight: bold;
}
}
}
.footer {
display: flex;
justify-content: space-between;
font-size: 24px;
color: #999;
}
}
.empty {
padding: 100px 0;
text-align: center;
color: #999;
font-size: 28px;
}

View File

@@ -0,0 +1,58 @@
import { View, Text, Image } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { distributorOrders } from '../../api'
import './orders.scss'
export default function Orders() {
const [list, setList] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useLoad(() => {
fetchData()
})
const fetchData = async () => {
try {
const res: any = await distributorOrders()
if (res.results) {
setList(res.results)
} else if (Array.isArray(res)) {
setList(res)
}
} catch (err) {
console.error(err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
return (
<View className='page-container'>
{list.length > 0 ? (
list.map((item: any) => (
<View className='item' key={item.id}>
<View className='row'>
<Text className='order-no'>: {item.wechat_trade_no || item.id}</Text>
<Text className='status'>{item.status === 'paid' ? '已支付' : item.status}</Text>
</View>
<View className='content'>
<Image className='img' src={item.config_image || ''} mode='aspectFill' />
<View className='info'>
<Text className='title'>{item.config_name || item.course_title || '商品'}</Text>
<Text className='price'>¥{item.total_price}</Text>
</View>
</View>
<View className='footer'>
<Text className='customer'>: {item.customer_name}</Text>
<Text className='time'>{item.created_at?.split('T')[0]}</Text>
</View>
</View>
))
) : (
<View className='empty'></View>
)}
</View>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的团队'
})

View File

@@ -0,0 +1,72 @@
.page-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 40px;
}
.header {
background: #fff;
padding: 30px;
display: flex;
justify-content: space-around;
margin-bottom: 20px;
.stat {
display: flex;
flex-direction: column;
align-items: center;
.val {
font-size: 36px;
font-weight: bold;
color: #333;
}
.lbl {
font-size: 24px;
color: #999;
margin-top: 10px;
}
}
}
.list {
background: #fff;
.list-header {
padding: 20px 30px;
font-size: 28px;
font-weight: bold;
border-bottom: 1px solid #eee;
}
.item {
display: flex;
align-items: center;
padding: 20px 30px;
border-bottom: 1px solid #eee;
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-right: 20px;
background: #eee;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
.name {
font-size: 28px;
color: #333;
}
.time {
font-size: 22px;
color: #999;
margin-top: 6px;
}
}
.level {
font-size: 24px;
color: #ff9800;
}
}
.empty {
padding: 50px;
text-align: center;
color: #999;
}
}

View File

@@ -0,0 +1,62 @@
import { View, Text, Image } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { distributorTeam } from '../../api'
import './team.scss'
export default function Team() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useLoad(() => {
fetchData()
})
const fetchData = async () => {
try {
const res = await distributorTeam()
setData(res)
} catch (err) {
console.error(err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
if (loading) return <View className='page-container'>Loading...</View>
if (!data) return <View className='page-container'>Error</View>
return (
<View className='page-container'>
<View className='header'>
<View className='stat'>
<Text className='val'>{data.children_count}</Text>
<Text className='lbl'></Text>
</View>
<View className='stat'>
<Text className='val'>¥{data.second_level_earnings}</Text>
<Text className='lbl'></Text>
</View>
</View>
<View className='list'>
<View className='list-header'></View>
{data.children?.length > 0 ? (
data.children.map((item: any) => (
<View className='item' key={item.id}>
<Image className='avatar' src={item.user_info?.avatar_url || ''} />
<View className='info'>
<Text className='name'>{item.user_info?.nickname || '用户'}</Text>
<Text className='time'>: {item.created_at?.split('T')[0]}</Text>
</View>
<Text className='level'>Lv.{item.level}</Text>
</View>
))
) : (
<View className='empty'></View>
)}
</View>
</View>
)
}