diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py index 1a0637f..7afbebb 100644 --- a/backend/shop/serializers.py +++ b/backend/shop/serializers.py @@ -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) diff --git a/backend/shop/views.py b/backend/shop/views.py index ba078cc..ffe38ca 100644 --- a/backend/shop/views.py +++ b/backend/shop/views.py @@ -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): diff --git a/miniprogram/src/api/index.ts b/miniprogram/src/api/index.ts index 48f2908..11eb6da 100644 --- a/miniprogram/src/api/index.ts +++ b/miniprogram/src/api/index.ts @@ -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 }) diff --git a/miniprogram/src/app.config.ts b/miniprogram/src/app.config.ts index 4dfe422..3d8913f 100644 --- a/miniprogram/src/app.config.ts +++ b/miniprogram/src/app.config.ts @@ -21,7 +21,10 @@ export default defineAppConfig({ 'index', 'register', 'invite', - 'withdraw' + 'withdraw', + 'team', + 'earnings', + 'orders' ] }, { diff --git a/miniprogram/src/app.ts b/miniprogram/src/app.ts index 9688842..dd997ff 100644 --- a/miniprogram/src/app.ts +++ b/miniprogram/src/app.ts @@ -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) { - 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) diff --git a/miniprogram/src/subpackages/distributor/earnings.config.ts b/miniprogram/src/subpackages/distributor/earnings.config.ts new file mode 100644 index 0000000..7f34c37 --- /dev/null +++ b/miniprogram/src/subpackages/distributor/earnings.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '收益明细' +}) diff --git a/miniprogram/src/subpackages/distributor/earnings.scss b/miniprogram/src/subpackages/distributor/earnings.scss new file mode 100644 index 0000000..0a4b48d --- /dev/null +++ b/miniprogram/src/subpackages/distributor/earnings.scss @@ -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; +} diff --git a/miniprogram/src/subpackages/distributor/earnings.tsx b/miniprogram/src/subpackages/distributor/earnings.tsx new file mode 100644 index 0000000..748de0b --- /dev/null +++ b/miniprogram/src/subpackages/distributor/earnings.tsx @@ -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([]) + 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 ( + + {list.length > 0 ? ( + list.map((item: any) => ( + + + {item.level === 1 ? '直接推广' : '团队奖励'} + +{item.amount} + + + + {item.order_info?.customer_name} - 订单金额 ¥{item.order_info?.total_price} + + {item.status === 'settled' ? '已结算' : '待结算'} + + {item.created_at?.replace('T', ' ').substring(0, 19)} + + )) + ) : ( + 暂无收益记录 + )} + + ) +} diff --git a/miniprogram/src/subpackages/distributor/index.tsx b/miniprogram/src/subpackages/distributor/index.tsx index a1aadb9..b215f8d 100644 --- a/miniprogram/src/subpackages/distributor/index.tsx +++ b/miniprogram/src/subpackages/distributor/index.tsx @@ -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 Loading... if (!info) return Error @@ -61,15 +63,19 @@ export default function DistributorIndex() { 推广二维码 - > + {'>'} - + 我的团队 - > + {'>'} - - 提现记录 - > + + 收益明细 + {'>'} + + + 分销订单 + {'>'} diff --git a/miniprogram/src/subpackages/distributor/orders.config.ts b/miniprogram/src/subpackages/distributor/orders.config.ts new file mode 100644 index 0000000..3bb5694 --- /dev/null +++ b/miniprogram/src/subpackages/distributor/orders.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '分销订单' +}) diff --git a/miniprogram/src/subpackages/distributor/orders.scss b/miniprogram/src/subpackages/distributor/orders.scss new file mode 100644 index 0000000..a1f1fa5 --- /dev/null +++ b/miniprogram/src/subpackages/distributor/orders.scss @@ -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; +} diff --git a/miniprogram/src/subpackages/distributor/orders.tsx b/miniprogram/src/subpackages/distributor/orders.tsx new file mode 100644 index 0000000..7169ecf --- /dev/null +++ b/miniprogram/src/subpackages/distributor/orders.tsx @@ -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([]) + 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 ( + + {list.length > 0 ? ( + list.map((item: any) => ( + + + 订单号: {item.wechat_trade_no || item.id} + {item.status === 'paid' ? '已支付' : item.status} + + + + + {item.config_name || item.course_title || '商品'} + ¥{item.total_price} + + + + 买家: {item.customer_name} + {item.created_at?.split('T')[0]} + + + )) + ) : ( + 暂无分销订单 + )} + + ) +} diff --git a/miniprogram/src/subpackages/distributor/team.config.ts b/miniprogram/src/subpackages/distributor/team.config.ts new file mode 100644 index 0000000..926f186 --- /dev/null +++ b/miniprogram/src/subpackages/distributor/team.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '我的团队' +}) diff --git a/miniprogram/src/subpackages/distributor/team.scss b/miniprogram/src/subpackages/distributor/team.scss new file mode 100644 index 0000000..b66daf4 --- /dev/null +++ b/miniprogram/src/subpackages/distributor/team.scss @@ -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; + } +} diff --git a/miniprogram/src/subpackages/distributor/team.tsx b/miniprogram/src/subpackages/distributor/team.tsx new file mode 100644 index 0000000..a4c260c --- /dev/null +++ b/miniprogram/src/subpackages/distributor/team.tsx @@ -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(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 Loading... + if (!data) return Error + + return ( + + + + {data.children_count} + 直推人数 + + + ¥{data.second_level_earnings} + 团队贡献收益 + + + + + 我的团队成员 + {data.children?.length > 0 ? ( + data.children.map((item: any) => ( + + + + {item.user_info?.nickname || '用户'} + 加入时间: {item.created_at?.split('T')[0]} + + Lv.{item.level} + + )) + ) : ( + 暂无成员 + )} + + + ) +}