feat(invitation): 新增返利统计汇总与时间筛选

This commit is contained in:
yml2213
2026-10-29 12:36:57 +08:00
parent 2029c1f4fb
commit 163ffb9eff
8 changed files with 913 additions and 14 deletions
@@ -56,6 +56,9 @@ export interface AdminInvitationQuery {
invitee_phone?: string
order_no?: string
trade_role?: string
source?: string
start_date?: string
end_date?: string
distribution_site_id?: number
inviter_id?: number
invitee_id?: number
@@ -63,6 +66,43 @@ export interface AdminInvitationQuery {
page_size?: number
}
export interface AdminRebateDailyStat {
date: string
ledger_count: number
rebate_amount_cent: number
order_amount_cent: number
pickup_amount_cent: number
}
export interface AdminRebateInviterStat {
inviter_id: number
inviter_phone: string
inviter_nickname: string
ledger_count: number
rebate_amount_cent: number
}
export interface AdminRebateSummary {
total_rebate_cent: number
ledger_count: number
inviter_count: number
invitee_count: number
avg_rebate_cent: number
order_rebate_cent: number
order_count: number
pickup_rebate_cent: number
pickup_count: number
buyer_rebate_cent: number
buyer_count: number
seller_rebate_cent: number
seller_count: number
first_month_cent: number
second_month_cent: number
subsequent_cent: number
daily: AdminRebateDailyStat[]
top_inviters: AdminRebateInviterStat[]
}
export async function fetchAdminInvitations(query: AdminInvitationQuery = {}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminInvitationRow>>>(
'/admin/invitations',
@@ -86,3 +126,31 @@ export async function fetchAdminRebates(query: AdminInvitationQuery = {}) {
total: Number(result?.total ?? 0),
}
}
export async function fetchAdminRebateSummary(query: AdminInvitationQuery = {}) {
const { data } = await apiClient.get<ApiResponse<AdminRebateSummary>>(
'/admin/invitations/rebates/summary',
{ params: query }
)
const result = data.data
return {
total_rebate_cent: Number(result?.total_rebate_cent ?? 0),
ledger_count: Number(result?.ledger_count ?? 0),
inviter_count: Number(result?.inviter_count ?? 0),
invitee_count: Number(result?.invitee_count ?? 0),
avg_rebate_cent: Number(result?.avg_rebate_cent ?? 0),
order_rebate_cent: Number(result?.order_rebate_cent ?? 0),
order_count: Number(result?.order_count ?? 0),
pickup_rebate_cent: Number(result?.pickup_rebate_cent ?? 0),
pickup_count: Number(result?.pickup_count ?? 0),
buyer_rebate_cent: Number(result?.buyer_rebate_cent ?? 0),
buyer_count: Number(result?.buyer_count ?? 0),
seller_rebate_cent: Number(result?.seller_rebate_cent ?? 0),
seller_count: Number(result?.seller_count ?? 0),
first_month_cent: Number(result?.first_month_cent ?? 0),
second_month_cent: Number(result?.second_month_cent ?? 0),
subsequent_cent: Number(result?.subsequent_cent ?? 0),
daily: Array.isArray(result?.daily) ? result.daily : [],
top_inviters: Array.isArray(result?.top_inviters) ? result.top_inviters : [],
} satisfies AdminRebateSummary
}
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Setting, User, Coin, Refresh, Search, Check } from '@element-plus/icons-vue'
import { Setting, User, Coin, Refresh, Search, Check, DataAnalysis } from '@element-plus/icons-vue'
import {
fetchAdminRebateConfig,
updateAdminRebateConfig,
type InvitationRebateConfig,
} from '@/features/invitation/api/invitation'
import {
fetchAdminRebateSummary,
type AdminRebateSummary,
} from '@/features/admin/api/adminInvitations'
import {
fetchDistributionSites,
type DistributionSite,
@@ -15,7 +19,7 @@ import { apiClient } from '@/shared/api/client'
import { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
const activeTab = ref<'config' | 'invitations' | 'rebates'>('config')
const activeTab = ref<'config' | 'invitations' | 'rebates' | 'summary'>('config')
// 站点列表
const sites = ref<DistributionSite[]>([])
@@ -55,6 +59,16 @@ const rebateQuery = reactive({
const rebates = ref<any[]>([])
const rebatesTotal = ref(0)
// 返利统计
const loadingSummary = ref(false)
const summaryQuery = reactive({
dateRange: null as string[] | null,
source: '',
trade_role: '',
distribution_site_id: undefined as number | undefined,
})
const summary = ref<AdminRebateSummary | null>(null)
onMounted(() => {
// 三个页签的数据一起预加载,避免邀请关系/返利流水标签在首次进入时显示 0。
void Promise.all([loadSites(), loadConfig(), loadInvitations(), loadRebates()])
@@ -160,12 +174,48 @@ async function loadRebates() {
}
}
function handleTabChange(tab: 'config' | 'invitations' | 'rebates') {
async function loadSummary() {
loadingSummary.value = true
try {
const params: Record<string, any> = {}
if (summaryQuery.dateRange && summaryQuery.dateRange.length === 2) {
params.start_date = summaryQuery.dateRange[0]
params.end_date = summaryQuery.dateRange[1]
}
if (summaryQuery.source) params.source = summaryQuery.source
if (summaryQuery.trade_role) params.trade_role = summaryQuery.trade_role
if (typeof summaryQuery.distribution_site_id === 'number') {
params.distribution_site_id = summaryQuery.distribution_site_id
}
summary.value = await fetchAdminRebateSummary(params)
} catch (err) {
ElMessage.error(readError(err, '获取返利统计失败'))
} finally {
loadingSummary.value = false
}
}
function resetSummary() {
summaryQuery.dateRange = null
summaryQuery.source = ''
summaryQuery.trade_role = ''
summaryQuery.distribution_site_id = undefined
void loadSummary()
}
function percentOf(part: number, total: number) {
if (!total) return '0%'
return `${Math.round((part / total) * 1000) / 10}%`
}
function handleTabChange(tab: 'config' | 'invitations' | 'rebates' | 'summary') {
activeTab.value = tab
if (tab === 'invitations') {
void loadInvitations()
} else if (tab === 'rebates') {
void loadRebates()
} else if (tab === 'summary') {
void loadSummary()
}
}
@@ -223,6 +273,13 @@ function cycleLabel(cycle: string) {
>
<el-icon><Coin /></el-icon> 返利结算流水 ({{ rebatesTotal }})
</button>
<button
class="nav-tab-btn"
:class="{ active: activeTab === 'summary' }"
@click="handleTabChange('summary')"
>
<el-icon><DataAnalysis /></el-icon> 返利统计
</button>
</div>
<!-- 配置面板 -->
@@ -492,6 +549,209 @@ function cycleLabel(cycle: string) {
/>
</div>
</div>
<!-- 返利统计面板 -->
<div v-if="activeTab === 'summary'" class="panel-card">
<div class="filter-bar">
<el-date-picker
v-model="summaryQuery.dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
unlink-panels
style="width: 260px"
/>
<el-select v-model="summaryQuery.source" placeholder="来源" clearable style="width: 120px">
<el-option label="订单" value="rental_order" />
<el-option label="提号" value="pickup" />
</el-select>
<el-select
v-model="summaryQuery.trade_role"
placeholder="交易端"
clearable
style="width: 120px"
>
<el-option label="买家端" value="buyer" />
<el-option label="卖家端" value="seller" />
</el-select>
<el-select
v-if="sites.length"
v-model="summaryQuery.distribution_site_id"
placeholder="全部分站"
clearable
style="width: 160px"
>
<el-option v-for="s in sites" :key="s.id" :value="s.id" :label="s.site_name" />
</el-select>
<el-button type="primary" :icon="Search" @click="loadSummary">查询</el-button>
<el-button :icon="Refresh" @click="resetSummary">重置</el-button>
</div>
<div class="table-hint">
统计口径与「返利结算流水」一致,按入账时间筛选,金额按实际发放返利汇总。
</div>
<div v-loading="loadingSummary" class="summary-body">
<div class="stat-grid">
<div class="stat-card stat-card-primary">
<div class="stat-label">返利总额</div>
<div class="stat-value">¥{{ formatCent(summary?.total_rebate_cent ?? 0) }}</div>
<div class="stat-sub">共 {{ summary?.ledger_count ?? 0 }} 笔结算流水</div>
</div>
<div class="stat-card">
<div class="stat-label">流水笔数</div>
<div class="stat-value">{{ summary?.ledger_count ?? 0 }}</div>
<div class="stat-sub">含订单与提号返利</div>
</div>
<div class="stat-card">
<div class="stat-label">邀请人数</div>
<div class="stat-value">{{ summary?.inviter_count ?? 0 }}</div>
<div class="stat-sub">产生过返利的邀请人</div>
</div>
<div class="stat-card">
<div class="stat-label">被邀请人数</div>
<div class="stat-value">{{ summary?.invitee_count ?? 0 }}</div>
<div class="stat-sub">产生过返利的被邀请人</div>
</div>
<div class="stat-card">
<div class="stat-label">平均单笔返利</div>
<div class="stat-value">¥{{ formatCent(summary?.avg_rebate_cent ?? 0) }}</div>
<div class="stat-sub">返利总额 ÷ 流水笔数</div>
</div>
</div>
<div class="breakdown-grid">
<div class="breakdown-card">
<div class="breakdown-title">按来源拆分</div>
<div class="breakdown-row">
<span class="breakdown-label">订单返利</span>
<span class="breakdown-num">
¥{{ formatCent(summary?.order_rebate_cent ?? 0) }} ·
{{ summary?.order_count ?? 0 }} 笔
</span>
<span class="breakdown-percent">
{{ percentOf(summary?.order_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
<div class="breakdown-row">
<span class="breakdown-label">提号返利</span>
<span class="breakdown-num">
¥{{ formatCent(summary?.pickup_rebate_cent ?? 0) }} ·
{{ summary?.pickup_count ?? 0 }} 笔
</span>
<span class="breakdown-percent">
{{ percentOf(summary?.pickup_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
</div>
<div class="breakdown-card">
<div class="breakdown-title">按交易端拆分</div>
<div class="breakdown-row">
<span class="breakdown-label">买家端</span>
<span class="breakdown-num">
¥{{ formatCent(summary?.buyer_rebate_cent ?? 0) }} ·
{{ summary?.buyer_count ?? 0 }} 笔
</span>
<span class="breakdown-percent">
{{ percentOf(summary?.buyer_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
<div class="breakdown-row">
<span class="breakdown-label">卖家端</span>
<span class="breakdown-num">
¥{{ formatCent(summary?.seller_rebate_cent ?? 0) }} ·
{{ summary?.seller_count ?? 0 }} 笔
</span>
<span class="breakdown-percent">
{{ percentOf(summary?.seller_rebate_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
</div>
<div class="breakdown-card">
<div class="breakdown-title">按周期阶段拆分</div>
<div class="breakdown-row">
<span class="breakdown-label">首月高额</span>
<span class="breakdown-num">¥{{ formatCent(summary?.first_month_cent ?? 0) }}</span>
<span class="breakdown-percent">
{{ percentOf(summary?.first_month_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
<div class="breakdown-row">
<span class="breakdown-label">次月奖励</span>
<span class="breakdown-num">¥{{ formatCent(summary?.second_month_cent ?? 0) }}</span>
<span class="breakdown-percent">
{{ percentOf(summary?.second_month_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
<div class="breakdown-row">
<span class="breakdown-label">长期分成</span>
<span class="breakdown-num">¥{{ formatCent(summary?.subsequent_cent ?? 0) }}</span>
<span class="breakdown-percent">
{{ percentOf(summary?.subsequent_cent ?? 0, summary?.total_rebate_cent ?? 0) }}
</span>
</div>
</div>
</div>
<div class="summary-section">
<div class="summary-section-title">每日返利趋势</div>
<el-table
:data="summary?.daily ?? []"
border
size="small"
max-height="340"
empty-text="当前筛选条件下暂无返利数据"
>
<el-table-column prop="date" label="日期" width="130" />
<el-table-column prop="ledger_count" label="流水笔数" width="110" align="right" />
<el-table-column label="返利合计" min-width="130" align="right">
<template #default="{ row }">
<strong style="color: #dc2626"
>+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong
>
</template>
</el-table-column>
<el-table-column label="订单返利" min-width="120" align="right">
<template #default="{ row }">¥{{ formatCent(row.order_amount_cent || 0) }}</template>
</el-table-column>
<el-table-column label="提号返利" min-width="120" align="right">
<template #default="{ row }">¥{{ formatCent(row.pickup_amount_cent || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="summary-section">
<div class="summary-section-title">返利 Top 10 邀请人</div>
<el-table
:data="summary?.top_inviters ?? []"
border
size="small"
empty-text="当前筛选条件下暂无邀请人数据"
>
<el-table-column label="排名" width="70" align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="邀请人" min-width="180">
<template #default="{ row }">
<div>{{ row.inviter_nickname || '-' }}</div>
<code style="font-size: 12px; color: #64748b">{{ row.inviter_phone || '-' }}</code>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="返利笔数" width="110" align="right" />
<el-table-column label="返利金额" min-width="130" align="right">
<template #default="{ row }">
<strong style="color: #dc2626"
>+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong
>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
@@ -616,6 +876,110 @@ function cycleLabel(cycle: string) {
color: #d97706;
}
.summary-body {
margin-top: 16px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat-card {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 14px 16px;
}
.stat-card-primary {
background: #eff6ff;
border-color: #bfdbfe;
}
.stat-label {
font-size: 12px;
color: #64748b;
}
.stat-value {
margin-top: 6px;
font-size: 22px;
font-weight: 700;
color: #1e293b;
line-height: 1.2;
}
.stat-card-primary .stat-value {
color: #dc2626;
}
.stat-sub {
margin-top: 6px;
font-size: 12px;
color: #94a3b8;
}
.breakdown-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin-top: 16px;
}
.breakdown-card {
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 14px 16px;
}
.breakdown-title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
padding-bottom: 10px;
margin-bottom: 8px;
border-bottom: 1px solid #f1f5f9;
}
.breakdown-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 13px;
}
.breakdown-label {
width: 70px;
color: #64748b;
}
.breakdown-num {
flex: 1;
text-align: right;
color: #334155;
}
.breakdown-percent {
width: 56px;
text-align: right;
font-weight: 600;
color: #2563eb;
}
.summary-section {
margin-top: 22px;
}
.summary-section-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
margin-bottom: 10px;
}
.pagination-box {
margin-top: 16px;
display: flex;