Browse Source

!268 统计:会员统计
Merge pull request !268 from 疯狂的世界/dev

芋道源码 1 year ago
parent
commit
532b237712

+ 91 - 0
src/api/mall/statistics/member.ts

@@ -0,0 +1,91 @@
+import request from '@/config/axios'
+import dayjs from 'dayjs'
+import { TradeStatisticsComparisonRespVO } from '@/api/mall/statistics/trade'
+import { formatDate } from '@/utils/formatTime'
+
+/** 会员分析 Request VO */
+export interface MemberAnalyseReqVO {
+  times: [dayjs.ConfigType, dayjs.ConfigType]
+}
+
+/** 会员分析 Response VO */
+export interface MemberAnalyseRespVO {
+  visitorCount: number
+  orderUserCount: number
+  payUserCount: number
+  atv: number
+  comparison: TradeStatisticsComparisonRespVO<MemberAnalyseComparisonRespVO>
+}
+
+/** 会员分析对照数据 Response VO */
+export interface MemberAnalyseComparisonRespVO {
+  userCount: number
+  activeUserCount: number
+  rechargeUserCount: number
+}
+
+/** 会员地区统计 Response VO */
+export interface MemberAreaStatisticsRespVO {
+  areaId: number
+  areaName: string
+  userCount: number
+  orderCreateCount: number
+  orderPayCount: number
+  orderPayPrice: number
+}
+
+/** 会员性别统计 Response VO */
+export interface MemberSexStatisticsRespVO {
+  sex: number
+  userCount: number
+}
+
+/** 会员统计 Response VO */
+export interface MemberSummaryRespVO {
+  userCount: number
+  rechargeUserCount: number
+  rechargePrice: number
+  expensePrice: number
+}
+
+/** 会员终端统计 Response VO */
+export interface MemberTerminalStatisticsRespVO {
+  terminal: number
+  userCount: number
+}
+
+// 查询会员统计
+export const getMemberSummary = () => {
+  return request.get<MemberSummaryRespVO>({
+    url: '/statistics/member/summary'
+  })
+}
+
+// 查询会员分析数据
+export const getMemberAnalyse = (params: MemberAnalyseReqVO) => {
+  return request.get<MemberAnalyseRespVO>({
+    url: '/statistics/member/analyse',
+    params: { times: [formatDate(params.times[0]), formatDate(params.times[1])] }
+  })
+}
+
+// 按照省份,查询会员统计列表
+export const getMemberAreaStatisticsList = () => {
+  return request.get<MemberAreaStatisticsRespVO[]>({
+    url: '/statistics/member/get-area-statistics-list'
+  })
+}
+
+// 按照性别,查询会员统计列表
+export const getMemberSexStatisticsList = () => {
+  return request.get<MemberSexStatisticsRespVO[]>({
+    url: '/statistics/member/get-sex-statistics-list'
+  })
+}
+
+// 按照终端,查询会员统计列表
+export const getMemberTerminalStatisticsList = () => {
+  return request.get<MemberTerminalStatisticsRespVO[]>({
+    url: '/statistics/member/get-terminal-statistics-list'
+  })
+}

+ 0 - 2
src/api/statistics/trade.ts → src/api/mall/statistics/trade.ts

@@ -2,8 +2,6 @@ import request from '@/config/axios'
 import dayjs from 'dayjs'
 import { formatDate } from '@/utils/formatTime'
 
-// todo @疯狂:挪到 mall 里哈
-
 /** 交易统计对照 Response VO */
 export interface TradeStatisticsComparisonRespVO<T> {
   value: T

File diff suppressed because it is too large
+ 0 - 0
src/assets/map/json/china.json


+ 3 - 1
src/plugins/echarts/index.ts

@@ -18,7 +18,8 @@ import {
   AriaComponent,
   ParallelComponent,
   LegendComponent,
-  ToolboxComponent
+  ToolboxComponent,
+  VisualMapComponent
 } from 'echarts/components'
 
 import { CanvasRenderer } from 'echarts/renderers'
@@ -32,6 +33,7 @@ echarts.use([
   PolarComponent,
   AriaComponent,
   ParallelComponent,
+  VisualMapComponent,
   BarChart,
   LineChart,
   PieChart,

+ 484 - 0
src/views/mall/statistics/member/index.vue

@@ -0,0 +1,484 @@
+<template>
+  <div class="flex flex-col">
+    <el-row :gutter="16" class="summary">
+      <el-col :sm="6" :xs="12" v-loading="loading">
+        <TradeTrendValue
+          title="累计会员数"
+          icon="fa-solid:users"
+          icon-color="bg-blue-100"
+          icon-bg-color="text-blue-500"
+          :value="summary?.userCount || 0"
+        />
+      </el-col>
+      <el-col :sm="6" :xs="12" v-loading="loading">
+        <TradeTrendValue
+          title="累计充值人数"
+          icon="fa-solid:user"
+          icon-color="bg-purple-100"
+          icon-bg-color="text-purple-500"
+          :value="summary?.rechargeUserCount || 0"
+        />
+      </el-col>
+      <el-col :sm="6" :xs="12" v-loading="loading">
+        <TradeTrendValue
+          title="累计充值金额"
+          icon="fa-solid:money-check-alt"
+          icon-color="bg-yellow-100"
+          icon-bg-color="text-yellow-500"
+          prefix="¥"
+          :decimals="2"
+          :value="fenToYuan(summary?.rechargePrice || 0)"
+        />
+      </el-col>
+      <el-col :sm="6" :xs="12" v-loading="loading">
+        <TradeTrendValue
+          title="累计消费金额"
+          icon="fa-solid:yen-sign"
+          icon-color="bg-green-100"
+          icon-bg-color="text-green-500"
+          prefix="¥"
+          :decimals="2"
+          :value="fenToYuan(summary?.expensePrice || 0)"
+        />
+      </el-col>
+    </el-row>
+    <el-row :gutter="16" class="mb-4">
+      <el-col :md="18" :sm="24">
+        <el-card shadow="never">
+          <template #header>
+            <div class="flex flex-row items-center justify-between">
+              <span>会员概览</span>
+              <!-- 查询条件 -->
+              <div class="my--2 flex flex-row items-center gap-2">
+                <el-radio-group v-model="shortcutDays" @change="handleDateTypeChange">
+                  <el-radio-button :label="1">昨天</el-radio-button>
+                  <el-radio-button :label="7">最近7天</el-radio-button>
+                  <el-radio-button :label="30">最近30天</el-radio-button>
+                </el-radio-group>
+                <el-date-picker
+                  v-model="queryParams.times"
+                  value-format="YYYY-MM-DD HH:mm:ss"
+                  type="daterange"
+                  start-placeholder="开始日期"
+                  end-placeholder="结束日期"
+                  :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+                  :shortcuts="shortcuts"
+                  class="!w-240px"
+                  @change="getMemberAnalyse"
+                />
+              </div>
+            </div>
+          </template>
+          <div class="min-w-225 py-1.75" v-loading="analyseLoading">
+            <div class="relative h-24 flex">
+              <div class="h-full w-75% bg-blue-50 <lg:w-35% <xl:w-55%">
+                <div class="ml-15 h-full flex flex-col justify-center">
+                  <div class="font-bold">
+                    注册用户数量:{{ analyseData?.comparison?.value?.userCount || 0 }}
+                  </div>
+                  <div class="mt-2 text-3.5">
+                    环比增长率:{{
+                      calculateRelativeRate(
+                        analyseData?.comparison?.value?.userCount,
+                        analyseData?.comparison?.reference?.userCount
+                      )
+                    }}%
+                  </div>
+                </div>
+              </div>
+              <div
+                class="trapezoid1 ml--38.5 mt-1.5 h-full w-77 flex flex-col items-center justify-center bg-blue-5 text-3.5 text-white"
+              >
+                <span class="text-6 font-bold">{{ analyseData?.visitorCount || 0 }}</span>
+                <span>访客</span>
+              </div>
+            </div>
+            <div class="relative h-24 flex">
+              <div class="h-full w-75% flex bg-cyan-50 <lg:w-35% <xl:w-55%">
+                <div class="ml-15 h-full flex flex-col justify-center">
+                  <div class="font-bold">
+                    活跃用户数量:{{ analyseData?.comparison?.value?.activeUserCount || 0 }}
+                  </div>
+                  <div class="mt-2 text-3.5">
+                    环比增长率:{{
+                      calculateRelativeRate(
+                        analyseData?.comparison?.value?.activeUserCount,
+                        analyseData?.comparison?.reference?.activeUserCount
+                      )
+                    }}%
+                  </div>
+                </div>
+              </div>
+              <div
+                class="trapezoid2 ml--28 mt-1.7 h-25 w-56 flex flex-col items-center justify-center bg-cyan-5 text-3.5 text-white"
+              >
+                <span class="text-6 font-bold">{{ analyseData?.orderUserCount || 0 }}</span>
+                <span>下单</span>
+              </div>
+            </div>
+            <div class="relative h-24 flex">
+              <div class="w-75% flex bg-slate-50 <lg:w-35% <xl:w-55%">
+                <div class="ml-15 h-full flex flex-row gap-x-16">
+                  <div class="flex flex-col justify-center">
+                    <div class="font-bold">
+                      充值用户数量:{{ analyseData?.comparison?.value?.rechargeUserCount || 0 }}
+                    </div>
+                    <div class="mt-2 text-3.5">
+                      环比增长率:{{
+                        calculateRelativeRate(
+                          analyseData?.comparison?.value?.rechargeUserCount,
+                          analyseData?.comparison?.reference?.rechargeUserCount
+                        )
+                      }}%
+                    </div>
+                  </div>
+                  <div class="flex flex-col justify-center">
+                    <div class="font-bold">客单价:{{ fenToYuan(analyseData?.atv || 0) }}</div>
+                  </div>
+                </div>
+              </div>
+              <div
+                class="trapezoid3 ml--18 mt-3.25 h-23 w-36 flex flex-col items-center justify-center bg-slate-5 text-3.5 text-white"
+              >
+                <span class="text-6 font-bold">{{ analyseData?.payUserCount || 0 }}</span>
+                <span>成交用户</span>
+              </div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :md="6" :sm="24">
+        <el-card shadow="never" header="会员终端" v-loading="loading">
+          <Echart :height="300" :options="terminalChartOptions" />
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row :gutter="16">
+      <el-col :md="18" :sm="24">
+        <el-card shadow="never" header="会员地域分布">
+          <el-row v-loading="loading">
+            <el-col :span="10">
+              <Echart :height="300" :options="areaChartOptions" />
+            </el-col>
+            <el-col :span="14">
+              <el-table :data="areaStatisticsList" :height="300">
+                <el-table-column
+                  label="省份"
+                  prop="areaName"
+                  align="center"
+                  min-width="80"
+                  show-overflow-tooltip
+                  sortable
+                  :sort-method="(obj1, obj2) => obj1.areaName.localeCompare(obj2.areaName, 'zh-CN')"
+                />
+                <el-table-column
+                  label="会员数量"
+                  prop="userCount"
+                  align="center"
+                  min-width="105"
+                  sortable
+                />
+                <el-table-column
+                  label="订单创建数量"
+                  prop="orderCreateCount"
+                  align="center"
+                  min-width="135"
+                  sortable
+                />
+                <el-table-column
+                  label="订单支付数量"
+                  prop="orderPayCount"
+                  align="center"
+                  min-width="135"
+                  sortable
+                />
+                <el-table-column
+                  label="订单支付金额"
+                  prop="orderPayPrice"
+                  align="center"
+                  min-width="135"
+                  sortable
+                  :formatter="fenToYuanFormat"
+                />
+              </el-table>
+            </el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+      <el-col :md="6" :sm="24">
+        <el-card shadow="never" header="会员性别比例" v-loading="loading">
+          <Echart :height="300" :options="sexChartOptions" />
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+<script lang="ts" setup>
+import * as TradeMemberApi from '@/api/mall/statistics/member'
+import TradeTrendValue from '../trade/components/TradeTrendValue.vue'
+import { EChartsOption } from 'echarts'
+import china from '@/assets/map/json/china.json'
+import dayjs from 'dayjs'
+import { fenToYuan } from '@/utils'
+import * as DateUtil from '@/utils/formatTime'
+import {
+  MemberAnalyseRespVO,
+  MemberAreaStatisticsRespVO,
+  MemberSexStatisticsRespVO,
+  MemberAnalyseReqVO,
+  MemberSummaryRespVO,
+  MemberTerminalStatisticsRespVO
+} from '@/api/mall/statistics/member'
+import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict'
+import echarts from '@/plugins/echarts'
+import { fenToYuanFormat } from '@/utils/formatter'
+
+/** 会员统计 */
+defineOptions({ name: 'MemberStatistics' })
+
+const loading = ref(true) // 加载中
+const analyseLoading = ref(true) // 会员概览加载中
+const queryParams = reactive<MemberAnalyseReqVO>({ times: ['', ''] }) // 会员概览查询参数
+const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天
+const summary = ref<MemberSummaryRespVO>() // 会员统计数据
+const analyseData = ref<MemberAnalyseRespVO>() // 会员分析数据
+const areaStatisticsList = shallowRef<MemberAreaStatisticsRespVO[]>() // 省份会员统计
+
+// 注册地图
+echarts?.registerMap('china', china!)
+
+/** 日期快捷选择 */
+const shortcuts = [
+  {
+    text: '昨天',
+    value: () => DateUtil.getDayRange(new Date(), -1)
+  },
+  {
+    text: '最近7天',
+    value: () => DateUtil.getLast7Days()
+  },
+  {
+    text: '本月',
+    value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')]
+  },
+  {
+    text: '最近30天',
+    value: () => DateUtil.getLast30Days()
+  },
+  {
+    text: '最近1年',
+    value: () => DateUtil.getLast1Year()
+  }
+]
+
+/** 会员终端统计图配置 */
+const terminalChartOptions = reactive<EChartsOption>({
+  tooltip: {
+    trigger: 'item',
+    confine: true,
+    formatter: '{a} <br/>{b} : {c} ({d}%)'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'right'
+  },
+  roseType: 'area',
+  series: [
+    {
+      name: '会员终端',
+      type: 'pie',
+      label: {
+        show: false
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 会员性别统计图配置 */
+const sexChartOptions = reactive<EChartsOption>({
+  tooltip: {
+    trigger: 'item',
+    confine: true,
+    formatter: '{a} <br/>{b} : {c} ({d}%)'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'right'
+  },
+  roseType: 'area',
+  series: [
+    {
+      name: '会员性别',
+      type: 'pie',
+      label: {
+        show: false
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+const areaChartOptions = reactive<EChartsOption>({
+  tooltip: {
+    trigger: 'item',
+    formatter: (params: any) => {
+      return `${params?.data?.areaName || params?.name}<br/>
+会员数量:${params?.data?.userCount || 0}<br/>
+订单创建数量:${params?.data?.orderCreateCount || 0}<br/>
+订单支付数量:${params?.data?.orderPayCount || 0}<br/>
+订单支付金额:${fenToYuan(params?.data?.orderPayPrice || 0)}`
+    }
+  },
+  visualMap: {
+    text: ['高', '低'],
+    realtime: false,
+    calculable: true,
+    top: 'middle',
+    inRange: {
+      color: ['#fff', '#3b82f6']
+    }
+  },
+  series: [
+    {
+      name: '会员地域分布',
+      type: 'map',
+      map: 'china',
+      roam: false,
+      selectedMode: false,
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 计算环比 */
+const calculateRelativeRate = (value?: number, reference?: number) => {
+  // 防止除0
+  if (!reference) return 0
+
+  return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
+}
+
+/** 设置时间范围 */
+function setTimes() {
+  const beginDate = dayjs().subtract(shortcutDays.value, 'd')
+  const yesterday = dayjs().subtract(1, 'd')
+  queryParams.times = DateUtil.getDateRange(beginDate, yesterday)
+}
+
+/** 处理会员概览查询(日期单选按钮组选择后) */
+const handleDateTypeChange = async () => {
+  // 设置时间范围
+  setTimes()
+  // 查询数据
+  await getMemberAnalyse()
+}
+
+/** 查询会员统计 */
+const getMemberSummary = async () => {
+  summary.value = await TradeMemberApi.getMemberSummary()
+}
+
+/** 按照省份,查询会员统计列表 */
+const getMemberAreaStatisticsList = async () => {
+  const list = await TradeMemberApi.getMemberAreaStatisticsList()
+  areaStatisticsList.value = list.map((item: MemberAreaStatisticsRespVO) => {
+    return {
+      ...item,
+      areaName: item.areaName
+        .replace('维吾尔自治区', '')
+        .replace('壮族自治区', '')
+        .replace('回族自治区', '')
+        .replace('自治区', '')
+        .replace('省', '')
+    }
+  })
+  let min = 0
+  let max = 0
+  areaChartOptions.series[0].data = areaStatisticsList.value.map((item) => {
+    min = Math.min(min, item.orderPayCount)
+    max = Math.max(max, item.orderPayCount)
+    return { ...item, name: item.areaName, value: item.orderPayCount || 0 }
+  })
+  areaChartOptions.visualMap.min = min
+  areaChartOptions.visualMap.max = max
+}
+
+/** 按照性别,查询会员统计列表 */
+const getMemberSexStatisticsList = async () => {
+  const list = await TradeMemberApi.getMemberSexStatisticsList()
+  const dictDataList = getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)
+  sexChartOptions.series[0].data = dictDataList.map((dictData: DictDataType) => {
+    const userCount = list.find((item: MemberSexStatisticsRespVO) => item.sex === dictData.value)
+      ?.userCount
+    return {
+      name: dictData.label,
+      value: userCount || 0
+    }
+  })
+}
+
+/** 按照终端,查询会员统计列表 */
+const getMemberTerminalStatisticsList = async () => {
+  const list = await TradeMemberApi.getMemberTerminalStatisticsList()
+  const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL)
+  terminalChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => {
+    const userCount = list.find(
+      (item: MemberTerminalStatisticsRespVO) => item.terminal === dictData.value
+    )?.userCount
+    return {
+      name: dictData.label,
+      value: userCount || 0
+    }
+  })
+}
+
+/** 查询会员概览数据列表 */
+const getMemberAnalyse = async () => {
+  analyseLoading.value = true
+  const times = queryParams.times
+  // 开始与截止在同一天的, 环比出不来, 需要延长一天
+  if (DateUtil.isSameDay(times[0], times[1])) {
+    // 前天
+    times[0] = DateUtil.formatDate(dayjs(times[0]).subtract(1, 'd'))
+  }
+  // 查询数据
+  analyseData.value = await TradeMemberApi.getMemberAnalyse({ times })
+  analyseLoading.value = false
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  loading.value = true
+  await Promise.all([
+    getMemberSummary(),
+    getMemberTerminalStatisticsList(),
+    getMemberAreaStatisticsList(),
+    getMemberSexStatisticsList(),
+    handleDateTypeChange()
+  ])
+  loading.value = false
+})
+</script>
+<style lang="scss" scoped>
+.summary {
+  .el-col {
+    margin-bottom: 1rem;
+  }
+}
+.trapezoid1 {
+  transform: perspective(5em) rotateX(-11deg);
+}
+.trapezoid2 {
+  transform: perspective(7em) rotateX(-20deg);
+}
+.trapezoid3 {
+  transform: perspective(3em) rotateX(-13deg);
+}
+</style>

+ 1 - 6
src/views/statistics/trade/components/TradeStatisticValue.vue → src/views/mall/statistics/trade/components/TradeStatisticValue.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="bg flex flex-col gap-2 p-6">
+  <div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6">
     <div class="flex items-center justify-between text-gray-500">
       <span>{{ title }}</span>
       <el-tooltip :content="tooltip" placement="top-start" v-if="tooltip">
@@ -34,8 +34,3 @@ defineProps({
   percent: propTypes.oneOfType([Number, String]).def(0)
 })
 </script>
-<style scoped>
-.bg {
-  background-color: var(--el-bg-color-overlay);
-}
-</style>

+ 7 - 9
src/views/statistics/trade/components/TradeTrendValue.vue → src/views/mall/statistics/trade/components/TradeTrendValue.vue

@@ -1,12 +1,12 @@
 <template>
-  <div class="mb-8 flex flex-row items-center gap-3">
+  <div class="flex flex-row items-center gap-3 rounded bg-[var(--el-bg-color-overlay)] p-4">
     <div
       class="h-12 w-12 flex flex-shrink-0 items-center justify-center rounded-1"
       :class="`${iconColor} ${iconBgColor}`"
     >
       <Icon :icon="icon" class="!text-6" />
     </div>
-    <div class="bg flex flex-col gap-1">
+    <div class="flex flex-col gap-1">
       <div class="flex items-center gap-1 text-gray-500">
         <span class="text-3.5">{{ title }}</span>
         <el-tooltip :content="tooltip" placement="top-start" v-if="tooltip">
@@ -17,7 +17,10 @@
         <div class="text-7">
           <CountTo :prefix="prefix" :end-val="value" :decimals="decimals" />
         </div>
-        <span :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'">
+        <span
+          v-if="percent != undefined"
+          :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'"
+        >
           <span class="text-sm">{{ Math.abs(toNumber(percent)) }}%</span>
           <Icon
             :icon="toNumber(percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'"
@@ -44,11 +47,6 @@ defineProps({
   prefix: propTypes.string.def(''),
   value: propTypes.number.def(0),
   decimals: propTypes.number.def(0),
-  percent: propTypes.oneOfType([Number, String]).def(0)
+  percent: propTypes.oneOfType([Number, String]).def(undefined)
 })
 </script>
-<style scoped>
-.bg {
-  background-color: var(--el-bg-color-overlay);
-}
-</style>

+ 3 - 3
src/views/statistics/trade/index.vue → src/views/mall/statistics/trade/index.vue

@@ -218,7 +218,7 @@
           />
         </el-col>
       </el-row>
-      <!-- 线图 -->
+      <!-- 线图 -->
       <el-skeleton :loading="trendLoading" animated>
         <Echart :height="500" :options="lineChartOptions" />
       </el-skeleton>
@@ -226,7 +226,7 @@
   </div>
 </template>
 <script lang="ts" setup>
-import * as TradeStatisticsApi from '@/api/statistics/trade'
+import * as TradeStatisticsApi from '@/api/mall/statistics/trade'
 import TradeStatisticValue from './components/TradeStatisticValue.vue'
 import TradeTrendValue from './components/TradeTrendValue.vue'
 import { EChartsOption } from 'echarts'
@@ -235,7 +235,7 @@ import {
   TradeSummaryRespVO,
   TradeTrendReqVO,
   TradeTrendSummaryRespVO
-} from '@/api/statistics/trade'
+} from '@/api/mall/statistics/trade'
 import dayjs from 'dayjs'
 import { fenToYuan } from '@/utils'
 import * as DateUtil from '@/utils/formatTime'

Some files were not shown because too many files changed in this diff