Преглед на файлове

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

YunaiV преди 1 година
родител
ревизия
ab1c506226
променени са 65 файла, в които са добавени 3450 реда и са изтрити 305 реда
  1. 1 0
      package.json
  2. 2 2
      src/api/crm/permission/index.ts
  3. 41 15
      src/api/crm/statistics/customer.ts
  4. 33 0
      src/api/crm/statistics/performance.ts
  5. 60 0
      src/api/crm/statistics/portrait.ts
  6. 1 1
      src/api/mall/statistics/member.ts
  7. BIN
      src/assets/imgs/diy/app-nav-bar-mp.png
  8. 5 1
      src/components/Crontab/src/Crontab.vue
  9. 3 0
      src/components/DictSelect/index.ts
  10. 47 0
      src/components/DictSelect/src/DictSelect.vue
  11. 90 0
      src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue
  12. 64 20
      src/components/DiyEditor/components/mobile/NavigationBar/config.ts
  13. 54 26
      src/components/DiyEditor/components/mobile/NavigationBar/index.vue
  14. 54 31
      src/components/DiyEditor/components/mobile/NavigationBar/property.vue
  15. 4 0
      src/components/FormCreate/index.ts
  16. 33 0
      src/components/FormCreate/src/MyFormCreateDesigner.vue
  17. 13 0
      src/components/FormCreate/src/config/index.ts
  18. 124 0
      src/components/FormCreate/src/config/useDictSelectRule.ts
  19. 80 0
      src/components/FormCreate/src/config/useUploadFileRule.ts
  20. 89 0
      src/components/FormCreate/src/config/useUploadImgRule.ts
  21. 84 0
      src/components/FormCreate/src/config/useUploadImgsRule.ts
  22. 93 0
      src/components/FormCreate/src/config/useUserSelectRule.ts
  23. 45 0
      src/components/FormCreate/src/useFormCreateDesigner.ts
  24. 79 0
      src/components/FormCreate/src/utils/index.ts
  25. 1 1
      src/components/MagicCubeEditor/index.vue
  26. 6 5
      src/components/UploadFile/src/UploadFile.vue
  27. 5 6
      src/components/UploadFile/src/UploadImg.vue
  28. 2 1
      src/components/UploadFile/src/UploadImgs.vue
  29. 19 9
      src/plugins/formCreate/index.ts
  30. 3 3
      src/utils/constants.ts
  31. 3 2
      src/utils/dict.ts
  32. 1 1
      src/utils/formCreate.ts
  33. 15 1
      src/utils/index.ts
  34. 2 0
      src/views/bpm/form/editor/index.vue
  35. 6 6
      src/views/bpm/processInstance/detail/index.vue
  36. 9 3
      src/views/crm/permission/components/TransferForm.vue
  37. 53 13
      src/views/crm/statistics/customer/components/CustomerConversionStat.vue
  38. 40 13
      src/views/crm/statistics/customer/components/CustomerDealCycle.vue
  39. 54 22
      src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue
  40. 34 19
      src/views/crm/statistics/customer/components/CustomerFollowUpType.vue
  41. 154 0
      src/views/crm/statistics/customer/components/CustomerPoolSummary.vue
  42. 59 27
      src/views/crm/statistics/customer/components/CustomerSummary.vue
  43. 78 46
      src/views/crm/statistics/customer/index.vue
  44. 227 0
      src/views/crm/statistics/performance/components/ContractCountPerformance.vue
  45. 227 0
      src/views/crm/statistics/performance/components/ContractPricePerformance.vue
  46. 227 0
      src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue
  47. 147 0
      src/views/crm/statistics/performance/index.vue
  48. 153 0
      src/views/crm/statistics/portrait/components/CustomerAddress.vue
  49. 197 0
      src/views/crm/statistics/portrait/components/CustomerIndustry.vue
  50. 198 0
      src/views/crm/statistics/portrait/components/CustomerLevel.vue
  51. 197 0
      src/views/crm/statistics/portrait/components/CustomerSource.vue
  52. 157 0
      src/views/crm/statistics/portrait/index.vue
  53. 1 1
      src/views/crm/statistics/rank/components/ContactCountRank.vue
  54. 0 0
      src/views/crm/statistics/rank/components/ContractCountRank.vue
  55. 8 1
      src/views/crm/statistics/rank/components/ContractPriceRank.vue
  56. 0 0
      src/views/crm/statistics/rank/components/CustomerCountRank.vue
  57. 0 0
      src/views/crm/statistics/rank/components/FollowCountRank.vue
  58. 0 0
      src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue
  59. 0 0
      src/views/crm/statistics/rank/components/ProductSalesRank.vue
  60. 8 1
      src/views/crm/statistics/rank/components/ReceivablePriceRank.vue
  61. 21 20
      src/views/crm/statistics/rank/index.vue
  62. 6 5
      src/views/infra/build/index.vue
  63. 2 2
      src/views/pay/app/components/channel/WeixinChannelForm.vue
  64. 3 1
      src/views/system/role/RoleDataPermissionForm.vue
  65. 28 0
      src/views/system/user/components/UserSelect.vue

+ 1 - 0
package.json

@@ -102,6 +102,7 @@
     "postcss-html": "^1.6.0",
     "postcss-scss": "^4.0.9",
     "prettier": "^3.2.5",
+    "prettier-eslint": "^16.3.0",
     "rimraf": "^5.0.5",
     "rollup": "^4.12.0",
     "sass": "^1.69.5",

+ 2 - 2
src/api/crm/permission/index.ts

@@ -15,9 +15,9 @@ export interface PermissionVO {
 }
 
 export interface TransferReqVO {
-  bizId: number // 模块编号
+  id: number // 模块编号
   newOwnerUserId: number // 新负责人的用户编号
-  oldOwnerPermissionLevel: number // 老负责人加入团队后的权限级别
+  oldOwnerPermissionLevel?: number // 老负责人加入团队后的权限级别
   toBizTypes?: number[] // 转移客户时,需要额外有【联系人】【商机】【合同】的 checkbox 选择
 }
 

+ 41 - 15
src/api/crm/statistics/customer.ts

@@ -14,21 +14,21 @@ export interface CrmStatisticsCustomerSummaryByUserRespVO {
   receivablePrice: number
 }
 
-export interface CrmStatisticsFollowupSummaryByDateRespVO {
+export interface CrmStatisticsFollowUpSummaryByDateRespVO {
   time: string
-  followupRecordCount: number
-  followupCustomerCount: number
+  followUpRecordCount: number
+  followUpCustomerCount: number
 }
 
-export interface CrmStatisticsFollowupSummaryByUserRespVO {
+export interface CrmStatisticsFollowUpSummaryByUserRespVO {
   ownerUserName: string
   followupRecordCount: number
   followupCustomerCount: number
 }
 
-export interface CrmStatisticsFollowupSummaryByTypeRespVO {
-  followupType: string
-  followupRecordCount: number
+export interface CrmStatisticsFollowUpSummaryByTypeRespVO {
+  followUpType: string
+  followUpRecordCount: number
 }
 
 export interface CrmStatisticsCustomerContractSummaryRespVO {
@@ -44,6 +44,18 @@ export interface CrmStatisticsCustomerContractSummaryRespVO {
   orderDate: Date
 }
 
+export interface CrmStatisticsPoolSummaryByDateRespVO {
+  time: string
+  customerPutCount: number
+  customerTakeCount: number
+}
+
+export interface CrmStatisticsPoolSummaryByUserRespVO {
+  ownerUserName: string
+  customerPutCount: number
+  customerTakeCount: number
+}
+
 export interface CrmStatisticsCustomerDealCycleByDateRespVO {
   time: string
   customerDealCycle: number
@@ -72,23 +84,23 @@ export const StatisticsCustomerApi = {
     })
   },
   // 2.1 客户跟进次数分析(按日期)
-  getFollowupSummaryByDate: (params: any) => {
+  getFollowUpSummaryByDate: (params: any) => {
     return request.get({
-      url: '/crm/statistics-customer/get-followup-summary-by-date',
+      url: '/crm/statistics-customer/get-follow-up-summary-by-date',
       params
     })
   },
   // 2.2 客户跟进次数分析(按用户)
-  getFollowupSummaryByUser: (params: any) => {
+  getFollowUpSummaryByUser: (params: any) => {
     return request.get({
-      url: '/crm/statistics-customer/get-followup-summary-by-user',
+      url: '/crm/statistics-customer/get-follow-up-summary-by-user',
       params
     })
   },
   // 3.1 获取客户跟进方式统计数
-  getFollowupSummaryByType: (params: any) => {
+  getFollowUpSummaryByType: (params: any) => {
     return request.get({
-      url: '/crm/statistics-customer/get-followup-summary-by-type',
+      url: '/crm/statistics-customer/get-follow-up-summary-by-type',
       params
     })
   },
@@ -99,14 +111,28 @@ export const StatisticsCustomerApi = {
       params
     })
   },
-  // 5.1 获取客户成交周期(按日期)
+  // 5.1 获取客户公海分析(按日期)
+  getPoolSummaryByDate: (param: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-pool-summary-by-date',
+      params: param
+    })
+  },
+  // 5.2 获取客户公海分析(按用户)
+  getPoolSummaryByUser: (param: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-pool-summary-by-user',
+      params: param
+    })
+  },
+  // 6.1 获取客户成交周期(按日期)
   getCustomerDealCycleByDate: (params: any) => {
     return request.get({
       url: '/crm/statistics-customer/get-customer-deal-cycle-by-date',
       params
     })
   },
-  // 5.2 获取客户成交周期(按用户)
+  // 6.2 获取客户成交周期(按用户)
   getCustomerDealCycleByUser: (params: any) => {
     return request.get({
       url: '/crm/statistics-customer/get-customer-deal-cycle-by-user',

+ 33 - 0
src/api/crm/statistics/performance.ts

@@ -0,0 +1,33 @@
+import request from '@/config/axios'
+
+export interface StatisticsPerformanceRespVO {
+  time: string
+  currentMonthCount: number
+  lastMonthCount: number
+  lastYearCount: number
+}
+
+// 排行 API
+export const StatisticsPerformanceApi = {
+  // 员工获得合同金额统计
+  getContractPricePerformance: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-performance/get-contract-price-performance',
+      params
+    })
+  },
+  // 员工获得回款统计
+  getReceivablePricePerformance: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-performance/get-receivable-price-performance',
+      params
+    })
+  },
+  //员工获得签约合同数量统计
+  getContractCountPerformance: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-performance/get-contract-count-performance',
+      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/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 */

BIN
src/assets/imgs/diy/app-nav-bar-mp.png


+ 5 - 1
src/components/Crontab/src/Crontab.vue

@@ -503,9 +503,13 @@ const submit = () => {
   emit('update:modelValue', defaultValue.value)
   dialogVisible.value = false
 }
+
+const inputChange = () => {
+  emit('update:modelValue', defaultValue.value)
+}
 </script>
 <template>
-  <el-input v-model="defaultValue" class="input-with-select" v-bind="$attrs">
+  <el-input v-model="defaultValue" class="input-with-select" v-bind="$attrs" @input="inputChange">
     <template #append>
       <el-select v-model="select" placeholder="生成器" style="width: 115px">
         <el-option label="每分钟" value="0 * * * * ?" />

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

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

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

@@ -0,0 +1,47 @@
+<!-- 数据字典 Select 选择器 -->
+<template>
+  <el-select class="w-1/1" v-bind="attrs">
+    <template v-if="valueType === 'int'">
+      <el-option
+        v-for="(dict, index) in getIntDictOptions(dictType)"
+        :key="index"
+        :label="dict.label"
+        :value="dict.value"
+      />
+    </template>
+    <template v-if="valueType === 'str'">
+      <el-option
+        v-for="(dict, index) in getStrDictOptions(dictType)"
+        :key="index"
+        :label="dict.label"
+        :value="dict.value"
+      />
+    </template>
+    <template v-if="valueType === 'bool'">
+      <el-option
+        v-for="(dict, index) in getBoolDictOptions(dictType)"
+        :key="index"
+        :label="dict.label"
+        :value="dict.value"
+      />
+    </template>
+  </el-select>
+</template>
+
+<script lang="ts" setup>
+import { getBoolDictOptions, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
+
+// 接受父组件参数
+interface Props {
+  modelValue?: any // 值
+  dictType: string // 字典类型
+  valueType: string // 字典值类型
+}
+
+withDefaults(defineProps<Props>(), {
+  dictType: '',
+  valueType: 'str'
+})
+const attrs = useAttrs()
+defineOptions({ name: 'DictSelect' })
+</script>

+ 90 - 0
src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="h-40px flex items-center justify-center">
+    <MagicCubeEditor
+      v-model="cellList"
+      class="m-b-16px"
+      :rows="1"
+      :cols="cellCount"
+      :cube-size="38"
+      @hot-area-selected="handleHotAreaSelected"
+    />
+    <img src="@/assets/imgs/diy/app-nav-bar-mp.png" alt="" class="h-30px w-76px" v-if="isMp" />
+  </div>
+  <template v-for="(cell, cellIndex) in cellList" :key="cellIndex">
+    <template v-if="selectedHotAreaIndex === cellIndex">
+      <el-form-item label="类型" :prop="`cell[${cellIndex}].type`">
+        <el-radio-group v-model="cell.type">
+          <el-radio label="text">文字</el-radio>
+          <el-radio label="image">图片</el-radio>
+          <el-radio label="search">搜索框</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <!-- 1. 文字 -->
+      <template v-if="cell.type === 'text'">
+        <el-form-item label="内容" :prop="`cell[${cellIndex}].text`">
+          <el-input v-model="cell!.text" maxlength="10" show-word-limit />
+        </el-form-item>
+        <el-form-item label="颜色" :prop="`cell[${cellIndex}].text`">
+          <ColorInput v-model="cell!.textColor" />
+        </el-form-item>
+      </template>
+      <!-- 2. 图片 -->
+      <template v-else-if="cell.type === 'image'">
+        <el-form-item label="图片" :prop="`cell[${cellIndex}].imgUrl`">
+          <UploadImg v-model="cell.imgUrl" :limit="1" height="56px" width="56px">
+            <template #tip>建议尺寸 56*56</template>
+          </UploadImg>
+        </el-form-item>
+        <el-form-item label="链接" :prop="`cell[${cellIndex}].url`">
+          <AppLinkInput v-model="cell.url" />
+        </el-form-item>
+      </template>
+      <!-- 3. 搜索框 -->
+      <template v-else>
+        <el-form-item label="提示文字" :prop="`cell[${cellIndex}].placeholder`">
+          <el-input v-model="cell.placeholder" maxlength="10" show-word-limit />
+        </el-form-item>
+        <el-form-item label="圆角" :prop="`cell[${cellIndex}].borderRadius`">
+          <el-slider
+            v-model="cell.borderRadius"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+      </template>
+    </template>
+  </template>
+</template>
+
+<script setup lang="ts">
+import { NavigationBarCellProperty } from '../config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+// 导航栏属性面板
+defineOptions({ name: 'NavigationBarCellProperty' })
+
+const props = defineProps<{
+  modelValue: NavigationBarCellProperty[]
+  isMp: boolean
+}>()
+const emit = defineEmits(['update:modelValue'])
+const { formData: cellList } = usePropertyForm(props.modelValue, emit)
+if (!cellList.value) cellList.value = []
+
+// 单元格数量:小程序6个(右侧胶囊按钮占了2个),其它平台8个
+const cellCount = computed(() => (props.isMp ? 6 : 8))
+
+// 选中的热区
+const selectedHotAreaIndex = ref(0)
+const handleHotAreaSelected = (cellValue: NavigationBarCellProperty, index: number) => {
+  selectedHotAreaIndex.value = index
+  if (!cellValue.type) {
+    cellValue.type = 'text'
+    cellValue.textColor = '#111111'
+  }
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 64 - 20
src/components/DiyEditor/components/mobile/NavigationBar/config.ts

@@ -2,22 +2,53 @@ import { DiyComponent } from '@/components/DiyEditor/util'
 
 /** 顶部导航栏属性 */
 export interface NavigationBarProperty {
-  // 页面标题
-  title: string
-  // 页面描述
-  description: string
-  // 顶部导航高度
-  navBarHeight: number
-  // 页面背景颜色
-  backgroundColor: string
-  // 页面背景图片
-  backgroundImage: string
+  // 背景类型
+  bgType: 'color' | 'img'
+  // 背景颜色
+  bgColor: string
+  // 图片链接
+  bgImg: string
   // 样式类型:默认 | 沉浸式
-  styleType: 'default' | 'immersion'
+  styleType: 'normal' | 'inner'
   // 常驻显示
   alwaysShow: boolean
-  // 是否显示返回按钮
-  showGoBack: boolean
+  // 小程序单元格列表
+  mpCells: NavigationBarCellProperty[]
+  // 其它平台单元格列表
+  otherCells: NavigationBarCellProperty[]
+  // 本地变量
+  _local: {
+    // 预览顶部导航(小程序)
+    previewMp: boolean
+    // 预览顶部导航(非小程序)
+    previewOther: boolean
+  }
+}
+
+/** 顶部导航栏 - 单元格 属性 */
+export interface NavigationBarCellProperty {
+  // 类型:文字 | 图片 | 搜索框
+  type: 'text' | 'image' | 'search'
+  // 宽度
+  width: number
+  // 高度
+  height: number
+  // 顶部位置
+  top: number
+  // 左侧位置
+  left: number
+  // 文字内容
+  text: string
+  // 文字颜色
+  textColor: string
+  // 图片地址
+  imgUrl: string
+  // 图片链接
+  url: string
+  // 搜索框:提示文字
+  placeholder: string
+  // 搜索框:边框圆角半径
+  borderRadius: number
 }
 
 // 定义组件
@@ -26,13 +57,26 @@ export const component = {
   name: '顶部导航栏',
   icon: 'tabler:layout-navbar',
   property: {
-    title: '页面标题',
-    description: '',
-    navBarHeight: 35,
-    backgroundColor: '#fff',
-    backgroundImage: '',
-    styleType: 'default',
+    bgType: 'color',
+    bgColor: '#fff',
+    bgImg: '',
+    styleType: 'normal',
     alwaysShow: true,
-    showGoBack: true
+    mpCells: [
+      {
+        type: 'text',
+        textColor: '#111111'
+      }
+    ],
+    otherCells: [
+      {
+        type: 'text',
+        textColor: '#111111'
+      }
+    ],
+    _local: {
+      previewMp: true,
+      previewOther: false
+    }
   }
 } as DiyComponent<NavigationBarProperty>

+ 54 - 26
src/components/DiyEditor/components/mobile/NavigationBar/index.vue

@@ -1,45 +1,73 @@
 <template>
-  <div
-    class="navigation-bar"
-    :style="{
-      height: `${property.navBarHeight}px`,
-      backgroundColor: property.backgroundColor,
-      backgroundImage: `url(${property.backgroundImage})`
-    }"
-  >
-    <!-- 左侧 -->
-    <div class="left">
-      <Icon icon="ep:arrow-left" v-show="property.showGoBack" />
+  <div class="navigation-bar" :style="bgStyle">
+    <div class="h-full w-full flex items-center">
+      <div v-for="(cell, cellIndex) in cellList" :key="cellIndex" :style="getCellStyle(cell)">
+        <span v-if="cell.type === 'text'">{{ cell.text }}</span>
+        <img v-else-if="cell.type === 'image'" :src="cell.imgUrl" alt="" class="h-full w-full" />
+        <SearchBar v-else :property="getSearchProp" />
+      </div>
     </div>
-    <!-- 中间 -->
-    <div
-      class="center"
-      :style="{
-        height: `${property.navBarHeight}px`,
-        lineHeight: `${property.navBarHeight}px`
-      }"
-    >
-      {{ property.title }}
-    </div>
-    <!-- 右侧 -->
-    <div class="right"></div>
+    <img
+      v-if="property._local?.previewMp"
+      src="@/assets/imgs/diy/app-nav-bar-mp.png"
+      alt=""
+      class="h-30px w-86px"
+    />
   </div>
 </template>
 <script setup lang="ts">
-import { NavigationBarProperty } from './config'
+import { NavigationBarCellProperty, NavigationBarProperty } from './config'
+import SearchBar from '@/components/DiyEditor/components/mobile/SearchBar/index.vue'
+import { StyleValue } from 'vue'
+import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config'
 
 /** 页面顶部导航栏 */
 defineOptions({ name: 'NavigationBar' })
 
-defineProps<{ property: NavigationBarProperty }>()
+const props = defineProps<{ property: NavigationBarProperty }>()
+
+// 背景
+const bgStyle = computed(() => {
+  const background =
+    props.property.bgType === 'img' && props.property.bgImg
+      ? `url(${props.property.bgImg}) no-repeat top center / 100% 100%`
+      : props.property.bgColor
+  return { background }
+})
+// 单元格列表
+const cellList = computed(() =>
+  props.property._local?.previewMp ? props.property.mpCells : props.property.otherCells
+)
+// 单元格宽度
+const cellWidth = computed(() => {
+  return props.property._local?.previewMp ? (375 - 80 - 86) / 6 : (375 - 90) / 8
+})
+// 获得单元格样式
+const getCellStyle = (cell: NavigationBarCellProperty) => {
+  return {
+    width: cell.width * cellWidth.value + (cell.width - 1) * 10 + 'px',
+    left: cell.left * cellWidth.value + (cell.left + 1) * 10 + 'px',
+    position: 'absolute'
+  } as StyleValue
+}
+// 获得搜索框属性
+const getSearchProp = (cell: NavigationBarCellProperty) => {
+  return {
+    height: 30,
+    showScan: false,
+    placeholder: cell.placeholder,
+    borderRadius: cell.borderRadius
+  } as SearchProperty
+}
 </script>
 <style lang="scss" scoped>
 .navigation-bar {
   display: flex;
-  height: 35px;
+  height: 50px;
   background: #fff;
   justify-content: space-between;
   align-items: center;
+  padding: 0 6px;
 
   /* 左边 */
   .left {

+ 54 - 31
src/components/DiyEditor/components/mobile/NavigationBar/property.vue

@@ -1,53 +1,73 @@
 <template>
   <el-form label-width="80px" :model="formData" :rules="rules">
-    <el-form-item label="页面标题" prop="title">
-      <el-input v-model="formData!.title" placeholder="页面标题" maxlength="25" show-word-limit />
-    </el-form-item>
-    <el-form-item label="页面描述" prop="description">
-      <el-input
-        type="textarea"
-        v-model="formData!.description"
-        placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
-      />
-    </el-form-item>
     <el-form-item label="样式" prop="styleType">
       <el-radio-group v-model="formData!.styleType">
-        <el-radio label="default">默认</el-radio>
-        <el-radio label="immersion">沉浸式</el-radio>
+        <el-radio label="normal">标准</el-radio>
+        <el-tooltip
+          content="沉侵式头部仅支持微信小程序、APP,建议页面第一个组件为图片展示类组件"
+          placement="top"
+        >
+          <el-radio label="inner">沉浸式</el-radio>
+        </el-tooltip>
       </el-radio-group>
     </el-form-item>
-    <el-form-item label="常驻显示" prop="alwaysShow" v-if="formData.styleType === 'immersion'">
+    <el-form-item label="常驻显示" prop="alwaysShow" v-if="formData.styleType === 'inner'">
       <el-radio-group v-model="formData!.alwaysShow">
         <el-radio :label="false">关闭</el-radio>
-        <el-radio :label="true">开启</el-radio>
+        <el-tooltip content="常驻显示关闭后,头部小组件将在页面滑动时淡入" placement="top">
+          <el-radio :label="true">开启</el-radio>
+        </el-tooltip>
       </el-radio-group>
     </el-form-item>
-    <el-form-item label="高度" prop="navBarHeight">
-      <el-slider
-        v-model="formData!.navBarHeight"
-        :max="100"
-        :min="35"
-        show-input
-        input-size="small"
-      />
-    </el-form-item>
-    <el-form-item label="返回按钮" prop="showGoBack">
-      <el-switch v-model="formData!.showGoBack" />
+    <el-form-item label="背景类型" prop="bgType">
+      <el-radio-group v-model="formData.bgType">
+        <el-radio label="color">纯色</el-radio>
+        <el-radio label="img">图片</el-radio>
+      </el-radio-group>
     </el-form-item>
-    <el-form-item label="背景颜色" prop="backgroundColor">
-      <ColorInput v-model="formData!.backgroundColor" />
+    <el-form-item label="背景颜色" prop="bgColor" v-if="formData.bgType === 'color'">
+      <ColorInput v-model="formData.bgColor" />
     </el-form-item>
-    <el-form-item label="背景图片" prop="backgroundImage">
-      <UploadImg v-model="formData!.backgroundImage" :limit="1">
-        <template #tip>建议宽度 750px</template>
-      </UploadImg>
+    <el-form-item label="背景图片" prop="bgImg" v-else>
+      <UploadImg v-model="formData.bgImg" :limit="1" width="56px" height="56px" />
     </el-form-item>
+    <el-card class="property-group" shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span>内容(小程序)</span>
+          <el-form-item prop="_local.previewMp" class="m-b-0!">
+            <el-checkbox
+              v-model="formData._local.previewMp"
+              @change="formData._local.previewOther = !formData._local.previewMp"
+              >预览</el-checkbox
+            >
+          </el-form-item>
+        </div>
+      </template>
+      <NavigationBarCellProperty v-model="formData.mpCells" is-mp />
+    </el-card>
+    <el-card class="property-group" shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span>内容(非小程序)</span>
+          <el-form-item prop="_local.previewOther" class="m-b-0!">
+            <el-checkbox
+              v-model="formData._local.previewOther"
+              @change="formData._local.previewMp = !formData._local.previewOther"
+              >预览</el-checkbox
+            >
+          </el-form-item>
+        </div>
+      </template>
+      <NavigationBarCellProperty v-model="formData.otherCells" :is-mp="false" />
+    </el-card>
   </el-form>
 </template>
 
 <script setup lang="ts">
 import { NavigationBarProperty } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
+import NavigationBarCellProperty from '@/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue'
 // 导航栏属性面板
 defineOptions({ name: 'NavigationBarProperty' })
 // 表单校验
@@ -58,6 +78,9 @@ const rules = {
 const props = defineProps<{ modelValue: NavigationBarProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
+if (!formData.value._local) {
+  formData.value._local = { previewMp: true, previewOther: false }
+}
 </script>
 
 <style scoped lang="scss"></style>

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

@@ -0,0 +1,4 @@
+import MyFormCreateDesigner from './src/MyFormCreateDesigner.vue'
+import { useFormCreateDesigner } from './src/useFormCreateDesigner'
+
+export { MyFormCreateDesigner, useFormCreateDesigner }

+ 33 - 0
src/components/FormCreate/src/MyFormCreateDesigner.vue

@@ -0,0 +1,33 @@
+<!-- TODO puhui999: 没啥问题的话准备移除 -->
+<template>
+  <FcDesigner ref="designer" height="780px" />
+</template>
+
+<script lang="ts" setup>
+import { useUploadFileRule, useUploadImgRule, useUploadImgsRule } from './config'
+
+defineOptions({ name: 'MyFormCreateDesigner' })
+
+const designer = ref() // 表单设计器
+const uploadFileRule = useUploadFileRule()
+const uploadImgRule = useUploadImgRule()
+const uploadImgsRule = useUploadImgsRule()
+
+onMounted(() => {
+  // 移除自带的上传组件规则
+  designer.value?.removeMenuItem('upload')
+  const components = [uploadFileRule, uploadImgRule, uploadImgsRule]
+  components.forEach((component) => {
+    //插入组件规则
+    designer.value?.addComponent(component)
+    //插入拖拽按钮到`main`分类下
+    designer.value?.appendMenuItem('main', {
+      icon: component.icon,
+      name: component.name,
+      label: component.label
+    })
+  })
+})
+</script>
+
+<style lang="scss" scoped></style>

+ 13 - 0
src/components/FormCreate/src/config/index.ts

@@ -0,0 +1,13 @@
+import { useUploadFileRule } from './useUploadFileRule'
+import { useUploadImgRule } from './useUploadImgRule'
+import { useUploadImgsRule } from './useUploadImgsRule'
+import { useDictSelectRule } from './useDictSelectRule'
+import { useUserSelectRule } from './useUserSelectRule'
+
+export {
+  useUploadFileRule,
+  useUploadImgRule,
+  useUploadImgsRule,
+  useDictSelectRule,
+  useUserSelectRule
+}

+ 124 - 0
src/components/FormCreate/src/config/useDictSelectRule.ts

@@ -0,0 +1,124 @@
+import { generateUUID } from '@/utils'
+import * as DictDataApi from '@/api/system/dict/dict.type'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useDictSelectRule = () => {
+  const label = '字典选择器'
+  const name = 'DictSelect'
+  const dictOptions = ref<{ label: string; value: string }[]>([]) // 字典类型下拉数据
+  onMounted(async () => {
+    const data = await DictDataApi.getSimpleDictTypeList()
+    if (!data || data.length === 0) {
+      return
+    }
+    dictOptions.value =
+      data?.map((item: DictDataApi.DictTypeVO) => ({
+        label: item.name,
+        value: item.type
+      })) ?? []
+  })
+  return {
+    icon: 'icon-select',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'select',
+          field: 'dictType',
+          title: '字典类型',
+          value: '',
+          options: dictOptions.value
+        },
+        {
+          type: 'select',
+          field: 'valueType',
+          title: '字典值类型',
+          value: 'str',
+          options: [
+            { label: '数字', value: 'int' },
+            { label: '字符串', value: 'str' },
+            { label: '布尔值', value: 'bool' }
+          ]
+        },
+        { type: 'switch', field: 'multiple', title: '是否多选' },
+        {
+          type: 'switch',
+          field: 'disabled',
+          title: '是否禁用'
+        },
+        { type: 'switch', field: 'clearable', title: '是否可以清空选项' },
+        {
+          type: 'switch',
+          field: 'collapseTags',
+          title: '多选时是否将选中值按文字的形式展示'
+        },
+        {
+          type: 'inputNumber',
+          field: 'multipleLimit',
+          title: '多选时用户最多可以选择的项目数,为 0 则不限制',
+          props: { min: 0 }
+        },
+        {
+          type: 'input',
+          field: 'autocomplete',
+          title: 'autocomplete 属性'
+        },
+        { type: 'input', field: 'placeholder', title: '占位符' },
+        {
+          type: 'switch',
+          field: 'filterable',
+          title: '是否可搜索'
+        },
+        { type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
+        {
+          type: 'input',
+          field: 'noMatchText',
+          title: '搜索条件无匹配时显示的文字'
+        },
+        {
+          type: 'switch',
+          field: 'remote',
+          title: '其中的选项是否从服务器远程加载'
+        },
+        {
+          type: 'Struct',
+          field: 'remoteMethod',
+          title: '自定义远程搜索方法'
+        },
+        { type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
+        {
+          type: 'switch',
+          field: 'reserveKeyword',
+          title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词'
+        },
+        {
+          type: 'switch',
+          field: 'defaultFirstOption',
+          title: '在输入框按下回车,选择第一个匹配项'
+        },
+        {
+          type: 'switch',
+          field: 'popperAppendToBody',
+          title: '是否将弹出框插入至 body 元素',
+          value: true
+        },
+        {
+          type: 'switch',
+          field: 'automaticDropdown',
+          title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单'
+        }
+      ])
+    }
+  }
+}

+ 80 - 0
src/components/FormCreate/src/config/useUploadFileRule.ts

@@ -0,0 +1,80 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUploadFileRule = () => {
+  const label = '文件上传'
+  const name = 'UploadFile'
+  return {
+    icon: 'icon-upload',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'select',
+          field: 'fileType',
+          title: '文件类型',
+          value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
+          options: [
+            { label: 'doc', value: 'doc' },
+            { label: 'xls', value: 'xls' },
+            { label: 'ppt', value: 'ppt' },
+            { label: 'txt', value: 'txt' },
+            { label: 'pdf', value: 'pdf' }
+          ],
+          props: {
+            multiple: true
+          }
+        },
+        {
+          type: 'switch',
+          field: 'autoUpload',
+          title: '是否在选取文件后立即进行上传',
+          value: true
+        },
+        {
+          type: 'switch',
+          field: 'drag',
+          title: '拖拽上传',
+          value: false
+        },
+        {
+          type: 'switch',
+          field: 'isShowTip',
+          title: '是否显示提示',
+          value: true
+        },
+        {
+          type: 'inputNumber',
+          field: 'fileSize',
+          title: '大小限制(MB)',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'inputNumber',
+          field: 'limit',
+          title: '数量限制',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'switch',
+          field: 'disabled',
+          title: '是否禁用',
+          value: false
+        }
+      ])
+    }
+  }
+}

+ 89 - 0
src/components/FormCreate/src/config/useUploadImgRule.ts

@@ -0,0 +1,89 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUploadImgRule = () => {
+  const label = '单图上传'
+  const name = 'UploadImg'
+  return {
+    icon: 'icon-upload',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'switch',
+          field: 'drag',
+          title: '拖拽上传',
+          value: false
+        },
+        {
+          type: 'select',
+          field: 'fileType',
+          title: '图片类型限制',
+          value: ['image/jpeg', 'image/png', 'image/gif'],
+          options: [
+            { label: 'image/apng', value: 'image/apng' },
+            { label: 'image/bmp', value: 'image/bmp' },
+            { label: 'image/gif', value: 'image/gif' },
+            { label: 'image/jpeg', value: 'image/jpeg' },
+            { label: 'image/pjpeg', value: 'image/pjpeg' },
+            { label: 'image/svg+xml', value: 'image/svg+xml' },
+            { label: 'image/tiff', value: 'image/tiff' },
+            { label: 'image/webp', value: 'image/webp' },
+            { label: 'image/x-icon', value: 'image/x-icon' }
+          ],
+          props: {
+            multiple: true
+          }
+        },
+        {
+          type: 'inputNumber',
+          field: 'fileSize',
+          title: '大小限制(MB)',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'input',
+          field: 'height',
+          title: '组件高度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'width',
+          title: '组件宽度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'borderradius',
+          title: '组件边框圆角',
+          value: '8px'
+        },
+        {
+          type: 'switch',
+          field: 'disabled',
+          title: '是否显示删除按钮',
+          value: true
+        },
+        {
+          type: 'switch',
+          field: 'showBtnText',
+          title: '是否显示按钮文字',
+          value: true
+        }
+      ])
+    }
+  }
+}

+ 84 - 0
src/components/FormCreate/src/config/useUploadImgsRule.ts

@@ -0,0 +1,84 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUploadImgsRule = () => {
+  const label = '多图上传'
+  const name = 'UploadImgs'
+  return {
+    icon: 'icon-upload',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'switch',
+          field: 'drag',
+          title: '拖拽上传',
+          value: false
+        },
+        {
+          type: 'select',
+          field: 'fileType',
+          title: '图片类型限制',
+          value: ['image/jpeg', 'image/png', 'image/gif'],
+          options: [
+            { label: 'image/apng', value: 'image/apng' },
+            { label: 'image/bmp', value: 'image/bmp' },
+            { label: 'image/gif', value: 'image/gif' },
+            { label: 'image/jpeg', value: 'image/jpeg' },
+            { label: 'image/pjpeg', value: 'image/pjpeg' },
+            { label: 'image/svg+xml', value: 'image/svg+xml' },
+            { label: 'image/tiff', value: 'image/tiff' },
+            { label: 'image/webp', value: 'image/webp' },
+            { label: 'image/x-icon', value: 'image/x-icon' }
+          ],
+          props: {
+            multiple: true
+          }
+        },
+        {
+          type: 'inputNumber',
+          field: 'fileSize',
+          title: '大小限制(MB)',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'inputNumber',
+          field: 'limit',
+          title: '数量限制',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'input',
+          field: 'height',
+          title: '组件高度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'width',
+          title: '组件宽度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'borderradius',
+          title: '组件边框圆角',
+          value: '8px'
+        }
+      ])
+    }
+  }
+}

+ 93 - 0
src/components/FormCreate/src/config/useUserSelectRule.ts

@@ -0,0 +1,93 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUserSelectRule = () => {
+  const label = '用户选择器'
+  const name = 'UserSelect'
+  return {
+    icon: 'icon-select',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        { type: 'switch', field: 'multiple', title: '是否多选' },
+        {
+          type: 'switch',
+          field: 'disabled',
+          title: '是否禁用'
+        },
+        { type: 'switch', field: 'clearable', title: '是否可以清空选项' },
+        {
+          type: 'switch',
+          field: 'collapseTags',
+          title: '多选时是否将选中值按文字的形式展示'
+        },
+        {
+          type: 'inputNumber',
+          field: 'multipleLimit',
+          title: '多选时用户最多可以选择的项目数,为 0 则不限制',
+          props: { min: 0 }
+        },
+        {
+          type: 'input',
+          field: 'autocomplete',
+          title: 'autocomplete 属性'
+        },
+        { type: 'input', field: 'placeholder', title: '占位符' },
+        {
+          type: 'switch',
+          field: 'filterable',
+          title: '是否可搜索'
+        },
+        { type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
+        {
+          type: 'input',
+          field: 'noMatchText',
+          title: '搜索条件无匹配时显示的文字'
+        },
+        {
+          type: 'switch',
+          field: 'remote',
+          title: '其中的选项是否从服务器远程加载'
+        },
+        {
+          type: 'Struct',
+          field: 'remoteMethod',
+          title: '自定义远程搜索方法'
+        },
+        { type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
+        {
+          type: 'switch',
+          field: 'reserveKeyword',
+          title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词'
+        },
+        {
+          type: 'switch',
+          field: 'defaultFirstOption',
+          title: '在输入框按下回车,选择第一个匹配项'
+        },
+        {
+          type: 'switch',
+          field: 'popperAppendToBody',
+          title: '是否将弹出框插入至 body 元素',
+          value: true
+        },
+        {
+          type: 'switch',
+          field: 'automaticDropdown',
+          title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单'
+        }
+      ])
+    }
+  }
+}

+ 45 - 0
src/components/FormCreate/src/useFormCreateDesigner.ts

@@ -0,0 +1,45 @@
+import {
+  useDictSelectRule,
+  useUploadFileRule,
+  useUploadImgRule,
+  useUploadImgsRule,
+  useUserSelectRule
+} from './config'
+import { Ref } from 'vue'
+
+/**
+ * 表单设计器增强 hook
+ * 新增
+ * - 文件上传
+ * - 单图上传
+ * - 多图上传
+ */
+export const useFormCreateDesigner = (designer: Ref) => {
+  const uploadFileRule = useUploadFileRule()
+  const uploadImgRule = useUploadImgRule()
+  const uploadImgsRule = useUploadImgsRule()
+  const dictSelectRule = useDictSelectRule()
+  const userSelectRule = useUserSelectRule()
+
+  onMounted(() => {
+    // 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
+    designer.value?.removeMenuItem('upload')
+    const components = [
+      uploadFileRule,
+      uploadImgRule,
+      uploadImgsRule,
+      dictSelectRule,
+      userSelectRule
+    ]
+    components.forEach((component) => {
+      // 插入组件规则
+      designer.value?.addComponent(component)
+      // 插入拖拽按钮到 `main` 分类下
+      designer.value?.appendMenuItem('main', {
+        icon: component.icon,
+        name: component.name,
+        label: component.label
+      })
+    })
+  })
+}

+ 79 - 0
src/components/FormCreate/src/utils/index.ts

@@ -0,0 +1,79 @@
+// TODO puhui999: 借鉴一下 form-create-designer utils 方法 🤣 (导入不了只能先 copy 过来用下)
+export function makeRequiredRule() {
+  return {
+    type: 'Required',
+    field: 'formCreate$required',
+    title: '是否必填'
+  }
+}
+
+export const localeProps = (t, prefix, rules) => {
+  return rules.map((rule) => {
+    if (rule.field === 'formCreate$required') {
+      rule.title = t('props.required') || rule.title
+    } else if (rule.field && rule.field !== '_optionType') {
+      rule.title = t('components.' + prefix + '.' + rule.field) || rule.title
+    }
+    return rule
+  })
+}
+
+export function upper(str) {
+  return str.replace(str[0], str[0].toLocaleUpperCase())
+}
+
+export function makeOptionsRule(t, to, userOptions) {
+  console.log(userOptions[0])
+  const options = [
+    { label: t('props.optionsType.struct'), value: 0 },
+    { label: t('props.optionsType.json'), value: 1 },
+    { label: '用户数据', value: 2 }
+  ]
+
+  const control = [
+    {
+      value: 0,
+      rule: [
+        {
+          type: 'TableOptions',
+          field: 'formCreate' + upper(to).replace('.', '>'),
+          props: { defaultValue: [] }
+        }
+      ]
+    },
+    {
+      value: 1,
+      rule: [
+        {
+          type: 'Struct',
+          field: 'formCreate' + upper(to).replace('.', '>'),
+          props: { defaultValue: [] }
+        }
+      ]
+    },
+    {
+      value: 2,
+      rule: [
+        {
+          type: 'TableOptions',
+          field: 'formCreate' + upper(to).replace('.', '>'),
+          props: { modelValue: [] }
+        }
+      ]
+    }
+  ]
+  options.splice(0, 0)
+  control.push()
+
+  return {
+    type: 'radio',
+    title: t('props.options'),
+    field: '_optionType',
+    value: 0,
+    options,
+    props: {
+      type: 'button'
+    },
+    control
+  }
+}

+ 1 - 1
src/components/MagicCubeEditor/index.vue

@@ -189,7 +189,7 @@ const emit = defineEmits(['update:modelValue', 'hotAreaSelected'])
 const emitUpdateModelValue = () => emit('update:modelValue', hotAreas)
 
 // 热区选中
-const selectedHotAreaIndex = ref(-1)
+const selectedHotAreaIndex = ref(0)
 const handleHotAreaSelected = (hotArea: Rect, index: number) => {
   selectedHotAreaIndex.value = index
   emit('hotAreaSelected', hotArea, index)

+ 6 - 5
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>
@@ -48,13 +49,13 @@ const emit = defineEmits(['update:modelValue'])
 
 const props = defineProps({
   modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
-  title: propTypes.string.def('文件上传'),
   fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
   fileSize: propTypes.number.def(5), // 大小限制(MB)
   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">

+ 19 - 9
src/plugins/formCreate/index.ts

@@ -1,22 +1,26 @@
 import type { App } from 'vue'
 // 👇使用 form-create 需额外全局引入 element plus 组件
 import {
+  ElAlert,
   ElAside,
-  ElPopconfirm,
-  ElHeader,
-  ElMain,
   ElContainer,
   ElDivider,
-  ElTransfer,
-  ElAlert,
-  ElTabs,
+  ElHeader,
+  ElMain,
+  ElPopconfirm,
   ElTable,
   ElTableColumn,
-  ElTabPane
+  ElTabPane,
+  ElTabs,
+  ElTransfer
 } from 'element-plus'
-
+import FcDesigner from '@form-create/designer'
 import formCreate from '@form-create/element-ui'
 import install from '@form-create/element-ui/auto-import'
+//======================= 自定义组件 =======================
+import { UploadFile, UploadImg, UploadImgs } from '@/components/UploadFile'
+import { DictSelect } from '@/components/DictSelect'
+import UserSelect from '@/views/system/user/components/UserSelect.vue'
 
 const components = [
   ElAside,
@@ -30,7 +34,12 @@ const components = [
   ElTabs,
   ElTable,
   ElTableColumn,
-  ElTabPane
+  ElTabPane,
+  UploadImg,
+  UploadImgs,
+  UploadFile,
+  DictSelect,
+  UserSelect
 ]
 
 // 参考 http://www.form-create.com/v3/element-ui/auto-import.html 文档
@@ -40,4 +49,5 @@ export const setupFormCreate = (app: App<Element>) => {
   })
   formCreate.use(install)
   app.use(formCreate)
+  app.use(FcDesigner)
 }

+ 3 - 3
src/utils/constants.ts

@@ -248,15 +248,15 @@ export const CouponTemplateTakeTypeEnum = {
  */
 export const PromotionProductScopeEnum = {
   ALL: {
-    scope: 10,
+    scope: 1,
     name: '通用劵'
   },
   SPU: {
-    scope: 20,
+    scope: 2,
     name: '商品劵'
   },
   CATEGORY: {
-    scope: 30,
+    scope: 3,
     name: '品类劵'
   }
 }

+ 3 - 2
src/utils/dict.ts

@@ -1,8 +1,8 @@
 /**
  * 数据字典工具类
  */
-import { useDictStoreWithOut } from '@/store/modules/dict'
-import { ElementPlusInfoType } from '@/types/elementPlus'
+import {useDictStoreWithOut} from '@/store/modules/dict'
+import {ElementPlusInfoType} from '@/types/elementPlus'
 
 const dictStore = useDictStoreWithOut()
 
@@ -104,6 +104,7 @@ export enum DICT_TYPE {
   USER_TYPE = 'user_type',
   COMMON_STATUS = 'common_status',
   TERMINAL = 'terminal', // 终端
+  DATE_INTERVAL = 'date_interval', // 数据间隔
 
   // ========== SYSTEM 模块 ==========
   SYSTEM_USER_SEX = 'system_user_sex',

+ 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)) {

+ 15 - 1
src/utils/index.ts

@@ -329,10 +329,11 @@ const ERP_PRICE_DIGIT = 2
  * 例如说:库存数量
  *
  * @param num 数量
+ * @package digit 保留的小数位数
  * @return 格式化后的数量
  */
 export const erpNumberFormatter = (num: number | string | undefined, digit: number) => {
-  if (num === null) {
+  if (num == null) {
     return ''
   }
   if (typeof num === 'string') {
@@ -404,3 +405,16 @@ export const erpPriceMultiply = (price: number, count: number) => {
   }
   return parseFloat((price * count).toFixed(ERP_PRICE_DIGIT))
 }
+
+/**
+ * 【ERP】百分比计算,四舍五入保留两位小数
+ *
+ * 如果 total 为 0,则返回 0
+ *
+ * @param value 当前值
+ * @param total 总值
+ */
+export const erpCalculatePercentage = (value: number, total: number) => {
+  if (total === 0) return 0
+  return ((value / total) * 100).toFixed(2)
+}

+ 2 - 0
src/views/bpm/form/editor/index.vue

@@ -45,6 +45,7 @@ import * as FormApi from '@/api/bpm/form'
 import FcDesigner from '@form-create/designer'
 import { encodeConf, encodeFields, setConfAndFields } from '@/utils/formCreate'
 import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useFormCreateDesigner } from '@/components/FormCreate'
 
 defineOptions({ name: 'BpmFormEditor' })
 
@@ -55,6 +56,7 @@ const { query } = useRoute() // 路由信息
 const { delView } = useTagsViewStore() // 视图操作
 
 const designer = ref() // 表单设计器
+useFormCreateDesigner(designer) // 表单设计器增强
 const dialogVisible = ref(false) // 弹窗是否展示
 const formLoading = ref(false) // 表单的加载中:提交的按钮禁用
 const formData = ref({

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

@@ -24,15 +24,15 @@
             {{ processInstance?.startUser.nickname }}
             <el-tag size="small" type="info">{{ processInstance?.startUser.deptName }}</el-tag>
           </el-form-item>
-          <el-card class="mb-15px !-mt-10px" v-if="runningTasks[index].formId > 0">
+          <el-card v-if="runningTasks[index].formId > 0" class="mb-15px !-mt-10px">
             <template #header>
               <span class="el-icon-picture-outline">
                 填写表单【{{ runningTasks[index]?.formName }}】
               </span>
             </template>
             <form-create
-              v-model:api="approveFormFApis[index]"
               v-model="approveForms[index].value"
+              v-model:api="approveFormFApis[index]"
               :option="approveForms[index].option"
               :rule="approveForms[index].rule"
             />
@@ -92,8 +92,8 @@
       <!-- 情况一:流程表单 -->
       <el-col v-if="processInstance?.processDefinition?.formType === 10" :offset="6" :span="16">
         <form-create
-          ref="fApi"
           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

+ 9 - 3
src/views/crm/permission/components/TransferForm.vue

@@ -19,7 +19,7 @@
         </el-select>
       </el-form-item>
       <el-form-item label="老负责人">
-        <el-radio-group v-model="oldOwnerHandler" @change="formData.oldOwnerPermissionLevel">
+        <el-radio-group v-model="oldOwnerHandler" @change="handleOwnerChange">
           <el-radio :label="false" size="large">移除</el-radio>
           <el-radio :label="true" size="large">加入团队</el-radio>
         </el-radio-group>
@@ -86,10 +86,16 @@ const open = async (bizId: number) => {
   dialogVisible.value = true
   dialogTitle.value = getDialogTitle()
   resetForm()
-  formData.value.bizId = bizId
+  formData.value.id = bizId
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
-
+// 老负责人负责方式
+const handleOwnerChange = (val: boolean) => {
+  if (!val) {
+    // 移除的话提交不带 oldOwnerPermissionLevel 参数
+    formData.value.oldOwnerPermissionLevel = undefined
+  }
+}
 /** 提交表单 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
 const submitForm = async () => {

+ 53 - 13
src/views/crm/statistics/customer/components/CustomerConversionStat.vue

@@ -10,11 +10,39 @@
   <!-- 统计列表 -->
   <el-card shadow="never" class="mt-16px">
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="序号" align="center" type="index" width="80" />
-      <el-table-column label="客户名称" align="center" prop="customerName" min-width="200" />
+      <el-table-column label="序号" align="center" type="index" width="80" fixed="left" />
+      <el-table-column
+        label="客户名称"
+        align="center"
+        prop="customerName"
+        min-width="200"
+        fixed="left"
+      />
       <el-table-column label="合同名称" align="center" prop="contractName" min-width="200" />
-      <el-table-column label="合同总金额" align="center" prop="totalPrice" min-width="200" />
-      <el-table-column label="回款金额" align="center" prop="receivablePrice" min-width="200" />
+      <el-table-column
+        label="合同总金额"
+        align="center"
+        prop="totalPrice"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="回款金额"
+        align="center"
+        prop="receivablePrice"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <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="客户行业" 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 label="负责人" align="center" prop="ownerUserName" min-width="200" />
       <el-table-column label="创建人" align="center" prop="creatorUserName" min-width="200" />
       <el-table-column
@@ -28,8 +56,9 @@
         label="下单日期"
         align="center"
         prop="orderDate"
-        :formatter="dateFormatter2"
+        :formatter="dateFormatter"
         min-width="200"
+        fixed="right"
       />
     </el-table>
   </el-card>
@@ -40,10 +69,12 @@ import {
   CrmStatisticsCustomerSummaryByDateRespVO
 } from '@/api/crm/statistics/customer'
 import { EChartsOption } from 'echarts'
-import { round } from 'lodash-es'
-import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
+import { dateFormatter } from '@/utils/formatTime'
+import { erpPriceTableColumnFormatter } from '@/utils'
+import { DICT_TYPE } from '@/utils/dict'
 
 defineOptions({ name: 'CustomerConversionStat' })
+
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
@@ -53,7 +84,7 @@ const list = ref<CrmStatisticsCustomerSummaryByDateRespVO[]>([]) // 列表的数
 const echartsOption = reactive<EChartsOption>({
   grid: {
     left: 20,
-    right: 20,
+    right: 40, // 让 X 轴右侧显示完整
     bottom: 20,
     containLabel: true
   },
@@ -93,10 +124,9 @@ const echartsOption = reactive<EChartsOption>({
   }
 }) as EChartsOption
 
-/** 获取统计数据 */
-const loadData = async () => {
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
   // 1. 加载统计数据
-  loading.value = true
   const customerCount = await StatisticsCustomerApi.getCustomerSummaryByDate(props.queryParams)
   const contractSummary = await StatisticsCustomerApi.getContractSummary(props.queryParams)
   // 2.1 更新 Echarts 数据
@@ -111,7 +141,7 @@ const loadData = async () => {
         return {
           name: item.time,
           value: item.customerCreateCount
-            ? round((item.customerDealCount / item.customerCreateCount) * 100, 2)
+            ? ((item.customerDealCount / item.customerCreateCount) * 100).toFixed(2)
             : 0
         }
       }
@@ -119,8 +149,18 @@ const loadData = async () => {
   }
   // 2.2 更新列表数据
   list.value = contractSummary
-  loading.value = false
 }
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
 defineExpose({ loadData })
 
 /** 初始化 */

+ 40 - 13
src/views/crm/statistics/customer/components/CustomerDealCycle.vue

@@ -26,11 +26,12 @@
 import {
   StatisticsCustomerApi,
   CrmStatisticsCustomerDealCycleByDateRespVO,
-  CrmStatisticsCustomerSummaryByDateRespVO,
+  CrmStatisticsCustomerSummaryByDateRespVO
 } from '@/api/crm/statistics/customer'
 import { EChartsOption } from 'echarts'
 
 defineOptions({ name: 'CustomerDealCycle' })
+
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
@@ -40,7 +41,7 @@ const list = ref<CrmStatisticsCustomerDealCycleByDateRespVO[]>([]) // 列表的
 const echartsOption = reactive<EChartsOption>({
   grid: {
     left: 20,
-    right: 20,
+    right: 40, // 让 X 轴右侧显示完整
     bottom: 20,
     containLabel: true
   },
@@ -49,12 +50,14 @@ const echartsOption = reactive<EChartsOption>({
     {
       name: '成交周期(天)',
       type: 'bar',
-      data: []
+      data: [],
+      yAxisIndex: 0
     },
     {
       name: '成交客户数',
       type: 'bar',
-      data: []
+      data: [],
+      yAxisIndex: 1
     }
   ],
   toolbox: {
@@ -74,10 +77,26 @@ const echartsOption = reactive<EChartsOption>({
       type: 'shadow'
     }
   },
-  yAxis: {
-    type: 'value',
-    name: '数量(个)'
-  },
+  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: '日期',
@@ -85,14 +104,13 @@ const echartsOption = reactive<EChartsOption>({
   }
 }) as EChartsOption
 
-/** 获取统计数据 */
-const loadData = async () => {
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
   // 1. 加载统计数据
-  loading.value = true
   const customerDealCycleByDate = await StatisticsCustomerApi.getCustomerDealCycleByDate(
     props.queryParams
   )
-    const customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate(
+  const customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate(
     props.queryParams
   )
   const customerDealCycleByUser = await StatisticsCustomerApi.getCustomerDealCycleByUser(
@@ -116,7 +134,16 @@ const loadData = async () => {
   }
   // 2.2 更新列表数据
   list.value = customerDealCycleByUser
-  loading.value = false
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
 }
 defineExpose({ loadData })
 

+ 54 - 22
src/views/crm/statistics/customer/components/CustomerFollowupSummary.vue → src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue

@@ -12,11 +12,11 @@
     <el-table v-loading="loading" :data="list">
       <el-table-column label="序号" align="center" type="index" width="80" />
       <el-table-column label="员工姓名" align="center" prop="ownerUserName" min-width="200" />
-      <el-table-column label="跟进次数" align="right" prop="followupRecordCount" min-width="200" />
+      <el-table-column label="跟进次数" align="right" prop="followUpRecordCount" min-width="200" />
       <el-table-column
         label="跟进客户数"
         align="right"
-        prop="followupCustomerCount"
+        prop="followUpCustomerCount"
         min-width="200"
       />
     </el-table>
@@ -25,22 +25,24 @@
 <script setup lang="ts">
 import {
   StatisticsCustomerApi,
-  CrmStatisticsFollowupSummaryByDateRespVO,
-  CrmStatisticsFollowupSummaryByUserRespVO
+  CrmStatisticsFollowUpSummaryByDateRespVO,
+  CrmStatisticsFollowUpSummaryByUserRespVO
 } from '@/api/crm/statistics/customer'
+import Echart from '@/components/Echart/src/Echart.vue'
 import { EChartsOption } from 'echarts'
 
 defineOptions({ name: 'CustomerFollowupSummary' })
+
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
-const list = ref<CrmStatisticsFollowupSummaryByUserRespVO[]>([]) // 列表的数据
+const list = ref<CrmStatisticsFollowUpSummaryByUserRespVO[]>([]) // 列表的数据
 
 /** 柱状图配置:纵向 */
 const echartsOption = reactive<EChartsOption>({
   grid: {
     left: 20,
-    right: 20,
+    right: 30, // 让 X 轴右侧显示完整
     bottom: 20,
     containLabel: true
   },
@@ -49,11 +51,13 @@ const echartsOption = reactive<EChartsOption>({
     {
       name: '跟进客户数',
       type: 'bar',
+      yAxisIndex: 0,
       data: []
     },
     {
       name: '跟进次数',
       type: 'bar',
+      yAxisIndex: 1,
       data: []
     }
   ],
@@ -74,46 +78,74 @@ const echartsOption = reactive<EChartsOption>({
       type: 'shadow'
     }
   },
-  yAxis: {
-    type: 'value',
-    name: '数量(个)'
-  },
+  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: '日期',
+    axisTick: {
+      alignWithLabel: true
+    },
     data: []
   }
 }) as EChartsOption
 
-/** 获取统计数据 */
-const loadData = async () => {
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
   // 1. 加载统计数据
   loading.value = true
-  const followupSummaryByDate = await StatisticsCustomerApi.getFollowupSummaryByDate(
+  const followUpSummaryByDate = await StatisticsCustomerApi.getFollowUpSummaryByDate(
     props.queryParams
   )
-  const followupSummaryByUser = await StatisticsCustomerApi.getFollowupSummaryByUser(
+  const followUpSummaryByUser = await StatisticsCustomerApi.getFollowUpSummaryByUser(
     props.queryParams
   )
   // 2.1 更新 Echarts 数据
   if (echartsOption.xAxis && echartsOption.xAxis['data']) {
-    echartsOption.xAxis['data'] = followupSummaryByDate.map(
-      (s: CrmStatisticsFollowupSummaryByDateRespVO) => s.time
+    echartsOption.xAxis['data'] = followUpSummaryByDate.map(
+      (s: CrmStatisticsFollowUpSummaryByDateRespVO) => s.time
     )
   }
   if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
-    echartsOption.series[0]['data'] = followupSummaryByDate.map(
-      (s: CrmStatisticsFollowupSummaryByDateRespVO) => s.followupCustomerCount
+    echartsOption.series[0]['data'] = followUpSummaryByDate.map(
+      (s: CrmStatisticsFollowUpSummaryByDateRespVO) => s.followUpCustomerCount
     )
   }
   if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
-    echartsOption.series[1]['data'] = followupSummaryByDate.map(
-      (s: CrmStatisticsFollowupSummaryByDateRespVO) => s.followupRecordCount
+    echartsOption.series[1]['data'] = followUpSummaryByDate.map(
+      (s: CrmStatisticsFollowUpSummaryByDateRespVO) => s.followUpRecordCount
     )
   }
   // 2.2 更新列表数据
-  list.value = followupSummaryByUser
-  loading.value = false
+  list.value = followUpSummaryByUser
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
 }
 defineExpose({ loadData })
 

+ 34 - 19
src/views/crm/statistics/customer/components/CustomerFollowupType.vue → src/views/crm/statistics/customer/components/CustomerFollowUpType.vue

@@ -11,8 +11,12 @@
   <el-card shadow="never" class="mt-16px">
     <el-table v-loading="loading" :data="list">
       <el-table-column label="序号" align="center" type="index" width="80" />
-      <el-table-column label="跟进方式" align="center" prop="followupType" min-width="200" />
-      <el-table-column label="个数" align="center" prop="followupRecordCount" min-width="200" />
+      <el-table-column label="跟进方式" align="center" prop="followUpType" min-width="200">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_FOLLOW_UP_TYPE" :value="scope.row.followUpType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="个数" align="center" prop="followUpRecordCount" min-width="200" />
       <el-table-column label="占比(%)" align="center" prop="portion" min-width="200" />
     </el-table>
   </el-card>
@@ -20,16 +24,19 @@
 <script setup lang="ts">
 import {
   StatisticsCustomerApi,
-  CrmStatisticsFollowupSummaryByTypeRespVO
+  CrmStatisticsFollowUpSummaryByTypeRespVO
 } from '@/api/crm/statistics/customer'
 import { EChartsOption } from 'echarts'
-import { round, sumBy } from 'lodash-es'
+import { sumBy } from 'lodash-es'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { erpCalculatePercentage } from '@/utils'
 
 defineOptions({ name: 'CustomerFollowupType' })
+
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
-const list = ref<CrmStatisticsFollowupSummaryByTypeRespVO[]>([]) // 列表的数据
+const list = ref<CrmStatisticsFollowUpSummaryByTypeRespVO[]>([]) // 列表的数据
 
 /** 饼图配置 */
 const echartsOption = reactive<EChartsOption>({
@@ -67,35 +74,43 @@ const echartsOption = reactive<EChartsOption>({
   ]
 }) as EChartsOption
 
-/** 获取统计数据 */
-const loadData = async () => {
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
   // 1. 加载统计数据
-  loading.value = true
-  const followupSummaryByType = await StatisticsCustomerApi.getFollowupSummaryByType(
+  const followUpSummaryByType = await StatisticsCustomerApi.getFollowUpSummaryByType(
     props.queryParams
   )
   // 2.1 更新 Echarts 数据
   if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
-    echartsOption.series[0]['data'] = followupSummaryByType.map(
-      (r: CrmStatisticsFollowupSummaryByTypeRespVO) => {
+    echartsOption.series[0]['data'] = followUpSummaryByType.map(
+      (row: CrmStatisticsFollowUpSummaryByTypeRespVO) => {
         return {
-          name: r.followupType,
-          value: r.followupRecordCount
+          name: getDictLabel(DICT_TYPE.CRM_FOLLOW_UP_TYPE, row.followUpType),
+          value: row.followUpRecordCount
         }
       }
     )
   }
   // 2.2 更新列表数据
-  const totalCount = sumBy(followupSummaryByType, 'followupRecordCount')
-  list.value = followupSummaryByType.map((r: CrmStatisticsFollowupSummaryByTypeRespVO) => {
+  const totalCount = sumBy(followUpSummaryByType, 'followUpRecordCount')
+  list.value = followUpSummaryByType.map((row: CrmStatisticsFollowUpSummaryByTypeRespVO) => {
     return {
-      followupType: r.followupType,
-      followupRecordCount: r.followupRecordCount,
-      portion: round((r.followupRecordCount / totalCount) * 100, 2)
+      ...row,
+      portion: erpCalculatePercentage(row.followUpRecordCount, totalCount)
     }
   })
-  loading.value = false
 }
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
 defineExpose({ loadData })
 
 /** 初始化 */

+ 154 - 0
src/views/crm/statistics/customer/components/CustomerPoolSummary.vue

@@ -0,0 +1,154 @@
+<!-- 客户总量统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="序号" align="center" type="index" width="80" fixed="left" />
+      <el-table-column label="员工姓名" prop="ownerUserName" min-width="100" fixed="left" />
+      <el-table-column
+        label="进入公海客户数"
+        align="right"
+        prop="customerPutCount"
+        min-width="200"
+      />
+      <el-table-column
+        label="公海领取客户数"
+        align="right"
+        prop="customerTakeCount"
+        min-width="200"
+      />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  CrmStatisticsPoolSummaryByDateRespVO,
+  CrmStatisticsPoolSummaryByUserRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerPoolSummary' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticsPoolSummaryByUserRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 40, // 让 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 poolSummaryByDate = await StatisticsCustomerApi.getPoolSummaryByDate(props.queryParams)
+  const poolSummaryByUser = await StatisticsCustomerApi.getPoolSummaryByUser(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = poolSummaryByDate.map(
+      (s: CrmStatisticsPoolSummaryByDateRespVO) => s.time
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = poolSummaryByDate.map(
+      (s: CrmStatisticsPoolSummaryByDateRespVO) => s.customerPutCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = poolSummaryByDate.map(
+      (s: CrmStatisticsPoolSummaryByDateRespVO) => s.customerTakeCount
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = poolSummaryByUser
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 59 - 27
src/views/crm/statistics/customer/components/CustomerSummary.vue

@@ -10,8 +10,8 @@
   <!-- 统计列表 -->
   <el-card shadow="never" class="mt-16px">
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="序号" align="center" type="index" width="80" />
-      <el-table-column label="员工姓名" prop="ownerUserName" min-width="100" />
+      <el-table-column label="序号" align="center" type="index" width="80" fixed="left" />
+      <el-table-column label="员工姓名" prop="ownerUserName" min-width="100" fixed="left" />
       <el-table-column
         label="新增客户数"
         align="right"
@@ -21,28 +21,31 @@
       <el-table-column label="成交客户数" align="right" prop="customerDealCount" min-width="200" />
       <el-table-column label="客户成交率(%)" align="right" min-width="200">
         <template #default="scope">
-          {{
-            scope.row.customerCreateCount !== 0
-              ? round((scope.row.customerDealCount / scope.row.customerCreateCount) * 100, 2)
-              : 0
-          }}
+          {{ erpCalculatePercentage(scope.row.customerDealCount, scope.row.customerCreateCount) }}
         </template>
       </el-table-column>
-      <el-table-column label="合同总金额" align="right" prop="contractPrice" min-width="200" />
-      <el-table-column label="回款金额" align="right" prop="receivablePrice" min-width="200" />
+      <el-table-column
+        label="合同总金额"
+        align="right"
+        prop="contractPrice"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="回款金额"
+        align="right"
+        prop="receivablePrice"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
       <el-table-column label="未回款金额" align="right" min-width="200">
-        <!-- TODO @dhb52:参考 util/index.ts 的 // ========== ERP 专属方法 ========== 部分,搞个两个方法,一个格式化百分比,一个计算百分比  -->
         <template #default="scope">
-          {{ round(scope.row.contractPrice - scope.row.receivablePrice, 2) }}
+          {{ erpCalculatePercentage(scope.row.receivablePrice, scope.row.contractPrice) }}
         </template>
       </el-table-column>
-      <el-table-column label="回款完成率(%)" align="right" min-width="200">
+      <el-table-column label="回款完成率(%)" align="right" min-width="200" fixed="right">
         <template #default="scope">
-          {{
-            scope.row.contractPrice !== 0
-              ? round((scope.row.receivablePrice / scope.row.contractPrice) * 100, 2)
-              : 0
-          }}
+          {{ erpCalculatePercentage(scope.row.receivablePrice, scope.row.contractPrice) }}
         </template>
       </el-table-column>
     </el-table>
@@ -55,9 +58,10 @@ import {
   CrmStatisticsCustomerSummaryByUserRespVO
 } from '@/api/crm/statistics/customer'
 import { EChartsOption } from 'echarts'
-import { round } from 'lodash-es'
+import { erpCalculatePercentage, erpPriceTableColumnFormatter } from '@/utils'
 
 defineOptions({ name: 'CustomerSummary' })
+
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中
@@ -67,7 +71,7 @@ const list = ref<CrmStatisticsCustomerSummaryByUserRespVO[]>([]) // 列表的数
 const echartsOption = reactive<EChartsOption>({
   grid: {
     left: 20,
-    right: 20,
+    right: 30, // 让 X 轴右侧显示完整
     bottom: 20,
     containLabel: true
   },
@@ -76,11 +80,13 @@ const echartsOption = reactive<EChartsOption>({
     {
       name: '新增客户数',
       type: 'bar',
+      yAxisIndex: 0,
       data: []
     },
     {
       name: '成交客户数',
       type: 'bar',
+      yAxisIndex: 1,
       data: []
     }
   ],
@@ -101,10 +107,26 @@ const echartsOption = reactive<EChartsOption>({
       type: 'shadow'
     }
   },
-  yAxis: {
-    type: 'value',
-    name: '数量(个)'
-  },
+  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: '日期',
@@ -112,10 +134,9 @@ const echartsOption = reactive<EChartsOption>({
   }
 }) as EChartsOption
 
-/** 获取统计数据 */
-const loadData = async () => {
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
   // 1. 加载统计数据
-  loading.value = true
   const customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate(
     props.queryParams
   )
@@ -138,10 +159,21 @@ const loadData = async () => {
       (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerDealCount
     )
   }
+
   // 2.2 更新列表数据
   list.value = customerSummaryByUser
-  loading.value = false
 }
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
 defineExpose({ loadData })
 
 /** 初始化 */

+ 78 - 46
src/views/crm/statistics/customer/index.vue

@@ -3,49 +3,77 @@
   <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')]"
+          @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"
-          class="!w-240px"
           :data="deptList"
           :props="defaultProps"
           check-strictly
+          class="!w-240px"
           node-key="id"
           placeholder="请选择归属部门"
-          @change="queryParams.userId = undefined"
+          @change="(queryParams.userId = undefined), handleQuery()"
         />
       </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="员工"
+          @change="handleQuery"
+        >
           <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>
@@ -54,24 +82,28 @@
   <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="poolSummary">
+        <CustomerPoolSummary ref="customerPoolSummaryRef" :query-params="queryParams" />
       </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-tabs>
   </el-col>
@@ -81,17 +113,20 @@
 import * as DeptApi from '@/api/system/dept'
 import * as UserApi from '@/api/system/user'
 import { useUserStore } from '@/store/modules/user'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime'
 import { defaultProps, handleTree } from '@/utils/tree'
-import CustomerSummary from './components/CustomerSummary.vue'
-import CustomerFollowupSummary from './components/CustomerFollowupSummary.vue'
-import CustomerFollowupType from './components/CustomerFollowupType.vue'
 import CustomerConversionStat from './components/CustomerConversionStat.vue'
 import CustomerDealCycle from './components/CustomerDealCycle.vue'
+import CustomerFollowUpSummary from './components/CustomerFollowUpSummary.vue'
+import CustomerFollowUpType from './components/CustomerFollowUpType.vue'
+import CustomerSummary from './components/CustomerSummary.vue'
+import CustomerPoolSummary from './components/CustomerPoolSummary.vue'
 
 defineOptions({ name: 'CrmStatisticsCustomer' })
 
 const queryParams = reactive({
+  interval: 2, // WEEK, 周
   deptId: useUserStore().getUser.deptId,
   userId: undefined,
   times: [
@@ -104,50 +139,47 @@ const queryParams = reactive({
 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('customerSummary')
-// 1.客户总量分析
-const customerSummaryRef = ref()
-// 2.客户跟进次数分析
-const followupSummaryRef = ref()
-// 3.客户跟进方式分析
-const followupTypeRef = ref()
-// 4.客户转化率分析
-const conversionStatRef = ref()
-// 5.公海客户分析
-// 缺 crm_owner_record 表
-// 6.成交周期分析
-const dealCycleRef = ref()
+const activeTab = ref('customerSummary') // 活跃标签
+const customerSummaryRef = ref() // 1. 客户总量分析
+const followUpSummaryRef = ref() // 2. 客户跟进次数分析
+const followUpTypeRef = ref() // 3. 客户跟进方式分析
+const conversionStatRef = ref() // 4. 客户转化率分析
+const customerPoolSummaryRef = ref() // 5. 客户公海分析
+const dealCycleRef = ref() // 6. 成交周期分析
 
 /** 搜索按钮操作 */
 const handleQuery = () => {
   switch (activeTab.value) {
-    case 'customerSummary':
+    case 'customerSummary': // 客户总量分析
       customerSummaryRef.value?.loadData?.()
       break
-    case 'followupSummary':
-      followupSummaryRef.value?.loadData?.()
+    case 'followUpSummary': // 客户跟进次数分析
+      followUpSummaryRef.value?.loadData?.()
       break
-    case 'followupType':
-      followupTypeRef.value?.loadData?.()
+    case 'followUpType': // 客户跟进方式分析
+      followUpTypeRef.value?.loadData?.()
       break
-    case 'conversionStat':
+    case 'conversionStat': // 客户转化率分析
       conversionStatRef.value?.loadData?.()
       break
-    case 'dealCycle':
+    case 'poolSummary': // 公海客户分析
+      customerPoolSummaryRef.value?.loadData?.()
+      break
+    case 'dealCycle': // 成交周期分析
       dealCycleRef.value?.loadData?.()
       break
   }
 }
 
-// 当 activeTab 改变时,刷新当前活动的 tab
+/** 当 activeTab 改变时,刷新当前活动的 tab */
 watch(activeTab, () => {
   handleQuery()
 })
@@ -158,7 +190,7 @@ const resetQuery = () => {
   handleQuery()
 }
 
-// 加载部门树
+/** 初始化 */
 onMounted(async () => {
   deptList.value = handleTree(await DeptApi.getSimpleDeptList())
   userList.value = handleTree(await UserApi.getSimpleUserList())

+ 227 - 0
src/views/crm/statistics/performance/components/ContractCountPerformance.vue

@@ -0,0 +1,227 @@
+<!-- 客户总量统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 TODO @scholar:统计列表的展示不对 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="tableData">
+      <el-table-column
+        v-for="item in columnsData"
+        :key="item.prop"
+        :label="item.label"
+        :prop="item.prop"
+        align="center"
+      >
+        <template #default="scope">
+          {{ scope.row[item.prop] }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { EChartsOption } from 'echarts'
+import {
+  StatisticsPerformanceApi,
+  StatisticsPerformanceRespVO
+} from '@/api/crm/statistics/performance'
+
+defineOptions({ name: 'ContractCountPerformance' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '当月合同数量(个)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '上月合同数量(个)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '去年同月合同数量(个)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '同比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    },
+    {
+      name: '环比增长率(%)',
+      type: 'line',
+      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: '数量(个)',
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    },
+    {
+      type: 'value',
+      name: '',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: {
+          width: 0
+        }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}%'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    }
+  ],
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const performanceList = await StatisticsPerformanceApi.getContractCountPerformance(
+    props.queryParams
+  )
+
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time)
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.currentMonthCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastMonthCount
+    )
+    echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL'
+    )
+  }
+  if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
+    echartsOption.series[2]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastYearCount
+    )
+    echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL'
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = performanceList
+  loading.value = false
+}
+
+// 初始化数据
+const columnsData = reactive([])
+const tableData = reactive([
+  { title: '当月合同数量统计(个)' },
+  { title: '上月合同数量统计(个)' },
+  { title: '去年当月合同数量统计(个)' },
+  { title: '同比增长率(%)' },
+  { title: '环比增长率(%)' }
+])
+
+// 定义 init 方法
+const init = () => {
+  const columnObj = { label: '日期', prop: 'title' }
+  columnsData.push(columnObj)
+
+  list.value.forEach((item, index) => {
+    const columnObj = { label: item.time, prop: 'prop' + index }
+    columnsData.push(columnObj)
+    tableData[0]['prop' + index] = item.currentMonthCount
+    tableData[1]['prop' + index] = item.lastMonthCount
+    tableData[2]['prop' + index] = item.lastYearCount
+    tableData[3]['prop' + index] =
+      item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL'
+    tableData[4]['prop' + index] =
+      item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL'
+  })
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(async () => {
+  await loadData()
+  init()
+})
+</script>

+ 227 - 0
src/views/crm/statistics/performance/components/ContractPricePerformance.vue

@@ -0,0 +1,227 @@
+<!-- 客户总量统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 TODO @scholar:统计列表的展示不对 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="tableData">
+      <el-table-column
+        v-for="item in columnsData"
+        :key="item.prop"
+        :label="item.label"
+        :prop="item.prop"
+        align="center"
+      >
+        <template #default="scope">
+          {{ scope.row[item.prop] }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { EChartsOption } from 'echarts'
+import {
+  StatisticsPerformanceApi,
+  StatisticsPerformanceRespVO
+} from '@/api/crm/statistics/performance'
+
+defineOptions({ name: 'ContractPricePerformance' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '当月合同金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '上月合同金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '去年同月合同金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '同比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    },
+    {
+      name: '环比增长率(%)',
+      type: 'line',
+      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: '金额(元)',
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    },
+    {
+      type: 'value',
+      name: '',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: {
+          width: 0
+        }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}%'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    }
+  ],
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const performanceList = await StatisticsPerformanceApi.getContractPricePerformance(
+    props.queryParams
+  )
+
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time)
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.currentMonthCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastMonthCount
+    )
+    echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL'
+    )
+  }
+  if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
+    echartsOption.series[2]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastYearCount
+    )
+    echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL'
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = performanceList
+  loading.value = false
+}
+
+// 初始化数据
+const columnsData = reactive([])
+const tableData = reactive([
+  { title: '当月合同金额统计(元)' },
+  { title: '上月合同金额统计(元)' },
+  { title: '去年当月合同金额统计(元)' },
+  { title: '同比增长率(%)' },
+  { title: '环比增长率(%)' }
+])
+
+// 定义 init 方法
+const init = () => {
+  const columnObj = { label: '日期', prop: 'title' }
+  columnsData.push(columnObj)
+
+  list.value.forEach((item, index) => {
+    const columnObj = { label: item.time, prop: 'prop' + index }
+    columnsData.push(columnObj)
+    tableData[0]['prop' + index] = item.currentMonthCount
+    tableData[1]['prop' + index] = item.lastMonthCount
+    tableData[2]['prop' + index] = item.lastYearCount
+    tableData[3]['prop' + index] =
+      item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL'
+    tableData[4]['prop' + index] =
+      item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL'
+  })
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(async () => {
+  await loadData()
+  init()
+})
+</script>

+ 227 - 0
src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue

@@ -0,0 +1,227 @@
+<!-- 客户总量统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 TODO @scholar:统计列表的展示不对 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="tableData">
+      <el-table-column
+        v-for="item in columnsData"
+        :key="item.prop"
+        :label="item.label"
+        :prop="item.prop"
+        align="center"
+      >
+        <template #default="scope">
+          {{ scope.row[item.prop] }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { EChartsOption } from 'echarts'
+import {
+  StatisticsPerformanceApi,
+  StatisticsPerformanceRespVO
+} from '@/api/crm/statistics/performance'
+
+defineOptions({ name: 'ContractPricePerformance' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '当月回款金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '上月回款金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '去年同月回款金额(元)',
+      type: 'line',
+      data: []
+    },
+    {
+      name: '同比增长率(%)',
+      type: 'line',
+      yAxisIndex: 1,
+      data: []
+    },
+    {
+      name: '环比增长率(%)',
+      type: 'line',
+      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: '金额(元)',
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    },
+    {
+      type: 'value',
+      name: '',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: {
+          width: 0
+        }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}%'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: {
+          color: '#BDBDBD'
+        }
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      }
+    }
+  ],
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取统计数据 */
+const loadData = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const performanceList = await StatisticsPerformanceApi.getReceivablePricePerformance(
+    props.queryParams
+  )
+
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time)
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.currentMonthCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastMonthCount
+    )
+    echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL'
+    )
+  }
+  if (echartsOption.series && echartsOption.series[2] && echartsOption.series[1]['data']) {
+    echartsOption.series[2]['data'] = performanceList.map(
+      (s: StatisticsPerformanceRespVO) => s.lastYearCount
+    )
+    echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
+      s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL'
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = performanceList
+  loading.value = false
+}
+
+// 初始化数据
+const columnsData = reactive([])
+const tableData = reactive([
+  { title: '当月回款金额统计(元)' },
+  { title: '上月回款金额统计(元)' },
+  { title: '去年当月回款金额统计(元)' },
+  { title: '同比增长率(%)' },
+  { title: '环比增长率(%)' }
+])
+
+// 定义 init 方法
+const init = () => {
+  const columnObj = { label: '日期', prop: 'title' }
+  columnsData.push(columnObj)
+
+  list.value.forEach((item, index) => {
+    const columnObj = { label: item.time, prop: 'prop' + index }
+    columnsData.push(columnObj)
+    tableData[0]['prop' + index] = item.currentMonthCount
+    tableData[1]['prop' + index] = item.lastMonthCount
+    tableData[2]['prop' + index] = item.lastYearCount
+    tableData[3]['prop' + index] =
+      item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL'
+    tableData[4]['prop' + index] =
+      item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL'
+  })
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(async () => {
+  await loadData()
+  init()
+})
+</script>

+ 147 - 0
src/views/crm/statistics/performance/index.vue

@@ -0,0 +1,147 @@
+<!-- 数据统计 - 员工客户分析 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="时间范围" prop="orderDate">
+        <el-date-picker
+          v-model="queryParams.times"
+          :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="deptId">
+        <el-tree-select
+          v-model="queryParams.deptId"
+          class="!w-240px"
+          :data="deptList"
+          :props="defaultProps"
+          check-strictly
+          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-option
+            v-for="(user, index) in userListByDeptId"
+            :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-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 员工业绩统计 -->
+  <el-col>
+    <el-tabs v-model="activeTab">
+      <!-- 员工合同统计 -->
+      <el-tab-pane label="员工合同数量统计" name="ContractCountPerformance" lazy>
+        <ContractCountPerformance :query-params="queryParams" ref="ContractCountPerformanceRef" />
+      </el-tab-pane>
+      <!-- 员工合同金额统计 -->
+      <el-tab-pane label="员工合同金额统计" name="ContractPricePerformance" lazy>
+        <ContractPricePerformance :query-params="queryParams" ref="ContractPricePerformanceRef" />
+      </el-tab-pane>
+      <!-- 员工回款金额统计 -->
+      <el-tab-pane label="员工回款金额统计" name="followupType" lazy>
+        <ReceivablePricePerformance
+          :query-params="queryParams"
+          ref="ReceivablePricePerformanceRef"
+        />
+      </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 ContractCountPerformance from './components/ContractCountPerformance.vue'
+import ContractPricePerformance from './components/ContractPricePerformance.vue'
+import ReceivablePricePerformance from './components/ReceivablePricePerformance.vue'
+
+defineOptions({ name: 'CrmStatisticsPerformance' })
+
+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('ContractCountPerformance')
+// 1.员工合同数量统计
+const ContractCountPerformanceRef = ref()
+// 2.员工合同金额统计
+const ContractPricePerformanceRef = ref()
+// 3.员工回款金额统计
+const ReceivablePricePerformanceRef = ref()
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  switch (activeTab.value) {
+    case 'ContractCountPerformance':
+      ContractCountPerformanceRef.value?.loadData?.()
+      break
+    case 'ContractPricePerformance':
+      ContractPricePerformanceRef.value?.loadData?.()
+      break
+    case 'ReceivablePricePerformance':
+      ReceivablePricePerformanceRef.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>

+ 153 - 0
src/views/crm/statistics/portrait/components/CustomerAddress.vue

@@ -0,0 +1,153 @@
+<!-- 客户城市分布 -->
+<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,
+  StatisticsPortraitApi
+} from '@/api/crm/statistics/portrait'
+
+// TODO @puhui999:address 换成 area 会更合适哈,
+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 StatisticsPortraitApi.getCustomerArea(props.queryParams)
+  areaStatisticsList.value = areaList.map((item: CrmStatisticCustomerAreaRespVO) => {
+    return {
+      ...item,
+      areaName: item.areaName // TODO @puhui999:这里最好注释下原因哈
+        .replace('维吾尔自治区', '')
+        .replace('壮族自治区', '')
+        .replace('回族自治区', '')
+        .replace('自治区', '')
+        .replace('省', '')
+    }
+  })
+  builderLeftMap()
+  builderRightMap()
+  loading.value = false
+}
+defineExpose({ loadData })
+
+// TODO @puhui999:builder 改成 build 更合理哈
+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>

+ 197 - 0
src/views/crm/statistics/portrait/components/CustomerIndustry.vue

@@ -0,0 +1,197 @@
+<!-- 客户行业分析 -->
+<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="客户行业" 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" />
+      <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticCustomerIndustryRespVO,
+  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 }>() // 搜索参数
+
+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 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: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId),
+        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: 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(() => {
+  loadData()
+})
+</script>

+ 198 - 0
src/views/crm/statistics/portrait/components/CustomerLevel.vue

@@ -0,0 +1,198 @@
+<!-- 客户来源分析 -->
+<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="客户级别" 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" />
+      <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticCustomerLevelRespVO,
+  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 }>() // 搜索参数
+
+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 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: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
+        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: 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) => {
+    // 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)
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 197 - 0
src/views/crm/statistics/portrait/components/CustomerSource.vue

@@ -0,0 +1,197 @@
+<!-- 客户来源分析 -->
+<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="客户来源" 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" />
+      <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" />
+    </el-table>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticCustomerSourceRespVO,
+  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 }>() // 搜索参数
+
+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 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: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
+        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: 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()
+})
+</script>

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

@@ -0,0 +1,157 @@
+<!-- 数据统计 - 客户画像 -->
+<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'
+// 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'
+
+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>

+ 1 - 1
src/views/crm/statistics/rank/ContactsCountRank.vue → src/views/crm/statistics/rank/components/ContactCountRank.vue

@@ -22,7 +22,7 @@ import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/ra
 import { EChartsOption } from 'echarts'
 import { clone } from 'lodash-es'
 
-defineOptions({ name: 'ContactsCountRank' })
+defineOptions({ name: 'ContactCountRank' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 
 const loading = ref(false) // 加载中

+ 0 - 0
src/views/crm/statistics/rank/ContractCountRank.vue → src/views/crm/statistics/rank/components/ContractCountRank.vue


+ 8 - 1
src/views/crm/statistics/rank/ContractPriceRank.vue → src/views/crm/statistics/rank/components/ContractPriceRank.vue

@@ -13,7 +13,13 @@
       <el-table-column label="公司排名" align="center" type="index" width="80" />
       <el-table-column label="签订人" align="center" prop="nickname" min-width="200" />
       <el-table-column label="部门" align="center" prop="deptName" min-width="200" />
-      <el-table-column label="合同金额(元)" align="center" prop="count" min-width="200" />
+      <el-table-column
+        label="合同金额(元)"
+        align="center"
+        prop="count"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
     </el-table>
   </el-card>
 </template>
@@ -21,6 +27,7 @@
 import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank'
 import { EChartsOption } from 'echarts'
 import { clone } from 'lodash-es'
+import { erpPriceTableColumnFormatter } from '@/utils'
 
 defineOptions({ name: 'ContractPriceRank' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数

+ 0 - 0
src/views/crm/statistics/rank/CustomerCountRank.vue → src/views/crm/statistics/rank/components/CustomerCountRank.vue


+ 0 - 0
src/views/crm/statistics/rank/FollowCountRank.vue → src/views/crm/statistics/rank/components/FollowCountRank.vue


+ 0 - 0
src/views/crm/statistics/rank/FollowCustomerCountRank.vue → src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue


+ 0 - 0
src/views/crm/statistics/rank/ProductSalesRank.vue → src/views/crm/statistics/rank/components/ProductSalesRank.vue


+ 8 - 1
src/views/crm/statistics/rank/ReceivablePriceRank.vue → src/views/crm/statistics/rank/components/ReceivablePriceRank.vue

@@ -13,7 +13,13 @@
       <el-table-column label="公司排名" align="center" type="index" width="80" />
       <el-table-column label="签订人" align="center" prop="nickname" min-width="200" />
       <el-table-column label="部门" align="center" prop="deptName" min-width="200" />
-      <el-table-column label="回款金额(元)" align="center" prop="count" min-width="200" />
+      <el-table-column
+        label="回款金额(元)"
+        align="center"
+        prop="count"
+        min-width="200"
+        :formatter="erpPriceTableColumnFormatter"
+      />
     </el-table>
   </el-card>
 </template>
@@ -21,6 +27,7 @@
 import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank'
 import { EChartsOption } from 'echarts'
 import { clone } from 'lodash-es'
+import { erpPriceTableColumnFormatter } from '@/utils'
 
 defineOptions({ name: 'ReceivablePriceRank' })
 const props = defineProps<{ queryParams: any }>() // 搜索参数

+ 21 - 20
src/views/crm/statistics/rank/index.vue

@@ -29,6 +29,7 @@
           check-strictly
           node-key="id"
           placeholder="请选择归属部门"
+          class="!w-240px"
         />
       </el-form-item>
       <el-form-item>
@@ -62,8 +63,8 @@
         <CustomerCountRank :query-params="queryParams" ref="customerCountRankRef" />
       </el-tab-pane>
       <!-- 新增联系人数排行 -->
-      <el-tab-pane label="新增联系人数排行" name="contactsCountRank" lazy>
-        <ContactsCountRank :query-params="queryParams" ref="contactsCountRankRef" />
+      <el-tab-pane label="新增联系人数排行" name="contactCountRank" lazy>
+        <ContactCountRank :query-params="queryParams" ref="contactCountRankRef" />
       </el-tab-pane>
       <!-- 跟进次数排行 -->
       <el-tab-pane label="跟进次数排行" name="followCountRank" lazy>
@@ -77,14 +78,14 @@
   </el-col>
 </template>
 <script lang="ts" setup>
-import ContractPriceRank from './ContractPriceRank.vue'
-import ReceivablePriceRank from './ReceivablePriceRank.vue'
-import ContractCountRank from './ContractCountRank.vue'
-import ProductSalesRank from './ProductSalesRank.vue'
-import CustomerCountRank from './CustomerCountRank.vue'
-import ContactsCountRank from './ContactsCountRank.vue'
-import FollowCountRank from './FollowCountRank.vue'
-import FollowCustomerCountRank from './FollowCustomerCountRank.vue'
+import ContractPriceRank from './components/ContractPriceRank.vue'
+import ReceivablePriceRank from './components/ReceivablePriceRank.vue'
+import ContractCountRank from './components/ContractCountRank.vue'
+import ProductSalesRank from './components/ProductSalesRank.vue'
+import CustomerCountRank from './components/CustomerCountRank.vue'
+import ContactCountRank from './components/ContactCountRank.vue'
+import FollowCountRank from './components/FollowCountRank.vue'
+import FollowCustomerCountRank from './components/FollowCustomerCountRank.vue'
 import { defaultProps, handleTree } from '@/utils/tree'
 import * as DeptApi from '@/api/system/dept'
 import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime'
@@ -109,35 +110,35 @@ const receivablePriceRankRef = ref() // ReceivablePriceRank 组件的引用
 const contractCountRankRef = ref() // ContractCountRank 组件的引用
 const productSalesRankRef = ref() // ProductSalesRank 组件的引用
 const customerCountRankRef = ref() // CustomerCountRank 组件的引用
-const contactsCountRankRef = ref() // ContactsCountRank 组件的引用
+const contactCountRankRef = ref() // ContactCountRank 组件的引用
 const followCountRankRef = ref() // FollowCountRank 组件的引用
 const followCustomerCountRankRef = ref() // FollowCustomerCountRank 组件的引用
 
 /** 搜索按钮操作 */
 const handleQuery = () => {
   switch (activeTab.value) {
-    case 'contractPriceRank':
+    case 'contractPriceRank': // 合同金额排行
       contractPriceRankRef.value?.loadData?.()
       break
-    case 'receivablePriceRank':
+    case 'receivablePriceRank': // 回款金额排行
       receivablePriceRankRef.value?.loadData?.()
       break
-    case 'contractCountRank':
+    case 'contractCountRank': // 签约合同排行
       contractCountRankRef.value?.loadData?.()
       break
-    case 'productSalesRank':
+    case 'productSalesRank': // 产品销量排行
       productSalesRankRef.value?.loadData?.()
       break
-    case 'customerCountRank':
+    case 'customerCountRank': // 新增客户数排行
       customerCountRankRef.value?.loadData?.()
       break
-    case 'contactsCountRank':
-      contactsCountRankRef.value?.loadData?.()
+    case 'contactCountRank': // 新增联系人数排行
+      contactCountRankRef.value?.loadData?.()
       break
-    case 'followCountRank':
+    case 'followCountRank': // 跟进次数排行
       followCountRankRef.value?.loadData?.()
       break
-    case 'followCustomerCountRank':
+    case 'followCustomerCountRank': // 跟进客户数排行
       followCustomerCountRankRef.value?.loadData?.()
       break
   }

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

@@ -23,15 +23,14 @@
       </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>
   </Dialog>
 </template>
 <script lang="ts" setup>
-defineOptions({ name: 'InfraBuild' })
-import FcDesigner from '@form-create/designer'
+import { useFormCreateDesigner } from '@/components/FormCreate'
 import { useClipboard } from '@vueuse/core'
 import { isString } from '@/utils/is'
 
@@ -41,6 +40,8 @@ import xml from 'highlight.js/lib/languages/java'
 import json from 'highlight.js/lib/languages/json'
 import formCreate from '@form-create/element-ui'
 
+defineOptions({ name: 'InfraBuild' })
+
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息
 
@@ -49,6 +50,7 @@ const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formType = ref(-1) // 表单的类型:0 - 生成 JSON;1 - 生成 Options;2 - 生成组件
 const formData = ref('') // 表单数据
+useFormCreateDesigner(designer) // 表单设计器增强
 
 /** 打开弹窗 */
 const openModel = (title: string) => {
@@ -82,14 +84,13 @@ const makeTemplate = () => {
   const opt = designer.value.getOption()
   return `<template>
     <form-create
-      v-model="fapi"
+      v-model:api="fApi"
       :rule="rule"
       :option="option"
       @submit="onSubmit"
     ></form-create>
   </template>
   <script setup lang=ts>
-    import formCreate from "@form-create/element-ui";
     const faps = ref(null)
     const rule = ref('')
     const option = ref('')

+ 2 - 2
src/views/pay/app/components/channel/WeixinChannelForm.vue

@@ -18,10 +18,10 @@
             <template #append>%</template>
           </el-input>
         </el-form-item>
-        <el-form-item label-width="180px" label="公众号 APPID" prop="config.appId">
+        <el-form-item label-width="180px" label="微信 APPID" prop="config.appId">
           <el-input
             v-model="formData.config.appId"
-            placeholder="请输入公众号 APPID"
+            placeholder="请输入微信 APPID"
             clearable
             :style="{ width: '100%' }"
           />

+ 3 - 1
src/views/system/role/RoleDataPermissionForm.vue

@@ -102,10 +102,12 @@ const open = async (row: RoleApi.RoleVO) => {
   formData.name = row.name
   formData.code = row.code
   formData.dataScope = row.dataScope
+  await nextTick()
+  row.dataScopeDeptIds?.forEach((deptId: number): void => {
 
   await nextTick()
   // 需要在 DOM 渲染完成后,再设置选中状态
-  row.dataScopeDeptIds?.forEach((deptId: number) => {
+  row.dataScopeDeptIds?.forEach((deptId: number): void => {
     treeRef.value.setChecked(deptId, true, false)
   })
 }

+ 28 - 0
src/views/system/user/components/UserSelect.vue

@@ -0,0 +1,28 @@
+<!-- TODO puhui999: 先单独一个后面封装成通用选择组件 -->
+<template>
+  <el-select class="w-1/1" v-bind="attrs">
+    <el-option
+      v-for="(dict, index) in userOptions"
+      :key="index"
+      :label="dict.nickname"
+      :value="dict.id"
+    />
+  </el-select>
+</template>
+
+<script lang="ts" setup>
+import * as UserApi from '@/api/system/user'
+
+defineOptions({ name: 'UserSelect' })
+
+const attrs = useAttrs()
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户下拉数据
+
+onMounted(async () => {
+  const data = await UserApi.getSimpleUserList()
+  if (!data || data.length === 0) {
+    return
+  }
+  userOptions.value = data
+})
+</script>