Procházet zdrojové kódy

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

# Conflicts:
#	src/api/infra/codegen/index.ts
#	src/views/system/sensitiveWord/index.vue
YunaiV před 10 měsíci
rodič
revize
7f3950018e
39 změnil soubory, kde provedl 1700 přidání a 318 odebrání
  1. 0 1
      package.json
  2. 26 0
      src/api/crm/statistics/customer.ts
  3. 58 0
      src/api/crm/statistics/funnel.ts
  4. 122 0
      src/api/infra/codegen/index.ts
  5. 0 1
      src/components/DictSelect/src/DictSelect.vue
  6. 1 2
      src/components/FormCreate/index.ts
  7. 0 33
      src/components/FormCreate/src/MyFormCreateDesigner.vue
  8. 3 1
      src/components/FormCreate/src/config/index.ts
  9. 71 0
      src/components/FormCreate/src/config/selectRule.ts
  10. 2 67
      src/components/FormCreate/src/config/useDictSelectRule.ts
  11. 32 0
      src/components/FormCreate/src/config/useEditorRule.ts
  12. 2 70
      src/components/FormCreate/src/config/useUserSelectRule.ts
  13. 8 0
      src/components/FormCreate/src/useFormCreateDesigner.ts
  14. 3 1
      src/plugins/formCreate/index.ts
  15. 7 6
      src/utils/dict.ts
  16. 18 1
      src/utils/index.ts
  17. 41 33
      src/views/crm/business/index.vue
  18. 153 0
      src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue
  19. 153 0
      src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue
  20. 1 1
      src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue
  21. 22 6
      src/views/crm/statistics/customer/index.vue
  22. 307 0
      src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue
  23. 259 0
      src/views/crm/statistics/funnel/components/BusinessSummary.vue
  24. 152 0
      src/views/crm/statistics/funnel/components/FunnelBusiness.vue
  25. 171 0
      src/views/crm/statistics/funnel/index.vue
  26. 7 13
      src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue
  27. 5 4
      src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue
  28. 5 5
      src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue
  29. 5 4
      src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue
  30. 13 14
      src/views/crm/statistics/portrait/index.vue
  31. 1 1
      src/views/infra/apiAccessLog/index.vue
  32. 1 1
      src/views/infra/file/index.vue
  33. 1 1
      src/views/infra/fileConfig/index.vue
  34. 36 41
      src/views/mall/statistics/member/index.vue
  35. 8 5
      src/views/mall/trade/order/form/OrderUpdatePriceForm.vue
  36. 2 2
      src/views/system/loginlog/index.vue
  37. 1 1
      src/views/system/mail/template/index.vue
  38. 2 2
      src/views/system/operatelog/index.vue
  39. 1 1
      src/views/system/post/index.vue

+ 0 - 1
package.json

@@ -137,7 +137,6 @@
     "url": "https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues"
   },
   "homepage": "https://gitee.com/yudaocode/yudao-ui-admin-vue3",
-  "packageManager": "pnpm@8.6.0",
   "engines": {
     "node": ">= 16.0.0",
     "pnpm": ">=8.6.0"

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

@@ -67,6 +67,18 @@ export interface CrmStatisticsCustomerDealCycleByUserRespVO {
   customerDealCount: number
 }
 
+export interface CrmStatisticsCustomerDealCycleByAreaRespVO {
+  areaName: string
+  customerDealCycle: number
+  customerDealCount: number
+}
+
+export interface CrmStatisticsCustomerDealCycleByProductRespVO {
+  productName: string
+  customerDealCycle: number
+  customerDealCount: number
+}
+
 // 客户分析 API
 export const StatisticsCustomerApi = {
   // 1.1 客户总量分析(按日期)
@@ -138,5 +150,19 @@ export const StatisticsCustomerApi = {
       url: '/crm/statistics-customer/get-customer-deal-cycle-by-user',
       params
     })
+  },
+  // 6.2 获取客户成交周期(按用户)
+  getCustomerDealCycleByArea: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-deal-cycle-by-area',
+      params
+    })
+  },
+  // 6.2 获取客户成交周期(按用户)
+  getCustomerDealCycleByProduct: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-deal-cycle-by-product',
+      params
+    })
   }
 }

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

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

+ 122 - 0
src/api/infra/codegen/index.ts

@@ -0,0 +1,122 @@
+import request from '@/config/axios'
+
+export type CodegenTableVO = {
+  id: number
+  tableId: number
+  isParentMenuIdValid: boolean
+  dataSourceConfigId: number
+  scene: number
+  tableName: string
+  tableComment: string
+  remark: string
+  moduleName: string
+  businessName: string
+  className: string
+  classComment: string
+  author: string
+  createTime: Date
+  updateTime: Date
+  templateType: number
+  parentMenuId: number
+}
+
+export type CodegenColumnVO = {
+  id: number
+  tableId: number
+  columnName: string
+  dataType: string
+  columnComment: string
+  nullable: number
+  primaryKey: number
+  ordinalPosition: number
+  javaType: string
+  javaField: string
+  dictType: string
+  example: string
+  createOperation: number
+  updateOperation: number
+  listOperation: number
+  listOperationCondition: string
+  listOperationResult: number
+  htmlType: string
+}
+
+export type DatabaseTableVO = {
+  name: string
+  comment: string
+}
+
+export type CodegenDetailVO = {
+  table: CodegenTableVO
+  columns: CodegenColumnVO[]
+}
+
+export type CodegenPreviewVO = {
+  filePath: string
+  code: string
+}
+
+export type CodegenUpdateReqVO = {
+  table: CodegenTableVO | any
+  columns: CodegenColumnVO[]
+}
+
+export type CodegenCreateListReqVO = {
+  dataSourceConfigId: number
+  tableNames: string[]
+}
+
+// 查询列表代码生成表定义
+export const getCodegenTableList = (dataSourceConfigId: number) => {
+  return request.get({ url: '/infra/codegen/table/list?dataSourceConfigId=' + dataSourceConfigId })
+}
+
+// 查询列表代码生成表定义
+export const getCodegenTablePage = (params: PageParam) => {
+  return request.get({ url: '/infra/codegen/table/page', params })
+}
+
+// 查询详情代码生成表定义
+export const getCodegenTable = (id: number) => {
+  return request.get({ url: '/infra/codegen/detail?tableId=' + id })
+}
+
+// 新增代码生成表定义
+export const createCodegenTable = (data: CodegenCreateListReqVO) => {
+  return request.post({ url: '/infra/codegen/create', data })
+}
+
+// 修改代码生成表定义
+export const updateCodegenTable = (data: CodegenUpdateReqVO) => {
+  return request.put({ url: '/infra/codegen/update', data })
+}
+
+// 基于数据库的表结构,同步数据库的表和字段定义
+export const syncCodegenFromDB = (id: number) => {
+  return request.put({ url: '/infra/codegen/sync-from-db?tableId=' + id })
+}
+
+// 预览生成代码
+export const previewCodegen = (id: number) => {
+  return request.get({ url: '/infra/codegen/preview?tableId=' + id })
+}
+
+// 下载生成代码
+export const downloadCodegen = (id: number) => {
+  return request.download({ url: '/infra/codegen/download?tableId=' + id })
+}
+
+// 获得表定义
+export const getSchemaTableList = (params) => {
+  return request.get({ url: '/infra/codegen/db/table/list', params })
+}
+
+// 基于数据库的表结构,创建代码生成器的表定义
+export const createCodegenList = (data) => {
+  return request.post({ url: '/infra/codegen/create-list', data })
+}
+
+// 删除代码生成表定义
+export const deleteCodegenTable = (id: number) => {
+  return request.delete({ url: '/infra/codegen/delete?tableId=' + id })
+}

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

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

+ 1 - 2
src/components/FormCreate/index.ts

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

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

@@ -1,33 +0,0 @@
-<!-- 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>

+ 3 - 1
src/components/FormCreate/src/config/index.ts

@@ -3,11 +3,13 @@ import { useUploadImgRule } from './useUploadImgRule'
 import { useUploadImgsRule } from './useUploadImgsRule'
 import { useDictSelectRule } from './useDictSelectRule'
 import { useUserSelectRule } from './useUserSelectRule'
+import { useEditorRule } from './useEditorRule'
 
 export {
   useUploadFileRule,
   useUploadImgRule,
   useUploadImgsRule,
   useDictSelectRule,
-  useUserSelectRule
+  useUserSelectRule,
+  useEditorRule
 }

+ 71 - 0
src/components/FormCreate/src/config/selectRule.ts

@@ -0,0 +1,71 @@
+const selectRule = [
+  { 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,是否在输入框获得焦点后自动弹出选项菜单'
+  }
+]
+
+export default selectRule

+ 2 - 67
src/components/FormCreate/src/config/useDictSelectRule.ts

@@ -1,6 +1,7 @@
 import { generateUUID } from '@/utils'
 import * as DictDataApi from '@/api/system/dict/dict.type'
 import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+import selectRule from '@/components/FormCreate/src/config/selectRule'
 
 export const useDictSelectRule = () => {
   const label = '字典选择器'
@@ -51,73 +52,7 @@ export const useDictSelectRule = () => {
             { 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,是否在输入框获得焦点后自动弹出选项菜单'
-        }
+        ...selectRule
       ])
     }
   }

+ 32 - 0
src/components/FormCreate/src/config/useEditorRule.ts

@@ -0,0 +1,32 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useEditorRule = () => {
+  const label = '富文本'
+  const name = 'Editor'
+  return {
+    icon: 'icon-editor',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'input',
+          field: 'height',
+          title: '高度'
+        },
+        { type: 'switch', field: 'readonly', title: '是否只读' }
+      ])
+    }
+  }
+}

+ 2 - 70
src/components/FormCreate/src/config/useUserSelectRule.ts

@@ -1,5 +1,6 @@
 import { generateUUID } from '@/utils'
 import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+import selectRule from '@/components/FormCreate/src/config/selectRule'
 
 export const useUserSelectRule = () => {
   const label = '用户选择器'
@@ -18,76 +19,7 @@ export const useUserSelectRule = () => {
       }
     },
     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,是否在输入框获得焦点后自动弹出选项菜单'
-        }
-      ])
+      return localeProps(t, name + '.props', [makeRequiredRule(), ...selectRule])
     }
   }
 }

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

@@ -1,5 +1,6 @@
 import {
   useDictSelectRule,
+  useEditorRule,
   useUploadFileRule,
   useUploadImgRule,
   useUploadImgsRule,
@@ -13,8 +14,12 @@ import { Ref } from 'vue'
  * - 文件上传
  * - 单图上传
  * - 多图上传
+ * - 字典选择器
+ * - 系统用户选择器
+ * - 富文本
  */
 export const useFormCreateDesigner = (designer: Ref) => {
+  const editorRule = useEditorRule()
   const uploadFileRule = useUploadFileRule()
   const uploadImgRule = useUploadImgRule()
   const uploadImgsRule = useUploadImgsRule()
@@ -24,7 +29,10 @@ export const useFormCreateDesigner = (designer: Ref) => {
   onMounted(() => {
     // 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
     designer.value?.removeMenuItem('upload')
+    // 移除自带的富文本组件规则,使用 editorRule 替代
+    designer.value?.removeMenuItem('fc-editor')
     const components = [
+      editorRule,
       uploadFileRule,
       uploadImgRule,
       uploadImgsRule,

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

@@ -21,6 +21,7 @@ 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'
+import { Editor } from '@/components/Editor'
 
 const components = [
   ElAside,
@@ -39,7 +40,8 @@ const components = [
   UploadImgs,
   UploadFile,
   DictSelect,
-  UserSelect
+  UserSelect,
+  Editor
 ]
 
 // 参考 http://www.form-create.com/v3/element-ui/auto-import.html 文档

+ 7 - 6
src/utils/dict.ts

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

+ 18 - 1
src/utils/index.ts

@@ -1,4 +1,4 @@
-import { toNumber } from 'lodash-es'
+import {toNumber} from 'lodash-es'
 
 /**
  *
@@ -418,3 +418,20 @@ export const erpCalculatePercentage = (value: number, total: number) => {
   if (total === 0) return 0
   return ((value / total) * 100).toFixed(2)
 }
+
+/**
+ * 适配 echarts map 的地名
+ *
+ * @param areaName 地区名称
+ */
+export const areaReplace = (areaName: string) => {
+  if (!areaName) {
+    return areaName
+  }
+  return areaName
+    .replace('维吾尔自治区', '')
+    .replace('壮族自治区', '')
+    .replace('回族自治区', '')
+    .replace('自治区', '')
+    .replace('省', '')
+}

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

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

+ 153 - 0
src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue

@@ -0,0 +1,153 @@
+<!-- 成交周期分析 -->
+<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" />
+      <el-table-column label="区域" align="center" prop="areaName" min-width="200" />
+      <el-table-column
+        label="成交周期(天)"
+        align="center"
+        prop="customerDealCycle"
+        min-width="200"
+      />
+      <el-table-column label="成交客户数" align="center" prop="customerDealCount" min-width="200" />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  CrmStatisticsCustomerDealCycleByAreaRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerDealCycleByArea' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticsCustomerDealCycleByAreaRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 40, // 让 X 轴右侧显示完整
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '成交周期(天)',
+      type: 'bar',
+      data: [],
+      yAxisIndex: 0
+    },
+    {
+      name: '成交客户数',
+      type: 'bar',
+      data: [],
+      yAxisIndex: 1
+    }
+  ],
+  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 customerDealCycleByArea = (
+    await StatisticsCustomerApi.getCustomerDealCycleByArea(props.queryParams)
+  ).map((s: CrmStatisticsCustomerDealCycleByAreaRespVO) => {
+    return {
+      areaName: s.areaName,
+      customerDealCycle: s.customerDealCycle,
+      customerDealCount: s.customerDealCount
+    }
+  })
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = customerDealCycleByArea.map(
+      (s: CrmStatisticsCustomerDealCycleByAreaRespVO) => s.areaName
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = customerDealCycleByArea.map(
+      (s: CrmStatisticsCustomerDealCycleByAreaRespVO) => s.customerDealCycle
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = customerDealCycleByArea.map(
+      (s: CrmStatisticsCustomerDealCycleByAreaRespVO) => s.customerDealCount
+    )
+  }
+  // 2.2 更新列表数据
+  list.value = customerDealCycleByArea
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 153 - 0
src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue

@@ -0,0 +1,153 @@
+<!-- 成交周期分析 -->
+<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" />
+      <el-table-column label="产品名称" align="center" prop="productName" min-width="200" />
+      <el-table-column
+        label="成交周期(天)"
+        align="center"
+        prop="customerDealCycle"
+        min-width="200"
+      />
+      <el-table-column label="成交客户数" align="center" prop="customerDealCount" min-width="200" />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  CrmStatisticsCustomerDealCycleByProductRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerDealCycleByProduct' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticsCustomerDealCycleByProductRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 40, // 让 X 轴右侧显示完整
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '成交周期(天)',
+      type: 'bar',
+      data: [],
+      yAxisIndex: 0
+    },
+    {
+      name: '成交客户数',
+      type: 'bar',
+      data: [],
+      yAxisIndex: 1
+    }
+  ],
+  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 customerDealCycleByProduct = (
+    await StatisticsCustomerApi.getCustomerDealCycleByProduct(props.queryParams)
+  ).map((s: CrmStatisticsCustomerDealCycleByProductRespVO) => {
+    return {
+      productName: s.productName ?? '未知',
+      customerDealCycle: s.customerDealCount,
+      customerDealCount: s.customerDealCount
+    }
+  })
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = customerDealCycleByProduct.map(
+      (s: CrmStatisticsCustomerDealCycleByProductRespVO) => s.productName
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = customerDealCycleByProduct.map(
+      (s: CrmStatisticsCustomerDealCycleByProductRespVO) => s.customerDealCycle
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = customerDealCycleByProduct.map(
+      (s: CrmStatisticsCustomerDealCycleByProductRespVO) => s.customerDealCount
+    )
+  }
+  // 2.2 更新列表数据
+  list.value = customerDealCycleByProduct
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 1 - 1
src/views/crm/statistics/customer/components/CustomerDealCycle.vue → src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue

@@ -30,7 +30,7 @@ import {
 } from '@/api/crm/statistics/customer'
 import { EChartsOption } from 'echarts'
 
-defineOptions({ name: 'CustomerDealCycle' })
+defineOptions({ name: 'CustomerDealCycleByUser' })
 
 const props = defineProps<{ queryParams: any }>() // 搜索参数
 

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

@@ -102,8 +102,14 @@
         <CustomerPoolSummary ref="customerPoolSummaryRef" :query-params="queryParams" />
       </el-tab-pane>
       <!-- 成交周期分析 -->
-      <el-tab-pane label="成交周期分析" lazy name="dealCycle">
-        <CustomerDealCycle ref="dealCycleRef" :query-params="queryParams" />
+      <el-tab-pane label="员工客户成交周期分析" lazy name="dealCycleByUser">
+        <CustomerDealCycleByUser ref="dealCycleByUserRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <el-tab-pane label="地区客户成交周期分析" lazy name="dealCycleByArea">
+        <CustomerDealCycleByArea ref="dealCycleByAreaRef" :query-params="queryParams" />
+      </el-tab-pane>
+      <el-tab-pane label="产品客户成交周期分析" lazy name="dealCycleByProduct">
+        <CustomerDealCycleByProduct ref="dealCycleByProductRef" :query-params="queryParams" />
       </el-tab-pane>
     </el-tabs>
   </el-col>
@@ -117,7 +123,9 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime'
 import { defaultProps, handleTree } from '@/utils/tree'
 import CustomerConversionStat from './components/CustomerConversionStat.vue'
-import CustomerDealCycle from './components/CustomerDealCycle.vue'
+import CustomerDealCycleByUser from './components/CustomerDealCycleByUser.vue'
+import CustomerDealCycleByArea from './components/CustomerDealCycleByArea.vue'
+import CustomerDealCycleByProduct from './components/CustomerDealCycleByProduct.vue'
 import CustomerFollowUpSummary from './components/CustomerFollowUpSummary.vue'
 import CustomerFollowUpType from './components/CustomerFollowUpType.vue'
 import CustomerSummary from './components/CustomerSummary.vue'
@@ -153,7 +161,9 @@ const followUpSummaryRef = ref() // 2. 客户跟进次数分析
 const followUpTypeRef = ref() // 3. 客户跟进方式分析
 const conversionStatRef = ref() // 4. 客户转化率分析
 const customerPoolSummaryRef = ref() // 5. 客户公海分析
-const dealCycleRef = ref() // 6. 成交周期分析
+const dealCycleByUserRef = ref() // 6. 成交周期分析(按员工)
+const dealCycleByAreaRef = ref() // 7. 成交周期分析(按地区)
+const dealCycleByProductRef = ref() // 8. 成交周期分析(按产品)
 
 /** 搜索按钮操作 */
 const handleQuery = () => {
@@ -173,8 +183,14 @@ const handleQuery = () => {
     case 'poolSummary': // 公海客户分析
       customerPoolSummaryRef.value?.loadData?.()
       break
-    case 'dealCycle': // 成交周期分析
-      dealCycleRef.value?.loadData?.()
+    case 'dealCycleByUser': // 成交周期分析
+      dealCycleByUserRef.value?.loadData?.()
+      break
+    case 'dealCycleByArea': // 成交周期分析
+      dealCycleByAreaRef.value?.loadData?.()
+      break
+    case 'dealCycleByProduct': // 成交周期分析
+      dealCycleByProductRef.value?.loadData?.()
       break
   }
 }

+ 307 - 0
src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue

@@ -0,0 +1,307 @@
+<!-- 客户总量统计 -->
+<template>
+  <!-- Echarts图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 统计列表 -->
+  <el-card class="mt-16px" shadow="never">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column align="center" fixed="left" label="序号" type="index" width="80" />
+      <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160">
+        <template #default="scope">
+          <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120">
+        <template #default="scope">
+          <el-link
+            :underline="false"
+            type="primary"
+            @click="openCustomerDetail(scope.row.customerId)"
+          >
+            {{ scope.row.customerName }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="erpPriceTableColumnFormatter"
+        align="center"
+        label="商机金额(元)"
+        prop="totalPrice"
+        width="140"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="预计成交日期"
+        prop="dealTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="contactNextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" />
+      <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="更新时间"
+        prop="updateTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="创建人" prop="creatorName" width="100px" />
+      <el-table-column
+        align="center"
+        fixed="right"
+        label="商机状态组"
+        prop="statusTypeName"
+        width="140"
+      />
+      <el-table-column
+        align="center"
+        fixed="right"
+        label="商机阶段"
+        prop="statusName"
+        width="120"
+      />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams0.pageSize"
+      v-model:page="queryParams0.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </el-card>
+</template>
+<script lang="ts" setup>
+import {
+  CrmStatisticsBusinessInversionRateSummaryByDateRespVO,
+  StatisticFunnelApi
+} from '@/api/crm/statistics/funnel'
+import { EChartsOption } from 'echarts'
+import { erpCalculatePercentage, erpPriceTableColumnFormatter } from '@/utils'
+import { dateFormatter } from '@/utils/formatTime'
+
+defineOptions({ name: 'BusinessSummary' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+const queryParams0 = reactive({
+  pageNo: 1,
+  pageSize: 10
+})
+const loading = ref(false) // 加载中
+const list = ref([]) // 列表的数据
+const total = ref(0)
+/** 将传进来的值赋值给 queryParams0 */
+watch(
+  () => props.queryParams,
+  (data) => {
+    if (!data) {
+      return
+    }
+    const newObj = { ...queryParams0, ...data }
+    Object.assign(queryParams0, newObj)
+  },
+  {
+    immediate: true
+  }
+)
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  color: ['#6ca2ff', '#6ac9d7', '#ff7474'],
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      // 坐标轴指示器,坐标轴触发有效
+      type: 'shadow' // 默认为直线,可选为:'line' | 'shadow'
+    }
+  },
+  legend: {
+    data: ['赢单转化率', '商机总数', '赢单商机数'],
+    bottom: '0px',
+    itemWidth: 14
+  },
+  grid: {
+    top: '40px',
+    left: '40px',
+    right: '40px',
+    bottom: '40px',
+    containLabel: true,
+    borderColor: '#fff'
+  },
+  xAxis: [
+    {
+      type: 'category',
+      data: [],
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: { width: 0 }
+      },
+      axisLabel: {
+        color: '#BDBDBD'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: { color: '#BDBDBD' }
+      },
+      splitLine: {
+        show: false
+      }
+    }
+  ],
+  yAxis: [
+    {
+      type: 'value',
+      name: '赢单转化率',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: { width: 0 }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}%'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: { color: '#BDBDBD' }
+      },
+      splitLine: {
+        show: false
+      }
+    },
+    {
+      type: 'value',
+      name: '商机数',
+      axisTick: {
+        alignWithLabel: true,
+        lineStyle: { width: 0 }
+      },
+      axisLabel: {
+        color: '#BDBDBD',
+        formatter: '{value}个'
+      },
+      /** 坐标轴轴线相关设置 */
+      axisLine: {
+        lineStyle: { color: '#BDBDBD' }
+      },
+      splitLine: {
+        show: false
+      }
+    }
+  ],
+  series: [
+    {
+      name: '赢单转化率',
+      type: 'line',
+      yAxisIndex: 0,
+      data: []
+    },
+    {
+      name: '商机总数',
+      type: 'bar',
+      yAxisIndex: 1,
+      barWidth: 15,
+      data: []
+    },
+    {
+      name: '赢单商机数',
+      type: 'bar',
+      yAxisIndex: 1,
+      barWidth: 15,
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
+  // 1. 加载统计数据
+  const businessSummaryByDate = await StatisticFunnelApi.getBusinessInversionRateSummaryByDate(
+    props.queryParams
+  )
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis[0] && echartsOption.xAxis[0]['data']) {
+    echartsOption.xAxis[0]['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => s.time
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) =>
+        erpCalculatePercentage(s.businessWinCount, s.businessCount)
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => s.businessCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
+    echartsOption.series[2]['data'] = businessSummaryByDate.map(
+      (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => s.businessWinCount
+    )
+  }
+
+  // 2.2 更新列表数据
+  await getList()
+}
+/** 获取商机列表 */
+const getList = async () => {
+  const data = await StatisticFunnelApi.getBusinessPageByDate(props.queryParams)
+  list.value = data.list
+  total.value = data.total
+}
+/** 打开客户详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmBusinessDetail', params: { id } })
+}
+
+/** 打开客户详情 */
+const openCustomerDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
src/views/infra/apiAccessLog/index.vue

@@ -80,7 +80,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['infra:api-error-log:export']"
+          v-hasPermi="['infra:api-access-log:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>

+ 1 - 1
src/views/infra/file/index.vue

@@ -96,7 +96,7 @@
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['infra:config:delete']"
+            v-hasPermi="['infra:file:delete']"
           >
             删除
           </el-button>

+ 1 - 1
src/views/infra/fileConfig/index.vue

@@ -107,7 +107,7 @@
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['infra:config:delete']"
+            v-hasPermi="['infra:file-config:delete']"
           >
             删除
           </el-button>

+ 36 - 41
src/views/mall/statistics/member/index.vue

@@ -3,44 +3,44 @@
 
   <div class="flex flex-col">
     <el-row :gutter="16" class="summary">
-      <el-col :sm="6" :xs="12" v-loading="loading">
+      <el-col v-loading="loading" :sm="6" :xs="12">
         <SummaryCard
-          title="累计会员数"
+          :value="summary?.userCount || 0"
           icon="fa-solid:users"
-          icon-color="bg-blue-100"
           icon-bg-color="text-blue-500"
-          :value="summary?.userCount || 0"
+          icon-color="bg-blue-100"
+          title="累计会员数"
         />
       </el-col>
-      <el-col :sm="6" :xs="12" v-loading="loading">
+      <el-col v-loading="loading" :sm="6" :xs="12">
         <SummaryCard
-          title="累计充值人数"
+          :value="summary?.rechargeUserCount || 0"
           icon="fa-solid:user"
-          icon-color="bg-purple-100"
           icon-bg-color="text-purple-500"
-          :value="summary?.rechargeUserCount || 0"
+          icon-color="bg-purple-100"
+          title="累计充值人数"
         />
       </el-col>
-      <el-col :sm="6" :xs="12" v-loading="loading">
+      <el-col v-loading="loading" :sm="6" :xs="12">
         <SummaryCard
-          title="累计充值金额"
+          :decimals="2"
+          :value="fenToYuan(summary?.rechargePrice || 0)"
           icon="fa-solid:money-check-alt"
-          icon-color="bg-yellow-100"
           icon-bg-color="text-yellow-500"
+          icon-color="bg-yellow-100"
           prefix="¥"
-          :decimals="2"
-          :value="fenToYuan(summary?.rechargePrice || 0)"
+          title="累计充值金额"
         />
       </el-col>
-      <el-col :sm="6" :xs="12" v-loading="loading">
+      <el-col v-loading="loading" :sm="6" :xs="12">
         <SummaryCard
-          title="累计消费金额"
+          :decimals="2"
+          :value="fenToYuan(summary?.expensePrice || 0)"
           icon="fa-solid:yen-sign"
-          icon-color="bg-green-100"
           icon-bg-color="text-green-500"
+          icon-color="bg-green-100"
           prefix="¥"
-          :decimals="2"
-          :value="fenToYuan(summary?.expensePrice || 0)"
+          title="累计消费金额"
         />
       </el-col>
     </el-row>
@@ -67,42 +67,42 @@
             <el-col :span="14">
               <el-table :data="areaStatisticsList" :height="300">
                 <el-table-column
-                  label="省份"
-                  prop="areaName"
+                  :sort-method="(obj1, obj2) => obj1.areaName.localeCompare(obj2.areaName, 'zh-CN')"
                   align="center"
+                  label="省份"
                   min-width="80"
+                  prop="areaName"
                   show-overflow-tooltip
                   sortable
-                  :sort-method="(obj1, obj2) => obj1.areaName.localeCompare(obj2.areaName, 'zh-CN')"
                 />
                 <el-table-column
-                  label="会员数量"
-                  prop="userCount"
                   align="center"
+                  label="会员数量"
                   min-width="105"
+                  prop="userCount"
                   sortable
                 />
                 <el-table-column
-                  label="订单创建数量"
-                  prop="orderCreateUserCount"
                   align="center"
+                  label="订单创建数量"
                   min-width="135"
+                  prop="orderCreateUserCount"
                   sortable
                 />
                 <el-table-column
-                  label="订单支付数量"
-                  prop="orderPayUserCount"
                   align="center"
+                  label="订单支付数量"
                   min-width="135"
+                  prop="orderPayUserCount"
                   sortable
                 />
                 <el-table-column
-                  label="订单支付金额"
-                  prop="orderPayPrice"
+                  :formatter="fenToYuanFormat"
                   align="center"
+                  label="订单支付金额"
                   min-width="135"
+                  prop="orderPayPrice"
                   sortable
-                  :formatter="fenToYuanFormat"
                 />
               </el-table>
             </el-col>
@@ -110,7 +110,7 @@
         </el-card>
       </el-col>
       <el-col :md="6" :sm="24">
-        <el-card shadow="never" v-loading="loading">
+        <el-card v-loading="loading" shadow="never">
           <template #header>
             <CardTitle title="会员性别比例" />
           </template>
@@ -122,16 +122,16 @@
 </template>
 <script lang="ts" setup>
 import * as MemberStatisticsApi from '@/api/mall/statistics/member'
-import SummaryCard from '@/components/SummaryCard/index.vue'
-import { EChartsOption } from 'echarts'
-import china from '@/assets/map/json/china.json'
-import { fenToYuan } from '@/utils'
 import {
   MemberAreaStatisticsRespVO,
   MemberSexStatisticsRespVO,
   MemberSummaryRespVO,
   MemberTerminalStatisticsRespVO
 } from '@/api/mall/statistics/member'
+import SummaryCard from '@/components/SummaryCard/index.vue'
+import { EChartsOption } from 'echarts'
+import china from '@/assets/map/json/china.json'
+import { areaReplace, fenToYuan } from '@/utils'
 import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict'
 import echarts from '@/plugins/echarts'
 import { fenToYuanFormat } from '@/utils/formatter'
@@ -246,12 +246,7 @@ const getMemberAreaStatisticsList = async () => {
   areaStatisticsList.value = list.map((item: MemberAreaStatisticsRespVO) => {
     return {
       ...item,
-      areaName: item.areaName
-        .replace('维吾尔自治区', '')
-        .replace('壮族自治区', '')
-        .replace('回族自治区', '')
-        .replace('自治区', '')
-        .replace('省', '')
+      areaName: areaReplace(item.areaName)
     }
   })
   let min = 0

+ 8 - 5
src/views/mall/trade/order/form/OrderUpdatePriceForm.vue

@@ -6,7 +6,7 @@
       </el-form-item>
       <el-form-item label="订单调价">
         <el-input-number v-model="formData.adjustPrice" :precision="2" :step="0.1" class="w-100%" />
-        <el-tag class="mt-10px" type="warning">订单调价。 正数,加价;负数,减价</el-tag>
+        <el-tag class="ml-10px" type="warning">订单调价。 正数,加价;负数,减价</el-tag>
       </el-form-item>
       <el-form-item label="调价后">
         <el-input v-model="formData.newPayPrice" disabled />
@@ -38,10 +38,13 @@ const formData = ref({
 })
 watch(
   () => formData.value.adjustPrice,
-  (data: number) => {
-    const num = formData.value.payPrice!.replace('元', '')
-    // @ts-ignore
-    formData.value.newPayPrice = (num * 1 + data).toFixed(2) + '元'
+  (adjustPrice: number | string) => {
+    const numMatch = formData.value.payPrice.match(/\d+(\.\d+)?/)
+    if (numMatch) {
+      const payPriceNum = parseFloat(numMatch[0])
+      adjustPrice = typeof adjustPrice === 'string' ? parseFloat(adjustPrice) : adjustPrice
+      formData.value.newPayPrice = (payPriceNum + adjustPrice).toFixed(2) + '元'
+    }
   }
 )
 

+ 2 - 2
src/views/system/loginlog/index.vue

@@ -47,7 +47,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['infra:config:export']"
+          v-hasPermi="['infra:login-log:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>
@@ -85,7 +85,7 @@
             link
             type="primary"
             @click="openDetail(scope.row)"
-            v-hasPermi="['infra:config:query']"
+            v-hasPermi="['infra:login-log:query']"
           >
             详情
           </el-button>

+ 1 - 1
src/views/system/mail/template/index.vue

@@ -10,7 +10,7 @@
           type="primary"
           plain
           @click="openForm('create')"
-          v-hasPermi="['system:mail-account:create']"
+          v-hasPermi="['system:mail-template:create']"
         >
           <Icon icon="ep:plus" class="mr-5px" /> 新增
         </el-button>

+ 2 - 2
src/views/system/operatelog/index.vue

@@ -80,7 +80,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['infra:config:export']"
+          v-hasPermi="['infra:operate-log:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>
@@ -111,7 +111,7 @@
             link
             type="primary"
             @click="openDetail(scope.row)"
-            v-hasPermi="['infra:config:query']"
+            v-hasPermi="['infra:operate-log:query']"
           >
             详情
           </el-button>

+ 1 - 1
src/views/system/post/index.vue

@@ -50,7 +50,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['infra:post:export']"
+          v-hasPermi="['system:post:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>