Browse Source

Merge branch 'dev-crm' of https://gitee.com/puhui999/yudao-ui-admin-vue3 into dev

# Conflicts:
#	src/views/bpm/definition/index.vue
#	src/views/bpm/model/index.vue
#	src/views/bpm/processInstance/create/index.vue
#	src/views/crm/statistics/customer/index.vue
YunaiV 1 year ago
parent
commit
34b788cb88

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

@@ -49,6 +49,36 @@ 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
@@ -112,5 +142,33 @@ export const StatisticsCustomerApi = {
       url: '/crm/statistics-customer/get-customer-deal-cycle-by-user',
       params
     })
+  },
+  // 6.1 获取客户行业统计数据
+  getCustomerIndustry: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-industry-summary',
+      params
+    })
+  },
+  // 6.1 获取客户来源统计数据
+  getCustomerSource: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-source-summary',
+      params
+    })
+  },
+  // 6.1 获取客户行业统计数据
+  getCustomerLevel: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-level-summary',
+      params
+    })
+  },
+  // 6.1 获取客户行业统计数据
+  getCustomerArea: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-area-summary',
+      params
+    })
   }
 }

+ 1 - 1
src/api/mall/statistics/member.ts

@@ -5,7 +5,7 @@ import { formatDate } from '@/utils/formatTime'
 
 /** 会员分析 Request VO */
 export interface MemberAnalyseReqVO {
-  times: [dayjs.ConfigType, dayjs.ConfigType]
+  times: dayjs.ConfigType[]
 }
 
 /** 会员分析 Response VO */

+ 3 - 0
src/components/FormCreate/index.ts

@@ -0,0 +1,3 @@
+import MyFormCreate from './src/MyFormCreate.vue'
+
+export { MyFormCreate }

+ 54 - 0
src/components/FormCreate/src/MyFormCreate.vue

@@ -0,0 +1,54 @@
+<template>
+  <form-create v-bind="attrs">
+    <!-- 保障 form-create 的原始插槽 -->
+    <template v-for="(_, name) in slots" #[name]="slotData">
+      <slot :name="name" v-bind="slotData || {}"></slot>
+    </template>
+    <!--  使用项目重新封装的文件上传组件实现文件上载  -->
+    <template #type-upload="scope">
+      <!--      {{ logC(scope) }}-->
+      <template v-if="scope.prop.props.uploadType === 'file'">
+        <!-- TODO puhui999: 考虑是否使用属性透传直接把整个 scope.prop.props 传递给组件 -->
+        <UploadFile
+          :disabled="scope.prop.props.disabled"
+          :limit="scope.prop.props.limit"
+          :modelValue="scope.model.value || scope.prop.value"
+          @update:modelValue="(val) => setValue(scope, val)"
+        />
+      </template>
+      <template v-if="scope.prop.props.uploadType === 'image' && scope.prop.props.limit === 1">
+        <UploadImg
+          :disabled="scope.prop.props.disabled"
+          :modelValue="scope.model.value || scope.prop.value"
+          @update:modelValue="(val) => setValue(scope, val)"
+        />
+      </template>
+      <template v-if="scope.prop.props.uploadType === 'image' && scope.prop.props.limit > 1">
+        <UploadImgs
+          :disabled="scope.prop.props.disabled"
+          :limit="scope.prop.props.limit"
+          :modelValue="scope.model.value || scope.prop.value"
+          @update:modelValue="(val) => setValue(scope, val)"
+        />
+      </template>
+    </template>
+  </form-create>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'MyFormCreate' })
+const attrs = useAttrs()
+const slots = useSlots()
+
+// 测试使用,查看组件 scope 值
+// const logC = (s) => {
+//   console.log(s)
+// }
+
+// 设置表单值
+const setValue = (scope: any, value: any) => {
+  const obj = {}
+  obj[scope.prop.field] = value
+  scope.api.setValue(obj)
+}
+</script>

+ 6 - 4
src/components/UploadFile/src/UploadFile.vue

@@ -6,7 +6,9 @@
       :action="uploadUrl"
       :auto-upload="autoUpload"
       :before-upload="beforeUpload"
+      :disabled="disabled"
       :drag="drag"
+      :http-request="httpRequest"
       :limit="props.limit"
       :multiple="props.limit > 1"
       :on-error="excelUploadError"
@@ -15,15 +17,14 @@
       :on-remove="handleRemove"
       :on-success="handleFileSuccess"
       :show-file-list="true"
-      :http-request="httpRequest"
       class="upload-file-uploader"
       name="file"
     >
-      <el-button type="primary">
+      <el-button v-if="!disabled" type="primary">
         <Icon icon="ep:upload-filled" />
         选取文件
       </el-button>
-      <template v-if="isShowTip" #tip>
+      <template v-if="isShowTip && !disabled" #tip>
         <div style="font-size: 8px">
           大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
         </div>
@@ -54,7 +55,8 @@ const props = defineProps({
   limit: propTypes.number.def(5), // 数量限制
   autoUpload: propTypes.bool.def(true), // 自动上传
   drag: propTypes.bool.def(false), // 拖拽上传
-  isShowTip: propTypes.bool.def(true) // 是否显示提示
+  isShowTip: propTypes.bool.def(true), // 是否显示提示
+  disabled: propTypes.bool.def(false) // 是否禁用上传组件 ==> 非必传(默认为 false)
 })
 
 // ========== 上传相关 ==========

+ 5 - 6
src/components/UploadFile/src/UploadImg.vue

@@ -6,17 +6,18 @@
       :action="uploadUrl"
       :before-upload="beforeUpload"
       :class="['upload', drag ? 'no-border' : '']"
+      :disabled="disabled"
       :drag="drag"
+      :http-request="httpRequest"
       :multiple="false"
       :on-error="uploadError"
       :on-success="uploadSuccess"
       :show-file-list="false"
-      :http-request="httpRequest"
     >
       <template v-if="modelValue">
         <img :src="modelValue" class="upload-image" />
         <div class="upload-handle" @click.stop>
-          <div class="handle-icon" @click="editImg" v-if="!disabled">
+          <div v-if="!disabled" class="handle-icon" @click="editImg">
             <Icon icon="ep:edit" />
             <span v-if="showBtnText">{{ t('action.edit') }}</span>
           </div>
@@ -77,10 +78,8 @@ const props = defineProps({
   height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px)
   width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px)
   borderradius: propTypes.string.def('8px'), // 组件边框圆角 ==> 非必传(默认为 8px)
-  // 是否显示删除按钮
-  showDelete: propTypes.bool.def(true),
-  // 是否显示按钮文字
-  showBtnText: propTypes.bool.def(true)
+  showDelete: propTypes.bool.def(true), // 是否显示删除按钮
+  showBtnText: propTypes.bool.def(true) // 是否显示按钮文字
 })
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗

+ 2 - 1
src/components/UploadFile/src/UploadImgs.vue

@@ -6,13 +6,14 @@
       :action="uploadUrl"
       :before-upload="beforeUpload"
       :class="['upload', drag ? 'no-border' : '']"
+      :disabled="disabled"
       :drag="drag"
+      :http-request="httpRequest"
       :limit="limit"
       :multiple="true"
       :on-error="uploadError"
       :on-exceed="handleExceed"
       :on-success="uploadSuccess"
-      :http-request="httpRequest"
       list-type="picture-card"
     >
       <div class="upload-empty">

+ 1 - 1
src/utils/formCreate.ts

@@ -40,7 +40,7 @@ export const setConfAndFields = (designerRef: object, conf: string, fields: stri
 export const setConfAndFields2 = (
   detailPreview: object,
   conf: string,
-  fields: string,
+  fields: string[],
   value?: object
 ) => {
   if (isRef(detailPreview)) {

+ 1 - 1
src/views/bpm/form/index.vue

@@ -88,7 +88,7 @@
 
   <!-- 表单详情的弹窗 -->
   <Dialog v-model="detailVisible" title="表单详情" width="800">
-    <form-create :option="detailData.option" :rule="detailData.rule" />
+    <my-form-create :option="detailData.option" :rule="detailData.rule" />
   </Dialog>
 </template>
 

+ 5 - 5
src/views/bpm/processInstance/detail/index.vue

@@ -91,9 +91,9 @@
       </template>
       <!-- 情况一:流程表单 -->
       <el-col v-if="processInstance?.processDefinition?.formType === 10" :offset="6" :span="16">
-        <form-create
-          ref="fApi"
+        <my-form-create
           v-model="detailForm.value"
+          v-model:api="fApi"
           :option="detailForm.option"
           :rule="detailForm.rule"
         />
@@ -280,9 +280,9 @@ const getProcessInstance = async () => {
         data.formVariables
       )
       nextTick().then(() => {
-        fApi.value?.fapi?.btn.show(false)
-        fApi.value?.fapi?.resetBtn.show(false)
-        fApi.value?.fapi?.disabled(true)
+        fApi.value?.btn.show(false)
+        fApi.value?.resetBtn.show(false)
+        fApi.value?.disabled(true)
       })
     } else {
       // 注意:data.processDefinition.formCustomViewPath 是组件的全路径,例如说:/crm/contract/detail/index.vue

+ 150 - 0
src/views/crm/statistics/customer/components/CustomerAddress.vue

@@ -0,0 +1,150 @@
+<!-- 客户城市分布 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption" />
+        </el-skeleton>
+      </el-col>
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption2" />
+        </el-skeleton>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import { EChartsOption } from 'echarts'
+import china from '@/assets/map/json/china.json'
+import echarts from '@/plugins/echarts'
+import {
+  CrmStatisticCustomerAreaRespVO,
+  StatisticsCustomerApi
+} from '@/api/crm/statistics/customer'
+
+defineOptions({ name: 'CustomerAddress' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+// 注册地图
+echarts?.registerMap('china', china as any)
+
+const loading = ref(false) // 加载中
+const areaStatisticsList = ref<CrmStatisticCustomerAreaRespVO[]>([]) // 列表的数据
+
+/** 地图配置 */
+const echartsOption = reactive<EChartsOption>({
+  title: {
+    text: '全部客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item',
+    showDelay: 0,
+    transitionDuration: 0.2
+  },
+  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 echartsOption2 = reactive<EChartsOption>({
+  title: {
+    text: '成交客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item',
+    showDelay: 0,
+    transitionDuration: 0.2
+  },
+  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 loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const areaList = await StatisticsCustomerApi.getCustomerArea(props.queryParams)
+  areaStatisticsList.value = areaList.map((item: CrmStatisticCustomerAreaRespVO) => {
+    return {
+      ...item,
+      areaName: item.areaName
+        .replace('维吾尔自治区', '')
+        .replace('壮族自治区', '')
+        .replace('回族自治区', '')
+        .replace('自治区', '')
+        .replace('省', '')
+    }
+  })
+  builderLeftMap()
+  builderRightMap()
+  loading.value = false
+}
+defineExpose({ loadData })
+
+const builderLeftMap = () => {
+  let min = 0
+  let max = 0
+  echartsOption.series![0].data = areaStatisticsList.value.map((item) => {
+    min = Math.min(min, item.customerCount || 0)
+    max = Math.max(max, item.customerCount || 0)
+    return { ...item, name: item.areaName, value: item.customerCount || 0 }
+  })
+  echartsOption.visualMap!['min'] = min
+  echartsOption.visualMap!['max'] = max
+}
+
+const builderRightMap = () => {
+  let min = 0
+  let max = 0
+  echartsOption2.series![0].data = areaStatisticsList.value.map((item) => {
+    min = Math.min(min, item.dealCount || 0)
+    max = Math.max(max, item.dealCount || 0)
+    return { ...item, name: item.areaName, value: item.dealCount || 0 }
+  })
+  echartsOption2.visualMap!['min'] = min
+  echartsOption2.visualMap!['max'] = max
+}
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 171 - 0
src/views/crm/statistics/customer/components/CustomerIndustry.vue

@@ -0,0 +1,171 @@
+<!-- 客户行业分析 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption" />
+        </el-skeleton>
+      </el-col>
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption2" />
+        </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="客户行业" min-width="200" prop="industryName" />
+      <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" />
+      <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticCustomerIndustryRespVO,
+  StatisticsCustomerApi
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerIndustry' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticCustomerIndustryRespVO[]>([]) // 列表的数据
+
+/** 饼图配置 */
+const echartsOption = reactive<EChartsOption>({
+  title: {
+    text: '全部客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '全部客户' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '全部客户',
+      type: 'pie',
+      radius: ['40%', '70%'],
+      avoidLabelOverlap: false,
+      itemStyle: {
+        borderRadius: 10,
+        borderColor: '#fff',
+        borderWidth: 2
+      },
+      label: {
+        show: false,
+        position: 'center'
+      },
+      emphasis: {
+        label: {
+          show: true,
+          fontSize: 40,
+          fontWeight: 'bold'
+        }
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+/** 饼图配置 */
+const echartsOption2 = reactive<EChartsOption>({
+  title: {
+    text: '成交客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '成交客户' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '成交客户',
+      type: 'pie',
+      radius: ['40%', '70%'],
+      avoidLabelOverlap: false,
+      itemStyle: {
+        borderRadius: 10,
+        borderColor: '#fff',
+        borderWidth: 2
+      },
+      label: {
+        show: false,
+        position: 'center'
+      },
+      emphasis: {
+        label: {
+          show: true,
+          fontSize: 40,
+          fontWeight: 'bold'
+        }
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const industryList = await StatisticsCustomerApi.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,
+        value: r.customerCount
+      }
+    })
+  }
+  // 2.2 更新 Echarts2 数据
+  if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) {
+    echartsOption2.series[0]['data'] = industryList.map((r: CrmStatisticCustomerIndustryRespVO) => {
+      return {
+        name: r.industryName,
+        value: r.dealCount
+      }
+    })
+  }
+  list.value = industryList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 171 - 0
src/views/crm/statistics/customer/components/CustomerLevel.vue

@@ -0,0 +1,171 @@
+<!-- 客户来源分析 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption" />
+        </el-skeleton>
+      </el-col>
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption2" />
+        </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="客户来源" min-width="200" prop="levelName" />
+      <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" />
+      <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticCustomerLevelRespVO,
+  StatisticsCustomerApi
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerSource' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticCustomerLevelRespVO[]>([]) // 列表的数据
+
+/** 饼图配置 */
+const echartsOption = reactive<EChartsOption>({
+  title: {
+    text: '全部客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '全部客户' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '全部客户',
+      type: 'pie',
+      radius: ['40%', '70%'],
+      avoidLabelOverlap: false,
+      itemStyle: {
+        borderRadius: 10,
+        borderColor: '#fff',
+        borderWidth: 2
+      },
+      label: {
+        show: false,
+        position: 'center'
+      },
+      emphasis: {
+        label: {
+          show: true,
+          fontSize: 40,
+          fontWeight: 'bold'
+        }
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+/** 饼图配置 */
+const echartsOption2 = reactive<EChartsOption>({
+  title: {
+    text: '成交客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '成交客户' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '成交客户',
+      type: 'pie',
+      radius: ['40%', '70%'],
+      avoidLabelOverlap: false,
+      itemStyle: {
+        borderRadius: 10,
+        borderColor: '#fff',
+        borderWidth: 2
+      },
+      label: {
+        show: false,
+        position: 'center'
+      },
+      emphasis: {
+        label: {
+          show: true,
+          fontSize: 40,
+          fontWeight: 'bold'
+        }
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const levelList = await StatisticsCustomerApi.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,
+        value: r.customerCount
+      }
+    })
+  }
+  // 2.2 更新 Echarts2 数据
+  if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) {
+    echartsOption2.series[0]['data'] = levelList.map((r: CrmStatisticCustomerLevelRespVO) => {
+      return {
+        name: r.levelName,
+        value: r.dealCount
+      }
+    })
+  }
+  list.value = levelList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 171 - 0
src/views/crm/statistics/customer/components/CustomerSource.vue

@@ -0,0 +1,171 @@
+<!-- 客户来源分析 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption" />
+        </el-skeleton>
+      </el-col>
+      <el-col :span="12">
+        <el-skeleton :loading="loading" animated>
+          <Echart :height="500" :options="echartsOption2" />
+        </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="客户来源" min-width="200" prop="sourceName" />
+      <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" />
+      <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticCustomerSourceRespVO,
+  StatisticsCustomerApi
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerSource' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticCustomerSourceRespVO[]>([]) // 列表的数据
+
+/** 饼图配置 */
+const echartsOption = reactive<EChartsOption>({
+  title: {
+    text: '全部客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '全部客户' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '全部客户',
+      type: 'pie',
+      radius: ['40%', '70%'],
+      avoidLabelOverlap: false,
+      itemStyle: {
+        borderRadius: 10,
+        borderColor: '#fff',
+        borderWidth: 2
+      },
+      label: {
+        show: false,
+        position: 'center'
+      },
+      emphasis: {
+        label: {
+          show: true,
+          fontSize: 40,
+          fontWeight: 'bold'
+        }
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+/** 饼图配置 */
+const echartsOption2 = reactive<EChartsOption>({
+  title: {
+    text: '成交客户',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '成交客户' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '成交客户',
+      type: 'pie',
+      radius: ['40%', '70%'],
+      avoidLabelOverlap: false,
+      itemStyle: {
+        borderRadius: 10,
+        borderColor: '#fff',
+        borderWidth: 2
+      },
+      label: {
+        show: false,
+        position: 'center'
+      },
+      emphasis: {
+        label: {
+          show: true,
+          fontSize: 40,
+          fontWeight: 'bold'
+        }
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const sourceList = await StatisticsCustomerApi.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,
+        value: r.customerCount
+      }
+    })
+  }
+  // 2.2 更新 Echarts2 数据
+  if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) {
+    echartsOption2.series[0]['data'] = sourceList.map((r: CrmStatisticCustomerSourceRespVO) => {
+      return {
+        name: r.sourceName,
+        value: r.dealCount
+      }
+    })
+  }
+  list.value = sourceList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 41 - 2
src/views/crm/statistics/customer/index.vue

@@ -80,8 +80,24 @@
         <CustomerConversionStat :query-params="queryParams" ref="conversionStatRef" />
       </el-tab-pane>
       <!-- 成交周期分析 -->
-      <el-tab-pane label="成交周期分析" name="dealCycle" lazy>
-        <CustomerDealCycle :query-params="queryParams" ref="dealCycleRef" />
+      <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>
@@ -99,6 +115,10 @@ 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' })
 
@@ -132,6 +152,13 @@ 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 = () => {
@@ -151,6 +178,18 @@ const handleQuery = () => {
     case 'dealCycle': // 成交周期分析
       dealCycleRef.value?.loadData?.()
       break
+    case 'addressRef':
+      addressRef.value?.loadData?.()
+      break
+    case 'levelRef':
+      levelRef.value?.loadData?.()
+      break
+    case 'sourceRef':
+      sourceRef.value?.loadData?.()
+      break
+    case 'industryRef':
+      industryRef.value?.loadData?.()
+      break
   }
 }
 

+ 4 - 5
src/views/infra/build/index.vue

@@ -23,7 +23,7 @@
       </el-button>
       <el-scrollbar height="580">
         <div>
-          <pre><code class="hljs" v-dompurify-html="highlightedCode(formData)"></code></pre>
+          <pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre>
         </div>
       </el-scrollbar>
     </div>
@@ -81,15 +81,14 @@ const makeTemplate = () => {
   const rule = designer.value.getRule()
   const opt = designer.value.getOption()
   return `<template>
-    <form-create
-      v-model="fapi"
+    <my-form-create
+      v-model:api="fApi"
       :rule="rule"
       :option="option"
       @submit="onSubmit"
-    ></form-create>
+    ></my-form-create>
   </template>
   <script setup lang=ts>
-    import formCreate from "@form-create/element-ui";
     const faps = ref(null)
     const rule = ref('')
     const option = ref('')