ydmyzx 5 mesi fa
parent
commit
948f79c6bb
39 ha cambiato i file con 6588 aggiunte e 0 eliminazioni
  1. 168 0
      src/api/crm/statistics/customer.ts
  2. 26 0
      src/api/mall/promotion/coupon/coupon.ts
  3. 90 0
      src/api/mall/promotion/coupon/couponTemplate.ts
  4. 182 0
      src/components/CountTo/src/CountTo.vue
  5. 1015 0
      src/components/Crontab/src/Crontab.vue
  6. 183 0
      src/components/Cropper/src/Cropper.vue
  7. 142 0
      src/components/Cropper/src/CropperAvatar.vue
  8. 221 0
      src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js
  9. 44 0
      src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js
  10. 14 0
      src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js
  11. 16 0
      src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js
  12. 239 0
      src/views/bpm/disbursement/create.vue
  13. 164 0
      src/views/bpm/oa/leave/create.vue
  14. 170 0
      src/views/crm/backlog/components/CustomerFollowList.vue
  15. 169 0
      src/views/crm/backlog/components/CustomerPutPoolRemindList.vue
  16. 180 0
      src/views/crm/backlog/components/CustomerTodayContactList.vue
  17. 259 0
      src/views/crm/customer/CustomerForm.vue
  18. 158 0
      src/views/crm/customer/CustomerImportForm.vue
  19. 43 0
      src/views/crm/customer/detail/CustomerDetailsHeader.vue
  20. 72 0
      src/views/crm/customer/detail/CustomerDetailsInfo.vue
  21. 150 0
      src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue
  22. 150 0
      src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue
  23. 85 0
      src/views/crm/customer/pool/CustomerDistributeForm.vue
  24. 170 0
      src/views/crm/statistics/customer/components/CustomerConversionStat.vue
  25. 153 0
      src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue
  26. 153 0
      src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue
  27. 154 0
      src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue
  28. 156 0
      src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue
  29. 120 0
      src/views/crm/statistics/customer/components/CustomerFollowUpType.vue
  30. 154 0
      src/views/crm/statistics/customer/components/CustomerPoolSummary.vue
  31. 183 0
      src/views/crm/statistics/customer/components/CustomerSummary.vue
  32. 98 0
      src/views/crm/statistics/rank/components/CustomerCountRank.vue
  33. 210 0
      src/views/erp/sale/customer/CustomerForm.vue
  34. 219 0
      src/views/mall/promotion/coupon/components/CouponSelect.vue
  35. 162 0
      src/views/mall/promotion/coupon/components/CouponSendForm.vue
  36. 388 0
      src/views/mall/promotion/coupon/template/CouponTemplateForm.vue
  37. 166 0
      src/views/mp/draft/components/CoverSelect.vue
  38. 135 0
      src/views/pay/transfer/CreatePayTransfer.vue
  39. 27 0
      types/custom-types.d.ts

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

@@ -0,0 +1,168 @@
+import request from '@/config/axios'
+
+export interface CrmStatisticsCustomerSummaryByDateRespVO {
+  time: string
+  customerCreateCount: number
+  customerDealCount: number
+}
+
+export interface CrmStatisticsCustomerSummaryByUserRespVO {
+  ownerUserName: string
+  customerCreateCount: number
+  customerDealCount: number
+  contractPrice: number
+  receivablePrice: number
+}
+
+export interface CrmStatisticsFollowUpSummaryByDateRespVO {
+  time: string
+  followUpRecordCount: number
+  followUpCustomerCount: number
+}
+
+export interface CrmStatisticsFollowUpSummaryByUserRespVO {
+  ownerUserName: string
+  followupRecordCount: number
+  followupCustomerCount: number
+}
+
+export interface CrmStatisticsFollowUpSummaryByTypeRespVO {
+  followUpType: string
+  followUpRecordCount: number
+}
+
+export interface CrmStatisticsCustomerContractSummaryRespVO {
+  customerName: string
+  contractName: string
+  totalPrice: number
+  receivablePrice: number
+  customerType: string
+  customerSource: string
+  ownerUserName: string
+  creatorUserName: string
+  createTime: Date
+  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
+}
+
+export interface CrmStatisticsCustomerDealCycleByUserRespVO {
+  ownerUserName: string
+  customerDealCycle: number
+  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 客户总量分析(按日期)
+  getCustomerSummaryByDate: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-summary-by-date',
+      params
+    })
+  },
+  // 1.2 客户总量分析(按用户)
+  getCustomerSummaryByUser: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-customer-summary-by-user',
+      params
+    })
+  },
+  // 2.1 客户跟进次数分析(按日期)
+  getFollowUpSummaryByDate: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-follow-up-summary-by-date',
+      params
+    })
+  },
+  // 2.2 客户跟进次数分析(按用户)
+  getFollowUpSummaryByUser: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-follow-up-summary-by-user',
+      params
+    })
+  },
+  // 3.1 获取客户跟进方式统计数
+  getFollowUpSummaryByType: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-follow-up-summary-by-type',
+      params
+    })
+  },
+  // 4.1 合同摘要信息(客户转化率页面)
+  getContractSummary: (params: any) => {
+    return request.get({
+      url: '/crm/statistics-customer/get-contract-summary',
+      params
+    })
+  },
+  // 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
+    })
+  },
+  // 6.2 获取客户成交周期(按用户)
+  getCustomerDealCycleByUser: (params: any) => {
+    return request.get({
+      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
+    })
+  }
+}

+ 26 - 0
src/api/mall/promotion/coupon/coupon.ts

@@ -0,0 +1,26 @@
+import request from '@/config/axios'
+
+// TODO @dhb52:vo 缺少
+
+// 删除优惠劵
+export const deleteCoupon = async (id: number) => {
+  return request.delete({
+    url: `/promotion/coupon/delete?id=${id}`
+  })
+}
+
+// 获得优惠劵分页
+export const getCouponPage = async (params: PageParam) => {
+  return request.get({
+    url: '/promotion/coupon/page',
+    params: params
+  })
+}
+
+// 发送优惠券
+export const sendCoupon = async (data: any) => {
+  return request.post({
+    url: '/promotion/coupon/send',
+    data: data
+  })
+}

+ 90 - 0
src/api/mall/promotion/coupon/couponTemplate.ts

@@ -0,0 +1,90 @@
+import request from '@/config/axios'
+
+export interface CouponTemplateVO {
+  id: number
+  name: string
+  status: number
+  totalCount: number
+  takeLimitCount: number
+  takeType: number
+  usePrice: number
+  productScope: number
+  productScopeValues: number[]
+  validityType: number
+  validStartTime: Date
+  validEndTime: Date
+  fixedStartTerm: number
+  fixedEndTerm: number
+  discountType: number
+  discountPercent: number
+  discountPrice: number
+  discountLimitPrice: number
+  takeCount: number
+  useCount: number
+}
+
+// 创建优惠劵模板
+export function createCouponTemplate(data: CouponTemplateVO) {
+  return request.post({
+    url: '/promotion/coupon-template/create',
+    data: data
+  })
+}
+
+// 更新优惠劵模板
+export function updateCouponTemplate(data: CouponTemplateVO) {
+  return request.put({
+    url: '/promotion/coupon-template/update',
+    data: data
+  })
+}
+
+// 更新优惠劵模板的状态
+export function updateCouponTemplateStatus(id: number, status: [0, 1]) {
+  const data = {
+    id,
+    status
+  }
+  return request.put({
+    url: '/promotion/coupon-template/update-status',
+    data: data
+  })
+}
+
+// 删除优惠劵模板
+export function deleteCouponTemplate(id: number) {
+  return request.delete({
+    url: '/promotion/coupon-template/delete?id=' + id
+  })
+}
+
+// 获得优惠劵模板
+export function getCouponTemplate(id: number) {
+  return request.get({
+    url: '/promotion/coupon-template/get?id=' + id
+  })
+}
+
+// 获得优惠劵模板分页
+export function getCouponTemplatePage(params: PageParam) {
+  return request.get({
+    url: '/promotion/coupon-template/page',
+    params: params
+  })
+}
+
+// 获得优惠劵模板分页
+export function getCouponTemplateList(ids: number[]) {
+  return request.get({
+    url: `/promotion/coupon-template/list?ids=${ids}`
+  })
+}
+
+// 导出优惠劵模板 Excel
+export function exportCouponTemplateExcel(params: PageParam) {
+  return request.get({
+    url: '/promotion/coupon-template/export-excel',
+    params: params,
+    responseType: 'blob'
+  })
+}

+ 182 - 0
src/components/CountTo/src/CountTo.vue

@@ -0,0 +1,182 @@
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import { isNumber } from '@/utils/is'
+import { propTypes } from '@/utils/propTypes'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'CountTo' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('count-to')
+
+const props = defineProps({
+  startVal: propTypes.number.def(0), // 开始播放值
+  endVal: propTypes.number.def(2021), // 最终值
+  duration: propTypes.number.def(3000), // 动画时长
+  autoplay: propTypes.bool.def(true), // 是否自动播放动画, 默认播放
+  decimals: propTypes.number.validate((value: number) => value >= 0).def(0), // 显示的小数位数, 默认不显示小数
+  decimal: propTypes.string.def('.'), // 小数分隔符号, 默认为点
+  separator: propTypes.string.def(','), // 数字每三位的分隔符, 默认为逗号
+  prefix: propTypes.string.def(''), // 前缀, 数值前面显示的内容
+  suffix: propTypes.string.def(''), // 后缀, 数值后面显示的内容
+  useEasing: propTypes.bool.def(true), // 是否使用缓动效果, 默认启用
+  easingFn: {
+    type: Function as PropType<(t: number, b: number, c: number, d: number) => number>,
+    default(t: number, b: number, c: number, d: number) {
+      return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b
+    } // 缓动函数
+  }
+})
+
+const emit = defineEmits(['mounted', 'callback'])
+
+const formatNumber = (num: number | string) => {
+  const { decimals, decimal, separator, suffix, prefix } = props
+  num = Number(num).toFixed(decimals)
+  num += ''
+  const x = num.split('.')
+  let x1 = x[0]
+  const x2 = x.length > 1 ? decimal + x[1] : ''
+  const rgx = /(\d+)(\d{3})/
+  if (separator && !isNumber(separator)) {
+    while (rgx.test(x1)) {
+      x1 = x1.replace(rgx, '$1' + separator + '$2')
+    }
+  }
+  return prefix + x1 + x2 + suffix
+}
+
+const state = reactive<{
+  localStartVal: number
+  printVal: number | null
+  displayValue: string
+  paused: boolean
+  localDuration: number | null
+  startTime: number | null
+  timestamp: number | null
+  rAF: any
+  remaining: number | null
+}>({
+  localStartVal: props.startVal,
+  displayValue: formatNumber(props.startVal),
+  printVal: null,
+  paused: false,
+  localDuration: props.duration,
+  startTime: null,
+  timestamp: null,
+  remaining: null,
+  rAF: null
+})
+
+const displayValue = toRef(state, 'displayValue')
+
+onMounted(() => {
+  if (props.autoplay) {
+    start()
+  }
+  emit('mounted')
+})
+
+const getCountDown = computed(() => {
+  return props.startVal > props.endVal
+})
+
+watch([() => props.startVal, () => props.endVal], () => {
+  if (props.autoplay) {
+    start()
+  }
+})
+
+const start = () => {
+  const { startVal, duration } = props
+  state.localStartVal = startVal
+  state.startTime = null
+  state.localDuration = duration
+  state.paused = false
+  state.rAF = requestAnimationFrame(count)
+}
+
+const pauseResume = () => {
+  if (state.paused) {
+    resume()
+    state.paused = false
+  } else {
+    pause()
+    state.paused = true
+  }
+}
+
+const pause = () => {
+  cancelAnimationFrame(state.rAF)
+}
+
+const resume = () => {
+  state.startTime = null
+  state.localDuration = +(state.remaining as number)
+  state.localStartVal = +(state.printVal as number)
+  requestAnimationFrame(count)
+}
+
+const reset = () => {
+  state.startTime = null
+  cancelAnimationFrame(state.rAF)
+  state.displayValue = formatNumber(props.startVal)
+}
+
+const count = (timestamp: number) => {
+  const { useEasing, easingFn, endVal } = props
+  if (!state.startTime) state.startTime = timestamp
+  state.timestamp = timestamp
+  const progress = timestamp - state.startTime
+  state.remaining = (state.localDuration as number) - progress
+  if (useEasing) {
+    if (unref(getCountDown)) {
+      state.printVal =
+        state.localStartVal -
+        easingFn(progress, 0, state.localStartVal - endVal, state.localDuration as number)
+    } else {
+      state.printVal = easingFn(
+        progress,
+        state.localStartVal,
+        endVal - state.localStartVal,
+        state.localDuration as number
+      )
+    }
+  } else {
+    if (unref(getCountDown)) {
+      state.printVal =
+        state.localStartVal -
+        (state.localStartVal - endVal) * (progress / (state.localDuration as number))
+    } else {
+      state.printVal =
+        state.localStartVal +
+        (endVal - state.localStartVal) * (progress / (state.localDuration as number))
+    }
+  }
+  if (unref(getCountDown)) {
+    state.printVal = state.printVal < endVal ? endVal : state.printVal
+  } else {
+    state.printVal = state.printVal > endVal ? endVal : state.printVal
+  }
+  state.displayValue = formatNumber(state.printVal!)
+  if (progress < (state.localDuration as number)) {
+    state.rAF = requestAnimationFrame(count)
+  } else {
+    emit('callback')
+  }
+}
+
+defineExpose({
+  pauseResume,
+  reset,
+  start,
+  pause
+})
+</script>
+
+<template>
+  <span :class="prefixCls">
+    {{ displayValue }}
+  </span>
+</template>

+ 1015 - 0
src/components/Crontab/src/Crontab.vue

@@ -0,0 +1,1015 @@
+<script lang="ts" setup>
+import { ElMessage } from 'element-plus'
+import { PropType } from 'vue'
+
+defineOptions({ name: 'Crontab' })
+
+interface shortcutsType {
+  text: string
+  value: string
+}
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: '* * * * * ?'
+  },
+  shortcuts: { type: Array as PropType<shortcutsType[]>, default: () => [] }
+})
+const defaultValue = ref('')
+const dialogVisible = ref(false)
+const getYear = () => {
+  let v: number[] = []
+  let y = new Date().getFullYear()
+  for (let i = 0; i < 11; i++) {
+    v.push(y + i)
+  }
+  return v
+}
+const cronValue = reactive({
+  second: {
+    type: '0',
+    range: {
+      start: 1,
+      end: 2
+    },
+    loop: {
+      start: 0,
+      end: 1
+    },
+    appoint: [] as string[]
+  },
+  minute: {
+    type: '0',
+    range: {
+      start: 1,
+      end: 2
+    },
+    loop: {
+      start: 0,
+      end: 1
+    },
+    appoint: [] as string[]
+  },
+  hour: {
+    type: '0',
+    range: {
+      start: 1,
+      end: 2
+    },
+    loop: {
+      start: 0,
+      end: 1
+    },
+    appoint: [] as string[]
+  },
+  day: {
+    type: '0',
+    range: {
+      start: 1,
+      end: 2
+    },
+    loop: {
+      start: 1,
+      end: 1
+    },
+    appoint: [] as string[]
+  },
+  month: {
+    type: '0',
+    range: {
+      start: 1,
+      end: 2
+    },
+    loop: {
+      start: 1,
+      end: 1
+    },
+    appoint: [] as string[]
+  },
+  week: {
+    type: '5',
+    range: {
+      start: '2',
+      end: '3'
+    },
+    loop: {
+      start: 0,
+      end: '2'
+    },
+    last: '2',
+    appoint: [] as string[]
+  },
+  year: {
+    type: '-1',
+    range: {
+      start: getYear()[0],
+      end: getYear()[1]
+    },
+    loop: {
+      start: getYear()[0],
+      end: 1
+    },
+    appoint: [] as string[]
+  }
+})
+const data = reactive({
+  second: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'],
+  minute: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'],
+  hour: [
+    '0',
+    '1',
+    '2',
+    '3',
+    '4',
+    '5',
+    '6',
+    '7',
+    '8',
+    '9',
+    '10',
+    '11',
+    '12',
+    '13',
+    '14',
+    '15',
+    '16',
+    '17',
+    '18',
+    '19',
+    '20',
+    '21',
+    '22',
+    '23'
+  ],
+  day: [
+    '1',
+    '2',
+    '3',
+    '4',
+    '5',
+    '6',
+    '7',
+    '8',
+    '9',
+    '10',
+    '11',
+    '12',
+    '13',
+    '14',
+    '15',
+    '16',
+    '17',
+    '18',
+    '19',
+    '20',
+    '21',
+    '22',
+    '23',
+    '24',
+    '25',
+    '26',
+    '27',
+    '28',
+    '29',
+    '30',
+    '31'
+  ],
+  month: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
+  week: [
+    {
+      value: '1',
+      label: '周日'
+    },
+    {
+      value: '2',
+      label: '周一'
+    },
+    {
+      value: '3',
+      label: '周二'
+    },
+    {
+      value: '4',
+      label: '周三'
+    },
+    {
+      value: '5',
+      label: '周四'
+    },
+    {
+      value: '6',
+      label: '周五'
+    },
+    {
+      value: '7',
+      label: '周六'
+    }
+  ],
+  year: getYear()
+})
+
+const value_second = computed(() => {
+  let v = cronValue.second
+  if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.start + '/' + v.loop.end
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : '*'
+  } else {
+    return '*'
+  }
+})
+const value_minute = computed(() => {
+  let v = cronValue.minute
+  if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.start + '/' + v.loop.end
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : '*'
+  } else {
+    return '*'
+  }
+})
+const value_hour = computed(() => {
+  let v = cronValue.hour
+  if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.start + '/' + v.loop.end
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : '*'
+  } else {
+    return '*'
+  }
+})
+const value_day = computed(() => {
+  let v = cronValue.day
+  if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.start + '/' + v.loop.end
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : '*'
+  } else if (v.type == '4') {
+    return 'L'
+  } else if (v.type == '5') {
+    return '?'
+  } else {
+    return '*'
+  }
+})
+const value_month = computed(() => {
+  let v = cronValue.month
+  if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.start + '/' + v.loop.end
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : '*'
+  } else {
+    return '*'
+  }
+})
+const value_week = computed(() => {
+  let v = cronValue.week
+  if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.end + '#' + v.loop.start
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : '*'
+  } else if (v.type == '4') {
+    return v.last + 'L'
+  } else if (v.type == '5') {
+    return '?'
+  } else {
+    return '*'
+  }
+})
+const value_year = computed(() => {
+  let v = cronValue.year
+  if (v.type == '-1') {
+    return ''
+  } else if (v.type == '0') {
+    return '*'
+  } else if (v.type == '1') {
+    return v.range.start + '-' + v.range.end
+  } else if (v.type == '2') {
+    return v.loop.start + '/' + v.loop.end
+  } else if (v.type == '3') {
+    return v.appoint.length > 0 ? v.appoint.join(',') : ''
+  } else {
+    return ''
+  }
+})
+watch(
+  () => cronValue.week.type,
+  (val) => {
+    if (val != '5') {
+      cronValue.day.type = '5'
+    }
+  }
+)
+watch(
+  () => cronValue.day.type,
+  (val) => {
+    if (val != '5') {
+      cronValue.week.type = '5'
+    }
+  }
+)
+watch(
+  () => props.modelValue,
+  () => {
+    defaultValue.value = props.modelValue
+  }
+)
+onMounted(() => {
+  defaultValue.value = props.modelValue
+})
+const emit = defineEmits(['update:modelValue'])
+const select = ref()
+watch(
+  () => select.value,
+  () => {
+    if (select.value == 'custom') {
+      open()
+    } else {
+      defaultValue.value = select.value
+      emit('update:modelValue', defaultValue.value)
+    }
+  }
+)
+const open = () => {
+  set()
+  dialogVisible.value = true
+}
+const set = () => {
+  defaultValue.value = props.modelValue
+  let arr = (props.modelValue || '* * * * * ?').split(' ')
+  //简单检查
+  if (arr.length < 6) {
+    ElMessage.warning('cron表达式错误,已转换为默认表达式')
+    arr = '* * * * * ?'.split(' ')
+  }
+
+  //秒
+  if (arr[0] == '*') {
+    cronValue.second.type = '0'
+  } else if (arr[0].includes('-')) {
+    cronValue.second.type = '1'
+    cronValue.second.range.start = Number(arr[0].split('-')[0])
+    cronValue.second.range.end = Number(arr[0].split('-')[1])
+  } else if (arr[0].includes('/')) {
+    cronValue.second.type = '2'
+    cronValue.second.loop.start = Number(arr[0].split('/')[0])
+    cronValue.second.loop.end = Number(arr[0].split('/')[1])
+  } else {
+    cronValue.second.type = '3'
+    cronValue.second.appoint = arr[0].split(',')
+  }
+  //分
+  if (arr[1] == '*') {
+    cronValue.minute.type = '0'
+  } else if (arr[1].includes('-')) {
+    cronValue.minute.type = '1'
+    cronValue.minute.range.start = Number(arr[1].split('-')[0])
+    cronValue.minute.range.end = Number(arr[1].split('-')[1])
+  } else if (arr[1].includes('/')) {
+    cronValue.minute.type = '2'
+    cronValue.minute.loop.start = Number(arr[1].split('/')[0])
+    cronValue.minute.loop.end = Number(arr[1].split('/')[1])
+  } else {
+    cronValue.minute.type = '3'
+    cronValue.minute.appoint = arr[1].split(',')
+  }
+  //小时
+  if (arr[2] == '*') {
+    cronValue.hour.type = '0'
+  } else if (arr[2].includes('-')) {
+    cronValue.hour.type = '1'
+    cronValue.hour.range.start = Number(arr[2].split('-')[0])
+    cronValue.hour.range.end = Number(arr[2].split('-')[1])
+  } else if (arr[2].includes('/')) {
+    cronValue.hour.type = '2'
+    cronValue.hour.loop.start = Number(arr[2].split('/')[0])
+    cronValue.hour.loop.end = Number(arr[2].split('/')[1])
+  } else {
+    cronValue.hour.type = '3'
+    cronValue.hour.appoint = arr[2].split(',')
+  }
+  //日
+  if (arr[3] == '*') {
+    cronValue.day.type = '0'
+  } else if (arr[3] == 'L') {
+    cronValue.day.type = '4'
+  } else if (arr[3] == '?') {
+    cronValue.day.type = '5'
+  } else if (arr[3].includes('-')) {
+    cronValue.day.type = '1'
+    cronValue.day.range.start = Number(arr[3].split('-')[0])
+    cronValue.day.range.end = Number(arr[3].split('-')[1])
+  } else if (arr[3].includes('/')) {
+    cronValue.day.type = '2'
+    cronValue.day.loop.start = Number(arr[3].split('/')[0])
+    cronValue.day.loop.end = Number(arr[3].split('/')[1])
+  } else {
+    cronValue.day.type = '3'
+    cronValue.day.appoint = arr[3].split(',')
+  }
+  //月
+  if (arr[4] == '*') {
+    cronValue.month.type = '0'
+  } else if (arr[4].includes('-')) {
+    cronValue.month.type = '1'
+    cronValue.month.range.start = Number(arr[4].split('-')[0])
+    cronValue.month.range.end = Number(arr[4].split('-')[1])
+  } else if (arr[4].includes('/')) {
+    cronValue.month.type = '2'
+    cronValue.month.loop.start = Number(arr[4].split('/')[0])
+    cronValue.month.loop.end = Number(arr[4].split('/')[1])
+  } else {
+    cronValue.month.type = '3'
+    cronValue.month.appoint = arr[4].split(',')
+  }
+  //周
+  if (arr[5] == '*') {
+    cronValue.week.type = '0'
+  } else if (arr[5] == '?') {
+    cronValue.week.type = '5'
+  } else if (arr[5].includes('-')) {
+    cronValue.week.type = '1'
+    cronValue.week.range.start = arr[5].split('-')[0]
+    cronValue.week.range.end = arr[5].split('-')[1]
+  } else if (arr[5].includes('#')) {
+    cronValue.week.type = '2'
+    cronValue.week.loop.start = Number(arr[5].split('#')[1])
+    cronValue.week.loop.end = arr[5].split('#')[0]
+  } else if (arr[5].includes('L')) {
+    cronValue.week.type = '4'
+    cronValue.week.last = arr[5].split('L')[0]
+  } else {
+    cronValue.week.type = '3'
+    cronValue.week.appoint = arr[5].split(',')
+  }
+  //年
+  if (!arr[6]) {
+    cronValue.year.type = '-1'
+  } else if (arr[6] == '*') {
+    cronValue.year.type = '0'
+  } else if (arr[6].includes('-')) {
+    cronValue.year.type = '1'
+    cronValue.year.range.start = Number(arr[6].split('-')[0])
+    cronValue.year.range.end = Number(arr[6].split('-')[1])
+  } else if (arr[6].includes('/')) {
+    cronValue.year.type = '2'
+    cronValue.year.loop.start = Number(arr[6].split('/')[1])
+    cronValue.year.loop.end = Number(arr[6].split('/')[0])
+  } else {
+    cronValue.year.type = '3'
+    cronValue.year.appoint = arr[6].split(',')
+  }
+}
+const submit = () => {
+  let year = value_year.value ? ' ' + value_year.value : ''
+  defaultValue.value =
+    value_second.value +
+    ' ' +
+    value_minute.value +
+    ' ' +
+    value_hour.value +
+    ' ' +
+    value_day.value +
+    ' ' +
+    value_month.value +
+    ' ' +
+    value_week.value +
+    year
+  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" @input="inputChange">
+    <template #append>
+      <el-select v-model="select" placeholder="生成器" style="width: 115px">
+        <el-option label="每分钟" value="0 * * * * ?" />
+        <el-option label="每小时" value="0 0 * * * ?" />
+        <el-option label="每天零点" value="0 0 0 * * ?" />
+        <el-option label="每月一号零点" value="0 0 0 1 * ?" />
+        <el-option label="每月最后一天零点" value="0 0 0 L * ?" />
+        <el-option label="每周星期日零点" value="0 0 0 ? * 1" />
+        <el-option
+          v-for="(item, index) in shortcuts"
+          :key="index"
+          :label="item.text"
+          :value="item.value"
+        />
+        <el-option label="自定义" value="custom" />
+      </el-select>
+    </template>
+  </el-input>
+
+  <el-dialog
+    v-model="dialogVisible"
+    :width="580"
+    append-to-body
+    destroy-on-close
+    title="cron规则生成器"
+  >
+    <div class="sc-cron">
+      <el-tabs>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>秒</h2>
+              <h4>{{ value_second }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form-item label="类型">
+              <el-radio-group v-model="cronValue.second.type">
+                <el-radio-button label="0">任意值</el-radio-button>
+                <el-radio-button label="1">范围</el-radio-button>
+                <el-radio-button label="2">间隔</el-radio-button>
+                <el-radio-button label="3">指定</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item v-if="cronValue.second.type == '1'" label="范围">
+              <el-input-number
+                v-model="cronValue.second.range.start"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+              <span style="padding: 0 15px">-</span>
+              <el-input-number
+                v-model="cronValue.second.range.end"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+            </el-form-item>
+            <el-form-item v-if="cronValue.second.type == '2'" label="间隔">
+              <el-input-number
+                v-model="cronValue.second.loop.start"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+              秒开始,每
+              <el-input-number
+                v-model="cronValue.second.loop.end"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+              秒执行一次
+            </el-form-item>
+            <el-form-item v-if="cronValue.second.type == '3'" label="指定">
+              <el-select v-model="cronValue.second.appoint" multiple style="width: 100%">
+                <el-option
+                  v-for="(item, index) in data.second"
+                  :key="index"
+                  :label="item"
+                  :value="item"
+                />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>分钟</h2>
+              <h4>{{ value_minute }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form-item label="类型">
+              <el-radio-group v-model="cronValue.minute.type">
+                <el-radio-button label="0">任意值</el-radio-button>
+                <el-radio-button label="1">范围</el-radio-button>
+                <el-radio-button label="2">间隔</el-radio-button>
+                <el-radio-button label="3">指定</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item v-if="cronValue.minute.type == '1'" label="范围">
+              <el-input-number
+                v-model="cronValue.minute.range.start"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+              <span style="padding: 0 15px">-</span>
+              <el-input-number
+                v-model="cronValue.minute.range.end"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+            </el-form-item>
+            <el-form-item v-if="cronValue.minute.type == '2'" label="间隔">
+              <el-input-number
+                v-model="cronValue.minute.loop.start"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+              分钟开始,每
+              <el-input-number
+                v-model="cronValue.minute.loop.end"
+                :max="59"
+                :min="0"
+                controls-position="right"
+              />
+              分钟执行一次
+            </el-form-item>
+            <el-form-item v-if="cronValue.minute.type == '3'" label="指定">
+              <el-select v-model="cronValue.minute.appoint" multiple style="width: 100%">
+                <el-option
+                  v-for="(item, index) in data.minute"
+                  :key="index"
+                  :label="item"
+                  :value="item"
+                />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>小时</h2>
+              <h4>{{ value_hour }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form-item label="类型">
+              <el-radio-group v-model="cronValue.hour.type">
+                <el-radio-button label="0">任意值</el-radio-button>
+                <el-radio-button label="1">范围</el-radio-button>
+                <el-radio-button label="2">间隔</el-radio-button>
+                <el-radio-button label="3">指定</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item v-if="cronValue.hour.type == '1'" label="范围">
+              <el-input-number
+                v-model="cronValue.hour.range.start"
+                :max="23"
+                :min="0"
+                controls-position="right"
+              />
+              <span style="padding: 0 15px">-</span>
+              <el-input-number
+                v-model="cronValue.hour.range.end"
+                :max="23"
+                :min="0"
+                controls-position="right"
+              />
+            </el-form-item>
+            <el-form-item v-if="cronValue.hour.type == '2'" label="间隔">
+              <el-input-number
+                v-model="cronValue.hour.loop.start"
+                :max="23"
+                :min="0"
+                controls-position="right"
+              />
+              小时开始,每
+              <el-input-number
+                v-model="cronValue.hour.loop.end"
+                :max="23"
+                :min="0"
+                controls-position="right"
+              />
+              小时执行一次
+            </el-form-item>
+            <el-form-item v-if="cronValue.hour.type == '3'" label="指定">
+              <el-select v-model="cronValue.hour.appoint" multiple style="width: 100%">
+                <el-option
+                  v-for="(item, index) in data.hour"
+                  :key="index"
+                  :label="item"
+                  :value="item"
+                />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>日</h2>
+              <h4>{{ value_day }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form-item label="类型">
+              <el-radio-group v-model="cronValue.day.type">
+                <el-radio-button label="0">任意值</el-radio-button>
+                <el-radio-button label="1">范围</el-radio-button>
+                <el-radio-button label="2">间隔</el-radio-button>
+                <el-radio-button label="3">指定</el-radio-button>
+                <el-radio-button label="4">本月最后一天</el-radio-button>
+                <el-radio-button label="5">不指定</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item v-if="cronValue.day.type == '1'" label="范围">
+              <el-input-number
+                v-model="cronValue.day.range.start"
+                :max="31"
+                :min="1"
+                controls-position="right"
+              />
+              <span style="padding: 0 15px">-</span>
+              <el-input-number
+                v-model="cronValue.day.range.end"
+                :max="31"
+                :min="1"
+                controls-position="right"
+              />
+            </el-form-item>
+            <el-form-item v-if="cronValue.day.type == '2'" label="间隔">
+              <el-input-number
+                v-model="cronValue.day.loop.start"
+                :max="31"
+                :min="1"
+                controls-position="right"
+              />
+              号开始,每
+              <el-input-number
+                v-model="cronValue.day.loop.end"
+                :max="31"
+                :min="1"
+                controls-position="right"
+              />
+              天执行一次
+            </el-form-item>
+            <el-form-item v-if="cronValue.day.type == '3'" label="指定">
+              <el-select v-model="cronValue.day.appoint" multiple style="width: 100%">
+                <el-option
+                  v-for="(item, index) in data.day"
+                  :key="index"
+                  :label="item"
+                  :value="item"
+                />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>月</h2>
+              <h4>{{ value_month }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form-item label="类型">
+              <el-radio-group v-model="cronValue.month.type">
+                <el-radio-button label="0">任意值</el-radio-button>
+                <el-radio-button label="1">范围</el-radio-button>
+                <el-radio-button label="2">间隔</el-radio-button>
+                <el-radio-button label="3">指定</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item v-if="cronValue.month.type == '1'" label="范围">
+              <el-input-number
+                v-model="cronValue.month.range.start"
+                :max="12"
+                :min="1"
+                controls-position="right"
+              />
+              <span style="padding: 0 15px">-</span>
+              <el-input-number
+                v-model="cronValue.month.range.end"
+                :max="12"
+                :min="1"
+                controls-position="right"
+              />
+            </el-form-item>
+            <el-form-item v-if="cronValue.month.type == '2'" label="间隔">
+              <el-input-number
+                v-model="cronValue.month.loop.start"
+                :max="12"
+                :min="1"
+                controls-position="right"
+              />
+              月开始,每
+              <el-input-number
+                v-model="cronValue.month.loop.end"
+                :max="12"
+                :min="1"
+                controls-position="right"
+              />
+              月执行一次
+            </el-form-item>
+            <el-form-item v-if="cronValue.month.type == '3'" label="指定">
+              <el-select v-model="cronValue.month.appoint" multiple style="width: 100%">
+                <el-option
+                  v-for="(item, index) in data.month"
+                  :key="index"
+                  :label="item"
+                  :value="item"
+                />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>周</h2>
+              <h4>{{ value_week }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form>
+              <el-form-item label="类型">
+                <el-radio-group v-model="cronValue.week.type">
+                  <el-radio-button label="0">任意值</el-radio-button>
+                  <el-radio-button label="1">范围</el-radio-button>
+                  <el-radio-button label="2">间隔</el-radio-button>
+                  <el-radio-button label="3">指定</el-radio-button>
+                  <el-radio-button label="4">本月最后一周</el-radio-button>
+                  <el-radio-button label="5">不指定</el-radio-button>
+                </el-radio-group>
+              </el-form-item>
+              <el-form-item v-if="cronValue.week.type == '1'" label="范围">
+                <el-select v-model="cronValue.week.range.start">
+                  <el-option
+                    v-for="(item, index) in data.week"
+                    :key="index"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+                <span style="padding: 0 15px">-</span>
+                <el-select v-model="cronValue.week.range.end">
+                  <el-option
+                    v-for="(item, index) in data.week"
+                    :key="index"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item v-if="cronValue.week.type == '2'" label="间隔">
+                第
+                <el-input-number
+                  v-model="cronValue.week.loop.start"
+                  :max="4"
+                  :min="1"
+                  controls-position="right"
+                />
+                周的星期
+                <el-select v-model="cronValue.week.loop.end">
+                  <el-option
+                    v-for="(item, index) in data.week"
+                    :key="index"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+                执行一次
+              </el-form-item>
+              <el-form-item v-if="cronValue.week.type == '3'" label="指定">
+                <el-select v-model="cronValue.week.appoint" multiple style="width: 100%">
+                  <el-option
+                    v-for="(item, index) in data.week"
+                    :key="index"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item v-if="cronValue.week.type == '4'" label="最后一周">
+                <el-select v-model="cronValue.week.last">
+                  <el-option
+                    v-for="(item, index) in data.week"
+                    :key="index"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+              </el-form-item>
+            </el-form>
+          </el-form>
+        </el-tab-pane>
+        <el-tab-pane>
+          <template #label>
+            <div class="sc-cron-num">
+              <h2>年</h2>
+              <h4>{{ value_year }}</h4>
+            </div>
+          </template>
+          <el-form>
+            <el-form-item label="类型">
+              <el-radio-group v-model="cronValue.year.type">
+                <el-radio-button label="-1">忽略</el-radio-button>
+                <el-radio-button label="0">任意值</el-radio-button>
+                <el-radio-button label="1">范围</el-radio-button>
+                <el-radio-button label="2">间隔</el-radio-button>
+                <el-radio-button label="3">指定</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item v-if="cronValue.year.type == '1'" label="范围">
+              <el-input-number v-model="cronValue.year.range.start" controls-position="right" />
+              <span style="padding: 0 15px">-</span>
+              <el-input-number v-model="cronValue.year.range.end" controls-position="right" />
+            </el-form-item>
+            <el-form-item v-if="cronValue.year.type == '2'" label="间隔">
+              <el-input-number v-model="cronValue.year.loop.start" controls-position="right" />
+              年开始,每
+              <el-input-number
+                v-model="cronValue.year.loop.end"
+                :min="1"
+                controls-position="right"
+              />
+              年执行一次
+            </el-form-item>
+            <el-form-item v-if="cronValue.year.type == '3'" label="指定">
+              <el-select v-model="cronValue.year.appoint" multiple style="width: 100%">
+                <el-option
+                  v-for="(item, index) in data.year"
+                  :key="index"
+                  :label="item"
+                  :value="item"
+                />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+      </el-tabs>
+    </div>
+
+    <template #footer>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+      <el-button type="primary" @click="submit()">确 认</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<style scoped>
+.sc-cron:deep(.el-tabs__item) {
+  height: auto;
+  padding: 0 7px;
+  line-height: 1;
+  vertical-align: bottom;
+}
+
+.sc-cron-num {
+  width: 100%;
+  margin-bottom: 15px;
+  text-align: center;
+}
+
+.sc-cron-num h2 {
+  margin-bottom: 15px;
+  font-size: 12px;
+  font-weight: normal;
+}
+
+.sc-cron-num h4 {
+  display: block;
+  width: 100%;
+  height: 32px;
+  padding: 0 15px;
+  font-size: 12px;
+  line-height: 30px;
+  background: var(--el-color-primary-light-9);
+  border-radius: 4px;
+}
+
+.sc-cron:deep(.el-tabs__item.is-active) .sc-cron-num h4 {
+  color: #fff;
+  background: var(--el-color-primary);
+}
+
+[data-theme='dark'] .sc-cron-num h4 {
+  background: var(--el-color-white);
+}
+
+.input-with-select .el-input-group__prepend {
+  background-color: var(--el-fill-color-blank);
+}
+</style>

+ 183 - 0
src/components/Cropper/src/Cropper.vue

@@ -0,0 +1,183 @@
+<template>
+  <div :class="getClass" :style="getWrapperStyle">
+    <img
+      v-show="isReady"
+      ref="imgElRef"
+      :alt="alt"
+      :crossorigin="crossorigin"
+      :src="src"
+      :style="getImageStyle"
+    />
+  </div>
+</template>
+<script lang="ts" setup>
+import { CSSProperties, PropType } from 'vue'
+import Cropper from 'cropperjs'
+import 'cropperjs/dist/cropper.css'
+import { useDesign } from '@/hooks/web/useDesign'
+import { propTypes } from '@/utils/propTypes'
+import { useDebounceFn } from '@vueuse/core'
+
+defineOptions({ name: 'Cropper' })
+
+type Options = Cropper.Options
+
+const defaultOptions: Options = {
+  aspectRatio: 1,
+  zoomable: true,
+  zoomOnTouch: true,
+  zoomOnWheel: true,
+  cropBoxMovable: true,
+  cropBoxResizable: true,
+  toggleDragModeOnDblclick: true,
+  autoCrop: true,
+  background: true,
+  highlight: true,
+  center: true,
+  responsive: true,
+  restore: true,
+  checkCrossOrigin: true,
+  checkOrientation: true,
+  scalable: true,
+  modal: true,
+  guides: true,
+  movable: true,
+  rotatable: true
+}
+
+const props = defineProps({
+  src: propTypes.string.def(''),
+  alt: propTypes.string.def(''),
+  circled: propTypes.bool.def(false),
+  realTimePreview: propTypes.bool.def(true),
+  height: propTypes.string.def('360px'),
+  crossorigin: {
+    type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
+    default: undefined
+  },
+  imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
+  options: { type: Object as PropType<Options>, default: () => ({}) }
+})
+
+const emit = defineEmits(['cropend', 'ready', 'cropendError'])
+const attrs = useAttrs()
+const imgElRef = ref<ElRef<HTMLImageElement>>()
+const cropper = ref<Nullable<Cropper>>()
+const isReady = ref(false)
+
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('cropper-image')
+const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80)
+
+const getImageStyle = computed((): CSSProperties => {
+  return {
+    height: props.height,
+    maxWidth: '100%',
+    ...props.imageStyle
+  }
+})
+
+const getClass = computed(() => {
+  return [
+    prefixCls,
+    attrs.class,
+    {
+      [`${prefixCls}--circled`]: props.circled
+    }
+  ]
+})
+const getWrapperStyle = computed((): CSSProperties => {
+  return { height: `${props.height}`.replace(/px/, '') + 'px' }
+})
+
+onMounted(init)
+
+onUnmounted(() => {
+  cropper.value?.destroy()
+})
+
+async function init() {
+  const imgEl = unref(imgElRef)
+  if (!imgEl) {
+    return
+  }
+  cropper.value = new Cropper(imgEl, {
+    ...defaultOptions,
+    ready: () => {
+      isReady.value = true
+      realTimeCroppered()
+      emit('ready', cropper.value)
+    },
+    crop() {
+      debounceRealTimeCroppered()
+    },
+    zoom() {
+      debounceRealTimeCroppered()
+    },
+    cropmove() {
+      debounceRealTimeCroppered()
+    },
+    ...props.options
+  })
+}
+
+// Real-time display preview
+function realTimeCroppered() {
+  props.realTimePreview && croppered()
+}
+
+// event: return base64 and width and height information after cropping
+function croppered() {
+  if (!cropper.value) {
+    return
+  }
+  let imgInfo = cropper.value.getData()
+  const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas()
+  canvas.toBlob((blob) => {
+    if (!blob) {
+      return
+    }
+    let fileReader: FileReader = new FileReader()
+    fileReader.readAsDataURL(blob)
+    fileReader.onloadend = (e) => {
+      emit('cropend', {
+        imgBase64: e.target?.result ?? '',
+        imgInfo
+      })
+    }
+    fileReader.onerror = () => {
+      emit('cropendError')
+    }
+  }, 'image/png')
+}
+
+// Get a circular picture canvas
+function getRoundedCanvas() {
+  const sourceCanvas = cropper.value!.getCroppedCanvas()
+  const canvas = document.createElement('canvas')
+  const context = canvas.getContext('2d')!
+  const width = sourceCanvas.width
+  const height = sourceCanvas.height
+  canvas.width = width
+  canvas.height = height
+  context.imageSmoothingEnabled = true
+  context.drawImage(sourceCanvas, 0, 0, width, height)
+  context.globalCompositeOperation = 'destination-in'
+  context.beginPath()
+  context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true)
+  context.fill()
+  return canvas
+}
+</script>
+<style lang="scss">
+$prefix-cls: #{$namespace}-cropper-image;
+
+.#{$prefix-cls} {
+  &--circled {
+    .cropper-view-box,
+    .cropper-face {
+      border-radius: 50%;
+    }
+  }
+}
+</style>

+ 142 - 0
src/components/Cropper/src/CropperAvatar.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="user-info-head" @click="open()">
+    <el-avatar v-if="sourceValue" :src="sourceValue" alt="avatar" class="img-circle img-lg" />
+    <el-avatar v-if="!sourceValue" :src="avatar" alt="avatar" class="img-circle img-lg" />
+    <el-button v-if="showBtn" :class="`${prefixCls}-upload-btn`" @click="open()">
+      {{ btnText ? btnText : t('cropper.selectImage') }}
+    </el-button>
+    <CopperModal
+      ref="cropperModelRef"
+      :srcValue="sourceValue"
+      @upload-success="handleUploadSuccess"
+    />
+  </div>
+</template>
+<script lang="ts" setup>
+import { useDesign } from '@/hooks/web/useDesign'
+
+import { propTypes } from '@/utils/propTypes'
+import { useI18n } from 'vue-i18n'
+import CopperModal from './CopperModal.vue'
+import avatar from '@/assets/imgs/avatar.jpg'
+
+defineOptions({ name: 'CropperAvatar' })
+
+const props = defineProps({
+  width: propTypes.string.def('200px'),
+  value: propTypes.string.def(''),
+  showBtn: propTypes.bool.def(true),
+  btnText: propTypes.string.def('')
+})
+
+const emit = defineEmits(['update:value', 'change'])
+const sourceValue = ref(props.value)
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('cropper-avatar')
+const message = useMessage()
+const { t } = useI18n()
+
+const cropperModelRef = ref()
+
+watchEffect(() => {
+  sourceValue.value = props.value
+})
+
+watch(
+  () => sourceValue.value,
+  (v: string) => {
+    emit('update:value', v)
+  }
+)
+
+function handleUploadSuccess({ source, data, filename }) {
+  sourceValue.value = source
+  emit('change', { source, data, filename })
+  message.success(t('cropper.uploadSuccess'))
+}
+
+function open() {
+  cropperModelRef.value.openModal()
+}
+
+function close() {
+  cropperModelRef.value.closeModal()
+}
+
+defineExpose({
+  open,
+  close
+})
+</script>
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}--cropper-avatar;
+
+.#{$prefix-cls} {
+  display: inline-block;
+  text-align: center;
+
+  &-image-wrapper {
+    overflow: hidden;
+    cursor: pointer;
+    border: 1px solid;
+    border-radius: 50%;
+
+    img {
+      width: 100%;
+    }
+  }
+
+  &-image-mask {
+    position: absolute;
+    width: inherit;
+    height: inherit;
+    cursor: pointer;
+    background: rgb(0 0 0 / 40%);
+    border: inherit;
+    border-radius: inherit;
+    opacity: 0;
+    transition: opacity 0.4s;
+
+    ::v-deep(svg) {
+      margin: auto;
+    }
+  }
+
+  &-image-mask:hover {
+    opacity: 40;
+  }
+
+  &-upload-btn {
+    margin: 10px auto;
+  }
+}
+
+.user-info-head {
+  position: relative;
+  display: inline-block;
+}
+
+.img-circle {
+  border-radius: 50%;
+}
+
+.img-lg {
+  width: 120px;
+  height: 120px;
+}
+
+.user-info-head:hover::after {
+  position: absolute;
+  inset: 0;
+  font-size: 24px;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  font-style: normal;
+  line-height: 110px;
+  color: #eee;
+  cursor: pointer;
+  background: rgb(0 0 0 / 50%);
+  border-radius: 50%;
+  content: '+';
+}
+</style>

+ 221 - 0
src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js

@@ -0,0 +1,221 @@
+import PaletteProvider from 'bpmn-js/lib/features/palette/PaletteProvider'
+import { assign } from 'min-dash'
+
+export default function CustomPalette(
+  palette,
+  create,
+  elementFactory,
+  spaceTool,
+  lassoTool,
+  handTool,
+  globalConnect,
+  translate
+) {
+  PaletteProvider.call(
+    this,
+    palette,
+    create,
+    elementFactory,
+    spaceTool,
+    lassoTool,
+    handTool,
+    globalConnect,
+    translate,
+    2000
+  )
+}
+
+const F = function () {} // 核心,利用空对象作为中介;
+F.prototype = PaletteProvider.prototype // 核心,将父类的原型赋值给空对象F;
+
+// 利用中介函数重写原型链方法
+F.prototype.getPaletteEntries = function () {
+  const actions = {},
+    create = this._create,
+    elementFactory = this._elementFactory,
+    spaceTool = this._spaceTool,
+    lassoTool = this._lassoTool,
+    handTool = this._handTool,
+    globalConnect = this._globalConnect,
+    translate = this._translate
+
+  function createAction(type, group, className, title, options) {
+    function createListener(event) {
+      const shape = elementFactory.createShape(assign({ type: type }, options))
+
+      if (options) {
+        shape.businessObject.di.isExpanded = options.isExpanded
+      }
+
+      create.start(event, shape)
+    }
+
+    const shortType = type.replace(/^bpmn:/, '')
+
+    return {
+      group: group,
+      className: className,
+      title: title || translate('Create {type}', { type: shortType }),
+      action: {
+        dragstart: createListener,
+        click: createListener
+      }
+    }
+  }
+
+  function createSubprocess(event) {
+    const subProcess = elementFactory.createShape({
+      type: 'bpmn:SubProcess',
+      x: 0,
+      y: 0,
+      isExpanded: true
+    })
+
+    const startEvent = elementFactory.createShape({
+      type: 'bpmn:StartEvent',
+      x: 40,
+      y: 82,
+      parent: subProcess
+    })
+
+    create.start(event, [subProcess, startEvent], {
+      hints: {
+        autoSelect: [startEvent]
+      }
+    })
+  }
+
+  function createParticipant(event) {
+    create.start(event, elementFactory.createParticipantShape())
+  }
+
+  assign(actions, {
+    'hand-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-hand-tool',
+      title: '激活抓手工具',
+      // title: translate("Activate the hand tool"),
+      action: {
+        click: function (event) {
+          handTool.activateHand(event)
+        }
+      }
+    },
+    'lasso-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-lasso-tool',
+      title: translate('Activate the lasso tool'),
+      action: {
+        click: function (event) {
+          lassoTool.activateSelection(event)
+        }
+      }
+    },
+    'space-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-space-tool',
+      title: translate('Activate the create/remove space tool'),
+      action: {
+        click: function (event) {
+          spaceTool.activateSelection(event)
+        }
+      }
+    },
+    'global-connect-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-connection-multi',
+      title: translate('Activate the global connect tool'),
+      action: {
+        click: function (event) {
+          globalConnect.toggle(event)
+        }
+      }
+    },
+    'tool-separator': {
+      group: 'tools',
+      separator: true
+    },
+    'create.start-event': createAction(
+      'bpmn:StartEvent',
+      'event',
+      'bpmn-icon-start-event-none',
+      translate('Create StartEvent')
+    ),
+    'create.intermediate-event': createAction(
+      'bpmn:IntermediateThrowEvent',
+      'event',
+      'bpmn-icon-intermediate-event-none',
+      translate('Create Intermediate/Boundary Event')
+    ),
+    'create.end-event': createAction(
+      'bpmn:EndEvent',
+      'event',
+      'bpmn-icon-end-event-none',
+      translate('Create EndEvent')
+    ),
+    'create.exclusive-gateway': createAction(
+      'bpmn:ExclusiveGateway',
+      'gateway',
+      'bpmn-icon-gateway-none',
+      translate('Create Gateway')
+    ),
+    'create.user-task': createAction(
+      'bpmn:UserTask',
+      'activity',
+      'bpmn-icon-user-task',
+      translate('Create User Task')
+    ),
+    'create.data-object': createAction(
+      'bpmn:DataObjectReference',
+      'data-object',
+      'bpmn-icon-data-object',
+      translate('Create DataObjectReference')
+    ),
+    'create.data-store': createAction(
+      'bpmn:DataStoreReference',
+      'data-store',
+      'bpmn-icon-data-store',
+      translate('Create DataStoreReference')
+    ),
+    'create.subprocess-expanded': {
+      group: 'activity',
+      className: 'bpmn-icon-subprocess-expanded',
+      title: translate('Create expanded SubProcess'),
+      action: {
+        dragstart: createSubprocess,
+        click: createSubprocess
+      }
+    },
+    'create.participant-expanded': {
+      group: 'collaboration',
+      className: 'bpmn-icon-participant',
+      title: translate('Create Pool/Participant'),
+      action: {
+        dragstart: createParticipant,
+        click: createParticipant
+      }
+    },
+    'create.group': createAction(
+      'bpmn:Group',
+      'artifact',
+      'bpmn-icon-group',
+      translate('Create Group')
+    )
+  })
+
+  return actions
+}
+
+CustomPalette.$inject = [
+  'palette',
+  'create',
+  'elementFactory',
+  'spaceTool',
+  'lassoTool',
+  'handTool',
+  'globalConnect',
+  'translate'
+]
+
+CustomPalette.prototype = new F() // 核心,将 F的实例赋值给子类;
+CustomPalette.prototype.constructor = CustomPalette // 修复子类CustomPalette的构造器指向,防止原型链的混乱;

+ 44 - 0
src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js

@@ -0,0 +1,44 @@
+// import translations from "./zh";
+//
+// export default function customTranslate(template, replacements) {
+//   replacements = replacements || {};
+//
+//   // Translate
+//   template = translations[template] || template;
+//
+//   // Replace
+//   return template.replace(/{([^}]+)}/g, function(_, key) {
+//     let str = replacements[key];
+//     if (
+//       translations[replacements[key]] !== null &&
+//       translations[replacements[key]] !== "undefined"
+//     ) {
+//       // eslint-disable-next-line no-mixed-spaces-and-tabs
+//       str = translations[replacements[key]];
+//       // eslint-disable-next-line no-mixed-spaces-and-tabs
+//     }
+//     return str || "{" + key + "}";
+//   });
+// }
+
+export default function customTranslate(translations) {
+  return function (template, replacements) {
+    replacements = replacements || {}
+    // Translate
+    template = translations[template] || template
+
+    // Replace
+    return template.replace(/{([^}]+)}/g, function (_, key) {
+      let str = replacements[key]
+      if (
+        translations[replacements[key]] !== null &&
+        translations[replacements[key]] !== undefined
+      ) {
+        // eslint-disable-next-line no-mixed-spaces-and-tabs
+        str = translations[replacements[key]]
+        // eslint-disable-next-line no-mixed-spaces-and-tabs
+      }
+      return str || '{' + key + '}'
+    })
+  }
+}

+ 14 - 0
src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js

@@ -0,0 +1,14 @@
+import BpmnRenderer from 'bpmn-js/lib/draw/BpmnRenderer'
+
+export default function CustomRenderer(config, eventBus, styles, pathMap, canvas, textRenderer) {
+  BpmnRenderer.call(this, config, eventBus, styles, pathMap, canvas, textRenderer, 2000)
+
+  this.handlers['label'] = function () {
+    return null
+  }
+}
+
+const F = function () {} // 核心,利用空对象作为中介;
+F.prototype = BpmnRenderer.prototype // 核心,将父类的原型赋值给空对象F;
+CustomRenderer.prototype = new F() // 核心,将 F的实例赋值给子类;
+CustomRenderer.prototype.constructor = CustomRenderer // 修复子类CustomRenderer的构造器指向,防止原型链的混乱;

+ 16 - 0
src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js

@@ -0,0 +1,16 @@
+import BpmnRules from 'bpmn-js/lib/features/rules/BpmnRules'
+import inherits from 'inherits'
+
+export default function CustomRules(eventBus) {
+  BpmnRules.call(this, eventBus)
+}
+
+inherits(CustomRules, BpmnRules)
+
+CustomRules.prototype.canDrop = function () {
+  return false
+}
+
+CustomRules.prototype.canMove = function () {
+  return false
+}

+ 239 - 0
src/views/bpm/disbursement/create.vue

@@ -0,0 +1,239 @@
+<template>
+
+  <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+  >
+<!--    <el-form-item label="用户编号" prop="userId">-->
+<!--      <el-input v-model="formData.userId" placeholder="请输入申请人的用户编号" />-->
+<!--    </el-form-item>-->
+    <el-form-item label="项目名称" prop="itemName">
+      <el-input v-model="formData.itemName" placeholder="请输入项目名称" />
+    </el-form-item>
+    <!-- <el-form-item label="项目类型" prop="itemType">
+      <el-select v-model="formData.itemType" placeholder="请选择项目类型">
+        <el-option
+          v-for="dict in getIntDictOptions(DICT_TYPE.BPM_DISBUESEMENT_ITEM_TYPE)"
+          :key="dict.value"
+          :label="dict.label"
+          :value="dict.value"
+        />
+      </el-select>
+    </el-form-item> -->
+    <!-- <el-form-item label="项目归属" prop="itemBelong">
+     <el-input v-model="formData.itemBelong" placeholder="请输入项目归属(上级项目)" />
+      <el-tree-select
+        v-model="formData.itemBelong"
+        :data="datadept"
+        :props="defaultProps"
+        check-strictly
+        node-key="id"
+        placeholder="请选择归属部门"
+      />
+    </el-form-item> -->
+    <!-- <el-form-item label="资金类型" prop="type">
+      <el-select
+        v-model="formData.type"
+        multiple
+        filterable
+        allow-create
+        default-first-option
+        placeholder="请选择资金申请类型">
+        <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_FUND_APPLY_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+        />
+      </el-select>
+    </el-form-item> -->
+    <!-- <el-form-item label="资金总额" prop="funding">
+      <el-input v-model="formData.funding" placeholder="请输入金额" />
+    </el-form-item> -->
+
+    <!-- <el-form-item label="申请原因" prop="reason">
+      <el-input v-model="formData.reason" placeholder="请输入备注" />
+    </el-form-item> -->
+    <!-- <el-form-item label="专项借款申请材料" prop="specialLoansFile">
+      <UploadFile v-model="formData.specialLoansFile" />
+    </el-form-item> -->
+    <!-- <el-form-item label="资金申请材料" prop="fundingApplicationFile">
+      <UploadFile v-model="formData.fundingApplicationFile" />
+    </el-form-item> -->
+    <!-- <el-form-item label="项目相关图层" prop="itemShp">
+      <el-select
+        v-model="formData.itemShp"
+        multiple
+        filterable
+        allow-create
+        default-first-option
+        placeholder="请选择项目相关图层">
+        <el-option
+          v-for="dict in datagisname"
+          :key="dict.id"
+          :label="dict.shpName"
+          :value="dict.shpName"
+        />
+      </el-select>
+    </el-form-item> -->
+
+    <!-- <el-form-item label="项目3D图层" prop="file">
+      <el-input v-model="formData.file" placeholder="请输入项目相关矢量图层" /> -->
+<!--      <el-select-->
+<!--        v-model="formData.file"-->
+<!--        :reserve-keyword="false"-->
+<!--        placeholder="请选择3DTiles模型"-->
+<!--      >-->
+<!--        <el-option-->
+<!--          v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_THREED_TILES)"-->
+<!--          :key="dict.value"-->
+<!--          :label="dict.label"-->
+<!--          :value="dict.value"-->
+<!--        />-->
+<!--      </el-select>-->
+    <!-- </el-form-item> -->
+    <!-- <el-form-item label="项目相关佐证材料" prop="itemImage"> -->
+      <!-- <UploadImg v-model="formData.itemImage" />
+    </el-form-item> -->
+<!--    <el-form-item label="其他文件" prop="file">-->
+<!--      <UploadFile v-model="formData.file" />-->
+<!--    </el-form-item>-->
+    <el-form-item>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </el-form-item>
+
+  </el-form>
+
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { DisbursementApi, DisbursementVO } from '@/api/bpm/disbursement'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import * as DefinitionApi from '@/api/bpm/definition'
+import * as UserApi from '@/api/system/user'
+import {defaultProps, handleTree} from "@/utils/tree";
+import {getSimpleDeptList} from "@/api/system/dept";
+import { getSimpleGisNameList } from "@/api/layer/gisname"
+
+const { delView } = useTagsViewStore() // 视图操作
+const { push, currentRoute } = useRouter() // 路由
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+defineOptions({ name: 'BpmDisbursementCreate' })
+const datadept = ref([])
+const datagisname = ref([])
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  userId: undefined,
+  type: undefined,
+  reason: undefined,
+  funding: undefined,
+  status: undefined,
+  processInstanceId: undefined,
+  file: undefined,
+  specialLoansFile: undefined,
+  itemName: undefined,
+  itemType: undefined,
+  itemBelong: undefined,
+  itemShp: [],
+  // itemShp: undefined,
+  itemImage: undefined,
+  fundingApplicationFile: undefined,
+})
+const formRules = reactive({
+  // userId: [{ required: true, message: '申请人的用户编号不能为空', trigger: 'blur' }],
+  itemName: [{ required: true, message: '项目名称不能为空', trigger: 'blur' }],
+  // itemType: [{ required: true, message: '项目类型不能为空', trigger: 'blur' }],
+  // type: [{ required: true, message: '申请类型不能为空', trigger: 'change' }],
+  // reason: [{ required: true, message: '申请原因不能为空', trigger: 'blur' }],
+  // funding: [{ required: true, message: '资金总额不能为空', trigger: 'blur' }],
+})
+const formRef = ref() // 表单 Ref
+// const deptList = ref<Tree[]>([]) // 树形结构
+
+// 指定审批人
+const processDefineKey = 'disbursement' // 流程定义 Key
+const startUserSelectTasks = ref([]) // 发起人需要选择审批人的用户任务列表
+const startUserSelectAssignees = ref({}) // 发起人选择审批人的数据
+const startUserSelectAssigneesFormRef = ref() // 发起人选择审批人的表单 Ref
+const startUserSelectAssigneesFormRules = ref({}) // 发起人选择审批人的表单 Rules
+const userList = ref<any[]>([]) // 用户列表
+
+/** 提交表单 */
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 校验指定审批人
+  if (startUserSelectTasks.value?.length > 0) {
+    await startUserSelectAssigneesFormRef.value.validate()
+  }
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as DisbursementVO
+
+    // 设置指定审批人
+    if (startUserSelectTasks.value?.length > 0) {
+      data.startUserSelectAssignees = startUserSelectAssignees.value
+    }
+
+    await DisbursementApi.createDisbursement(data)
+    message.success(t('common.createSuccess'))
+    // 关闭当前 Tab
+    delView(unref(currentRoute))
+    await push({ name: 'Disbursement' })
+  } finally {
+    formLoading.value = false
+  }
+}
+
+const deptdatalist = async () => {
+  const res = await getSimpleDeptList()
+  datadept.value = res
+}
+
+const gisnamedatalist = async () => {
+  // const res =
+  datagisname.value = await getSimpleGisNameList();
+}
+
+
+
+/** 初始化 */
+onMounted(async () => {
+  // const processDefinitionDetail = await DefinitionApi.getProcessDefinition(
+  //   undefined,
+  //   processDefineKey
+  // )
+  // if (!processDefinitionDetail) {
+  //   message.error('资金拨付的流程模型未配置,请检查!')
+  //   return
+  // }
+  startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks
+  // 设置指定审批人
+  if (startUserSelectTasks.value?.length > 0) {
+    // 设置校验规则
+    for (const userTask of startUserSelectTasks.value) {
+      startUserSelectAssignees.value[userTask.id] = []
+      startUserSelectAssigneesFormRules.value[userTask.id] = [
+        { required: true, message: '请选择审批人', trigger: 'blur' }
+      ]
+    }
+    // 加载用户列表
+    userList.value = await UserApi.getSimpleUserList()
+  }
+  deptdatalist()
+  gisnamedatalist()
+})
+</script>

+ 164 - 0
src/views/bpm/oa/leave/create.vue

@@ -0,0 +1,164 @@
+<template>
+  <el-form
+    ref="formRef"
+    v-loading="formLoading"
+    :model="formData"
+    :rules="formRules"
+    label-width="80px"
+  >
+    <el-form-item label="请假类型" prop="type">
+      <el-select v-model="formData.type" clearable placeholder="请选择请假类型">
+        <el-option
+          v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
+          :key="dict.value"
+          :label="dict.label"
+          :value="dict.value"
+        />
+      </el-select>
+    </el-form-item>
+    <el-form-item label="开始时间" prop="startTime">
+      <el-date-picker
+        v-model="formData.startTime"
+        clearable
+        placeholder="请选择开始时间"
+        type="datetime"
+        value-format="x"
+      />
+    </el-form-item>
+    <el-form-item label="结束时间" prop="endTime">
+      <el-date-picker
+        v-model="formData.endTime"
+        clearable
+        placeholder="请选择结束时间"
+        type="datetime"
+        value-format="x"
+      />
+    </el-form-item>
+    <el-form-item label="原因" prop="reason">
+      <el-input v-model="formData.reason" placeholder="请输请假原因" type="textarea" />
+    </el-form-item>
+    <el-col v-if="startUserSelectTasks.length > 0">
+      <el-card class="mb-10px">
+        <template #header>指定审批人</template>
+        <el-form
+          :model="startUserSelectAssignees"
+          :rules="startUserSelectAssigneesFormRules"
+          ref="startUserSelectAssigneesFormRef"
+        >
+          <el-form-item
+            v-for="userTask in startUserSelectTasks"
+            :key="userTask.id"
+            :label="`任务【${userTask.name}】`"
+            :prop="userTask.id"
+          >
+            <el-select
+              v-model="startUserSelectAssignees[userTask.id]"
+              multiple
+              placeholder="请选择审批人"
+            >
+              <el-option
+                v-for="user in userList"
+                :key="user.id"
+                :label="user.nickname"
+                :value="user.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-form>
+      </el-card>
+    </el-col>
+    <el-form-item>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+    </el-form-item>
+  </el-form>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as LeaveApi from '@/api/bpm/leave'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import * as DefinitionApi from '@/api/bpm/definition'
+import * as UserApi from '@/api/system/user'
+
+defineOptions({ name: 'BpmOALeaveCreate' })
+
+const message = useMessage() // 消息弹窗
+const { delView } = useTagsViewStore() // 视图操作
+const { push, currentRoute } = useRouter() // 路由
+
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  type: undefined,
+  reason: undefined,
+  startTime: undefined,
+  endTime: undefined
+})
+const formRules = reactive({
+  type: [{ required: true, message: '请假类型不能为空', trigger: 'blur' }],
+  reason: [{ required: true, message: '请假原因不能为空', trigger: 'change' }],
+  startTime: [{ required: true, message: '请假开始时间不能为空', trigger: 'change' }],
+  endTime: [{ required: true, message: '请假结束时间不能为空', trigger: 'change' }]
+})
+const formRef = ref() // 表单 Ref
+
+// 指定审批人
+const processDefineKey = 'oa_leave' // 流程定义 Key
+const startUserSelectTasks = ref([]) // 发起人需要选择审批人的用户任务列表
+const startUserSelectAssignees = ref({}) // 发起人选择审批人的数据
+const startUserSelectAssigneesFormRef = ref() // 发起人选择审批人的表单 Ref
+const startUserSelectAssigneesFormRules = ref({}) // 发起人选择审批人的表单 Rules
+const userList = ref<any[]>([]) // 用户列表
+
+/** 提交表单 */
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 校验指定审批人
+  if (startUserSelectTasks.value?.length > 0) {
+    await startUserSelectAssigneesFormRef.value.validate()
+  }
+
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = { ...formData.value } as unknown as LeaveApi.LeaveVO
+    // 设置指定审批人
+    if (startUserSelectTasks.value?.length > 0) {
+      data.startUserSelectAssignees = startUserSelectAssignees.value
+    }
+    await LeaveApi.createLeave(data)
+    message.success('发起成功')
+    // 关闭当前 Tab
+    delView(unref(currentRoute))
+    await push({ name: 'BpmOALeave' })
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  const processDefinitionDetail = await DefinitionApi.getProcessDefinition(
+    undefined,
+    processDefineKey
+  )
+  if (!processDefinitionDetail) {
+    message.error('OA 请假的流程模型未配置,请检查!')
+    return
+  }
+  startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks
+  // 设置指定审批人
+  if (startUserSelectTasks.value?.length > 0) {
+    // 设置校验规则
+    for (const userTask of startUserSelectTasks.value) {
+      startUserSelectAssignees.value[userTask.id] = []
+      startUserSelectAssigneesFormRules.value[userTask.id] = [
+        { required: true, message: '请选择审批人', trigger: 'blur' }
+      ]
+    }
+    // 加载用户列表
+    userList.value = await UserApi.getSimpleUserList()
+  }
+})
+</script>

+ 170 - 0
src/views/crm/backlog/components/CustomerFollowList.vue

@@ -0,0 +1,170 @@
+<!-- 分配给我的客户 -->
+<!-- WHERE followUpStatus = ? -->
+<template>
+  <ContentWrap>
+    <div class="pb-5 text-xl">分配给我的客户</div>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="状态" prop="followUpStatus">
+        <el-select
+          v-model="queryParams.followUpStatus"
+          class="!w-240px"
+          placeholder="状态"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in FOLLOWUP_STATUS"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="客户名称" fixed="left" 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" 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 label="手机" align="center" prop="mobile" width="120" />
+      <el-table-column label="电话" align="center" prop="telephone" width="130" />
+      <el-table-column label="邮箱" align="center" prop="email" width="180" />
+      <el-table-column align="center" label="客户级别" prop="level" width="135">
+        <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="客户行业" 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
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="contactNextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column align="center" label="锁定状态" prop="lockStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="成交状态" prop="dealStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" />
+      <el-table-column label="地址" align="center" prop="detailAddress" width="180" />
+      <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140">
+        <template #default="scope"> {{ scope.row.poolDay }} 天</template>
+      </el-table-column>
+      <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="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>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import * as CustomerApi from '@/api/crm/customer'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { FOLLOWUP_STATUS } from './common'
+
+defineOptions({ name: 'CrmCustomerFollowList' })
+
+const { push } = useRouter()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  sceneType: 1,
+  followUpStatus: false
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CustomerApi.getCustomerPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 打开客户详情 */
+const openDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 激活时 */
+onActivated(async () => {
+  await getList()
+})
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 169 - 0
src/views/crm/backlog/components/CustomerPutPoolRemindList.vue

@@ -0,0 +1,169 @@
+<!-- 待进入公海的客户 -->
+<template>
+  <ContentWrap>
+    <div class="pb-5 text-xl"> 待进入公海的客户 </div>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="归属" prop="sceneType">
+        <el-select
+          v-model="queryParams.sceneType"
+          class="!w-240px"
+          placeholder="归属"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in SCENE_TYPES"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="客户名称" fixed="left" 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" 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 label="手机" align="center" prop="mobile" width="120" />
+      <el-table-column label="电话" align="center" prop="telephone" width="130" />
+      <el-table-column label="邮箱" align="center" prop="email" width="180" />
+      <el-table-column align="center" label="客户级别" prop="level" width="135">
+        <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="客户行业" 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
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="contactNextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column align="center" label="锁定状态" prop="lockStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="成交状态" prop="dealStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" />
+      <el-table-column label="地址" align="center" prop="detailAddress" width="180" />
+      <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140">
+        <template #default="scope"> {{ scope.row.poolDay }} 天</template>
+      </el-table-column>
+      <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="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>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import * as CustomerApi from '@/api/crm/customer'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { SCENE_TYPES } from './common'
+
+defineOptions({ name: 'CrmCustomerPutPoolRemindList' })
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  sceneType: 1, // 我负责的
+  pool: true // 固定 公海参数为 true
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CustomerApi.getPutPoolRemindCustomerPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 打开客户详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 激活时 */
+onActivated(async () => {
+  await getList()
+})
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss"></style>

+ 180 - 0
src/views/crm/backlog/components/CustomerTodayContactList.vue

@@ -0,0 +1,180 @@
+<template>
+  <ContentWrap>
+    <div class="pb-5 text-xl"> 今日需联系客户 </div>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="状态" prop="contactStatus">
+        <el-select
+          v-model="queryParams.contactStatus"
+          class="!w-240px"
+          placeholder="状态"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in CONTACT_STATUS"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="归属" prop="sceneType">
+        <el-select
+          v-model="queryParams.sceneType"
+          class="!w-240px"
+          placeholder="归属"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in SCENE_TYPES"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="客户名称" fixed="left" 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" 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 label="手机" align="center" prop="mobile" width="120" />
+      <el-table-column label="电话" align="center" prop="telephone" width="130" />
+      <el-table-column label="邮箱" align="center" prop="email" width="180" />
+      <el-table-column align="center" label="客户级别" prop="level" width="135">
+        <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="客户行业" 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
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="contactNextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column align="center" label="锁定状态" prop="lockStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="成交状态" prop="dealStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="最后跟进时间"
+        prop="contactLastTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" />
+      <el-table-column label="地址" align="center" prop="detailAddress" width="180" />
+      <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140">
+        <template #default="scope"> {{ scope.row.poolDay }} 天</template>
+      </el-table-column>
+      <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="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>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import * as CustomerApi from '@/api/crm/customer'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { CONTACT_STATUS, SCENE_TYPES } from './common'
+
+defineOptions({ name: 'CrmCustomerTodayContactList' })
+
+const { push } = useRouter()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  contactStatus: 1,
+  sceneType: 1,
+  pool: null // 是否公海数据
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CustomerApi.getCustomerPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 打开客户详情 */
+const openDetail = (id: number) => {
+  push({ name: 'CrmCustomerDetail', params: { id } })
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss"></style>

+ 259 - 0
src/views/crm/customer/CustomerForm.vue

@@ -0,0 +1,259 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="客户名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入客户名称" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="客户来源" prop="source">
+            <el-select v-model="formData.source" placeholder="请选择客户来源" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="手机" prop="mobile">
+            <el-input v-model="formData.mobile" placeholder="请输入手机" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="负责人" prop="ownerUserId">
+            <el-select
+              v-model="formData.ownerUserId"
+              :disabled="formType !== 'create'"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in userOptions"
+                :key="item.id"
+                :label="item.nickname"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="电话" prop="telephone">
+            <el-input v-model="formData.telephone" placeholder="请输入电话" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="邮箱" prop="email">
+            <el-input v-model="formData.email" placeholder="请输入邮箱" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="微信" prop="wechat">
+            <el-input v-model="formData.wechat" placeholder="请输入微信" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="QQ" prop="qq">
+            <el-input v-model="formData.qq" placeholder="请输入 QQ" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="客户行业" prop="industryId">
+            <el-select v-model="formData.industryId" placeholder="请选择客户行业" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="客户级别" prop="level">
+            <el-select v-model="formData.level" placeholder="请选择客户级别" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="地址" prop="areaId">
+            <el-cascader
+              v-model="formData.areaId"
+              :options="areaList"
+              :props="defaultProps"
+              class="w-1/1"
+              clearable
+              filterable
+              placeholder="请选择城市"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="详细地址" prop="detailAddress">
+            <el-input v-model="formData.detailAddress" placeholder="请输入详细地址" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="下次联系时间" prop="contactNextTime">
+            <el-date-picker
+              v-model="formData.contactNextTime"
+              placeholder="选择下次联系时间"
+              type="datetime"
+              value-format="x"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="备注" prop="remark">
+            <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as CustomerApi from '@/api/crm/customer'
+import * as AreaApi from '@/api/system/area'
+import { defaultProps } from '@/utils/tree'
+import * as UserApi from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const areaList = ref([]) // 地区列表
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  contactNextTime: undefined,
+  ownerUserId: 0,
+  mobile: undefined,
+  telephone: undefined,
+  qq: undefined,
+  wechat: undefined,
+  email: undefined,
+  areaId: undefined,
+  detailAddress: undefined,
+  industryId: undefined,
+  level: undefined,
+  source: undefined,
+  remark: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await CustomerApi.getCustomer(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得地区列表
+  areaList.value = await AreaApi.getAreaTree()
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+  // 默认新建时选中自己
+  if (formType.value === 'create') {
+    formData.value.ownerUserId = useUserStore().getUser.id
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as CustomerApi.CustomerVO
+    if (formType.value === 'create') {
+      await CustomerApi.createCustomer(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CustomerApi.updateCustomer(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    contactNextTime: undefined,
+    ownerUserId: 0,
+    mobile: undefined,
+    telephone: undefined,
+    qq: undefined,
+    wechat: undefined,
+    email: undefined,
+    areaId: undefined,
+    detailAddress: undefined,
+    industryId: undefined,
+    level: undefined,
+    source: undefined,
+    remark: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 158 - 0
src/views/crm/customer/CustomerImportForm.vue

@@ -0,0 +1,158 @@
+<!-- 客户导入窗口 -->
+<template>
+  <Dialog v-model="dialogVisible" title="客户导入" width="400">
+    <div class="flex items-center my-10px">
+      <span class="mr-10px">负责人</span>
+      <el-select v-model="ownerUserId" class="!w-240px" clearable>
+        <el-option
+          v-for="item in userOptions"
+          :key="item.id"
+          :label="item.nickname"
+          :value="item.id"
+        />
+      </el-select>
+    </div>
+    <el-upload
+      ref="uploadRef"
+      v-model:file-list="fileList"
+      :auto-upload="false"
+      :disabled="formLoading"
+      :limit="1"
+      :on-exceed="handleExceed"
+      accept=".xlsx, .xls"
+      action="none"
+      drag
+    >
+      <Icon icon="ep:upload" />
+      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+      <template #tip>
+        <div class="el-upload__tip text-center">
+          <div class="el-upload__tip">
+            <el-checkbox v-model="updateSupport" />
+            是否更新已经存在的客户数据(“客户名称”重复)
+          </div>
+          <span>仅允许导入 xls、xlsx 格式文件。</span>
+          <el-link
+            :underline="false"
+            style="font-size: 12px; vertical-align: baseline"
+            type="primary"
+            @click="importTemplate"
+          >
+            下载模板
+          </el-link>
+        </div>
+      </template>
+    </el-upload>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as CustomerApi from '@/api/crm/customer'
+import download from '@/utils/download'
+import type { UploadUserFile } from 'element-plus'
+import * as UserApi from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+
+defineOptions({ name: 'SystemUserImportForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const uploadRef = ref()
+const fileList = ref<UploadUserFile[]>([]) // 文件列表
+const updateSupport = ref(false) // 是否更新已经存在的客户数据
+const ownerUserId = ref<undefined | number>() // 负责人编号
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  await resetForm()
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+  ownerUserId.value = useUserStore().getUser.id
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const submitForm = async () => {
+  if (fileList.value.length == 0) {
+    message.error('请上传文件')
+    return
+  }
+
+  formLoading.value = true
+  try {
+    const formData = new FormData()
+    formData.append('updateSupport', String(updateSupport.value))
+    formData.append('file', fileList.value[0].raw as Blob)
+    formData.append('ownerUserId', String(ownerUserId.value))
+    const res = await CustomerApi.handleImport(formData)
+    submitFormSuccess(res)
+  } catch {
+    submitFormError()
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 文件上传成功 */
+const emits = defineEmits(['success'])
+const submitFormSuccess = (response: any) => {
+  if (response.code !== 0) {
+    message.error(response.msg)
+    formLoading.value = false
+    return
+  }
+  // 拼接提示语
+  const data = response.data
+  let text = '上传成功数量:' + data.createCustomerNames.length + ';'
+  for (let customerName of data.createCustomerNames) {
+    text += '< ' + customerName + ' >'
+  }
+  text += '更新成功数量:' + data.updateCustomerNames.length + ';'
+  for (const customerName of data.updateCustomerNames) {
+    text += '< ' + customerName + ' >'
+  }
+  text += '更新失败数量:' + Object.keys(data.failureCustomerNames).length + ';'
+  for (const customerName in data.failureCustomerNames) {
+    text += '< ' + customerName + ': ' + data.failureCustomerNames[customerName] + ' >'
+  }
+  message.alert(text)
+  formLoading.value = false
+  dialogVisible.value = false
+  // 发送操作成功的事件
+  emits('success')
+}
+
+/** 上传错误提示 */
+const submitFormError = (): void => {
+  message.error('上传失败,请您重新上传!')
+  formLoading.value = false
+}
+
+/** 重置表单 */
+const resetForm = async () => {
+  // 重置上传状态和文件
+  fileList.value = []
+  updateSupport.value = false
+  ownerUserId.value = undefined
+  await nextTick()
+  uploadRef.value?.clearFiles()
+}
+
+/** 文件数超出提示 */
+const handleExceed = (): void => {
+  message.error('最多只能上传一个文件!')
+}
+
+/** 下载模板操作 */
+const importTemplate = async () => {
+  const res = await CustomerApi.importCustomerTemplate()
+  download.excel(res, '客户导入模版.xls')
+}
+</script>

+ 43 - 0
src/views/crm/customer/detail/CustomerDetailsHeader.vue

@@ -0,0 +1,43 @@
+<template>
+  <div v-loading="loading">
+    <div class="flex items-start justify-between">
+      <div>
+        <!-- 左上:客户基本信息 -->
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ customer.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="vertical">
+      <el-descriptions-item label="客户级别">
+        <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="customer.level" />
+      </el-descriptions-item>
+      <el-descriptions-item label="成交状态">
+        {{ customer.dealStatus ? '已成交' : '未成交' }}
+      </el-descriptions-item>
+      <el-descriptions-item label="负责人">{{ customer.ownerUserName }}</el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(customer.createTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import * as CustomerApi from '@/api/crm/customer'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'CrmCustomerDetailsHeader' })
+defineProps<{
+  customer: CustomerApi.CustomerVO // 客户信息
+  loading: boolean // 加载中
+}>()
+</script>

+ 72 - 0
src/views/crm/customer/detail/CustomerDetailsInfo.vue

@@ -0,0 +1,72 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames" class="">
+      <el-collapse-item name="basicInfo">
+        <template #title>
+          <span class="text-base font-bold">基本信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="客户名称">
+            {{ customer.name }}
+          </el-descriptions-item>
+          <el-descriptions-item label="客户来源">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="customer.source" />
+          </el-descriptions-item>
+          <el-descriptions-item label="手机">{{ customer.mobile }}</el-descriptions-item>
+          <el-descriptions-item label="电话">{{ customer.telephone }}</el-descriptions-item>
+          <el-descriptions-item label="邮箱">{{ customer.email }}</el-descriptions-item>
+          <el-descriptions-item label="地址">
+            {{ customer.areaName }} {{ customer.detailAddress }}
+          </el-descriptions-item>
+          <el-descriptions-item label="QQ">{{ customer.qq }}</el-descriptions-item>
+          <el-descriptions-item label="微信">{{ customer.wechat }}</el-descriptions-item>
+          <el-descriptions-item label="客户行业">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="customer.industryId" />
+          </el-descriptions-item>
+          <el-descriptions-item label="客户级别">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="customer.level" />
+          </el-descriptions-item>
+          <el-descriptions-item label="下次联系时间">
+            {{ formatDate(customer.contactNextTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">{{ customer.remark }}</el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+      <el-collapse-item name="systemInfo">
+        <template #title>
+          <span class="text-base font-bold">系统信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="负责人">{{ customer.ownerUserName }}</el-descriptions-item>
+          <el-descriptions-item label="最后跟进记录">
+            {{ customer.contactLastContent }}
+          </el-descriptions-item>
+          <el-descriptions-item label="最后跟进时间">
+            {{ formatDate(customer.contactLastTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ customer.creatorName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ formatDate(customer.createTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="更新时间">
+            {{ formatDate(customer.updateTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as CustomerApi from '@/api/crm/customer'
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'CrmCustomerDetailsInfo' })
+const { customer } = defineProps<{
+  customer: CustomerApi.CustomerVO // 客户明细
+}>()
+
+const activeNames = ref(['basicInfo', 'systemInfo']) // 展示的折叠面板
+</script>
+<style lang="scss" scoped></style>

+ 150 - 0
src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue

@@ -0,0 +1,150 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="200px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="规则适用人群" prop="userIds">
+        <el-select multiple filterable v-model="formData.userIds">
+          <el-option
+            v-for="item in userOptions"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="规则适用部门" prop="deptIds">
+        <el-tree-select
+          v-model="formData.deptIds"
+          :data="deptTree"
+          :props="defaultProps"
+          multiple
+          filterable
+          check-strictly
+          node-key="id"
+          placeholder="请选择规则适用部门"
+        />
+      </el-form-item>
+      <el-form-item
+        :label="
+          formData.type === LimitConfType.CUSTOMER_QUANTITY_LIMIT
+            ? '拥有客户数上限'
+            : '锁定客户数上限'
+        "
+        prop="maxCount"
+      >
+        <el-input-number v-model="formData.maxCount" placeholder="请输入数量上限" />
+      </el-form-item>
+      <el-form-item
+        label="成交客户是否占用拥有客户数"
+        v-if="formData.type === LimitConfType.CUSTOMER_QUANTITY_LIMIT"
+        prop="dealCountEnabled"
+      >
+        <el-switch v-model="formData.dealCountEnabled" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as CustomerLimitConfigApi from '@/api/crm/customer/limitConfig'
+import * as DeptApi from '@/api/system/dept'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as UserApi from '@/api/system/user'
+import { cloneDeep } from 'lodash-es'
+import { LimitConfType } from '@/api/crm/customer/limitConfig'
+import { aw } from '../../../../../dist-prod/assets/index-9eac537b'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  type: LimitConfType.CUSTOMER_LOCK_LIMIT, // 给个默认值,避免 IDE 报错
+  userIds: undefined,
+  deptIds: undefined,
+  maxCount: undefined,
+  dealCountEnabled: false
+})
+const formRules = reactive({
+  type: [{ required: true, message: '规则类型不能为空', trigger: 'change' }],
+  maxCount: [{ required: true, message: '数量上限不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const deptTree = ref() // 部门树形结构
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 打开弹窗 */
+const open = async (type: string, limitConfType: LimitConfType, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await CustomerLimitConfigApi.getCustomerLimitConfig(id)
+    } finally {
+      formLoading.value = false
+    }
+  } else {
+    formData.value.type = limitConfType
+  }
+  // 获得部门树
+  deptTree.value = handleTree(await DeptApi.getSimpleDeptList())
+  // 获得用户
+  userOptions.value = await UserApi.getSimpleUserList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as CustomerLimitConfigApi.CustomerLimitConfigVO
+    if (formType.value === 'create') {
+      await CustomerLimitConfigApi.createCustomerLimitConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CustomerLimitConfigApi.updateCustomerLimitConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    type: LimitConfType.CUSTOMER_LOCK_LIMIT,
+    userIds: undefined,
+    deptIds: undefined,
+    maxCount: undefined,
+    dealCountEnabled: false
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 150 - 0
src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue

@@ -0,0 +1,150 @@
+<template>
+  <el-button plain @click="handleQuery"> <Icon icon="ep:refresh" class="mr-5px" /> 刷新 </el-button>
+  <el-button
+    type="primary"
+    plain
+    @click="openForm('create')"
+    v-hasPermi="['crm:customer-limit-config:create']"
+  >
+    <Icon icon="ep:plus" class="mr-5px" /> 新增
+  </el-button>
+  <el-table
+    v-loading="loading"
+    :data="list"
+    :stripe="true"
+    :show-overflow-tooltip="true"
+    class="mt-4"
+  >
+    <el-table-column label="编号" align="center" prop="id" />
+    <el-table-column
+      label="规则适用人群"
+      align="center"
+      :formatter="(row) => row.users?.map((user: any) => user.nickname).join(',')"
+    />
+    <el-table-column
+      label="规则适用部门"
+      align="center"
+      :formatter="(row) => row.depts?.map((dept: any) => dept.name).join(',')"
+    />
+    <el-table-column
+      :label="
+        confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT ? '拥有客户数上限' : '锁定客户数上限'
+      "
+      align="center"
+      prop="maxCount"
+    />
+    <el-table-column
+      v-if="confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT"
+      label="成交客户是否占用拥有客户数"
+      align="center"
+      prop="dealCountEnabled"
+      min-width="100"
+    >
+      <template #default="scope">
+        <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealCountEnabled" />
+      </template>
+    </el-table-column>
+    <el-table-column
+      label="创建时间"
+      align="center"
+      prop="createTime"
+      :formatter="dateFormatter"
+      width="180px"
+    />
+    <el-table-column label="操作" align="center" min-width="110" fixed="right">
+      <template #default="scope">
+        <el-button
+          link
+          type="primary"
+          @click="openForm('update', scope.row.id)"
+          v-hasPermi="['crm:customer-limit-config:update']"
+        >
+          编辑
+        </el-button>
+        <el-button
+          link
+          type="danger"
+          @click="handleDelete(scope.row.id)"
+          v-hasPermi="['crm:customer-limit-config:delete']"
+        >
+          删除
+        </el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+  <!-- 分页 -->
+  <Pagination
+    :total="total"
+    v-model:page="queryParams.pageNo"
+    v-model:limit="queryParams.pageSize"
+    @pagination="getList"
+  />
+
+  <!-- 表单弹窗:添加/修改 -->
+  <CustomerLimitConfigForm ref="formRef" @success="getList" />
+</template>
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as CustomerLimitConfigApi from '@/api/crm/customer/limitConfig'
+import CustomerLimitConfigForm from './CustomerLimitConfigForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+import { LimitConfType } from '@/api/crm/customer/limitConfig'
+
+defineOptions({ name: 'CustomerLimitConfigList' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const { confType } = defineProps<{ confType: LimitConfType }>()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: confType
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CustomerLimitConfigApi.getCustomerLimitConfigPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, confType, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await CustomerLimitConfigApi.deleteCustomerLimitConfig(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 85 - 0
src/views/crm/customer/pool/CustomerDistributeForm.vue

@@ -0,0 +1,85 @@
+<template>
+  <Dialog v-model="dialogVisible" title="分配客户">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item label="负责人" prop="ownerUserId">
+        <el-select v-model="formData.ownerUserId" class="w-1/1">
+          <el-option
+            v-for="item in userOptions"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as CustomerApi from '@/api/crm/customer'
+import * as UserApi from '@/api/system/user'
+import { distributeCustomer } from '@/api/crm/customer'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const formData = ref({
+  id: undefined,
+  ownerUserId: undefined
+})
+const formRules = reactive({
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  formData.value.id = id
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    await CustomerApi.distributeCustomer([formData.value.id], formData.value.ownerUserId)
+    message.success('分配客户成功')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    ownerUserId: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 170 - 0
src/views/crm/statistics/customer/components/CustomerConversionStat.vue

@@ -0,0 +1,170 @@
+<!-- 客户转化率分析 -->
+<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="客户名称"
+        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"
+        :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
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        min-width="200"
+      />
+      <el-table-column
+        label="下单日期"
+        align="center"
+        prop="orderDate"
+        :formatter="dateFormatter"
+        min-width="200"
+        fixed="right"
+      />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  CrmStatisticsCustomerSummaryByDateRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+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) // 加载中
+const list = ref<CrmStatisticsCustomerSummaryByDateRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 40, // 让 X 轴右侧显示完整
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {},
+  series: [
+    {
+      name: '客户转化率',
+      type: 'line',
+      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: '转化率(%)'
+  },
+  xAxis: {
+    type: 'category',
+    name: '日期',
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
+  // 1. 加载统计数据
+  const customerCount = await StatisticsCustomerApi.getCustomerSummaryByDate(props.queryParams)
+  const contractSummary = await StatisticsCustomerApi.getContractSummary(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = customerCount.map(
+      (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.time
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = customerCount.map(
+      (item: CrmStatisticsCustomerSummaryByDateRespVO) => {
+        return {
+          name: item.time,
+          value: item.customerCreateCount
+            ? ((item.customerDealCount / item.customerCreateCount) * 100).toFixed(2)
+            : 0
+        }
+      }
+    )
+  }
+  // 2.2 更新列表数据
+  list.value = contractSummary
+}
+
+/** 获取统计数据 */
+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/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>

+ 154 - 0
src/views/crm/statistics/customer/components/CustomerDealCycleByUser.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" />
+      <el-table-column label="日期" align="center" prop="ownerUserName" 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,
+  CrmStatisticsCustomerDealCycleByDateRespVO,
+  CrmStatisticsCustomerSummaryByDateRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+
+defineOptions({ name: 'CustomerDealCycleByUser' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticsCustomerDealCycleByDateRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+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 customerDealCycleByDate = await StatisticsCustomerApi.getCustomerDealCycleByDate(
+    props.queryParams
+  )
+  const customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate(
+    props.queryParams
+  )
+  const customerDealCycleByUser = await StatisticsCustomerApi.getCustomerDealCycleByUser(
+    props.queryParams
+  )
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = customerDealCycleByDate.map(
+      (s: CrmStatisticsCustomerDealCycleByDateRespVO) => s.time
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = customerDealCycleByDate.map(
+      (s: CrmStatisticsCustomerDealCycleByDateRespVO) => s.customerDealCycle
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = customerSummaryByDate.map(
+      (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerDealCount
+    )
+  }
+  // 2.2 更新列表数据
+  list.value = customerDealCycleByUser
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 156 - 0
src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue

@@ -0,0 +1,156 @@
+<!-- 客户跟进次数分析 -->
+<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="ownerUserName" min-width="200" />
+      <el-table-column label="跟进次数" align="right" prop="followUpRecordCount" min-width="200" />
+      <el-table-column
+        label="跟进客户数"
+        align="right"
+        prop="followUpCustomerCount"
+        min-width="200"
+      />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  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 echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    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: '日期',
+    axisTick: {
+      alignWithLabel: true
+    },
+    data: []
+  }
+}) as EChartsOption
+
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
+  // 1. 加载统计数据
+  loading.value = true
+  const followUpSummaryByDate = await StatisticsCustomerApi.getFollowUpSummaryByDate(
+    props.queryParams
+  )
+  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
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    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
+    )
+  }
+  // 2.2 更新列表数据
+  list.value = followUpSummaryByUser
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 120 - 0
src/views/crm/statistics/customer/components/CustomerFollowUpType.vue

@@ -0,0 +1,120 @@
+<!-- 客户跟进方式分析 -->
+<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="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>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  CrmStatisticsFollowUpSummaryByTypeRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+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 echartsOption = reactive<EChartsOption>({
+  title: {
+    text: '客户跟进方式分析',
+    left: 'center'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'left'
+  },
+  tooltip: {
+    trigger: 'item',
+    formatter: '{b} : {c}% '
+  },
+  toolbox: {
+    feature: {
+      saveAsImage: { show: true, name: '客户跟进方式分析' } // 保存为图片
+    }
+  },
+  series: [
+    {
+      name: '跟进方式',
+      type: 'pie',
+      radius: '50%',
+      data: [],
+      emphasis: {
+        itemStyle: {
+          shadowBlur: 10,
+          shadowOffsetX: 0,
+          shadowColor: 'rgba(0, 0, 0, 0.5)'
+        }
+      }
+    }
+  ]
+}) as EChartsOption
+
+/** 获取数据并填充图表 */
+const fetchAndFill = async () => {
+  // 1. 加载统计数据
+  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(
+      (row: CrmStatisticsFollowUpSummaryByTypeRespVO) => {
+        return {
+          name: getDictLabel(DICT_TYPE.CRM_FOLLOW_UP_TYPE, row.followUpType),
+          value: row.followUpRecordCount
+        }
+      }
+    )
+  }
+  // 2.2 更新列表数据
+  const totalCount = sumBy(followUpSummaryByType, 'followUpRecordCount')
+  list.value = followUpSummaryByType.map((row: CrmStatisticsFollowUpSummaryByTypeRespVO) => {
+    return {
+      ...row,
+      portion: erpCalculatePercentage(row.followUpRecordCount, totalCount)
+    }
+  })
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

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

+ 183 - 0
src/views/crm/statistics/customer/components/CustomerSummary.vue

@@ -0,0 +1,183 @@
+<!-- 客户总量统计 -->
+<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="customerCreateCount"
+        min-width="200"
+      />
+      <el-table-column label="成交客户数" align="right" prop="customerDealCount" min-width="200" />
+      <el-table-column label="客户成交率(%)" align="right" min-width="200">
+        <template #default="scope">
+          {{ erpCalculatePercentage(scope.row.customerDealCount, scope.row.customerCreateCount) }}
+        </template>
+      </el-table-column>
+      <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">
+        <template #default="scope">
+          {{ erpCalculatePercentage(scope.row.receivablePrice, scope.row.contractPrice) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="回款完成率(%)" align="right" min-width="200" fixed="right">
+        <template #default="scope">
+          {{ erpCalculatePercentage(scope.row.receivablePrice, scope.row.contractPrice) }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import {
+  StatisticsCustomerApi,
+  CrmStatisticsCustomerSummaryByDateRespVO,
+  CrmStatisticsCustomerSummaryByUserRespVO
+} from '@/api/crm/statistics/customer'
+import { EChartsOption } from 'echarts'
+import { erpCalculatePercentage, erpPriceTableColumnFormatter } from '@/utils'
+
+defineOptions({ name: 'CustomerSummary' })
+
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<CrmStatisticsCustomerSummaryByUserRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:纵向 */
+const echartsOption = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    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 customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate(
+    props.queryParams
+  )
+  const customerSummaryByUser = await StatisticsCustomerApi.getCustomerSummaryByUser(
+    props.queryParams
+  )
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.xAxis && echartsOption.xAxis['data']) {
+    echartsOption.xAxis['data'] = customerSummaryByDate.map(
+      (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.time
+    )
+  }
+  if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) {
+    echartsOption.series[0]['data'] = customerSummaryByDate.map(
+      (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerCreateCount
+    )
+  }
+  if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) {
+    echartsOption.series[1]['data'] = customerSummaryByDate.map(
+      (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerDealCount
+    )
+  }
+
+  // 2.2 更新列表数据
+  list.value = customerSummaryByUser
+}
+
+/** 获取统计数据 */
+const loadData = async () => {
+  loading.value = true
+  try {
+    await fetchAndFill()
+  } finally {
+    loading.value = false
+  }
+}
+
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

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

@@ -0,0 +1,98 @@
+<!-- 新增客户数排行 -->
+<template>
+  <!-- 柱状图 -->
+  <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="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>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank'
+import { EChartsOption } from 'echarts'
+import { clone } from 'lodash-es'
+
+defineOptions({ name: 'CustomerCountRank' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:横向 */
+const echartsOption = reactive<EChartsOption>({
+  dataset: {
+    dimensions: ['nickname', 'count'],
+    source: []
+  },
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {
+    top: 50
+  },
+  series: [
+    {
+      name: '新增客户数排行',
+      type: 'bar'
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        yAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '新增客户数排行' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  xAxis: {
+    type: 'value',
+    name: '新增客户数(个)'
+  },
+  yAxis: {
+    type: 'category',
+    name: '创建人'
+  }
+}) as EChartsOption
+
+/** 获取新增客户数排行 */
+const loadData = async () => {
+  // 1. 加载排行数据
+  loading.value = true
+  const rankingList = await StatisticsRankApi.getCustomerCountRank(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.dataset && echartsOption.dataset['source']) {
+    echartsOption.dataset['source'] = clone(rankingList).reverse()
+  }
+  // 2.2 更新列表数据
+  list.value = rankingList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 210 - 0
src/views/erp/sale/customer/CustomerForm.vue

@@ -0,0 +1,210 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入名称" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="联系人" prop="contact">
+            <el-input v-model="formData.contact" placeholder="请输入联系人" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="手机号码" prop="mobile">
+            <el-input v-model="formData.mobile" placeholder="请输入手机号码" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="联系电话" prop="telephone">
+            <el-input v-model="formData.telephone" placeholder="请输入联系电话" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="电子邮箱" prop="email">
+            <el-input v-model="formData.email" placeholder="请输入电子邮箱" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="传真" prop="fax">
+            <el-input v-model="formData.fax" placeholder="请输入传真" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="开启状态" prop="status">
+            <el-radio-group v-model="formData.status">
+              <el-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :label="dict.value"
+              >
+                {{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="排序" prop="sort">
+            <el-input-number
+              v-model="formData.sort"
+              placeholder="请输入排序"
+              class="!w-1/1"
+              :precision="0"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="纳税人识别号" prop="taxNo">
+            <el-input v-model="formData.taxNo" placeholder="请输入纳税人识别号" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="税率(%)" prop="taxPercent">
+            <el-input-number
+              v-model="formData.taxPercent"
+              :min="0"
+              :precision="2"
+              placeholder="请输入税率"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="开户行" prop="bankName">
+            <el-input v-model="formData.bankName" placeholder="请输入开户行" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="开户账号" prop="bankAccount">
+            <el-input v-model="formData.bankAccount" placeholder="请输入开户账号" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="开户地址" prop="bankAddress">
+            <el-input v-model="formData.bankAddress" placeholder="请输入开户地址" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="备注" prop="remark">
+            <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer'
+import { CommonStatusEnum } from '@/utils/constants'
+
+/** ERP 客户 表单 */
+defineOptions({ name: 'CustomerForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  contact: undefined,
+  mobile: undefined,
+  telephone: undefined,
+  email: undefined,
+  fax: undefined,
+  remark: undefined,
+  status: undefined,
+  sort: undefined,
+  taxNo: undefined,
+  taxPercent: undefined,
+  bankName: undefined,
+  bankAccount: undefined,
+  bankAddress: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await CustomerApi.getCustomer(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as CustomerVO
+    if (formType.value === 'create') {
+      await CustomerApi.createCustomer(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CustomerApi.updateCustomer(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    contact: undefined,
+    mobile: undefined,
+    telephone: undefined,
+    email: undefined,
+    fax: undefined,
+    remark: undefined,
+    status: CommonStatusEnum.ENABLE,
+    sort: undefined,
+    taxNo: undefined,
+    taxPercent: undefined,
+    bankName: undefined,
+    bankAccount: undefined,
+    bankAddress: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 219 - 0
src/views/mall/promotion/coupon/components/CouponSelect.vue

@@ -0,0 +1,219 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%">
+    <!-- 搜索工作栏 -->
+    <ContentWrap>
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="82px"
+      >
+        <el-form-item label="优惠券名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            class="!w-240px"
+            clearable
+            placeholder="请输入优惠劵名"
+            @keyup="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="优惠类型" prop="discountType">
+          <el-select
+            v-model="queryParams.discountType"
+            class="!w-240px"
+            clearable
+            placeholder="请选择优惠券类型"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="优惠券状态" prop="status">
+          <el-select
+            v-model="queryParams.status"
+            class="!w-240px"
+            clearable
+            placeholder="请选择优惠券状态"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="创建时间" prop="createTime">
+          <el-date-picker
+            v-model="queryParams.createTime"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-240px"
+            end-placeholder="结束日期"
+            start-placeholder="开始日期"
+            type="daterange"
+            value-format="YYYY-MM-DD HH:mm:ss"
+          />
+        </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>
+
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange">
+        <el-table-column type="selection" width="55" />
+        <el-table-column label="优惠券名称" min-width="140" prop="name" />
+        <el-table-column label="类型" min-width="80" prop="productScope">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="scope.row.productScope" />
+          </template>
+        </el-table-column>
+        <el-table-column label="优惠" min-width="100" prop="discount">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" />
+            {{ discountFormat(scope.row) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="领取方式" min-width="100" prop="takeType">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="validityTypeFormat"
+          align="center"
+          label="使用时间"
+          prop="validityType"
+          width="185"
+        />
+        <el-table-column align="center" label="发放数量" prop="totalCount" />
+        <el-table-column
+          :formatter="remainedCountFormat"
+          align="center"
+          label="剩余数量"
+          prop="totalCount"
+        />
+        <el-table-column
+          :formatter="takeLimitCountFormat"
+          align="center"
+          label="领取上限"
+          prop="takeLimitCount"
+        />
+        <el-table-column align="center" label="状态" prop="status">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import {
+  discountFormat,
+  remainedCountFormat,
+  takeLimitCountFormat,
+  validityTypeFormat
+} from '@/views/mall/promotion/coupon/formatter'
+import { dateFormatter } from '@/utils/formatTime'
+import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+
+defineOptions({ name: 'CouponSelect' })
+
+defineProps<{
+  multipleSelection: CouponTemplateApi.CouponTemplateVO[]
+}>()
+const emit = defineEmits<{
+  (e: 'update:multipleSelection', v: CouponTemplateApi.CouponTemplateVO[])
+}>()
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('选择优惠卷') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 字典表格数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: null,
+  discountType: null,
+  type: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // 执行查询
+    const data = await CouponTemplateApi.getCouponTemplatePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef?.value?.resetFields()
+  handleQuery()
+}
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  resetQuery()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+const handleSelectionChange = (val: CouponTemplateApi.CouponTemplateVO[]) => {
+  emit('update:multipleSelection', val)
+}
+
+const submitForm = () => {
+  dialogVisible.value = false
+}
+// TODO @puhui999:提前 todo,先不用改;未来单独成组件,其它模块可以服用;例如说,满减送,可以选择优惠劵;
+</script>

+ 162 - 0
src/views/mall/promotion/coupon/components/CouponSendForm.vue

@@ -0,0 +1,162 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="发送优惠券" width="70%">
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="82px"
+    >
+      <el-form-item label="优惠券名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          class="!w-240px"
+          placeholder="请输入优惠劵名"
+          clearable
+          @keyup="handleQuery"
+        />
+      </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>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+      <el-table-column align="center" label="优惠券名称" prop="name" min-width="60" />
+      <el-table-column
+        label="优惠金额 / 折扣"
+        align="center"
+        prop="discount"
+        :formatter="discountFormat"
+        min-width="60"
+      />
+      <el-table-column
+        align="center"
+        label="最低消费"
+        prop="usePrice"
+        min-width="60"
+        :formatter="usePriceFormat"
+      />
+      <el-table-column
+        align="center"
+        label="有效期限"
+        prop="validityType"
+        min-width="140"
+        :formatter="validityTypeFormat"
+      />
+      <el-table-column
+        align="center"
+        label="剩余数量"
+        min-width="60"
+        :formatter="remainedCountFormat"
+      />
+      <el-table-column label="操作" align="center" min-width="60px" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            :disabled="sendLoading"
+            :loading="sendLoading"
+            @click="handleSendCoupon(scope.row.id)"
+            v-hasPermi="['member:level:update']"
+          >
+            发送
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+    <div class="clear-both"></div>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+import * as CouponApi from '@/api/mall/promotion/coupon/coupon'
+import {
+  discountFormat,
+  remainedCountFormat,
+  usePriceFormat,
+  validityTypeFormat
+} from '@/views/mall/promotion/coupon/formatter'
+import { CouponTemplateTakeTypeEnum } from '@/utils/constants'
+
+defineOptions({ name: 'PromotionCouponSendForm' })
+
+const message = useMessage() // 消息弹窗
+const total = ref(0) // 列表的总页数
+const list = ref<any[]>([]) // 列表的数据
+const loading = ref(false) // 列表的加载中
+const sendLoading = ref(false) // 发送按钮的加载中
+const dialogVisible = ref(false) // 弹窗的是否展示
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  canTakeTypes: [CouponTemplateTakeTypeEnum.ADMIN.type]
+}) // 查询参数
+const queryFormRef = ref() // 搜索的表单
+// 领取人的编号列表
+let userIds: number[] = []
+
+/** 打开弹窗 */
+const open = (ids: number[]) => {
+  userIds = ids
+  // 打开时重置查询,防止发送列表剩余数量未更新的问题
+  resetQuery()
+
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CouponTemplateApi.getCouponTemplatePage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef?.value?.resetFields()
+  handleQuery()
+}
+
+/** 发送操作 **/
+const handleSendCoupon = async (templateId: number) => {
+  try {
+    sendLoading.value = true
+    await CouponApi.sendCoupon({ templateId, userIds })
+    // 提示
+    message.success('发送成功')
+    dialogVisible.value = false
+  } finally {
+    sendLoading.value = false
+  }
+}
+</script>

+ 388 - 0
src/views/mall/promotion/coupon/template/CouponTemplateForm.vue

@@ -0,0 +1,388 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="140px"
+    >
+      <el-form-item label="优惠券名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入优惠券名称" />
+      </el-form-item>
+      <el-form-item label="优惠劵类型" prop="productScope">
+        <el-radio-group v-model="formData.productScope">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="formData.productScope === PromotionProductScopeEnum.SPU.scope"
+        label="商品"
+        prop="productSpuIds"
+      >
+        <SpuShowcase v-model="formData.productSpuIds" />
+      </el-form-item>
+      <el-form-item
+        v-if="formData.productScope === PromotionProductScopeEnum.CATEGORY.scope"
+        label="分类"
+        prop="productCategoryIds"
+      >
+        <ProductCategorySelect v-model="formData.productCategoryIds" />
+      </el-form-item>
+      <el-form-item label="优惠类型" prop="discountType">
+        <el-radio-group v-model="formData.discountType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="formData.discountType === PromotionDiscountTypeEnum.PRICE.type"
+        label="优惠券面额"
+        prop="discountPrice"
+      >
+        <el-input-number
+          v-model="formData.discountPrice"
+          :min="0"
+          :precision="2"
+          class="mr-2 !w-400px"
+          placeholder="请输入优惠金额,单位:元"
+        />
+        元
+      </el-form-item>
+      <el-form-item
+        v-if="formData.discountType === PromotionDiscountTypeEnum.PERCENT.type"
+        label="优惠券折扣"
+        prop="discountPercent"
+      >
+        <el-input-number
+          v-model="formData.discountPercent"
+          :max="9.9"
+          :min="1"
+          :precision="1"
+          class="mr-2 !w-400px"
+          placeholder="优惠券折扣不能小于 1 折,且不可大于 9.9 折"
+        />
+        折
+      </el-form-item>
+      <el-form-item
+        v-if="formData.discountType === PromotionDiscountTypeEnum.PERCENT.type"
+        label="最多优惠"
+        prop="discountLimitPrice"
+      >
+        <el-input-number
+          v-model="formData.discountLimitPrice"
+          :min="0"
+          :precision="2"
+          class="mr-2 !w-400px"
+          placeholder="请输入最多优惠"
+        />
+        元
+      </el-form-item>
+      <el-form-item label="满多少元可以使用" prop="usePrice">
+        <el-input-number
+          v-model="formData.usePrice"
+          :min="0"
+          :precision="2"
+          class="mr-2 !w-400px"
+          placeholder="无门槛请设为 0"
+        />
+        元
+      </el-form-item>
+      <el-form-item label="领取方式" prop="takeType">
+        <el-radio-group v-model="formData.takeType">
+          <el-radio :key="1" :label="1">直接领取</el-radio>
+          <el-radio :key="2" :label="2">指定发放</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item v-if="formData.takeType === 1" label="发放数量" prop="totalCount">
+        <el-input-number
+          v-model="formData.totalCount"
+          :min="-1"
+          :precision="0"
+          class="mr-2 !w-400px"
+          placeholder="发放数量,没有之后不能领取或发放,-1 为不限制"
+        />
+        张
+      </el-form-item>
+      <el-form-item v-if="formData.takeType === 1" label="每人限领个数" prop="takeLimitCount">
+        <el-input-number
+          v-model="formData.takeLimitCount"
+          :min="-1"
+          :precision="0"
+          class="mr-2 !w-400px"
+          placeholder="设置为 -1 时,可无限领取"
+        />
+        张
+      </el-form-item>
+      <el-form-item label="有效期类型" prop="validityType">
+        <el-radio-group v-model="formData.validityType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="formData.validityType === CouponTemplateValidityTypeEnum.DATE.type"
+        label="固定日期"
+        prop="validTimes"
+      >
+        <el-date-picker
+          v-model="formData.validTimes"
+          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]"
+          style="width: 240px"
+          type="datetimerange"
+          value-format="x"
+        />
+      </el-form-item>
+      <el-form-item
+        v-if="formData.validityType === CouponTemplateValidityTypeEnum.TERM.type"
+        label="领取日期"
+        prop="fixedStartTerm"
+      >
+        第
+        <el-input-number
+          v-model="formData.fixedStartTerm"
+          :min="0"
+          :precision="0"
+          class="mx-2"
+          placeholder="0 为今天生效"
+        />
+        至
+        <el-input-number
+          v-model="formData.fixedEndTerm"
+          :min="0"
+          :precision="0"
+          class="mx-2"
+          placeholder="请输入结束天数"
+        />
+        天有效
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+import {
+  CouponTemplateValidityTypeEnum,
+  PromotionDiscountTypeEnum,
+  PromotionProductScopeEnum
+} from '@/utils/constants'
+import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
+import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue'
+import { convertToInteger, formatToFraction } from '@/utils'
+
+defineOptions({ name: 'CouponTemplateForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  discountType: PromotionDiscountTypeEnum.PRICE.type,
+  discountPrice: undefined,
+  discountPercent: undefined,
+  discountLimitPrice: undefined,
+  usePrice: undefined,
+  takeType: 1,
+  totalCount: undefined,
+  takeLimitCount: undefined,
+  validityType: CouponTemplateValidityTypeEnum.DATE.type,
+  validTimes: [],
+  validStartTime: undefined,
+  validEndTime: undefined,
+  fixedStartTerm: undefined,
+  fixedEndTerm: undefined,
+  productScope: PromotionProductScopeEnum.ALL.scope,
+  productScopeValues: [], // 商品范围:值为 品类编号列表 或 商品编号列表 ,用于提交
+  productCategoryIds: [], // 仅用于表单,不提交
+  productSpuIds: [] // 仅用于表单,不提交
+})
+const formRules = reactive({
+  name: [{ required: true, message: '优惠券名称不能为空', trigger: 'blur' }],
+  discountType: [{ required: true, message: '优惠券类型不能为空', trigger: 'change' }],
+  discountPrice: [{ required: true, message: '优惠券面额不能为空', trigger: 'blur' }],
+  discountPercent: [{ required: true, message: '优惠券折扣不能为空', trigger: 'blur' }],
+  discountLimitPrice: [{ required: true, message: '最多优惠不能为空', trigger: 'blur' }],
+  usePrice: [{ required: true, message: '满多少元可以使用不能为空', trigger: 'blur' }],
+  takeType: [{ required: true, message: '领取方式不能为空', trigger: 'change' }],
+  totalCount: [{ required: true, message: '发放数量不能为空', trigger: 'blur' }],
+  takeLimitCount: [{ required: true, message: '每人限领个数不能为空', trigger: 'blur' }],
+  validityType: [{ required: true, message: '有效期类型不能为空', trigger: 'change' }],
+  validTimes: [{ required: true, message: '固定日期不能为空', trigger: 'change' }],
+  fixedStartTerm: [{ required: true, message: '开始领取天数不能为空', trigger: 'blur' }],
+  fixedEndTerm: [{ required: true, message: '开始领取天数不能为空', trigger: 'blur' }],
+  productScope: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }],
+  productSpuIds: [{ required: true, message: '商品不能为空', trigger: 'blur' }],
+  productCategoryIds: [{ required: true, message: '分类不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await CouponTemplateApi.getCouponTemplate(id)
+      formData.value = {
+        ...data,
+        discountPrice: formatToFraction(data.discountPrice),
+        discountPercent:
+          data.discountPercent !== undefined ? data.discountPercent / 10.0 : undefined,
+        discountLimitPrice: formatToFraction(data.discountLimitPrice),
+        usePrice: formatToFraction(data.usePrice),
+        validTimes: [data.validStartTime, data.validEndTime]
+      }
+      // 获得商品范围
+      await getProductScope()
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = {
+      ...formData.value,
+      discountPrice: convertToInteger(formData.value.discountPrice),
+      discountPercent:
+        formData.value.discountPercent !== undefined
+          ? formData.value.discountPercent * 10
+          : undefined,
+      discountLimitPrice: convertToInteger(formData.value.discountLimitPrice),
+      usePrice: convertToInteger(formData.value.usePrice),
+      validStartTime:
+        formData.value.validTimes && formData.value.validTimes.length === 2
+          ? formData.value.validTimes[0]
+          : undefined,
+      validEndTime:
+        formData.value.validTimes && formData.value.validTimes.length === 2
+          ? formData.value.validTimes[1]
+          : undefined
+    } as unknown as CouponTemplateApi.CouponTemplateVO
+
+    // 设置商品范围
+    setProductScopeValues(data)
+
+    if (formType.value === 'create') {
+      await CouponTemplateApi.createCouponTemplate(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CouponTemplateApi.updateCouponTemplate(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    discountType: PromotionDiscountTypeEnum.PRICE.type,
+    discountPrice: undefined,
+    discountPercent: undefined,
+    discountLimitPrice: undefined,
+    usePrice: undefined,
+    takeType: 1,
+    totalCount: undefined,
+    takeLimitCount: undefined,
+    validityType: CouponTemplateValidityTypeEnum.DATE.type,
+    validTimes: [],
+    validStartTime: undefined,
+    validEndTime: undefined,
+    fixedStartTerm: undefined,
+    fixedEndTerm: undefined,
+    productScope: PromotionProductScopeEnum.ALL.scope,
+    productScopeValues: [],
+    productSpuIds: [],
+    productCategoryIds: []
+  }
+  formRef.value?.resetFields()
+}
+
+/** 获得商品范围 */
+const getProductScope = async () => {
+  switch (formData.value.productScope) {
+    case PromotionProductScopeEnum.SPU.scope:
+      // 设置商品编号
+      formData.value.productSpuIds = formData.value.productScopeValues
+      break
+    case PromotionProductScopeEnum.CATEGORY.scope:
+      await nextTick(() => {
+        let productCategoryIds = formData.value.productScopeValues
+        if (Array.isArray(productCategoryIds) && productCategoryIds.length > 0) {
+          // 单选时使用数组不能反显
+          productCategoryIds = productCategoryIds[0]
+        }
+        // 设置品类编号
+        formData.value.productCategoryIds = productCategoryIds
+      })
+      break
+    default:
+      break
+  }
+}
+
+/** 设置商品范围 */
+function setProductScopeValues(data: CouponTemplateApi.CouponTemplateVO) {
+  switch (formData.value.productScope) {
+    case PromotionProductScopeEnum.SPU.scope:
+      data.productScopeValues = formData.value.productSpuIds
+      break
+    case PromotionProductScopeEnum.CATEGORY.scope:
+      data.productScopeValues = Array.isArray(formData.value.productCategoryIds)
+        ? formData.value.productCategoryIds
+        : [formData.value.productCategoryIds]
+      break
+    default:
+      break
+  }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 166 - 0
src/views/mp/draft/components/CoverSelect.vue

@@ -0,0 +1,166 @@
+<template>
+  <div>
+    <p>封面:</p>
+    <div class="thumb-div">
+      <el-image
+        v-if="newsItem.thumbUrl"
+        style="width: 300px; max-height: 300px"
+        :src="newsItem.thumbUrl"
+        fit="contain"
+      />
+      <Icon
+        v-else
+        icon="ep:plus"
+        class="avatar-uploader-icon"
+        :class="isFirst ? 'avatar' : 'avatar1'"
+      />
+      <div class="thumb-but">
+        <el-upload
+          :action="UPLOAD_URL"
+          :headers="HEADERS"
+          multiple
+          :limit="1"
+          :file-list="fileList"
+          :data="uploadData"
+          :before-upload="onBeforeUpload"
+          :on-error="onUploadError"
+          :on-success="onUploadSuccess"
+        >
+          <template #trigger>
+            <el-button size="small" type="primary">本地上传</el-button>
+          </template>
+          <el-button
+            size="small"
+            type="primary"
+            @click="showImageDialog = true"
+            style="margin-left: 5px"
+          >
+            素材库选择
+          </el-button>
+          <template #tip>
+            <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div>
+          </template>
+        </el-upload>
+      </div>
+      <el-dialog
+        title="选择图片"
+        v-model="showImageDialog"
+        width="80%"
+        append-to-body
+        destroy-on-close
+      >
+        <WxMaterialSelect
+          type="image"
+          :account-id="accountId!"
+          @select-material="onMaterialSelected"
+        />
+      </el-dialog>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import WxMaterialSelect from '@/views/mp/components/wx-material-select'
+import { getAccessToken } from '@/utils/auth'
+import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'
+import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
+import { NewsItem } from './types'
+const message = useMessage()
+
+const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
+const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
+
+const props = defineProps<{
+  modelValue: NewsItem
+  isFirst: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: NewsItem)
+}>()
+const newsItem = computed<NewsItem>({
+  get() {
+    return props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+
+const accountId = inject<number>('accountId')
+const showImageDialog = ref(false)
+
+const fileList = ref<UploadFiles>([])
+interface UploadData {
+  type: UploadType
+  accountId: number
+}
+const uploadData: UploadData = reactive({
+  type: UploadType.Image,
+  accountId: accountId!
+})
+
+/** 素材选择完成事件*/
+const onMaterialSelected = (item: any) => {
+  showImageDialog.value = false
+  newsItem.value.thumbMediaId = item.mediaId
+  newsItem.value.thumbUrl = item.url
+}
+
+const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
+  useBeforeUpload(UploadType.Image, 2)(rawFile)
+
+const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 重置上传文件的表单
+  fileList.value = []
+
+  // 设置草稿的封面字段
+  newsItem.value.thumbMediaId = res.data.mediaId
+  newsItem.value.thumbUrl = res.data.url
+}
+
+const onUploadError = (err: Error) => {
+  message.error('上传失败: ' + err.message)
+}
+</script>
+
+<style lang="scss" scoped>
+.el-upload__tip {
+  margin-left: 5px;
+}
+
+.thumb-div {
+  display: inline-block;
+  width: 100%;
+  text-align: center;
+
+  .avatar-uploader-icon {
+    width: 120px;
+    height: 120px;
+    font-size: 28px;
+    line-height: 120px;
+    color: #8c939d;
+    text-align: center;
+    border: 1px solid #d9d9d9;
+  }
+
+  .avatar {
+    width: 230px;
+    height: 120px;
+  }
+
+  .avatar1 {
+    width: 120px;
+    height: 120px;
+  }
+
+  .thumb-but {
+    margin: 5px;
+  }
+}
+</style>

+ 135 - 0
src/views/pay/transfer/CreatePayTransfer.vue

@@ -0,0 +1,135 @@
+<template>
+  <Dialog title="发起转账" v-model="dialogVisible" width="800px">
+    <el-card style="margin-top: 10px">
+      <el-descriptions title="转账信息" :column="2" border>
+        <el-descriptions-item label="转账类型">
+          {{ typeName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="转账金额(元)">
+          ¥{{ (transfer.price / 100.0).toFixed(2) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="收款人姓名">
+          {{ transfer.userName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="支付宝登录账号" v-if="transfer.type === 1">
+          {{ transfer.alipayLogonId }}
+        </el-descriptions-item>
+        <el-descriptions-item label="微信 openid" v-if="transfer.type === 2">
+          {{ transfer.openid }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-card>
+    <el-card style="margin-top: 20px">
+      <template #header>
+        <div class="card-header">
+          <span>选择转账渠道</span>
+        </div>
+      </template>
+      <div>
+        <el-radio-group v-model="channelCode">
+          <el-radio
+            label="alipay_pc"
+            :disabled="transfer.type === 2 || transfer.type === 3 || transfer.type === 4"
+          >
+            <img :src="svg_alipay_app" />
+          </el-radio>
+          <el-radio
+            label="wx_app"
+            :disabled="transfer.type === 1 || transfer.type === 3 || transfer.type === 4"
+          >
+            <img :src="svg_wx_app" />
+          </el-radio>
+        </el-radio-group>
+      </div>
+    </el-card>
+    <el-divider />
+    <div style="text-align: right">
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </div>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import * as PayTransferApi from '@/api/pay/transfer'
+import { computed, PropType } from 'vue'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+// 导入图标
+import svg_alipay_app from '@/assets/svgs/pay/icon/alipay_app.svg'
+import svg_wx_app from '@/assets/svgs/pay/icon/wx_app.svg'
+import { yuanToFen } from '@/utils'
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const formLoading = ref(false) // 提交的按钮禁用
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+defineOptions({ name: 'CreatePayTransfer' })
+
+// 提交数据
+let submitTransferData: PayTransferApi.TransferVO
+
+const transfer = reactive({
+  appId: undefined,
+  channelCode: undefined,
+  merchantTransferId: undefined,
+  type: undefined,
+  price: undefined,
+  subject: undefined,
+  userName: undefined,
+  alipayLogonId: undefined,
+  openid: undefined
+})
+const dialogVisible = ref(false)
+const typeName = computed(() => {
+  return getDictLabel(DICT_TYPE.PAY_TRANSFER_TYPE, transfer.type)
+})
+const channelCode = computed(() => {
+  let channelCode = 'alipay_pc'
+  if (transfer.type === 2) {
+    channelCode = 'wx_app'
+  }
+  // TODO 银行卡和钱包 转账待实现
+  return channelCode
+})
+
+/** 打开弹窗 */
+const showPayTransfer = async (payTransfer: PayTransferApi.TransferVO) => {
+  dialogVisible.value = true
+  submitTransferData = payTransfer
+  transfer.merchantTransferId = payTransfer.merchantTransferId
+  transfer.price = payTransfer.price
+  transfer.userName = payTransfer.userName
+  transfer.type = payTransfer.type
+  transfer.appId = payTransfer.appId
+  transfer.subject = payTransfer.subject
+  transfer.alipayLogonId = payTransfer.alipayLogonId
+  transfer.openid = payTransfer.openid
+}
+/** 关闭弹窗 */
+const close = async () => {
+  dialogVisible.value = false
+}
+defineExpose({ showPayTransfer, close }) // 提供 showPayTransfer, close 方法,用于打开, 关闭弹窗
+
+const submitForm = async () => {
+  // 校验表单
+  formLoading.value = true
+  try {
+    submitTransferData.channelCode = channelCode.value
+    await PayTransferApi.createTransfer(submitTransferData)
+    message.success('发起转账成功. 是否转账成功,以转账订单状态为准')
+    // 发送操作成功的事件
+    emit('success')
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 27 - 0
types/custom-types.d.ts

@@ -0,0 +1,27 @@
+import { SlateDescendant } from '@wangeditor/editor'
+
+declare module 'slate' {
+  interface CustomTypes {
+    // 扩展 text
+    Text: {
+      text: string
+      bold?: boolean
+      italic?: boolean
+      code?: boolean
+      through?: boolean
+      underline?: boolean
+      sup?: boolean
+      sub?: boolean
+      color?: string
+      bgColor?: string
+      fontSize?: string
+      fontFamily?: string
+    }
+
+    // 扩展 Element 的 type 属性
+    Element: {
+      type: string
+      children: SlateDescendant[]
+    }
+  }
+}