Forráskód Böngészése

!415 CRM: 完善用户画像数据统计
Merge pull request !415 from puhui999/dev-crm

芋道源码 1 éve
szülő
commit
8926dd1173

+ 0 - 59
src/api/crm/statistics/customer.ts

@@ -49,36 +49,6 @@ export interface CrmStatisticsCustomerDealCycleByDateRespVO {
   customerDealCycle: number
 }
 
-export interface CrmStatisticCustomerBaseRespVO {
-  customerCount: number
-  dealCount: number
-  dealPortion: number
-}
-
-export interface CrmStatisticCustomerIndustryRespVO extends CrmStatisticCustomerBaseRespVO {
-  industryId: number
-  industryName: string
-  industryPortion: number
-}
-
-export interface CrmStatisticCustomerSourceRespVO extends CrmStatisticCustomerBaseRespVO {
-  source: number
-  sourceName: string
-  sourcePortion: number
-}
-
-export interface CrmStatisticCustomerLevelRespVO extends CrmStatisticCustomerBaseRespVO {
-  level: number
-  levelName: string
-  levelPortion: number
-}
-
-export interface CrmStatisticCustomerAreaRespVO extends CrmStatisticCustomerBaseRespVO {
-  areaId: number
-  areaName: string
-  areaPortion: number
-}
-
 export interface CrmStatisticsCustomerDealCycleByUserRespVO {
   ownerUserName: string
   customerDealCycle: number
@@ -142,34 +112,5 @@ export const StatisticsCustomerApi = {
       url: '/crm/statistics-customer/get-customer-deal-cycle-by-user',
       params
     })
-  },
-  // TODO @puhui999:下面这些拆出去哈;
-  // 6.1 获取客户行业统计数据
-  getCustomerIndustry: (params: any) => {
-    return request.get({
-      url: '/crm/statistics-portrait/get-customer-industry-summary',
-      params
-    })
-  },
-  // 6.1 获取客户来源统计数据
-  getCustomerSource: (params: any) => {
-    return request.get({
-      url: '/crm/statistics-portrait/get-customer-source-summary',
-      params
-    })
-  },
-  // 6.1 获取客户行业统计数据
-  getCustomerLevel: (params: any) => {
-    return request.get({
-      url: '/crm/statistics-portrait/get-customer-level-summary',
-      params
-    })
-  },
-  // 6.1 获取客户行业统计数据
-  getCustomerArea: (params: any) => {
-    return request.get({
-      url: '/crm/statistics-portrait/get-customer-area-summary',
-      params
-    })
   }
 }

+ 60 - 0
src/api/crm/statistics/portrait.ts

@@ -0,0 +1,60 @@
+import request from '@/config/axios'
+
+export interface CrmStatisticCustomerBaseRespVO {
+  customerCount: number
+  dealCount: number
+  dealPortion: string | number
+}
+
+export interface CrmStatisticCustomerIndustryRespVO extends CrmStatisticCustomerBaseRespVO {
+  industryId: number
+  industryPortion: string | number
+}
+
+export interface CrmStatisticCustomerSourceRespVO extends CrmStatisticCustomerBaseRespVO {
+  source: number
+  sourcePortion: string | number
+}
+
+export interface CrmStatisticCustomerLevelRespVO extends CrmStatisticCustomerBaseRespVO {
+  level: number
+  levelPortion: string | number
+}
+
+export interface CrmStatisticCustomerAreaRespVO extends CrmStatisticCustomerBaseRespVO {
+  areaId: number
+  areaName: string
+  areaPortion: string | number
+}
+
+// 客户分析 API
+export const StatisticsPortraitApi = {
+  // 1. 获取客户行业统计数据
+  getCustomerIndustry: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-portrait/get-customer-industry-summary',
+      params
+    })
+  },
+  // 2. 获取客户来源统计数据
+  getCustomerSource: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-portrait/get-customer-source-summary',
+      params
+    })
+  },
+  // 3. 获取客户级别统计数据
+  getCustomerLevel: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-portrait/get-customer-level-summary',
+      params
+    })
+  },
+  // 4. 获取客户地区统计数据
+  getCustomerArea: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-portrait/get-customer-area-summary',
+      params
+    })
+  }
+}

+ 1 - 1
src/utils/dict.ts

@@ -137,7 +137,7 @@ export enum DICT_TYPE {
   INFRA_FILE_STORAGE = 'infra_file_storage',
 
   // ========== BPM 模块 ==========
-  BPM_MODEL_FORM_TYPE = 'bpm_model_category',
+  BPM_MODEL_FORM_TYPE = 'bpm_model_form_type',
   BPM_TASK_CANDIDATE_STRATEGY = 'bpm_task_candidate_strategy',
   BPM_PROCESS_INSTANCE_STATUS = 'bpm_process_instance_status',
   BPM_TASK_STATUS = 'bpm_task_status',

+ 22 - 56
src/views/crm/statistics/customer/index.vue

@@ -3,22 +3,22 @@
   <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="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"
-          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
         />
       </el-form-item>
       <el-form-item label="时间间隔" prop="interval">
@@ -34,28 +34,34 @@
       <el-form-item label="归属部门" prop="deptId">
         <el-tree-select
           v-model="queryParams.deptId"
-          class="!w-240px"
           :data="deptList"
           :props="defaultProps"
           check-strictly
+          class="!w-240px"
           node-key="id"
           placeholder="请选择归属部门"
           @change="queryParams.userId = undefined"
         />
       </el-form-item>
       <el-form-item label="员工" prop="userId">
-        <el-select v-model="queryParams.userId" class="!w-240px" placeholder="员工" clearable>
+        <el-select v-model="queryParams.userId" class="!w-240px" clearable placeholder="员工">
           <el-option
             v-for="(user, index) in userListByDeptId"
+            :key="index"
             :label="user.nickname"
             :value="user.id"
-            :key="index"
           />
         </el-select>
       </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 @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>
@@ -64,41 +70,25 @@
   <el-col>
     <el-tabs v-model="activeTab">
       <!-- 客户总量分析 -->
-      <el-tab-pane label="客户总量分析" name="customerSummary" lazy>
-        <CustomerSummary :query-params="queryParams" ref="customerSummaryRef" />
+      <el-tab-pane label="客户总量分析" lazy name="customerSummary">
+        <CustomerSummary ref="customerSummaryRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 客户跟进次数分析 -->
-      <el-tab-pane label="客户跟进次数分析" name="followUpSummary" lazy>
-        <CustomerFollowUpSummary :query-params="queryParams" ref="followUpSummaryRef" />
+      <el-tab-pane label="客户跟进次数分析" lazy name="followUpSummary">
+        <CustomerFollowUpSummary ref="followUpSummaryRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 客户跟进方式分析 -->
-      <el-tab-pane label="客户跟进方式分析" name="followUpType" lazy>
-        <CustomerFollowUpType :query-params="queryParams" ref="followUpTypeRef" />
+      <el-tab-pane label="客户跟进方式分析" lazy name="followUpType">
+        <CustomerFollowUpType ref="followUpTypeRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 客户转化率分析 -->
-      <el-tab-pane label="客户转化率分析" name="conversionStat" lazy>
-        <CustomerConversionStat :query-params="queryParams" ref="conversionStatRef" />
+      <el-tab-pane label="客户转化率分析" lazy name="conversionStat">
+        <CustomerConversionStat ref="conversionStatRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 成交周期分析 -->
       <el-tab-pane label="成交周期分析" lazy name="dealCycle">
         <CustomerDealCycle ref="dealCycleRef" :query-params="queryParams" />
       </el-tab-pane>
-      <!-- 城市分布分析 -->
-      <el-tab-pane label="城市分布分析" lazy name="addressRef">
-        <CustomerAddress ref="addressRef" :query-params="queryParams" />
-      </el-tab-pane>
-      <!-- 客户级别分析 -->
-      <el-tab-pane label="客户级别分析" lazy name="levelRef">
-        <CustomerLevel ref="levelRef" :query-params="queryParams" />
-      </el-tab-pane>
-      <!-- 客户来源分析 -->
-      <el-tab-pane label="客户来源分析" lazy name="sourceRef">
-        <CustomerSource ref="sourceRef" :query-params="queryParams" />
-      </el-tab-pane>
-      <!-- 客户行业分析 -->
-      <el-tab-pane label="客户行业分析" lazy name="industryRef">
-        <CustomerIndustry ref="industryRef" :query-params="queryParams" />
-      </el-tab-pane>
     </el-tabs>
   </el-col>
 </template>
@@ -115,10 +105,6 @@ import CustomerFollowUpType from './components/CustomerFollowUpType.vue'
 import CustomerConversionStat from './components/CustomerConversionStat.vue'
 import CustomerDealCycle from './components/CustomerDealCycle.vue'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import CustomerAddress from './components/CustomerAddress.vue'
-import CustomerIndustry from './components/CustomerIndustry.vue'
-import CustomerSource from './components/CustomerSource.vue'
-import CustomerLevel from './components/CustomerLevel.vue'
 
 defineOptions({ name: 'CrmStatisticsCustomer' })
 
@@ -152,13 +138,6 @@ const conversionStatRef = ref() // 4. 客户转化率分析
 // 5. TODO 公海客户分析
 // 缺 crm_owner_record 表 TODO @dhb52:可以先做界面 + 接口,接口数据直接写死返回,相当于 mock 出来
 const dealCycleRef = ref() // 6. 成交周期分析
-const addressRef = ref()
-// 客户级别
-const levelRef = ref()
-// 客户来源
-const sourceRef = ref()
-// 客户行业
-const industryRef = ref()
 
 /** 搜索按钮操作 */
 const handleQuery = () => {
@@ -178,19 +157,6 @@ const handleQuery = () => {
     case 'dealCycle': // 成交周期分析
       dealCycleRef.value?.loadData?.()
       break
-    // TODO @puhui999:这 4 个拆出去哈;独立一个【客户画像】菜单
-    case 'addressRef':
-      addressRef.value?.loadData?.()
-      break
-    case 'levelRef':
-      levelRef.value?.loadData?.()
-      break
-    case 'sourceRef':
-      sourceRef.value?.loadData?.()
-      break
-    case 'industryRef':
-      industryRef.value?.loadData?.()
-      break
   }
 }
 

+ 3 - 3
src/views/crm/statistics/customer/components/CustomerAddress.vue → src/views/crm/statistics/portrait/components/CustomerAddress.vue

@@ -22,8 +22,8 @@ import china from '@/assets/map/json/china.json'
 import echarts from '@/plugins/echarts'
 import {
   CrmStatisticCustomerAreaRespVO,
-  StatisticsCustomerApi
-} from '@/api/crm/statistics/customer'
+  StatisticsPortraitApi
+} from '@/api/crm/statistics/portrait'
 
 defineOptions({ name: 'CustomerAddress' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
@@ -102,7 +102,7 @@ const echartsOption2 = reactive<EChartsOption>({
 const loadData = async () => {
   // 1. 加载统计数据
   loading.value = true
-  const areaList = await StatisticsCustomerApi.getCustomerArea(props.queryParams)
+  const areaList = await StatisticsPortraitApi.getCustomerArea(props.queryParams)
   areaStatisticsList.value = areaList.map((item: CrmStatisticCustomerAreaRespVO) => {
     return {
       ...item,

+ 32 - 6
src/views/crm/statistics/customer/components/CustomerIndustry.vue → src/views/crm/statistics/portrait/components/CustomerIndustry.vue

@@ -20,7 +20,11 @@
   <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="客户行业" min-width="200" prop="industryName" />
+      <el-table-column align="center" label="客户行业" prop="industryId" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" />
+        </template>
+      </el-table-column>
       <el-table-column align="center" label="客户个数" min-width="200" prop="customerCount" />
       <el-table-column align="center" label="成交个数" min-width="200" prop="dealCount" />
       <el-table-column align="center" label="行业占比(%)" min-width="200" prop="industryPortion" />
@@ -31,9 +35,12 @@
 <script lang="ts" setup>
 import {
   CrmStatisticCustomerIndustryRespVO,
-  StatisticsCustomerApi
-} from '@/api/crm/statistics/customer'
+  StatisticsPortraitApi
+} from '@/api/crm/statistics/portrait'
 import { EChartsOption } from 'echarts'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { getSumValue } from '@/utils'
+import { isEmpty } from '@/utils/is'
 
 defineOptions({ name: 'CustomerIndustry' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
@@ -140,12 +147,12 @@ const echartsOption2 = reactive<EChartsOption>({
 const loadData = async () => {
   // 1. 加载统计数据
   loading.value = true
-  const industryList = await StatisticsCustomerApi.getCustomerIndustry(props.queryParams)
+  const industryList = await StatisticsPortraitApi.getCustomerIndustry(props.queryParams)
   // 2.1 更新 Echarts 数据
   if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
     echartsOption.series[0]['data'] = industryList.map((r: CrmStatisticCustomerIndustryRespVO) => {
       return {
-        name: r.industryName,
+        name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId),
         value: r.customerCount
       }
     })
@@ -154,15 +161,34 @@ const loadData = async () => {
   if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) {
     echartsOption2.series[0]['data'] = industryList.map((r: CrmStatisticCustomerIndustryRespVO) => {
       return {
-        name: r.industryName,
+        name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId),
         value: r.dealCount
       }
     })
   }
+  // 3. 计算比例
+  calculateProportion(industryList)
   list.value = industryList
   loading.value = false
 }
 defineExpose({ loadData })
+/**
+ * 计算比例
+ */
+const calculateProportion = (sourceList: CrmStatisticCustomerIndustryRespVO[]) => {
+  if (isEmpty(sourceList)) {
+    return
+  }
+  // 这里类型丢失了所以重新搞个变量
+  const list = sourceList as unknown as CrmStatisticCustomerIndustryRespVO[]
+  const sumCustomerCount = getSumValue(list.map((item) => item.customerCount))
+  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)
+  })
+}
 
 /** 初始化 */
 onMounted(() => {

+ 32 - 6
src/views/crm/statistics/customer/components/CustomerLevel.vue → src/views/crm/statistics/portrait/components/CustomerLevel.vue

@@ -20,7 +20,11 @@
   <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="客户来源" min-width="200" prop="levelName" />
+      <el-table-column align="center" label="客户级别" prop="level" width="200">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" />
+        </template>
+      </el-table-column>
       <el-table-column align="center" label="客户个数" min-width="200" prop="customerCount" />
       <el-table-column align="center" label="成交个数" min-width="200" prop="dealCount" />
       <el-table-column align="center" label="级别占比(%)" min-width="200" prop="levelPortion" />
@@ -31,9 +35,12 @@
 <script lang="ts" setup>
 import {
   CrmStatisticCustomerLevelRespVO,
-  StatisticsCustomerApi
-} from '@/api/crm/statistics/customer'
+  StatisticsPortraitApi
+} from '@/api/crm/statistics/portrait'
 import { EChartsOption } from 'echarts'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { getSumValue } from '@/utils'
+import { isEmpty } from '@/utils/is'
 
 defineOptions({ name: 'CustomerSource' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
@@ -140,12 +147,12 @@ const echartsOption2 = reactive<EChartsOption>({
 const loadData = async () => {
   // 1. 加载统计数据
   loading.value = true
-  const levelList = await StatisticsCustomerApi.getCustomerLevel(props.queryParams)
+  const levelList = await StatisticsPortraitApi.getCustomerLevel(props.queryParams)
   // 2.1 更新 Echarts 数据
   if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
     echartsOption.series[0]['data'] = levelList.map((r: CrmStatisticCustomerLevelRespVO) => {
       return {
-        name: r.levelName,
+        name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
         value: r.customerCount
       }
     })
@@ -154,15 +161,34 @@ const loadData = async () => {
   if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) {
     echartsOption2.series[0]['data'] = levelList.map((r: CrmStatisticCustomerLevelRespVO) => {
       return {
-        name: r.levelName,
+        name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
         value: r.dealCount
       }
     })
   }
+  // 3. 计算比例
+  calculateProportion(levelList)
   list.value = levelList
   loading.value = false
 }
 defineExpose({ loadData })
+/**
+ * 计算比例
+ */
+const calculateProportion = (levelList: CrmStatisticCustomerLevelRespVO[]) => {
+  if (isEmpty(levelList)) {
+    return
+  }
+  // 这里类型丢失了所以重新搞个变量
+  const list = levelList as unknown as CrmStatisticCustomerLevelRespVO[]
+  const sumCustomerCount = getSumValue(list.map((item) => item.customerCount))
+  const sumDealCount = getSumValue(list.map((item) => item.dealCount))
+  list.forEach((item) => {
+    item.levelPortion =
+      item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2)
+    item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2)
+  })
+}
 
 /** 初始化 */
 onMounted(() => {

+ 33 - 6
src/views/crm/statistics/customer/components/CustomerSource.vue → src/views/crm/statistics/portrait/components/CustomerSource.vue

@@ -20,7 +20,11 @@
   <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="客户来源" min-width="200" prop="sourceName" />
+      <el-table-column align="center" label="客户来源" prop="source" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" />
+        </template>
+      </el-table-column>
       <el-table-column align="center" label="客户个数" min-width="200" prop="customerCount" />
       <el-table-column align="center" label="成交个数" min-width="200" prop="dealCount" />
       <el-table-column align="center" label="来源占比(%)" min-width="200" prop="sourcePortion" />
@@ -31,9 +35,12 @@
 <script lang="ts" setup>
 import {
   CrmStatisticCustomerSourceRespVO,
-  StatisticsCustomerApi
-} from '@/api/crm/statistics/customer'
+  StatisticsPortraitApi
+} from '@/api/crm/statistics/portrait'
 import { EChartsOption } from 'echarts'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { isEmpty } from '@/utils/is'
+import { getSumValue } from '@/utils'
 
 defineOptions({ name: 'CustomerSource' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
@@ -140,12 +147,12 @@ const echartsOption2 = reactive<EChartsOption>({
 const loadData = async () => {
   // 1. 加载统计数据
   loading.value = true
-  const sourceList = await StatisticsCustomerApi.getCustomerSource(props.queryParams)
+  const sourceList = await StatisticsPortraitApi.getCustomerSource(props.queryParams)
   // 2.1 更新 Echarts 数据
   if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
     echartsOption.series[0]['data'] = sourceList.map((r: CrmStatisticCustomerSourceRespVO) => {
       return {
-        name: r.sourceName,
+        name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
         value: r.customerCount
       }
     })
@@ -154,16 +161,36 @@ const loadData = async () => {
   if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) {
     echartsOption2.series[0]['data'] = sourceList.map((r: CrmStatisticCustomerSourceRespVO) => {
       return {
-        name: r.sourceName,
+        name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
         value: r.dealCount
       }
     })
   }
+  // 3. 计算比例
+  calculateProportion(sourceList)
   list.value = sourceList
   loading.value = false
 }
 defineExpose({ loadData })
 
+/**
+ * 计算比例
+ */
+const calculateProportion = (sourceList: CrmStatisticCustomerSourceRespVO[]) => {
+  if (isEmpty(sourceList)) {
+    return
+  }
+  // 这里类型丢失了所以重新搞个变量
+  const list = sourceList as unknown as CrmStatisticCustomerSourceRespVO[]
+  const sumCustomerCount = getSumValue(list.map((item) => item.customerCount))
+  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)
+  })
+}
+
 /** 初始化 */
 onMounted(() => {
   loadData()

+ 156 - 0
src/views/crm/statistics/portrait/index.vue

@@ -0,0 +1,156 @@
+<!-- 数据统计 - 客户画像 -->
+<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"
+        />
+      </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"
+        />
+      </el-form-item>
+      <el-form-item label="员工" prop="userId">
+        <el-select v-model="queryParams.userId" class="!w-240px" clearable placeholder="员工">
+          <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="addressRef">
+        <CustomerAddress ref="addressRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <!-- 客户级别分析 -->
+      <el-tab-pane label="客户级别分析" lazy name="levelRef">
+        <CustomerLevel ref="levelRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <!-- 客户来源分析 -->
+      <el-tab-pane label="客户来源分析" lazy name="sourceRef">
+        <CustomerSource ref="sourceRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <!-- 客户行业分析 -->
+      <el-tab-pane label="客户行业分析" lazy name="industryRef">
+        <CustomerIndustry ref="industryRef" :query-params="queryParams" />
+      </el-tab-pane>
+    </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 CustomerAddress from './components/CustomerAddress.vue'
+import CustomerIndustry from './components/CustomerIndustry.vue'
+import CustomerSource from './components/CustomerSource.vue'
+import CustomerLevel from './components/CustomerLevel.vue'
+
+defineOptions({ name: 'CrmStatisticsPortrait' })
+
+const queryParams = reactive({
+  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('addressRef') // 活跃标签
+const addressRef = ref() // 客户地区分布
+const levelRef = ref() // 客户级别
+const sourceRef = ref() // 客户来源
+const industryRef = ref() // 客户行业
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  switch (activeTab.value) {
+    case 'addressRef':
+      addressRef.value?.loadData?.()
+      break
+    case 'levelRef':
+      levelRef.value?.loadData?.()
+      break
+    case 'sourceRef':
+      sourceRef.value?.loadData?.()
+      break
+    case 'industryRef':
+      industryRef.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>