This commit is contained in:
2026-04-27 14:49:00 +08:00
parent 59471a655c
commit 69c66046b2
18 changed files with 777 additions and 128 deletions

View File

@@ -35,6 +35,30 @@ export const getAgentRewards = (params) => {
}) })
} }
/**
* 获取我的下级列表
* @param {Object} params 查询参数 {page, page_size}
*/
export const getAgentSubordinateList = (params) => {
return request({
url: '/agent/subordinate/list',
method: 'GET',
params
})
}
/**
* 获取下级贡献详情
* @param {Object} params 查询参数 {subordinate_id, page, page_size}
*/
export const getAgentSubordinateContributionDetail = (params) => {
return request({
url: '/agent/subordinate/contribution/detail',
method: 'GET',
params
})
}
/** /**
* 获取代理状态 * 获取代理状态
*/ */

View File

@@ -13,6 +13,7 @@ declare global {
const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const buildPromotionH5Url: typeof import('./utils/promotionH5Url.js')['buildPromotionH5Url'] const buildPromotionH5Url: typeof import('./utils/promotionH5Url.js')['buildPromotionH5Url']
const calculatePromotionPricing: typeof import('./utils/promotionPricing.js')['calculatePromotionPricing']
const chatCrypto: typeof import('./utils/chatCrypto.js')['default'] const chatCrypto: typeof import('./utils/chatCrypto.js')['default']
const chatEncrypt: typeof import('./utils/chatEncrypt.js')['default'] const chatEncrypt: typeof import('./utils/chatEncrypt.js')['default']
const computed: typeof import('vue')['computed'] const computed: typeof import('vue')['computed']
@@ -134,6 +135,7 @@ declare global {
const resolveComponent: typeof import('vue')['resolveComponent'] const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef'] const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const safeTruncate: typeof import('./utils/promotionPricing.js')['safeTruncate']
const setMiniPromotionShareFriend: typeof import('./utils/miniPromotionSharePayload.js')['setMiniPromotionShareFriend'] const setMiniPromotionShareFriend: typeof import('./utils/miniPromotionSharePayload.js')['setMiniPromotionShareFriend']
const setupRouterGuard: typeof import('./utils/routerGuard.js')['setupRouterGuard'] const setupRouterGuard: typeof import('./utils/routerGuard.js')['setupRouterGuard']
const shallowReactive: typeof import('vue')['shallowReactive'] const shallowReactive: typeof import('vue')['shallowReactive']
@@ -358,6 +360,7 @@ declare module 'vue' {
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']> readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']> readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly buildPromotionH5Url: UnwrapRef<typeof import('./utils/promotionH5Url.js')['buildPromotionH5Url']> readonly buildPromotionH5Url: UnwrapRef<typeof import('./utils/promotionH5Url.js')['buildPromotionH5Url']>
readonly calculatePromotionPricing: UnwrapRef<typeof import('./utils/promotionPricing.js')['calculatePromotionPricing']>
readonly chatCrypto: UnwrapRef<typeof import('./utils/chatCrypto.js')['default']> readonly chatCrypto: UnwrapRef<typeof import('./utils/chatCrypto.js')['default']>
readonly chatEncrypt: UnwrapRef<typeof import('./utils/chatEncrypt.js')['default']> readonly chatEncrypt: UnwrapRef<typeof import('./utils/chatEncrypt.js')['default']>
readonly computed: UnwrapRef<typeof import('vue')['computed']> readonly computed: UnwrapRef<typeof import('vue')['computed']>
@@ -479,6 +482,7 @@ declare module 'vue' {
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']> readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']> readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']> readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly safeTruncate: UnwrapRef<typeof import('./utils/promotionPricing.js')['safeTruncate']>
readonly setMiniPromotionShareFriend: UnwrapRef<typeof import('./utils/miniPromotionSharePayload.js')['setMiniPromotionShareFriend']> readonly setMiniPromotionShareFriend: UnwrapRef<typeof import('./utils/miniPromotionSharePayload.js')['setMiniPromotionShareFriend']>
readonly setupRouterGuard: UnwrapRef<typeof import('./utils/routerGuard.js')['setupRouterGuard']> readonly setupRouterGuard: UnwrapRef<typeof import('./utils/routerGuard.js')['setupRouterGuard']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']> readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>

1
src/components.d.ts vendored
View File

@@ -24,6 +24,7 @@ declare module 'vue' {
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default'] WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default'] WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.vue')['default'] WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.vue')['default']
WdPagination: typeof import('wot-design-uni/components/wd-pagination/wd-pagination.vue')['default']
WdPicker: typeof import('wot-design-uni/components/wd-picker/wd-picker.vue')['default'] WdPicker: typeof import('wot-design-uni/components/wd-picker/wd-picker.vue')['default']
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default'] WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
WdTabbar: typeof import('wot-design-uni/components/wd-tabbar/wd-tabbar.vue')['default'] WdTabbar: typeof import('wot-design-uni/components/wd-tabbar/wd-tabbar.vue')['default']

View File

@@ -11,8 +11,7 @@
<div class="border-b border-gray-200"> <div class="border-b border-gray-200">
<wd-input v-model="price" type="number" label="¥" label-width="28px" size="large" <wd-input v-model="price" type="number" label="¥" label-width="28px" size="large"
:placeholder="priceRangePlaceholder" @blur="onBlurPrice" :placeholder="priceRangePlaceholder" @blur="onBlurPrice" custom-class="wd-input" />
custom-class="wd-input" />
</div> </div>
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
<div>推广收益为<span class="text-orange-500"> {{ promotionRevenue }} </span></div> <div>推广收益为<span class="text-orange-500"> {{ promotionRevenue }} </span></div>
@@ -40,6 +39,7 @@
<script setup> <script setup>
import { ref, computed, watch, toRefs } from 'vue' import { ref, computed, watch, toRefs } from 'vue'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
import { calculatePromotionPricing, safeTruncate } from '@/utils/promotionPricing.js'
const props = defineProps({ const props = defineProps({
defaultPrice: { defaultPrice: {
type: Number, type: Number,
@@ -67,31 +67,12 @@ const priceRangePlaceholder = computed(() => {
return `${min} - ${max}` return `${min} - ${max}`
}) })
const costPrice = computed(() => { const pricingResult = computed(() => {
if (!productConfig.value) return 0.00 return calculatePromotionPricing(price.value, productConfig.value)
// 平台定价成本
let platformPricing = 0
platformPricing += productConfig.value.cost_price
if (price.value > productConfig.value.p_pricing_standard) {
platformPricing += (price.value - productConfig.value.p_pricing_standard) * productConfig.value.p_overpricing_ratio
}
if (productConfig.value.a_pricing_standard > platformPricing && productConfig.value.a_pricing_end > platformPricing && productConfig.value.a_overpricing_ratio > 0) {
if (price.value > productConfig.value.a_pricing_standard) {
if (price.value > productConfig.value.a_pricing_end) {
platformPricing += (productConfig.value.a_pricing_end - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
} else {
platformPricing += (price.value - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
}) })
const promotionRevenue = computed(() => { const costPrice = computed(() => pricingResult.value.costPrice)
return safeTruncate(price.value - costPrice.value) const promotionRevenue = computed(() => pricingResult.value.promotionRevenue)
});
// 价格校验与修正逻辑 // 价格校验与修正逻辑
const validatePrice = (currentPrice) => { const validatePrice = (currentPrice) => {
@@ -130,15 +111,6 @@ const validatePrice = (currentPrice) => {
} }
return { newPrice, message }; return { newPrice, message };
} }
function safeTruncate(num, decimals = 2) {
if (isNaN(num) || !isFinite(num)) return "0.00";
const factor = 10 ** decimals;
const scaled = Math.trunc(num * factor);
const truncated = scaled / factor;
return truncated.toFixed(decimals);
}
const isManualConfirm = ref(false) const isManualConfirm = ref(false)
const onConfirm = () => { const onConfirm = () => {
if (isManualConfirm.value) return if (isManualConfirm.value) return

View File

@@ -118,7 +118,23 @@
"path": "pages/rewardsDetails", "path": "pages/rewardsDetails",
"type": "page", "type": "page",
"layout": "page", "layout": "page",
"title": "收益明细", "title": "代理奖励收益明细",
"agent": true,
"auth": true
},
{
"path": "pages/subordinateDetail",
"type": "page",
"layout": "page",
"title": "下级贡献详情",
"agent": true,
"auth": true
},
{
"path": "pages/subordinateList",
"type": "page",
"layout": "page",
"title": "我的下级",
"agent": true, "agent": true,
"auth": true "auth": true
}, },

View File

@@ -29,7 +29,7 @@ const promoteDateOptions = [
] ]
const selectedPromoteDate = ref('today') const selectedPromoteDate = ref('today')
// 团队奖励数据(保留:下级推广/下级转化/下级提现) // 活跃下级奖励数据(保留:下级推广/下级转化/下级提现)
const teamDateOptions = [ const teamDateOptions = [
{ label: '今日', value: 'today' }, { label: '今日', value: 'today' },
{ label: '近7天', value: 'week' }, { label: '近7天', value: 'week' },
@@ -94,6 +94,12 @@ function goToRewardsDetail() {
}) })
} }
function toSubordinateList() {
uni.navigateTo({
url: '/pages/subordinateList'
})
}
function toWithdraw() { function toWithdraw() {
// 弹出公众号二维码提示提现 // 弹出公众号二维码提示提现
showGzhQrcode.value = true showGzhQrcode.value = true
@@ -118,8 +124,6 @@ function toWithdrawDetails() {
<view class="rounded-xl shadow-lg mb-4 bg-gradient-to-r from-blue-50/70 to-blue-100/50 p-6"> <view class="rounded-xl shadow-lg mb-4 bg-gradient-to-r from-blue-50/70 to-blue-100/50 p-6">
<view class="flex justify-between items-center mb-3"> <view class="flex justify-between items-center mb-3">
<view class="flex items-center"> <view class="flex items-center">
<text class="text-lg font-bold text-gray-800">余额</text> <text class="text-lg font-bold text-gray-800">余额</text>
</view> </view>
<text class="text-3xl text-blue-600 font-bold">¥ {{ (data?.balance || 0).toFixed(2) }}</text> <text class="text-3xl text-blue-600 font-bold">¥ {{ (data?.balance || 0).toFixed(2) }}</text>
@@ -185,11 +189,11 @@ function toWithdrawDetails() {
</view> </view>
</view> </view>
<!-- 团队奖励移除活跃下级/新增活跃保留下级推广转化提现 --> <!-- 活跃下级奖励保留下级推广转化提现 -->
<view class="rounded-xl shadow-lg bg-gradient-to-r from-green-50/40 to-cyan-50/30 p-6"> <view class="rounded-xl shadow-lg bg-gradient-to-r from-green-50/40 to-cyan-50/30 p-6">
<view class="flex justify-between items-center mb-4"> <view class="flex justify-between items-center mb-4">
<view class="flex items-center"> <view class="flex items-center">
<text class="text-lg font-bold text-gray-800">团队奖励</text> <text class="text-lg font-bold text-gray-800">活跃下级奖励</text>
</view> </view>
</view> </view>
@@ -204,7 +208,7 @@ function toWithdrawDetails() {
</view> </view>
</view> </view>
<view class="grid grid-cols-1 gap-2 mb-6"> <view class="grid grid-cols-2 gap-2 mb-6">
<view class="bg-green-50/60 p-3 rounded-lg backdrop-blur-sm"> <view class="bg-green-50/60 p-3 rounded-lg backdrop-blur-sm">
<view class="flex items-center text-sm text-gray-500"> <view class="flex items-center text-sm text-gray-500">
<text>{{ teamTimeText }}下级推广奖励</text> <text>{{ teamTimeText }}下级推广奖励</text>
@@ -225,9 +229,19 @@ function toWithdrawDetails() {
</view> </view>
</view> </view>
<view class="flex items-center justify-between text-green-500 text-sm font-semibold" @click="goToRewardsDetail"> <view class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<view
class="w-full rounded-full border border-gray-200/80 bg-white/90 py-3 px-4 text-center text-sm text-gray-600 font-medium shadow-sm"
@click="goToRewardsDetail"
>
<text>查看奖励明细</text> <text>查看奖励明细</text>
<text class="text-lg"></text> </view>
<view
class="w-full rounded-full bg-gradient-to-r from-green-500 to-emerald-500 py-3 px-4 text-center text-sm text-white font-medium shadow-sm"
@click="toSubordinateList"
>
<text>查看我的下级</text>
</view>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -708,12 +708,6 @@ function selectType(type) {
// 申请VIP或SVIP // 申请VIP或SVIP
async function applyVip() { async function applyVip() {
// 如果是VIP想升级到SVIP提示联系客服
if (isVip.value && selectedType.value === 'svip') {
contactService()
return
}
// 如果是SVIP要降级到VIP提示不能降级 // 如果是SVIP要降级到VIP提示不能降级
if (isSvip.value && selectedType.value === 'vip') { if (isSvip.value && selectedType.value === 'vip') {
toast.error('SVIP会员不能降级到VIP会员') toast.error('SVIP会员不能降级到VIP会员')

View File

@@ -18,7 +18,7 @@
<view v-if="isAgent" class="absolute -bottom-2 -right-2"> <view v-if="isAgent" class="absolute -bottom-2 -right-2">
<view class="flex items-center justify-center rounded-full px-3 py-1 text-xs font-bold text-white shadow-sm" <view class="flex items-center justify-center rounded-full px-3 py-1 text-xs font-bold text-white shadow-sm"
:class="levelGradient.badge"> :class="levelGradient.badge">
{{ levelNames[level] }} {{ levelNames[normalizedLevel] }}
</view> </view>
</view> </view>
</view> </view>
@@ -29,7 +29,7 @@
{{ !isLoggedIn ? '点击登录' : (userType === 0 ? '绑定手机号' : maskName(userName)) }} {{ !isLoggedIn ? '点击登录' : (userType === 0 ? '绑定手机号' : maskName(userName)) }}
</view> </view>
<view v-if="isAgent" class="text-sm font-medium" :class="levelGradient.text"> <view v-if="isAgent" class="text-sm font-medium" :class="levelGradient.text">
🎖 {{ levelText[level] }} 🎖 {{ levelText[normalizedLevel] }}
</view> </view>
</view> </view>
</view> </view>
@@ -106,6 +106,17 @@ const isAgent = ref(false)
const level = ref('normal') const level = ref('normal')
const ExpiryTime = ref('') const ExpiryTime = ref('')
const normalizedLevel = computed(() => {
const raw = String(level.value || '').trim()
if (!raw || ['NORMAL', 'NORNAL'].includes(raw.toUpperCase()) || raw === 'normal' || raw === 'nornal')
return 'NORMAL'
if (raw.toUpperCase().includes('SVIP'))
return 'SVIP'
if (raw.toUpperCase().includes('VIP'))
return 'VIP'
return 'NORMAL'
})
const showUpdateProgress = ref(false) const showUpdateProgress = ref(false)
@@ -136,6 +147,7 @@ onBeforeMount(() => {
const levelNames = { const levelNames = {
normal: '普通代理', normal: '普通代理',
NORMAL: '普通代理',
'': '普通代理', '': '普通代理',
VIP: 'VIP代理', VIP: 'VIP代理',
SVIP: 'SVIP代理', SVIP: 'SVIP代理',
@@ -143,37 +155,41 @@ const levelNames = {
const levelText = { const levelText = {
normal: '基础代理特权', normal: '基础代理特权',
NORMAL: '基础代理特权',
'': '基础代理特权', '': '基础代理特权',
VIP: '高级代理特权', VIP: '高级代理特权',
SVIP: '尊享代理特权', SVIP: '尊享代理特权',
} }
const isVipOrSvip = computed(() => { const isVipOrSvip = computed(() => {
const l = (level.value || '').toString() const l = normalizedLevel.value
return ['VIP', 'SVIP'].includes(l) return ['VIP', 'SVIP'].includes(l)
}) })
const levelGradient = computed(() => ({ const levelGradient = computed(() => ({
border: { border: {
normal: 'bg-green-300', normal: 'bg-green-300',
NORMAL: 'bg-green-300',
'': 'bg-green-300', '': 'bg-green-300',
VIP: 'bg-gradient-to-r from-yellow-400 to-amber-500', VIP: 'bg-gradient-to-r from-yellow-400 to-amber-500',
SVIP: 'bg-gradient-to-r from-purple-400 to-pink-400 shadow-[0_0_15px_rgba(163,51,200,0.2)]', SVIP: 'bg-gradient-to-r from-purple-400 to-pink-400 shadow-[0_0_15px_rgba(163,51,200,0.2)]',
}[level.value], }[normalizedLevel.value],
badge: { badge: {
normal: 'bg-green-500', normal: 'bg-green-500',
NORMAL: 'bg-green-500',
'': 'bg-green-500', '': 'bg-green-500',
VIP: 'bg-gradient-to-r from-yellow-500 to-amber-600', VIP: 'bg-gradient-to-r from-yellow-500 to-amber-600',
SVIP: 'bg-gradient-to-r from-purple-500 to-pink-500', SVIP: 'bg-gradient-to-r from-purple-500 to-pink-500',
}[level.value], }[normalizedLevel.value],
text: { text: {
normal: 'text-green-600', normal: 'text-green-600',
NORMAL: 'text-green-600',
'': 'text-green-600', '': 'text-green-600',
VIP: 'text-amber-600', VIP: 'text-amber-600',
SVIP: 'text-purple-600', SVIP: 'text-purple-600',
}[level.value], }[normalizedLevel.value],
})) }))
function toHistory() { function toHistory() {
@@ -247,8 +263,9 @@ function toBindPhone() {
const getDefaultAvatar = () => { const getDefaultAvatar = () => {
if (!isAgent.value) return '/static/image/head_shot.webp' if (!isAgent.value) return '/static/image/head_shot.webp'
switch (level.value) { switch (normalizedLevel.value) {
case 'normal': case 'normal':
case 'NORMAL':
case '': case '':
return '/static/image/shot_nonal.png' return '/static/image/shot_nonal.png'
case 'VIP': case 'VIP':

View File

@@ -69,6 +69,7 @@
import { getProductConfig, generatePromotionLink } from '@/apis/agent' import { getProductConfig, generatePromotionLink } from '@/apis/agent'
import { usePromotionShareHandlers } from '@/composables/usePromotionShareHandlers' import { usePromotionShareHandlers } from '@/composables/usePromotionShareHandlers'
import { getAgentTabShareTitle } from '@/utils/runtimeEnv.js' import { getAgentTabShareTitle } from '@/utils/runtimeEnv.js'
import { calculatePromotionPricing } from '@/utils/promotionPricing.js'
import PriceInputPopup from '@/components/PriceInputPopup.vue' import PriceInputPopup from '@/components/PriceInputPopup.vue'
import VipBanner from '@/components/VipBanner.vue' import VipBanner from '@/components/VipBanner.vue'
import QRcode from '@/components/QRcode.vue' import QRcode from '@/components/QRcode.vue'
@@ -103,46 +104,12 @@ const reportTypes = computed(() => {
.filter(item => !!item.value) .filter(item => !!item.value)
}) })
// 计算成本价格 const pricingResult = computed(() => {
const costPrice = computed(() => { return calculatePromotionPricing(formData.value.clientPrice, pickerProductConfig.value)
if (!pickerProductConfig.value) return '0.00'
// 平台定价成本
let platformPricing = 0
platformPricing += pickerProductConfig.value.cost_price
if (formData.value.clientPrice > pickerProductConfig.value.p_pricing_standard) {
platformPricing += (formData.value.clientPrice - pickerProductConfig.value.p_pricing_standard) * pickerProductConfig.value.p_overpricing_ratio
}
if (pickerProductConfig.value.a_pricing_standard > platformPricing &&
pickerProductConfig.value.a_pricing_end > platformPricing &&
pickerProductConfig.value.a_overpricing_ratio > 0) {
if (formData.value.clientPrice > pickerProductConfig.value.a_pricing_standard) {
if (formData.value.clientPrice > pickerProductConfig.value.a_pricing_end) {
platformPricing += (pickerProductConfig.value.a_pricing_end - pickerProductConfig.value.a_pricing_standard) * pickerProductConfig.value.a_overpricing_ratio
} else {
platformPricing += (formData.value.clientPrice - pickerProductConfig.value.a_pricing_standard) * pickerProductConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
}) })
// 计算推广收益 const costPrice = computed(() => pricingResult.value.costPrice)
const promotionRevenue = computed(() => { const promotionRevenue = computed(() => pricingResult.value.promotionRevenue)
return safeTruncate(formData.value.clientPrice - costPrice.value)
})
// 安全截断数字保留2位小数
function safeTruncate(num, decimals = 2) {
if (isNaN(num) || !isFinite(num)) return "0.00"
const factor = 10 ** decimals
const scaled = Math.trunc(num * factor)
const truncated = scaled / factor
return truncated.toFixed(decimals)
}
// 生成推广码 // 生成推广码
const generatePromotionCode = async () => { const generatePromotionCode = async () => {

View File

@@ -7,14 +7,23 @@
<view v-for="(item, index) in list" :key="index" class="mx-4 my-2 bg-white rounded-lg p-4 shadow-sm"> <view v-for="(item, index) in list" :key="index" class="mx-4 my-2 bg-white rounded-lg p-4 shadow-sm">
<view class="flex justify-between items-center mb-2"> <view class="flex justify-between items-center mb-2">
<text class="text-gray-500 text-sm">{{ item.create_time || '-' }}</text> <text class="text-gray-500 text-sm">{{ item.create_time || '-' }}</text>
<text class="text-green-500 font-bold">+{{ item.amount.toFixed(2) }}</text> <text class="font-bold" :class="getAmountColor(item)">
{{ getAmountPrefix(item) }}{{ getDisplayAmount(item).toFixed(2) }}
</text>
</view> </view>
<view class="flex items-center"> <view class="flex items-center justify-between">
<text class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium" <text class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
:class="getReportTypeStyle(item.product_name)"> :class="getReportTypeStyle(item.product_name)">
<text class="w-2 h-2 rounded-full mr-1 inline-block" :class="getDotColor(item.product_name)"></text> <text class="w-2 h-2 rounded-full mr-1 inline-block" :class="getDotColor(item.product_name)"></text>
{{ item.product_name }} {{ item.product_name }}
</text> </text>
<text class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="getStatusStyle(item)">
{{ getStatusText(item) }}
</text>
</view>
<view v-if="toNumber(item.refunded_amount) > 0 && getDisplayAmount(item) > 0" class="mt-2 text-xs text-gray-400">
原始 {{ toNumber(item.amount).toFixed(2) }}已退 {{ toNumber(item.refunded_amount).toFixed(2) }}
</view> </view>
</view> </view>
@@ -25,7 +34,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { getAgentCommission } from '@/apis/agent' import { getAgentCommission } from '@/apis/agent'
// 颜色配置(根据产品名称映射) // 颜色配置(根据产品名称映射)
@@ -59,6 +68,57 @@ const getDotColor = (name) => {
return (typeColors[name] || typeColors.default).dot return (typeColors[name] || typeColors.default).dot
} }
const toNumber = (value) => {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
// 与 H5 对齐:优先显示 net_amount缺失时退化为 amount-refunded_amount
const getDisplayAmount = (item) => {
if (item && item.net_amount !== undefined && item.net_amount !== null)
return toNumber(item.net_amount)
return toNumber(item?.amount) - toNumber(item?.refunded_amount)
}
const getStatusNumber = (item) => {
return Number(item?.status)
}
const getAmountColor = (item) => {
const status = getStatusNumber(item)
if (status === 2)
return 'text-red-500'
if (toNumber(item?.refunded_amount) > 0)
return 'text-orange-500'
return 'text-green-500'
}
const getAmountPrefix = (item) => {
return getStatusNumber(item) === 2 ? '-' : '+'
}
const getStatusText = (item) => {
const status = getStatusNumber(item)
if (status === 2)
return '已退款'
if (status === 1)
return toNumber(item?.refunded_amount) > 0 ? '冻结中(部分退款)' : '冻结中'
if (status === 0)
return toNumber(item?.refunded_amount) > 0 ? '已结算(部分退款)' : '已结算'
return '未知状态'
}
const getStatusStyle = (item) => {
const status = getStatusNumber(item)
if (status === 2)
return 'bg-red-100 text-red-800'
if (status === 1)
return toNumber(item?.refunded_amount) > 0 ? 'bg-orange-100 text-orange-800' : 'bg-yellow-100 text-yellow-800'
if (status === 0)
return toNumber(item?.refunded_amount) > 0 ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
return 'bg-gray-100 text-gray-800'
}
// 加载更多数据 // 加载更多数据
const onLoadMore = async () => { const onLoadMore = async () => {
if (loadMoreStatus.value === 'noMore') return if (loadMoreStatus.value === 'noMore') return

View File

@@ -153,7 +153,7 @@ defineExpose({
<route type="page" lang="json"> <route type="page" lang="json">
{ {
"layout": "page", "layout": "page",
"title": "收益明细", "title": "代理奖励收益明细",
"agent": true, "agent": true,
"auth": true "auth": true
} }

View File

@@ -0,0 +1,296 @@
<script setup>
import { onMounted, ref } from 'vue'
import { getAgentSubordinateContributionDetail } from '@/apis/agent'
const loading = ref(false)
const page = ref(1)
const pageSize = 8
const total = ref(0)
const rewardDetails = ref([])
const userInfo = ref({})
const summary = ref({})
const statistics = ref([])
const subordinateId = ref(0)
function normalizeAgentLevel(value) {
const raw = String(value || '').trim()
if (!raw)
return 'NORMAL'
const cleaned = raw.replace(/代理$/, '')
if (cleaned === '普通')
return 'NORMAL'
const upper = cleaned.toUpperCase()
if (upper.includes('SVIP'))
return 'SVIP'
if (upper.includes('VIP'))
return 'VIP'
return 'NORMAL'
}
function getAgentLevelLabel(value) {
return {
NORMAL: '普通代理',
VIP: 'VIP代理',
SVIP: 'SVIP代理',
}[normalizeAgentLevel(value)] || '普通代理'
}
function getRewardTypeClass(type) {
const typeMap = {
descendant_promotion: 'bg-blue-100 text-blue-600',
cost: 'bg-green-100 text-green-600',
pricing: 'bg-purple-100 text-purple-600',
descendant_withdraw: 'bg-yellow-100 text-yellow-600',
descendant_upgrade_vip: 'bg-red-100 text-red-600',
descendant_upgrade_svip: 'bg-orange-100 text-orange-600',
}
return typeMap[type] || 'bg-gray-100 text-gray-600'
}
function getRewardTypeDescription(type) {
const descriptionMap = {
descendant_promotion: '推广奖励',
cost: '成本贡献',
pricing: '定价贡献',
descendant_withdraw: '提现收益',
descendant_upgrade_vip: '转化VIP奖励',
descendant_upgrade_svip: '转化SVIP奖励',
}
return descriptionMap[type] || '未知类型'
}
function formatTime(timeStr) {
if (!timeStr)
return '-'
const date = new Date(timeStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function formatNumber(num) {
if (!num)
return '0.00'
return Number(num).toFixed(2)
}
async function fetchRewardDetails() {
if (loading.value || !subordinateId.value)
return
loading.value = true
try {
const res = await getAgentSubordinateContributionDetail({
subordinate_id: subordinateId.value,
page: page.value,
page_size: pageSize,
})
if (res.code === 200) {
if (page.value === 1) {
userInfo.value = {
createTime: res.data.create_time,
level: res.data.level_name || '',
mobile: res.data.mobile,
}
summary.value = {
totalReward: res.data.total_earnings,
totalContribution: res.data.total_contribution,
totalOrders: res.data.total_orders,
}
statistics.value = [
{ type: 'descendant_promotion', amount: 0, count: 0, description: '推广奖励' },
{ type: 'cost', amount: 0, count: 0, description: '成本贡献' },
{ type: 'pricing', amount: 0, count: 0, description: '定价贡献' },
{ type: 'descendant_withdraw', amount: 0, count: 0, description: '提现收益' },
{ type: 'descendant_upgrade_vip', amount: 0, count: 0, description: '转化VIP奖励' },
{ type: 'descendant_upgrade_svip', amount: 0, count: 0, description: '转化SVIP奖励' },
]
const stats = res.data.stats || {}
const map = {
descendant_promotion: ['descendant_promotion_amount', 'descendant_promotion_count'],
cost: ['cost_amount', 'cost_count'],
pricing: ['pricing_amount', 'pricing_count'],
descendant_withdraw: ['descendant_withdraw_amount', 'descendant_withdraw_count'],
descendant_upgrade_vip: ['descendant_upgrade_vip_amount', 'descendant_upgrade_vip_count'],
descendant_upgrade_svip: ['descendant_upgrade_svip_amount', 'descendant_upgrade_svip_count'],
}
statistics.value.forEach((item) => {
const keys = map[item.type]
if (!keys)
return
item.amount = stats[keys[0]] || 0
item.count = stats[keys[1]] || 0
})
}
total.value = res.data.total || 0
rewardDetails.value = res.data.list || []
}
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
fetchRewardDetails()
}
onLoad((query) => {
const parsed = Number.parseInt(String(query?.id || query?.subordinate_id || ''), 10)
subordinateId.value = Number.isFinite(parsed) && parsed > 0 ? parsed : 0
})
onMounted(() => {
if (!subordinateId.value) {
uni.showToast({ title: '下级参数错误', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
fetchRewardDetails()
})
</script>
<template>
<view class="reward-detail">
<view class="p-4">
<view class="mb-4 rounded-xl bg-white p-5 shadow-sm">
<view class="mb-4 flex items-center justify-between">
<view class="flex items-center space-x-3">
<view class="text-xl text-gray-800 font-semibold">
{{ userInfo.mobile }}
</view>
<text class="rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-600 font-medium">
{{ getAgentLevelLabel(userInfo.level) }}
</text>
</view>
</view>
<view class="mb-4 text-sm text-gray-500">
成为下级代理时间{{ formatTime(userInfo.createTime) }}
</view>
<view class="grid grid-cols-3 gap-4">
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
总推广单量
</view>
<view class="text-xl text-blue-600 font-semibold">
{{ summary.totalOrders }}
</view>
</view>
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
总收益
</view>
<view class="text-xl text-green-600 font-semibold">
¥{{ formatNumber(summary.totalReward) }}
</view>
</view>
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
总贡献
</view>
<view class="text-xl text-purple-600 font-semibold">
¥{{ formatNumber(summary.totalContribution) }}
</view>
</view>
</view>
</view>
<view class="mb-4 rounded-xl bg-white p-4 shadow-sm">
<view class="mb-3 text-base text-gray-800 font-medium">
贡献统计
</view>
<view class="grid grid-cols-2 gap-3">
<view
v-for="item in statistics"
:key="item.type"
class="flex items-center rounded-lg p-2"
:class="getRewardTypeClass(item.type).split(' ')[0]"
>
<view class="flex-1">
<view class="text-sm font-medium" :class="getRewardTypeClass(item.type).split(' ')[1]">
{{ item.description }}
</view>
<view class="mt-1 flex items-center justify-between">
<view class="text-xs text-gray-500">
{{ item.count }}
</view>
<view class="text-sm font-medium" :class="getRewardTypeClass(item.type).split(' ')[1]">
¥{{ formatNumber(item.amount) }}
</view>
</view>
</view>
</view>
</view>
</view>
<view class="mb-4 rounded-xl bg-white p-4 shadow-sm">
<view class="text-base text-gray-800 font-medium">
贡献记录
</view>
<view class="detail-scroll p-4">
<view v-if="rewardDetails.length === 0" class="py-8 text-center text-gray-500">
暂无贡献记录
</view>
<view v-for="item in rewardDetails" v-else :key="item.id" class="reward-item">
<view class="mb-3 border-b border-gray-200 pb-3">
<view class="flex items-center justify-between">
<view>
<view class="text-gray-800 font-medium">
{{ getRewardTypeDescription(item.type) }}
</view>
<view class="text-xs text-gray-500">
{{ formatTime(item.create_time) }}
</view>
</view>
<view class="text-right">
<view class="text-base font-semibold" :class="getRewardTypeClass(item.type).split(' ')[1]">
¥{{ formatNumber(item.amount) }}
</view>
</view>
</view>
</view>
</view>
<view v-if="loading" class="py-3 text-center text-sm text-gray-400">
加载中...
</view>
</view>
<view class="px-4 pb-4">
<wd-pagination
v-model="page"
:total="total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.reward-detail {
min-height: 100vh;
background-color: #f5f5f5;
}
.reward-item {
transition: transform 0.2s;
}
.reward-item:active {
transform: scale(0.98);
}
.detail-scroll {
min-height: 50vh;
}
</style>
<route type="page" lang="json">
{
"layout": "page",
"title": "下级贡献详情",
"agent": true,
"auth": true
}
</route>

View File

@@ -0,0 +1,215 @@
<script setup>
import { onMounted, ref } from 'vue'
import { getAgentSubordinateList } from '@/apis/agent'
const subordinates = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = 8
const statistics = ref({
totalSubordinates: 0,
})
function normalizeLevel(value) {
const raw = String(value || '').trim()
if (!raw)
return 'NORMAL'
const cleaned = raw.replace(/代理$/, '')
if (cleaned === '普通')
return 'NORMAL'
const upper = cleaned.toUpperCase()
if (upper.includes('SVIP'))
return 'SVIP'
if (upper.includes('VIP'))
return 'VIP'
return 'NORMAL'
}
function getLevelText(item) {
const level = normalizeLevel(item?.level_name || item?.level)
return {
NORMAL: '普通代理',
VIP: 'VIP代理',
SVIP: 'SVIP代理',
}[level] || '普通代理'
}
function getLevelClass(levelText) {
switch (levelText) {
case 'SVIP代理':
return 'bg-purple-100 text-purple-600'
case 'VIP代理':
return 'bg-blue-100 text-blue-600'
default:
return 'bg-gray-100 text-gray-600'
}
}
function formatNumber(num) {
if (!num)
return '0.00'
return Number(num).toFixed(2)
}
async function fetchSubordinates() {
if (loading.value)
return
loading.value = true
try {
const res = await getAgentSubordinateList({
page: page.value,
page_size: pageSize,
})
if (res.code === 200) {
statistics.value.totalSubordinates = res.data.total || 0
subordinates.value = res.data.list || []
}
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
fetchSubordinates()
}
function viewDetail(item) {
const id = Number.parseInt(String(item?.id ?? ''), 10)
if (!Number.isFinite(id) || id <= 0) {
uni.showToast({ title: '下级信息异常,无法查看详情', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/subordinateDetail?id=${encodeURIComponent(String(id))}`,
})
}
onMounted(() => {
fetchSubordinates()
})
</script>
<template>
<view class="subordinate-list">
<view class="p-4 pb-0">
<view class="rounded-xl bg-white p-4 shadow-sm">
<view class="flex items-center justify-center">
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
下级总数
</view>
<view class="text-2xl text-blue-600 font-semibold">
{{ statistics.totalSubordinates }}
</view>
</view>
</view>
</view>
</view>
<view class="subordinate-scroll p-4">
<view v-for="(item, index) in subordinates" :key="item.id" class="subordinate-item">
<view class="mb-4 flex flex-col rounded-xl bg-white p-5 shadow-sm">
<view class="mb-4 flex items-center justify-between">
<view class="flex items-center space-x-3">
<view class="h-6 w-6 flex items-center justify-center rounded-full bg-blue-100 text-sm text-blue-600 font-medium">
{{ index + 1 }}
</view>
<view class="text-xl text-gray-800 font-semibold">
{{ item.mobile }}
</view>
<text class="rounded-full px-3 py-1 text-sm font-medium" :class="[getLevelClass(getLevelText(item))]">
{{ getLevelText(item) }}
</text>
</view>
</view>
<view class="mb-5 text-sm text-gray-500">
成为下级代理时间{{ item.create_time }}
</view>
<view class="grid grid-cols-3 mb-5 gap-6">
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
总推广单量
</view>
<view class="text-xl text-blue-600 font-semibold">
{{ item.total_orders }}
</view>
</view>
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
总收益
</view>
<view class="text-xl text-green-600 font-semibold">
¥{{ formatNumber(item.total_earnings) }}
</view>
</view>
<view class="text-center">
<view class="mb-1 text-sm text-gray-500">
总贡献
</view>
<view class="text-xl text-purple-600 font-semibold">
¥{{ formatNumber(item.total_contribution) }}
</view>
</view>
</view>
<view class="flex justify-end">
<view
class="inline-flex items-center rounded-full from-blue-500 to-blue-400 bg-gradient-to-r px-4 py-2 text-sm text-white shadow-sm"
@click="viewDetail(item)"
>
查看详情
</view>
</view>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!subordinates.length" class="py-4 text-center text-sm text-gray-400">
暂无下级代理
</view>
</view>
<view class="px-4 pb-4">
<wd-pagination
v-model="page"
:total="statistics.totalSubordinates"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</view>
</template>
<style scoped>
.subordinate-list {
min-height: 100vh;
background-color: #f5f5f5;
}
.subordinate-scroll {
height: calc(100vh - 180px);
}
.subordinate-item {
transition: transform 0.2s;
}
.subordinate-item:active {
transform: scale(0.98);
}
</style>
<route type="page" lang="json">
{
"layout": "page",
"title": "我的下级",
"agent": true,
"auth": true
}
</route>

2
src/uni-pages.d.ts vendored
View File

@@ -18,6 +18,8 @@ interface NavigateToOptions {
"/pages/promote" | "/pages/promote" |
"/pages/promoteDetails" | "/pages/promoteDetails" |
"/pages/rewardsDetails" | "/pages/rewardsDetails" |
"/pages/subordinateDetail" |
"/pages/subordinateList" |
"/pages/withdrawDetails"; "/pages/withdrawDetails";
} }
interface RedirectToOptions extends NavigateToOptions {} interface RedirectToOptions extends NavigateToOptions {}

View File

@@ -0,0 +1,46 @@
export function safeTruncate(num, decimals = 2) {
if (Number.isNaN(num) || !Number.isFinite(num))
return '0.00'
const factor = 10 ** decimals
const scaled = Math.trunc(num * factor)
return (scaled / factor).toFixed(decimals)
}
function calculatePlatformOverpricingCost(price, config) {
if (price <= config.p_pricing_standard)
return 0
return (price - config.p_pricing_standard) * config.p_overpricing_ratio
}
function calculateSuperiorOverpricingCost(price, config) {
if (config.a_overpricing_ratio <= 0)
return 0
if (price <= config.a_pricing_standard)
return 0
if (config.a_pricing_end <= config.a_pricing_standard)
return 0
const superiorRangeAmount = Math.min(price, config.a_pricing_end) - config.a_pricing_standard
return Math.max(0, superiorRangeAmount) * config.a_overpricing_ratio
}
export function calculatePromotionPricing(priceInput, config) {
if (!config)
return { costPrice: '0.00', promotionRevenue: '0.00' }
const price = Number(priceInput)
if (!Number.isFinite(price))
return { costPrice: '0.00', promotionRevenue: '0.00' }
const baseCost = Number(config.cost_price) || 0
const platformOverpricingCost = calculatePlatformOverpricingCost(price, config)
const superiorOverpricingCost = calculateSuperiorOverpricingCost(price, config)
const totalCost = baseCost + platformOverpricingCost + superiorOverpricingCost
const revenue = price - totalCost
return {
costPrice: safeTruncate(totalCost),
promotionRevenue: safeTruncate(revenue),
}
}

View File

@@ -9,6 +9,8 @@ const BASE_URL = getApiPrefix()
const BASE_URL = getApiBaseUrl() const BASE_URL = getApiBaseUrl()
// #endif // #endif
const TEMP_USER_INVALID_CODE = 100012 const TEMP_USER_INVALID_CODE = 100012
const USER_DISABLED_CODE = 100011
const USER_CANCELLED_CODE = 100013
let wxMiniReauthPromise = null let wxMiniReauthPromise = null
const clearAuthData = () => { const clearAuthData = () => {
@@ -19,6 +21,13 @@ const clearAuthData = () => {
uni.removeStorageSync('agentInfo') uni.removeStorageSync('agentInfo')
} }
const forceLogoutToLogin = () => {
clearAuthData()
uni.reLaunch({
url: '/pages/login',
})
}
const silentWxMiniReAuth = () => { const silentWxMiniReAuth = () => {
if (wxMiniReauthPromise) if (wxMiniReauthPromise)
return wxMiniReauthPromise return wxMiniReauthPromise
@@ -90,6 +99,11 @@ function request(options) {
success: (res) => { success: (res) => {
// 响应拦截器逻辑 // 响应拦截器逻辑
if (res.statusCode === 200) { if (res.statusCode === 200) {
if (res.data?.code === USER_DISABLED_CODE || res.data?.code === USER_CANCELLED_CODE) {
forceLogoutToLogin()
reject(res.data)
return
}
if (res.data?.code === TEMP_USER_INVALID_CODE) { if (res.data?.code === TEMP_USER_INVALID_CODE) {
if (!options.__retriedAfterReauth) { if (!options.__retriedAfterReauth) {
silentWxMiniReAuth() silentWxMiniReAuth()
@@ -111,10 +125,12 @@ function request(options) {
resolve(res.data) resolve(res.data)
} }
else if (res.statusCode === 401) { else if (res.statusCode === 401) {
uni.removeStorageSync('token') forceLogoutToLogin()
uni.redirectTo({ reject(res.data)
url: '/pages/login', }
}) else if (res.statusCode === 403) {
forceLogoutToLogin()
reject(res.data)
} }
else { else {
uni.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) uni.showToast({ title: res.data.msg || '请求失败', icon: 'none' })

View File

@@ -1,55 +1,60 @@
import pagesJson from '@/pages.json' import pagesJson from "@/pages.json";
const pagesConfig = pagesJson.pages const pagesConfig = pagesJson.pages;
// 检查登录状态 // 检查登录状态
function checkIsLoggedIn() { function checkIsLoggedIn() {
return !!uni.getStorageSync('token') return !!uni.getStorageSync("token");
} }
// 检查是否是代理 // 检查是否是代理
function checkIsAgent() { function checkIsAgent() {
const agentInfo = uni.getStorageSync('agentInfo') const agentInfo = uni.getStorageSync("agentInfo");
return agentInfo?.isAgent return agentInfo?.isAgent;
} }
function checkBindMobile() { function checkBindMobile() {
const userInfo = uni.getStorageSync('userInfo') const userInfo = uni.getStorageSync("userInfo");
return userInfo?.mobile.length > 0 return userInfo?.mobile.length > 0;
} }
// 添加路由拦截器 // 添加路由拦截器
function setupRouterGuard() { function setupRouterGuard() {
const methods = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab'] const methods = ["navigateTo", "redirectTo", "reLaunch", "switchTab"];
methods.forEach(method => { methods.forEach((method) => {
uni.addInterceptor(method, { uni.addInterceptor(method, {
invoke(e) { invoke(e) {
const url = e.url.split('?')[0] const url = e.url.split("?")[0];
const currentPageConfig = pagesConfig.find(page => '/' +page.path === url) // 根据路径查找 pages.json 中的配置 const currentPageConfig = pagesConfig.find(
(page) => "/" + page.path === url,
); // 根据路径查找 pages.json 中的配置
// 检查是否需要登录 // 检查是否需要登录
if (currentPageConfig?.auth && (!checkIsLoggedIn() || !checkBindMobile())) { if (
currentPageConfig?.auth &&
(!checkIsLoggedIn() || !checkBindMobile())
) {
// 跳转到登录页 // 跳转到登录页
uni.redirectTo({ uni.redirectTo({
url: '/pages/login' url: "/pages/login",
}) });
return false return false;
} }
// 检查是否需要代理身份 // 检查是否需要代理身份
if (currentPageConfig?.agent && !checkIsAgent()) { if (currentPageConfig?.agent && !checkIsAgent()) {
uni.navigateTo({ uni.navigateTo({
url: '/pages/invitationAgentApply' url: "/pages/invitationAgentApply",
}) });
return false return false;
} }
return true return true;
}, },
fail() { fail() {
return false return false;
} },
}) });
}) });
} }
export { setupRouterGuard } export { setupRouterGuard };