Browse Source

!426 CRM: 完善销售漏斗分析
Merge pull request !426 from puhui999/dev-crm

芋道源码 1 year ago
parent
commit
56829ff921

+ 0 - 0
.env.local → .env.local-dev


+ 3 - 3
package.json

@@ -6,11 +6,11 @@
   "private": false,
   "scripts": {
     "i": "pnpm install",
-    "dev": "vite --mode local-dev",
+    "local-server": "vite --mode local-dev",
     "dev-server": "vite --mode dev",
     "ts:check": "vue-tsc --noEmit",
-    "build:local-dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev",
-    "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev",
+    "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev",
+    "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
     "build:test": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode test",
     "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage",
     "build:prod": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode prod",

+ 45 - 0
src/api/crm/statistics/funnel.ts

@@ -0,0 +1,45 @@
+import request from '@/config/axios'
+
+export interface CrmStatisticFunnelRespVO {
+  customerCount: number // 客户数
+  businessCount: number // 商机数
+  winCount: number // 赢单数
+}
+
+export interface CrmStatisticsBusinessSummaryByDateRespVO {
+  time: string // 时间
+  businessCreateCount: number // 商机数
+  businessDealCount: number // 商机金额
+}
+
+// 客户分析 API
+export const StatisticFunnelApi = {
+  // 1. 获取销售漏斗统计数据
+  getFunnelSummary: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-funnel/get-funnel-summary',
+      params
+    })
+  },
+  // 2. 获取商机结束状态统计
+  getBusinessEndStatusSummary: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-funnel/get-business-end-status-summary',
+      params
+    })
+  },
+  // 3. 获取新增商机分析(按日期)
+  getBusinessSummaryByDate: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-funnel/get-business-summary-by-date',
+      params
+    })
+  },
+  // 4. 获取商机列表(按日期)
+  getBusinessPageByDate: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-funnel/get-business-page-by-date',
+      params
+    })
+  }
+}

+ 0 - 1
src/components/DictSelect/src/DictSelect.vue

@@ -33,7 +33,6 @@ import { getBoolDictOptions, getIntDictOptions, getStrDictOptions } from '@/util
 
 // 接受父组件参数
 interface Props {
-  modelValue?: any // 值
   dictType: string // 字典类型
   valueType: string // 字典值类型
 }

+ 7 - 6
src/utils/dict.ts

@@ -197,14 +197,15 @@ export enum DICT_TYPE {
   // ========== CRM - 客户管理模块 ==========
   CRM_AUDIT_STATUS = 'crm_audit_status', // CRM 审批状态
   CRM_BIZ_TYPE = 'crm_biz_type', // CRM 业务类型
+  CRM_BUSINESS_END_STATUS_TYPE = 'crm_business_end_status_type', // CRM 商机结束状态类型
   CRM_RECEIVABLE_RETURN_TYPE = 'crm_receivable_return_type', // CRM 回款的还款方式
-  CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry',
-  CRM_CUSTOMER_LEVEL = 'crm_customer_level',
-  CRM_CUSTOMER_SOURCE = 'crm_customer_source',
-  CRM_PRODUCT_STATUS = 'crm_product_status',
+  CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry', // CRM 客户所属行业
+  CRM_CUSTOMER_LEVEL = 'crm_customer_level', // CRM 客户级别
+  CRM_CUSTOMER_SOURCE = 'crm_customer_source', // CRM 客户来源
+  CRM_PRODUCT_STATUS = 'crm_product_status', // CRM 商品状态
   CRM_PERMISSION_LEVEL = 'crm_permission_level', // CRM 数据权限的级别
-  CRM_PRODUCT_UNIT = 'crm_product_unit', // 产品单位
-  CRM_FOLLOW_UP_TYPE = 'crm_follow_up_type', // 跟进方式
+  CRM_PRODUCT_UNIT = 'crm_product_unit', // CRM 产品单位
+  CRM_FOLLOW_UP_TYPE = 'crm_follow_up_type', // CRM 跟进方式
 
   // ========== ERP - 企业资源计划模块  ==========
   ERP_AUDIT_STATUS = 'erp_audit_status', // ERP 审批状态

+ 41 - 33
src/views/crm/business/index.vue

@@ -5,35 +5,43 @@
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
-      class="-mb-15px"
-      :model="queryParams"
       ref="queryFormRef"
       :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
       label-width="68px"
     >
       <el-form-item label="商机名称" prop="name">
         <el-input
           v-model="queryParams.name"
-          placeholder="请输入商机名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入商机名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
-        <el-button type="primary" @click="openForm('create')" v-hasPermi="['crm:business:create']">
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button v-hasPermi="['crm:business:create']" type="primary" @click="openForm('create')">
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
         <el-button
-          type="success"
+          v-hasPermi="['crm:business:export']"
+          :loading="exportLoading"
           plain
+          type="success"
           @click="handleExport"
-          :loading="exportLoading"
-          v-hasPermi="['crm:business:export']"
         >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
         </el-button>
       </el-form-item>
     </el-form>
@@ -46,8 +54,8 @@
       <el-tab-pane label="我参与的" name="2" />
       <el-tab-pane label="下属负责的" name="3" />
     </el-tabs>
-    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column align="center" label="商机名称" fixed="left" prop="name" width="160">
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160">
         <template #default="scope">
           <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
             {{ scope.row.name }}
@@ -66,17 +74,17 @@
         </template>
       </el-table-column>
       <el-table-column
-        label="商机金额(元)"
+        :formatter="erpPriceTableColumnFormatter"
         align="center"
+        label="商机金额(元)"
         prop="totalPrice"
         width="140"
-        :formatter="erpPriceTableColumnFormatter"
       />
       <el-table-column
-        label="预计成交日期"
+        :formatter="dateFormatter"
         align="center"
+        label="预计成交日期"
         prop="dealTime"
-        :formatter="dateFormatter"
         width="180px"
       />
       <el-table-column align="center" label="备注" prop="remark" width="200" />
@@ -97,49 +105,49 @@
         width="180px"
       />
       <el-table-column
-        label="更新时间"
+        :formatter="dateFormatter"
         align="center"
+        label="更新时间"
         prop="updateTime"
-        :formatter="dateFormatter"
         width="180px"
       />
       <el-table-column
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
-        :formatter="dateFormatter"
         width="180px"
       />
       <el-table-column align="center" label="创建人" prop="creatorName" width="100px" />
       <el-table-column
-        label="商机状态组"
         align="center"
-        prop="statusTypeName"
         fixed="right"
+        label="商机状态组"
+        prop="statusTypeName"
         width="140"
       />
       <el-table-column
-        label="商机阶段"
         align="center"
-        prop="statusName"
         fixed="right"
+        label="商机阶段"
+        prop="statusName"
         width="120"
       />
-      <el-table-column label="操作" align="center" fixed="right" width="130px">
+      <el-table-column align="center" fixed="right" label="操作" width="130px">
         <template #default="scope">
           <el-button
+            v-hasPermi="['crm:business:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['crm:business:update']"
           >
             编辑
           </el-button>
           <el-button
+            v-hasPermi="['crm:business:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['crm:business:delete']"
           >
             删除
           </el-button>
@@ -148,9 +156,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -159,7 +167,7 @@
   <BusinessForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts">
+<script lang="ts" setup>
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
 import * as BusinessApi from '@/api/crm/business'
@@ -216,7 +224,7 @@ const handleTabClick = (tab: TabsPaneContext) => {
 }
 
 /** 打开客户详情 */
-const { currentRoute, push } = useRouter()
+const { push } = useRouter()
 const openDetail = (id: number) => {
   push({ name: 'CrmBusinessDetail', params: { id } })
 }

+ 259 - 0
src/views/crm/statistics/funnel/components/BusinessSummary.vue

@@ -0,0 +1,259 @@
+<!-- 客户总量统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 -->
+  <el-card class="mt-16px" shadow="never">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column align="center" fixed="left" label="序号" type="index" width="80" />
+      <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160">
+        <template #default="scope">
+          <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openCustomerDetail(scope.row.customerId)"
+          >
+            {{ scope.row.customerName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="erpPriceTableColumnFormatter"
+        align="center"
+        label="商机金额(元)"
+        prop="totalPrice"
+        width="140"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="预计成交日期"
+        prop="dealTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="contactNextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" />
+      <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="更新时间"
+        prop="updateTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="创建人" prop="creatorName" width="100px" />
+      <el-table-column
+        align="center"
+        fixed="right"
+        label="商机状态组"
+        prop="statusTypeName"
+        width="140"
+      />
+      <el-table-column
+        align="center"
+        fixed="right"
+        label="商机阶段"
+        prop="statusName"
+        width="120"
+      />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams0.pageSize"
+      v-model:page="queryParams0.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticsBusinessSummaryByDateRespVO,
+  StatisticFunnelApi
+} from '@/api/crm/statistics/funnel'
+import { EChartsOption } from 'echarts'
+import { erpPriceTableColumnFormatter } from '@/utils'
+import { dateFormatter } from '@/utils/formatTime'
+
+defineOptions({ name: 'BusinessSummary' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+const queryParams0 = reactive({
+  pageNo: 1,
+  pageSize: 10
+})
+const loading = ref(false) // 加载中
+const list = ref([]) // 列表的数据
+const total = ref(0)
+/** 将传进来的值赋值给 formData */
+watch(
+  () => props.queryParams,
+  (data) => {
+    if (!data) {
+      return
+    }
+    const newObj = { ...queryParams0, ...data }
+    Object.assign(queryParams0, newObj)
+  },
+  {
+    immediate: true
+  }
+)
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 30,
+    right: 30, // 让 X 轴右侧显示完整
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '新增商机数量',
+      type: 'bar',
+      yAxisIndex: 0,
+      data: []
+    },
+    {
+      name: '新增商机金额',
+      type: 'bar',
+      yAxisIndex: 1,
+      data: []
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        xAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  yAxis: [
+    {
+      type: 'value',
+      name: '新增商机数量',
+      min: 0,
+      minInterval: 1 // 显示整数刻度
+    },
+    {
+      type: 'value',
+      name: '新增商机金额',
+      min: 0,
+      minInterval: 1, // 显示整数刻度
+      splitLine: {
+        lineStyle: {
+          type: 'dotted', // 右侧网格线虚化, 减少混乱
+          opacity: 0.7
+        }
+      }
+    }
+  ],
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
+  // 1. 加载统计数据
+  const businessSummaryByDate = await StatisticFunnelApi.getBusinessSummaryByDate(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessSummaryByDateRespVO) => s.time
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessSummaryByDateRespVO) => s.businessCreateCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessSummaryByDateRespVO) => s.businessDealCount
+    )
+  }
+
+  // 2.2 更新列表数据
+  await getList()
+}
+/** 获取商机列表 */
+const getList = async () => {
+  const data = await StatisticFunnelApi.getBusinessPageByDate(props.queryParams)
+  list.value = data.list
+  total.value = data.total
+}
+/** 打开客户详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmBusinessDetail', params: { id } })
+}
+
+/** 打开客户详情 */
+const openCustomerDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 135 - 0
src/views/crm/statistics/funnel/components/FunnelBusiness.vue

@@ -0,0 +1,135 @@
+<!-- 销售漏斗分析 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-row>
+      <el-col :span="24">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption" />
+        </el-skeleton>
+      </el-col>
+    </el-row>
+  </el-card>
+
+  <!-- 统计列表 -->
+  <el-card class="mt-16px" shadow="never">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column align="center" label="序号" type="index" width="80" />
+      <el-table-column align="center" label="阶段" prop="endStatus" width="200">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE" :value="scope.row.endStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="商机数" min-width="200" prop="businessCount" />
+      <el-table-column align="center" label="商机总金额(元)" min-width="200" prop="totalPrice" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import { CrmStatisticFunnelRespVO, StatisticFunnelApi } from '@/api/crm/statistics/funnel'
+import { EChartsOption } from 'echarts'
+import { DICT_TYPE } from '@/utils/dict'
+import echarts from '@/plugins/echarts'
+import { FunnelChart } from 'echarts/charts'
+
+echarts?.use([FunnelChart])
+defineOptions({ name: 'FunnelBusiness' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticFunnelRespVO[]>([]) // 列表的数据
+
+/** 销售漏斗 */
+const echartsOption = reactive<EChartsOption>({
+  title: {
+    text: '销售漏斗'
+  },
+  tooltip: {
+    trigger: 'item',
+    formatter: '{a} <br/>{b}'
+  },
+  toolbox: {
+    feature: {
+      dataView: { readOnly: false },
+      restore: {},
+      saveAsImage: {}
+    }
+  },
+  legend: {
+    data: ['客户', '商机', '赢单']
+  },
+  series: [
+    {
+      name: '销售漏斗',
+      type: 'funnel',
+      left: '10%',
+      top: 60,
+      bottom: 60,
+      width: '80%',
+      min: 0,
+      max: 100,
+      minSize: '0%',
+      maxSize: '100%',
+      sort: 'descending',
+      gap: 2,
+      label: {
+        show: true,
+        position: 'inside'
+      },
+      labelLine: {
+        length: 10,
+        lineStyle: {
+          width: 1,
+          type: 'solid'
+        }
+      },
+      itemStyle: {
+        borderColor: '#fff',
+        borderWidth: 1
+      },
+      emphasis: {
+        label: {
+          fontSize: 20
+        }
+      },
+      data: [
+        { value: 60, name: '客户-0个' },
+        { value: 40, name: '商机-0个' },
+        { value: 20, name: '赢单-0个' }
+      ]
+    }
+  ]
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  // 1. 加载漏斗数据
+  const data = (await StatisticFunnelApi.getFunnelSummary(
+    props.queryParams
+  )) as CrmStatisticFunnelRespVO
+  // 2.1 更新 Echarts 数据
+  if (
+    !!data &&
+    echartsOption.series &&
+    echartsOption.series[0] &&
+    echartsOption.series[0]['data']
+  ) {
+    // tips:写死 value 值是为了保持漏斗顺序不变
+    const list: { value: number; name: string }[] = []
+    list.push({ value: 60, name: `客户-${data.customerCount || 0}个` })
+    list.push({ value: 40, name: `商机-${data.businessCount || 0}个` })
+    list.push({ value: 20, name: `赢单-${data.winCount || 0}个` })
+    echartsOption.series[0]['data'] = list
+  }
+  // 2.2 获取商机结束状态统计
+  list.value = await StatisticFunnelApi.getBusinessEndStatusSummary(props.queryParams)
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 165 - 0
src/views/crm/statistics/funnel/index.vue

@@ -0,0 +1,165 @@
+<!-- 数据统计 - 客户画像 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="时间范围" prop="orderDate">
+        <el-date-picker
+          v-model="queryParams.times"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          :shortcuts="defaultShortcuts"
+          class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          @change="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="时间间隔" prop="interval">
+        <el-select
+          v-model="queryParams.interval"
+          class="!w-240px"
+          placeholder="间隔类型"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.DATE_INTERVAL)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="归属部门" prop="deptId">
+        <el-tree-select
+          v-model="queryParams.deptId"
+          :data="deptList"
+          :props="defaultProps"
+          check-strictly
+          class="!w-240px"
+          node-key="id"
+          placeholder="请选择归属部门"
+          @change="(queryParams.userId = undefined), handleQuery()"
+        />
+      </el-form-item>
+      <el-form-item label="员工" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          class="!w-240px"
+          clearable
+          placeholder="员工"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(user, index) in userListByDeptId"
+            :key="index"
+            :label="user.nickname"
+            :value="user.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          查询
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 客户统计 -->
+  <el-col>
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="销售漏斗分析" lazy name="funnelRef">
+        <FunnelBusiness ref="funnelRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <el-tab-pane label="新增商机分析" lazy name="businessSummaryRef">
+        <BusinessSummary ref="businessSummaryRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <el-tab-pane label="商机转化率分析" lazy name="sourceRef" />
+    </el-tabs>
+  </el-col>
+</template>
+
+<script lang="ts" setup>
+import * as DeptApi from '@/api/system/dept'
+import * as UserApi from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime'
+import { defaultProps, handleTree } from '@/utils/tree'
+import FunnelBusiness from './components/FunnelBusiness.vue'
+import BusinessSummary from './components/BusinessSummary.vue'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+defineOptions({ name: 'CrmStatisticsFunnel' })
+
+const queryParams = reactive({
+  interval: 2, // WEEK, 周
+  deptId: useUserStore().getUser.deptId,
+  userId: undefined,
+  times: [
+    // 默认显示最近一周的数据
+    formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
+    formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24)))
+  ]
+})
+
+const queryFormRef = ref() // 搜索的表单
+const deptList = ref<Tree[]>([]) // 部门树形结构
+const userList = ref<UserApi.UserVO[]>([]) // 全量用户清单
+
+/** 根据选择的部门筛选员工清单 */
+const userListByDeptId = computed(() =>
+  queryParams.deptId
+    ? userList.value.filter((u: UserApi.UserVO) => u.deptId === queryParams.deptId)
+    : []
+)
+
+const activeTab = ref('funnelRef') // 活跃标签
+const funnelRef = ref() // 销售漏斗
+const businessSummaryRef = ref() // 新增商机分析
+const sourceRef = ref() // 客户来源
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  switch (activeTab.value) {
+    case 'funnelRef':
+      funnelRef.value?.loadData?.()
+      break
+    case 'businessSummaryRef':
+      businessSummaryRef.value?.loadData?.()
+      break
+    case 'sourceRef':
+      sourceRef.value?.loadData?.()
+      break
+  }
+}
+
+/** 当 activeTab 改变时,刷新当前活动的 tab */
+watch(activeTab, () => {
+  handleQuery()
+})
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 初始化 */
+onMounted(async () => {
+  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
+  userList.value = handleTree(await UserApi.getSimpleUserList())
+})
+</script>

+ 11 - 13
src/views/crm/statistics/portrait/components/CustomerAddress.vue → src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue

@@ -25,8 +25,7 @@ import {
   StatisticsPortraitApi
 } from '@/api/crm/statistics/portrait'
 
-// TODO @puhui999:address 换成 area 会更合适哈,
-defineOptions({ name: 'CustomerAddress' })
+defineOptions({ name: 'PortraitCustomerArea' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 // 注册地图
@@ -107,22 +106,21 @@ const loadData = async () => {
   areaStatisticsList.value = areaList.map((item: CrmStatisticCustomerAreaRespVO) => {
     return {
       ...item,
-      areaName: item.areaName // TODO @puhui999:这里最好注释下原因哈
-        .replace('维吾尔自治区', '')
-        .replace('壮族自治区', '')
-        .replace('回族自治区', '')
-        .replace('自治区', '')
-        .replace('省', '')
+      areaName: item.areaName // TODO @puhui999:这里最好注释下原因哈, 🤣 我从 mall copy 过来的
+      // .replace('维吾尔自治区', '')
+      // .replace('壮族自治区', '')
+      // .replace('回族自治区', '')
+      // .replace('自治区', '')
+      // .replace('省', '')
     }
   })
-  builderLeftMap()
-  builderRightMap()
+  buildLeftMap()
+  buildRightMap()
   loading.value = false
 }
 defineExpose({ loadData })
 
-// TODO @puhui999:builder 改成 build 更合理哈
-const builderLeftMap = () => {
+const buildLeftMap = () => {
   let min = 0
   let max = 0
   echartsOption.series![0].data = areaStatisticsList.value.map((item) => {
@@ -134,7 +132,7 @@ const builderLeftMap = () => {
   echartsOption.visualMap!['max'] = max
 }
 
-const builderRightMap = () => {
+const buildRightMap = () => {
   let min = 0
   let max = 0
   echartsOption2.series![0].data = areaStatisticsList.value.map((item) => {

+ 5 - 4
src/views/crm/statistics/portrait/components/CustomerIndustry.vue → src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue

@@ -39,10 +39,10 @@ import {
 } from '@/api/crm/statistics/portrait'
 import { EChartsOption } from 'echarts'
 import { DICT_TYPE, getDictLabel } from '@/utils/dict'
-import { getSumValue } from '@/utils'
+import { erpCalculatePercentage, getSumValue } from '@/utils'
 import { isEmpty } from '@/utils/is'
 
-defineOptions({ name: 'CustomerIndustry' })
+defineOptions({ name: 'PortraitCustomerIndustry' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
@@ -185,8 +185,9 @@ const calculateProportion = (sourceList: CrmStatisticCustomerIndustryRespVO[]) =
   const sumDealCount = getSumValue(list.map((item) => item.dealCount))
   list.forEach((item) => {
     item.industryPortion =
-      item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2)
-    item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2)
+      item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount)
+    item.dealPortion =
+      item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount)
   })
 }
 

+ 5 - 5
src/views/crm/statistics/portrait/components/CustomerLevel.vue → src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue

@@ -39,10 +39,10 @@ import {
 } from '@/api/crm/statistics/portrait'
 import { EChartsOption } from 'echarts'
 import { DICT_TYPE, getDictLabel } from '@/utils/dict'
-import { getSumValue } from '@/utils'
+import { erpCalculatePercentage, getSumValue } from '@/utils'
 import { isEmpty } from '@/utils/is'
 
-defineOptions({ name: 'CustomerSource' })
+defineOptions({ name: 'PortraitCustomerLevel' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
@@ -184,10 +184,10 @@ const calculateProportion = (levelList: CrmStatisticCustomerLevelRespVO[]) => {
   const sumCustomerCount = getSumValue(list.map((item) => item.customerCount))
   const sumDealCount = getSumValue(list.map((item) => item.dealCount))
   list.forEach((item) => {
-    // TODO @puhui999:可以使用 erpCalculatePercentage 方法
     item.levelPortion =
-      item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2)
-    item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2)
+      item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount)
+    item.dealPortion =
+      item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount)
   })
 }
 

+ 5 - 4
src/views/crm/statistics/portrait/components/CustomerSource.vue → src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue

@@ -40,9 +40,9 @@ import {
 import { EChartsOption } from 'echarts'
 import { DICT_TYPE, getDictLabel } from '@/utils/dict'
 import { isEmpty } from '@/utils/is'
-import { getSumValue } from '@/utils'
+import { erpCalculatePercentage, getSumValue } from '@/utils'
 
-defineOptions({ name: 'CustomerSource' })
+defineOptions({ name: 'PortraitCustomerSource' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
@@ -185,8 +185,9 @@ const calculateProportion = (sourceList: CrmStatisticCustomerSourceRespVO[]) =>
   const sumDealCount = getSumValue(list.map((item) => item.dealCount))
   list.forEach((item) => {
     item.sourcePortion =
-      item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2)
-    item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2)
+      item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount)
+    item.dealPortion =
+      item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount)
   })
 }
 

+ 13 - 14
src/views/crm/statistics/portrait/index.vue

@@ -60,20 +60,20 @@
   <el-col>
     <el-tabs v-model="activeTab">
       <!-- 城市分布分析 -->
-      <el-tab-pane label="城市分布分析" lazy name="addressRef">
-        <CustomerAddress ref="addressRef" :query-params="queryParams" />
+      <el-tab-pane label="城市分布分析" lazy name="areaRef">
+        <PortraitCustomerArea ref="areaRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 客户级别分析 -->
       <el-tab-pane label="客户级别分析" lazy name="levelRef">
-        <CustomerLevel ref="levelRef" :query-params="queryParams" />
+        <PortraitCustomerLevel ref="levelRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 客户来源分析 -->
       <el-tab-pane label="客户来源分析" lazy name="sourceRef">
-        <CustomerSource ref="sourceRef" :query-params="queryParams" />
+        <PortraitCustomerSource ref="sourceRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 客户行业分析 -->
       <el-tab-pane label="客户行业分析" lazy name="industryRef">
-        <CustomerIndustry ref="industryRef" :query-params="queryParams" />
+        <PortraitCustomerIndustry ref="industryRef" :query-params="queryParams" />
       </el-tab-pane>
     </el-tabs>
   </el-col>
@@ -85,11 +85,10 @@ import * as UserApi from '@/api/system/user'
 import { useUserStore } from '@/store/modules/user'
 import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime'
 import { defaultProps, handleTree } from '@/utils/tree'
-// TODO @puhui999:最好命名带上模块名,如:CrmStatisticsPortrait
-import CustomerAddress from './components/CustomerAddress.vue'
-import CustomerIndustry from './components/CustomerIndustry.vue'
-import CustomerSource from './components/CustomerSource.vue'
-import CustomerLevel from './components/CustomerLevel.vue'
+import PortraitCustomerArea from './components/PortraitCustomerArea.vue'
+import PortraitCustomerIndustry from './components/PortraitCustomerIndustry.vue'
+import PortraitCustomerSource from './components/PortraitCustomerSource.vue'
+import PortraitCustomerLevel from './components/PortraitCustomerLevel.vue'
 
 defineOptions({ name: 'CrmStatisticsPortrait' })
 
@@ -114,8 +113,8 @@ const userListByDeptId = computed(() =>
     : []
 )
 
-const activeTab = ref('addressRef') // 活跃标签
-const addressRef = ref() // 客户地区分布
+const activeTab = ref('areaRef') // 活跃标签
+const areaRef = ref() // 客户地区分布
 const levelRef = ref() // 客户级别
 const sourceRef = ref() // 客户来源
 const industryRef = ref() // 客户行业
@@ -123,8 +122,8 @@ const industryRef = ref() // 客户行业
 /** 搜索按钮操作 */
 const handleQuery = () => {
   switch (activeTab.value) {
-    case 'addressRef':
-      addressRef.value?.loadData?.()
+    case 'areaRef':
+      areaRef.value?.loadData?.()
       break
     case 'levelRef':
       levelRef.value?.loadData?.()