|
@@ -0,0 +1,570 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="analytics-page">
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Date range and controls -->
|
|
|
|
|
+ <el-card shadow="hover" class="controls-card">
|
|
|
|
|
+ <div class="controls-row">
|
|
|
|
|
+ <div class="date-pickers">
|
|
|
|
|
+ <span class="control-label">统计范围</span>
|
|
|
|
|
+ <el-date-picker
|
|
|
|
|
+ v-model="dateRange"
|
|
|
|
|
+ type="daterange"
|
|
|
|
|
+ range-separator="至"
|
|
|
|
|
+ start-placeholder="开始日期"
|
|
|
|
|
+ end-placeholder="结束日期"
|
|
|
|
|
+ value-format="YYYY-MM-DD"
|
|
|
|
|
+ @change="onDateRangeChange"
|
|
|
|
|
+ style="width: 260px"
|
|
|
|
|
+ />
|
|
|
|
|
+ <el-button type="primary" @click="loadAll" :loading="loading" style="margin-left: 8px">查询</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Summary cards -->
|
|
|
|
|
+ <el-row :gutter="16" class="stats-row">
|
|
|
|
|
+ <el-col :span="4" v-for="card in summaryCards" :key="card.label">
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card" :body-style="{ padding: '16px' }">
|
|
|
|
|
+ <div class="stat-label">{{ card.label }}</div>
|
|
|
|
|
+ <div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Tabs: 趋势图 + 高级分析 -->
|
|
|
|
|
+ <el-card shadow="hover">
|
|
|
|
|
+ <el-tabs v-model="activeTab">
|
|
|
|
|
+ <el-tab-pane label="收入趋势" name="revenue">
|
|
|
|
|
+ <div class="chart-toolbar">
|
|
|
|
|
+ <el-radio-group v-model="revenueGroupBy" size="small" @change="loadRevenueTrend">
|
|
|
|
|
+ <el-radio-button value="day">按日</el-radio-button>
|
|
|
|
|
+ <el-radio-button value="week">按周</el-radio-button>
|
|
|
|
|
+ <el-radio-button value="month">按月</el-radio-button>
|
|
|
|
|
+ </el-radio-group>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div ref="revenueChartRef" class="chart-container"></div>
|
|
|
|
|
+ </el-tab-pane>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tab-pane label="用户增长" name="users">
|
|
|
|
|
+ <div class="chart-toolbar">
|
|
|
|
|
+ <el-radio-group v-model="userGroupBy" size="small" @change="loadUserGrowth">
|
|
|
|
|
+ <el-radio-button value="day">按日</el-radio-button>
|
|
|
|
|
+ <el-radio-button value="week">按周</el-radio-button>
|
|
|
|
|
+ <el-radio-button value="month">按月</el-radio-button>
|
|
|
|
|
+ </el-radio-group>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div ref="userChartRef" class="chart-container"></div>
|
|
|
|
|
+ </el-tab-pane>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Phase 2 tabs -->
|
|
|
|
|
+ <el-tab-pane label="转化漏斗" name="funnel">
|
|
|
|
|
+ <div ref="funnelChartRef" class="chart-container"></div>
|
|
|
|
|
+ </el-tab-pane>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tab-pane label="分销 Top10" name="distribution">
|
|
|
|
|
+ <el-table :data="topPromoters" v-loading="distLoading" stripe size="small">
|
|
|
|
|
+ <el-table-column label="排名" type="index" width="60" />
|
|
|
|
|
+ <el-table-column label="昵称" width="160">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <div class="promoter-cell">
|
|
|
|
|
+ <el-avatar :src="row.avatarUrl" :size="28" style="margin-right: 8px" />
|
|
|
|
|
+ <span>{{ row.nickname }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="directCount" label="直推" width="80" align="center" />
|
|
|
|
|
+ <el-table-column prop="indirectCount" label="间推" width="80" align="center" />
|
|
|
|
|
+ <el-table-column label="总佣金" width="120" align="right">
|
|
|
|
|
+ <template #default="{ row }">¥{{ (row.totalCommission / 100).toFixed(2) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <div class="dist-summary">
|
|
|
|
|
+ <span>推广者总数:<strong>{{ distSummary.totalPromoters }}</strong></span>
|
|
|
|
|
+ <span style="margin-left: 24px">总佣金:<strong>¥{{ (distSummary.totalCommission / 100).toFixed(2) }}</strong></span>
|
|
|
|
|
+ <span style="margin-left: 24px">平均佣金:<strong>¥{{ (distSummary.avgCommission / 100).toFixed(2) }}</strong></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="export-bar">
|
|
|
|
|
+ <el-button size="small" @click="exportDistribution">导出 CSV</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-tab-pane>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tab-pane label="提现统计" name="withdraw">
|
|
|
|
|
+ <div v-loading="wdLoading" class="withdraw-stats-grid">
|
|
|
|
|
+ <el-row :gutter="16">
|
|
|
|
|
+ <el-col :span="6" v-for="s in withdrawStatCards" :key="s.label">
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card" :body-style="{ padding: '14px' }">
|
|
|
|
|
+ <div class="stat-label">{{ s.label }}</div>
|
|
|
|
|
+ <div class="stat-value" :style="{ color: s.color, fontSize: '20px' }">{{ s.value }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+ <div ref="withdrawChartRef" class="chart-container" style="height: 240px; margin-top: 16px"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-tab-pane>
|
|
|
|
|
+ </el-tabs>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
|
|
|
|
|
+import * as echarts from 'echarts'
|
|
|
|
|
+import request from '@/utils/request'
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── State ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+const loading = ref(false)
|
|
|
|
|
+const distLoading = ref(false)
|
|
|
|
|
+const wdLoading = ref(false)
|
|
|
|
|
+const activeTab = ref('revenue')
|
|
|
|
|
+const revenueGroupBy = ref('day')
|
|
|
|
|
+const userGroupBy = ref('day')
|
|
|
|
|
+
|
|
|
|
|
+const dateRange = ref<[string, string]>([getDefaultStart(), getDefaultEnd()])
|
|
|
|
|
+
|
|
|
|
|
+const summary = ref<Record<string, any>>({})
|
|
|
|
|
+const revenueTrend = ref<any[]>([])
|
|
|
|
|
+const userGrowth = ref<any[]>([])
|
|
|
|
|
+const topPromoters = ref<any[]>([])
|
|
|
|
|
+const distSummary = ref<any>({})
|
|
|
|
|
+const withdrawStats = ref<Record<string, any>>({})
|
|
|
|
|
+
|
|
|
|
|
+const revenueChartRef = ref<HTMLElement>()
|
|
|
|
|
+const userChartRef = ref<HTMLElement>()
|
|
|
|
|
+const funnelChartRef = ref<HTMLElement>()
|
|
|
|
|
+const withdrawChartRef = ref<HTMLElement>()
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Helpers ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+function getDefaultStart(): string {
|
|
|
|
|
+ const d = new Date()
|
|
|
|
|
+ d.setDate(d.getDate() - 30)
|
|
|
|
|
+ return d.toISOString().slice(0, 10)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getDefaultEnd(): string {
|
|
|
|
|
+ return new Date().toISOString().slice(0, 10)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatYuan(fen: number): string {
|
|
|
|
|
+ return '¥' + (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatPercent(val: number): string {
|
|
|
|
|
+ return val.toFixed(1) + '%'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fmt(dateStr?: string): string | undefined {
|
|
|
|
|
+ return dateStr || undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Summary Cards ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+const summaryCards = computed(() => [
|
|
|
|
|
+ { label: '总收入', value: formatYuan(summary.value.totalRevenue ?? 0), color: '#10B981' },
|
|
|
|
|
+ { label: '订单数', value: summary.value.totalOrders ?? '-', color: '#3B82F6' },
|
|
|
|
|
+ { label: '平均客单价', value: formatYuan(summary.value.avgOrderValue ?? 0), color: '#8B5CF6' },
|
|
|
|
|
+ { label: '总用户', value: summary.value.totalUsers ?? '-', color: '#1F2937' },
|
|
|
|
|
+ { label: '新增用户', value: summary.value.newUsers ?? '-', color: '#3B82F6' },
|
|
|
|
|
+ { label: 'VIP 用户', value: summary.value.vipUsers ?? '-', color: '#F59E0B' },
|
|
|
|
|
+ { label: '新增 VIP', value: summary.value.newVip ?? '-', color: '#F59E0B' },
|
|
|
|
|
+ { label: 'VIP 转化率', value: summary.value.vipConversionRate != null ? formatPercent(summary.value.vipConversionRate) : '-', color: '#EF4444' },
|
|
|
|
|
+])
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Withdraw Stat Cards ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+const withdrawStatCards = computed(() => {
|
|
|
|
|
+ const ws = withdrawStats.value
|
|
|
|
|
+ return [
|
|
|
|
|
+ { label: '提现总数', value: ws.totalCount ?? '-', color: '#1F2937' },
|
|
|
|
|
+ { label: '提现总额', value: formatYuan(ws.totalAmount ?? 0), color: '#10B981' },
|
|
|
|
|
+ { label: '成功金额', value: formatYuan(ws.successAmount ?? 0), color: '#3B82F6' },
|
|
|
|
|
+ { label: '成功率', value: ws.successRate != null ? formatPercent(ws.successRate) : '-', color: '#8B5CF6' },
|
|
|
|
|
+ ]
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Date change ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+function onDateRangeChange() {
|
|
|
|
|
+ loadAll()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Data Loading ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+async function loadAll() {
|
|
|
|
|
+ loading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
|
|
|
|
|
+ const payload = { startDate: start, endDate: end }
|
|
|
|
|
+
|
|
|
|
|
+ const [summaryData, revenueData, userData] = await Promise.all([
|
|
|
|
|
+ request.post('/admin/analytics/summary', payload) as Promise<any>,
|
|
|
|
|
+ request.post('/admin/analytics/revenue-trend', { ...payload, groupBy: revenueGroupBy.value }) as Promise<any>,
|
|
|
|
|
+ request.post('/admin/analytics/user-growth', { ...payload, groupBy: userGroupBy.value }) as Promise<any>,
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ summary.value = summaryData
|
|
|
|
|
+ revenueTrend.value = revenueData
|
|
|
|
|
+ userGrowth.value = userData
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ renderRevenueChart()
|
|
|
|
|
+ renderUserChart()
|
|
|
|
|
+ })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadRevenueTrend() {
|
|
|
|
|
+ const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
|
|
|
|
|
+ revenueTrend.value = await request.post('/admin/analytics/revenue-trend', {
|
|
|
|
|
+ startDate: start, endDate: end, groupBy: revenueGroupBy.value
|
|
|
|
|
+ }) as any
|
|
|
|
|
+ nextTick(renderRevenueChart)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadUserGrowth() {
|
|
|
|
|
+ const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
|
|
|
|
|
+ userGrowth.value = await request.post('/admin/analytics/user-growth', {
|
|
|
|
|
+ startDate: start, endDate: end, groupBy: userGroupBy.value
|
|
|
|
|
+ }) as any
|
|
|
|
|
+ nextTick(renderUserChart)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Revenue Chart ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+function renderRevenueChart() {
|
|
|
|
|
+ if (!revenueChartRef.value || revenueTrend.value.length === 0) {
|
|
|
|
|
+ if (revenueChartRef.value) {
|
|
|
|
|
+ const chart = echarts.getInstanceByDom(revenueChartRef.value) || echarts.init(revenueChartRef.value)
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ graphic: { type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 16, fill: '#999' } }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const chart = echarts.getInstanceByDom(revenueChartRef.value) || echarts.init(revenueChartRef.value)
|
|
|
|
|
+ const dates = revenueTrend.value.map((d: any) => d.date)
|
|
|
|
|
+ const revenue = revenueTrend.value.map((d: any) => (d.revenue / 100).toFixed(2))
|
|
|
|
|
+ const orderCount = revenueTrend.value.map((d: any) => d.orderCount)
|
|
|
|
|
+
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ tooltip: {
|
|
|
|
|
+ trigger: 'axis',
|
|
|
|
|
+ axisPointer: { type: 'cross' },
|
|
|
|
|
+ formatter: (params: any) => {
|
|
|
|
|
+ let html = `<strong>${params[0].axisValue}</strong><br/>`
|
|
|
|
|
+ params.forEach((p: any) => {
|
|
|
|
|
+ html += `${p.marker} ${p.seriesName}:${p.seriesName === '收入(元)' ? '¥' : ''}${p.value}<br/>`
|
|
|
|
|
+ })
|
|
|
|
|
+ return html
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ legend: { data: ['收入(元)', '订单数'], bottom: 0 },
|
|
|
|
|
+ grid: { left: '8%', right: '5%', top: '6%', bottom: '22%' },
|
|
|
|
|
+ xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 11 } },
|
|
|
|
|
+ yAxis: [
|
|
|
|
|
+ { type: 'value', name: '收入(元)' },
|
|
|
|
|
+ { type: 'value', name: '订单数' },
|
|
|
|
|
+ ],
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '收入(元)',
|
|
|
|
|
+ type: 'line',
|
|
|
|
|
+ data: revenue,
|
|
|
|
|
+ smooth: true,
|
|
|
|
|
+ lineStyle: { color: '#10B981', width: 2 },
|
|
|
|
|
+ itemStyle: { color: '#10B981' },
|
|
|
|
|
+ areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
|
|
+ { offset: 0, color: 'rgba(16,185,129,0.25)' },
|
|
|
|
|
+ { offset: 1, color: 'rgba(16,185,129,0.02)' },
|
|
|
|
|
+ ])},
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '订单数',
|
|
|
|
|
+ type: 'bar',
|
|
|
|
|
+ yAxisIndex: 1,
|
|
|
|
|
+ data: orderCount,
|
|
|
|
|
+ barWidth: '40%',
|
|
|
|
|
+ itemStyle: { color: '#3B82F6', borderRadius: [3, 3, 0, 0] },
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── User Growth Chart ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+function renderUserChart() {
|
|
|
|
|
+ if (!userChartRef.value || userGrowth.value.length === 0) {
|
|
|
|
|
+ if (userChartRef.value) {
|
|
|
|
|
+ const chart = echarts.getInstanceByDom(userChartRef.value) || echarts.init(userChartRef.value)
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ graphic: { type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 16, fill: '#999' } }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const chart = echarts.getInstanceByDom(userChartRef.value) || echarts.init(userChartRef.value)
|
|
|
|
|
+ const dates = userGrowth.value.map((d: any) => d.date)
|
|
|
|
|
+ const newUsers = userGrowth.value.map((d: any) => d.newUsers)
|
|
|
|
|
+ const newVip = userGrowth.value.map((d: any) => d.newVip)
|
|
|
|
|
+ const newPractitioner = userGrowth.value.map((d: any) => d.newPractitioner)
|
|
|
|
|
+
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ tooltip: {
|
|
|
|
|
+ trigger: 'axis',
|
|
|
|
|
+ axisPointer: { type: 'cross' },
|
|
|
|
|
+ formatter: (params: any) => {
|
|
|
|
|
+ let html = `<strong>${params[0].axisValue}</strong><br/>`
|
|
|
|
|
+ params.forEach((p: any) => {
|
|
|
|
|
+ html += `${p.marker} ${p.seriesName}:${p.value}<br/>`
|
|
|
|
|
+ })
|
|
|
|
|
+ return html
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ legend: { data: ['新增用户', '新增VIP', '新增能量师'], bottom: 0 },
|
|
|
|
|
+ grid: { left: '8%', right: '5%', top: '6%', bottom: '22%' },
|
|
|
|
|
+ xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 11 } },
|
|
|
|
|
+ yAxis: { type: 'value', minInterval: 1 },
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '新增用户',
|
|
|
|
|
+ type: 'bar',
|
|
|
|
|
+ stack: 'users',
|
|
|
|
|
+ data: newUsers,
|
|
|
|
|
+ itemStyle: { color: '#3B82F6', borderRadius: [0, 0, 0, 0] },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '新增VIP',
|
|
|
|
|
+ type: 'bar',
|
|
|
|
|
+ stack: 'users',
|
|
|
|
|
+ data: newVip,
|
|
|
|
|
+ itemStyle: { color: '#F59E0B' },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '新增能量师',
|
|
|
|
|
+ type: 'bar',
|
|
|
|
|
+ stack: 'users',
|
|
|
|
|
+ data: newPractitioner,
|
|
|
|
|
+ itemStyle: { color: '#10B981', borderRadius: [3, 3, 0, 0] },
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Funnel Chart (Phase 2) ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+async function loadFunnel() {
|
|
|
|
|
+ const data: any = await request.post('/admin/analytics/funnel', {})
|
|
|
|
|
+ if (!funnelChartRef.value) return
|
|
|
|
|
+
|
|
|
|
|
+ const chart = echarts.getInstanceByDom(funnelChartRef.value) || echarts.init(funnelChartRef.value)
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
|
|
|
|
+ series: [{
|
|
|
|
|
+ type: 'funnel',
|
|
|
|
|
+ left: '15%',
|
|
|
|
|
+ right: '15%',
|
|
|
|
|
+ minSize: '20%',
|
|
|
|
|
+ maxSize: '100%',
|
|
|
|
|
+ sort: 'descending',
|
|
|
|
|
+ gap: 4,
|
|
|
|
|
+ label: { show: true, formatter: '{b}\n{c} ({d}%)', fontSize: 13 },
|
|
|
|
|
+ itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
+ data: [
|
|
|
|
|
+ { name: '注册用户', value: data.registered, itemStyle: { color: '#3B82F6' } },
|
|
|
|
|
+ { name: 'C端付费', value: data.paidC, itemStyle: { color: '#10B981' } },
|
|
|
|
|
+ { name: '超级会员', value: data.paidSuper, itemStyle: { color: '#8B5CF6' } },
|
|
|
|
|
+ { name: '能量师', value: data.practitioner, itemStyle: { color: '#F59E0B' } },
|
|
|
|
|
+ ],
|
|
|
|
|
+ }],
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Distribution (Phase 2) ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+async function loadDistribution() {
|
|
|
|
|
+ distLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data: any = await request.post('/admin/analytics/distribution', {})
|
|
|
|
|
+ topPromoters.value = data.topPromoters ?? []
|
|
|
|
|
+ distSummary.value = {
|
|
|
|
|
+ totalPromoters: data.totalPromoters ?? 0,
|
|
|
|
|
+ totalCommission: data.totalCommission ?? 0,
|
|
|
|
|
+ avgCommission: data.avgCommission ?? 0,
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ distLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Withdraw Stats (Phase 2) ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+async function loadWithdrawStats() {
|
|
|
|
|
+ wdLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [start, end] = dateRange.value || [getDefaultStart(), getDefaultEnd()]
|
|
|
|
|
+ const data: any = await request.post('/admin/analytics/withdraw-stats', { startDate: start, endDate: end })
|
|
|
|
|
+ withdrawStats.value = data
|
|
|
|
|
+
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ if (!withdrawChartRef.value) return
|
|
|
|
|
+ const chart = echarts.getInstanceByDom(withdrawChartRef.value) || echarts.init(withdrawChartRef.value)
|
|
|
|
|
+ const byChannel = data.byChannel ?? {}
|
|
|
|
|
+ const channelNames: Record<string, string> = {
|
|
|
|
|
+ REFUND: '退费',
|
|
|
|
|
+ WECHAT_TRANSFER: '微信企业付款',
|
|
|
|
|
+ THIRD_PARTY: '第三方',
|
|
|
|
|
+ }
|
|
|
|
|
+ const pieData = Object.entries(byChannel).map(([k, v]) => ({
|
|
|
|
|
+ name: channelNames[k] || k,
|
|
|
|
|
+ value: (v as number) / 100,
|
|
|
|
|
+ }))
|
|
|
|
|
+ if (pieData.length === 0) {
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ graphic: { type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 16, fill: '#999' } }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ tooltip: { trigger: 'item', formatter: '{b}: ¥{c}' },
|
|
|
|
|
+ series: [{
|
|
|
|
|
+ type: 'pie',
|
|
|
|
|
+ radius: ['38%', '62%'],
|
|
|
|
|
+ data: pieData,
|
|
|
|
|
+ label: { show: true, formatter: '{b}\n¥{c}' },
|
|
|
|
|
+ }],
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ wdLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── CSV Export (Phase 2) ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+function exportDistribution() {
|
|
|
|
|
+ if (topPromoters.value.length === 0) return
|
|
|
|
|
+ const headers = ['排名,昵称,直推数,间推数,总佣金(元)']
|
|
|
|
|
+ const rows = topPromoters.value.map((p: any, i: number) => {
|
|
|
|
|
+ const nick = p.nickname?.replace(/,/g, ' ') || '未知'
|
|
|
|
|
+ return `${i + 1},${nick},${p.directCount},${p.indirectCount},${(p.totalCommission / 100).toFixed(2)}`
|
|
|
|
|
+ })
|
|
|
|
|
+ const csv = '\uFEFF' + [...headers, ...rows].join('\n')
|
|
|
|
|
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
|
|
+ const a = document.createElement('a')
|
|
|
|
|
+ a.href = url
|
|
|
|
|
+ a.download = 'distribution-top10.csv'
|
|
|
|
|
+ a.click()
|
|
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Tab Change Handler ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+watch(activeTab, (tab) => {
|
|
|
|
|
+ if (tab === 'funnel') {
|
|
|
|
|
+ nextTick(loadFunnel)
|
|
|
|
|
+ } else if (tab === 'distribution') {
|
|
|
|
|
+ loadDistribution()
|
|
|
|
|
+ } else if (tab === 'withdraw') {
|
|
|
|
|
+ loadWithdrawStats()
|
|
|
|
|
+ } else if (tab === 'revenue') {
|
|
|
|
|
+ nextTick(renderRevenueChart)
|
|
|
|
|
+ } else if (tab === 'users') {
|
|
|
|
|
+ nextTick(renderUserChart)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+/* ──────────── Lifecycle ──────────── */
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ loadAll()
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+.analytics-page {
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.controls-card {
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.controls-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.date-pickers {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.control-label {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stats-row {
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stat-card {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stat-label {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #6B7280;
|
|
|
|
|
+ margin-bottom: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stat-value {
|
|
|
|
|
+ font-size: 22px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #1F2937;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-toolbar {
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-container {
|
|
|
|
|
+ height: 340px;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.promoter-cell {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.dist-summary {
|
|
|
|
|
+ margin-top: 16px;
|
|
|
|
|
+ padding: 12px 16px;
|
|
|
|
|
+ background: #F9FAFB;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.export-bar {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.withdraw-stats-grid {
|
|
|
|
|
+ min-height: 100px;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|