ydmyzx 5 ماه پیش
والد
کامیت
12cbd928bd
33فایلهای تغییر یافته به همراه4567 افزوده شده و 0 حذف شده
  1. 61 0
      src/api/mall/product/brand.ts
  2. 56 0
      src/api/mall/product/category.ts
  3. 14 0
      src/api/mall/promotion/bargain/bargainHelp.ts
  4. 19 0
      src/api/mall/promotion/bargain/bargainRecord.ts
  5. 37 0
      src/components/Card/src/CardTitle.vue
  6. 90 0
      src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue
  7. 1020 0
      src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json
  8. 130 0
      src/layout/components/Breadcrumb/src/Breadcrumb.vue
  9. 96 0
      src/views/Profile/components/BasicInfo.vue
  10. 163 0
      src/views/ai/chat/manager/ChatConversationList.vue
  11. 124 0
      src/views/bpm/category/CategoryForm.vue
  12. 287 0
      src/views/crm/business/BusinessForm.vue
  13. 108 0
      src/views/crm/business/BusinessUpdateStatusForm.vue
  14. 186 0
      src/views/crm/business/components/BusinessList.vue
  15. 156 0
      src/views/crm/business/components/BusinessListModal.vue
  16. 183 0
      src/views/crm/business/components/BusinessProductForm.vue
  17. 37 0
      src/views/crm/business/detail/BusinessDetailsHeader.vue
  18. 61 0
      src/views/crm/business/detail/BusinessDetailsInfo.vue
  19. 66 0
      src/views/crm/business/detail/BusinessProductList.vue
  20. 194 0
      src/views/crm/business/status/BusinessStatusForm.vue
  21. 307 0
      src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue
  22. 259 0
      src/views/crm/statistics/funnel/components/BusinessSummary.vue
  23. 87 0
      src/views/infra/codegen/components/BasicInfoForm.vue
  24. 123 0
      src/views/mall/product/brand/BrandForm.vue
  25. 135 0
      src/views/mall/product/category/CategoryForm.vue
  26. 90 0
      src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue
  27. BIN
      src/views/mall/promotion/kefu/components/asserts/bizui.png
  28. BIN
      src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png
  29. BIN
      src/views/mall/promotion/kefu/components/asserts/bukesiyi.png
  30. 152 0
      src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue
  31. 137 0
      src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue
  32. 73 0
      src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue
  33. 116 0
      src/views/mp/components/wx-msg/card.scss

+ 61 - 0
src/api/mall/product/brand.ts

@@ -0,0 +1,61 @@
+import request from '@/config/axios'
+
+/**
+ * 商品品牌
+ */
+export interface BrandVO {
+  /**
+   * 品牌编号
+   */
+  id?: number
+  /**
+   * 品牌名称
+   */
+  name: string
+  /**
+   * 品牌图片
+   */
+  picUrl: string
+  /**
+   * 品牌排序
+   */
+  sort?: number
+  /**
+   * 品牌描述
+   */
+  description?: string
+  /**
+   * 开启状态
+   */
+  status: number
+}
+
+// 创建商品品牌
+export const createBrand = (data: BrandVO) => {
+  return request.post({ url: '/product/brand/create', data })
+}
+
+// 更新商品品牌
+export const updateBrand = (data: BrandVO) => {
+  return request.put({ url: '/product/brand/update', data })
+}
+
+// 删除商品品牌
+export const deleteBrand = (id: number) => {
+  return request.delete({ url: `/product/brand/delete?id=${id}` })
+}
+
+// 获得商品品牌
+export const getBrand = (id: number) => {
+  return request.get({ url: `/product/brand/get?id=${id}` })
+}
+
+// 获得商品品牌列表
+export const getBrandParam = (params: PageParam) => {
+  return request.get({ url: '/product/brand/page', params })
+}
+
+// 获得商品品牌精简信息列表
+export const getSimpleBrandList = () => {
+  return request.get({ url: '/product/brand/list-all-simple' })
+}

+ 56 - 0
src/api/mall/product/category.ts

@@ -0,0 +1,56 @@
+import request from '@/config/axios'
+
+/**
+ * 产品分类
+ */
+export interface CategoryVO {
+  /**
+   * 分类编号
+   */
+  id?: number
+  /**
+   * 父分类编号
+   */
+  parentId?: number
+  /**
+   * 分类名称
+   */
+  name: string
+  /**
+   * 移动端分类图
+   */
+  picUrl: string
+  /**
+   * 分类排序
+   */
+  sort: number
+  /**
+   * 开启状态
+   */
+  status: number
+}
+
+// 创建商品分类
+export const createCategory = (data: CategoryVO) => {
+  return request.post({ url: '/product/category/create', data })
+}
+
+// 更新商品分类
+export const updateCategory = (data: CategoryVO) => {
+  return request.put({ url: '/product/category/update', data })
+}
+
+// 删除商品分类
+export const deleteCategory = (id: number) => {
+  return request.delete({ url: `/product/category/delete?id=${id}` })
+}
+
+// 获得商品分类
+export const getCategory = (id: number) => {
+  return request.get({ url: `/product/category/get?id=${id}` })
+}
+
+// 获得商品分类列表
+export const getCategoryList = (params: any) => {
+  return request.get({ url: '/product/category/list', params })
+}

+ 14 - 0
src/api/mall/promotion/bargain/bargainHelp.ts

@@ -0,0 +1,14 @@
+import request from '@/config/axios'
+
+export interface BargainHelpVO {
+  id: number
+  record: number
+  userId: number
+  reducePrice: number
+  endTime: Date
+}
+
+// 查询砍价记录列表
+export const getBargainHelpPage = async (params) => {
+  return await request.get({ url: `/promotion/bargain-help/page`, params })
+}

+ 19 - 0
src/api/mall/promotion/bargain/bargainRecord.ts

@@ -0,0 +1,19 @@
+import request from '@/config/axios'
+
+export interface BargainRecordVO {
+  id: number
+  activityId: number
+  userId: number
+  spuId: number
+  skuId: number
+  bargainFirstPrice: number
+  bargainPrice: number
+  status: number
+  orderId: number
+  endTime: Date
+}
+
+// 查询砍价记录列表
+export const getBargainRecordPage = async (params) => {
+  return await request.get({ url: `/promotion/bargain-record/page`, params })
+}

+ 37 - 0
src/components/Card/src/CardTitle.vue

@@ -0,0 +1,37 @@
+<script lang="ts" setup>
+defineComponent({
+  name: 'CardTitle'
+})
+
+defineProps({
+  title: {
+    type: String,
+    required: true
+  }
+})
+</script>
+
+<template>
+  <span class="card-title">{{ title }}</span>
+</template>
+
+<style scoped lang="scss">
+.card-title {
+  font-size: 14px;
+  font-weight: 600;
+
+  &::before {
+    position: relative;
+    top: 8px;
+    left: -5px;
+    display: inline-block;
+    width: 3px;
+    height: 14px;
+    //background-color: #105cfb;
+    background: var(--el-color-primary);
+    border-radius: 5px;
+    content: '';
+    transform: translateY(-50%);
+  }
+}
+</style>

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

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

+ 1020 - 0
src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json

@@ -0,0 +1,1020 @@
+{
+  "name": "Camunda",
+  "uri": "http://camunda.org/schema/1.0/bpmn",
+  "prefix": "camunda",
+  "xml": {
+    "tagAlias": "lowerCase"
+  },
+  "associations": [],
+  "types": [
+    {
+      "name": "Definitions",
+      "isAbstract": true,
+      "extends": ["bpmn:Definitions"],
+      "properties": [
+        {
+          "name": "diagramRelationId",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "InOutBinding",
+      "superClass": ["Element"],
+      "isAbstract": true,
+      "properties": [
+        {
+          "name": "source",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "sourceExpression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "target",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "businessKey",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "local",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": false
+        },
+        {
+          "name": "variables",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "In",
+      "superClass": ["InOutBinding"],
+      "meta": {
+        "allowedIn": ["bpmn:CallActivity", "bpmn:SignalEventDefinition"]
+      }
+    },
+    {
+      "name": "Out",
+      "superClass": ["InOutBinding"],
+      "meta": {
+        "allowedIn": ["bpmn:CallActivity"]
+      }
+    },
+    {
+      "name": "AsyncCapable",
+      "isAbstract": true,
+      "extends": ["bpmn:Activity", "bpmn:Gateway", "bpmn:Event"],
+      "properties": [
+        {
+          "name": "async",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": false
+        },
+        {
+          "name": "asyncBefore",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": false
+        },
+        {
+          "name": "asyncAfter",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": false
+        },
+        {
+          "name": "exclusive",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": true
+        }
+      ]
+    },
+    {
+      "name": "JobPriorized",
+      "isAbstract": true,
+      "extends": ["bpmn:Process", "camunda:AsyncCapable"],
+      "properties": [
+        {
+          "name": "jobPriority",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "SignalEventDefinition",
+      "isAbstract": true,
+      "extends": ["bpmn:SignalEventDefinition"],
+      "properties": [
+        {
+          "name": "async",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": false
+        }
+      ]
+    },
+    {
+      "name": "ErrorEventDefinition",
+      "isAbstract": true,
+      "extends": ["bpmn:ErrorEventDefinition"],
+      "properties": [
+        {
+          "name": "errorCodeVariable",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "errorMessageVariable",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "Error",
+      "isAbstract": true,
+      "extends": ["bpmn:Error"],
+      "properties": [
+        {
+          "name": "camunda:errorMessage",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "PotentialStarter",
+      "superClass": ["Element"],
+      "properties": [
+        {
+          "name": "resourceAssignmentExpression",
+          "type": "bpmn:ResourceAssignmentExpression"
+        }
+      ]
+    },
+    {
+      "name": "FormSupported",
+      "isAbstract": true,
+      "extends": ["bpmn:StartEvent", "bpmn:UserTask"],
+      "properties": [
+        {
+          "name": "formHandlerClass",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "formKey",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "TemplateSupported",
+      "isAbstract": true,
+      "extends": ["bpmn:Process", "bpmn:FlowElement"],
+      "properties": [
+        {
+          "name": "modelerTemplate",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "modelerTemplateVersion",
+          "isAttr": true,
+          "type": "Integer"
+        }
+      ]
+    },
+    {
+      "name": "Initiator",
+      "isAbstract": true,
+      "extends": ["bpmn:StartEvent"],
+      "properties": [
+        {
+          "name": "initiator",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "ScriptTask",
+      "isAbstract": true,
+      "extends": ["bpmn:ScriptTask"],
+      "properties": [
+        {
+          "name": "resultVariable",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "resource",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "Process",
+      "isAbstract": true,
+      "extends": ["bpmn:Process"],
+      "properties": [
+        {
+          "name": "candidateStarterGroups",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateStarterUsers",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "versionTag",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "historyTimeToLive",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "isStartableInTasklist",
+          "isAttr": true,
+          "type": "Boolean",
+          "default": true
+        }
+      ]
+    },
+    {
+      "name": "EscalationEventDefinition",
+      "isAbstract": true,
+      "extends": ["bpmn:EscalationEventDefinition"],
+      "properties": [
+        {
+          "name": "escalationCodeVariable",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "FormalExpression",
+      "isAbstract": true,
+      "extends": ["bpmn:FormalExpression"],
+      "properties": [
+        {
+          "name": "resource",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "Assignable",
+      "extends": ["bpmn:UserTask"],
+      "properties": [
+        {
+          "name": "assignee",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateUsers",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateGroups",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "dueDate",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "followUpDate",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "priority",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateStrategy",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateParam",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "CallActivity",
+      "extends": ["bpmn:CallActivity"],
+      "properties": [
+        {
+          "name": "calledElementBinding",
+          "isAttr": true,
+          "type": "String",
+          "default": "latest"
+        },
+        {
+          "name": "calledElementVersion",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "calledElementVersionTag",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "calledElementTenantId",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "caseRef",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "caseBinding",
+          "isAttr": true,
+          "type": "String",
+          "default": "latest"
+        },
+        {
+          "name": "caseVersion",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "caseTenantId",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "variableMappingClass",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "variableMappingDelegateExpression",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "ServiceTaskLike",
+      "extends": [
+        "bpmn:ServiceTask",
+        "bpmn:BusinessRuleTask",
+        "bpmn:SendTask",
+        "bpmn:MessageEventDefinition"
+      ],
+      "properties": [
+        {
+          "name": "expression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "class",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "delegateExpression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "resultVariable",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "DmnCapable",
+      "extends": ["bpmn:BusinessRuleTask"],
+      "properties": [
+        {
+          "name": "decisionRef",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "decisionRefBinding",
+          "isAttr": true,
+          "type": "String",
+          "default": "latest"
+        },
+        {
+          "name": "decisionRefVersion",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "mapDecisionResult",
+          "isAttr": true,
+          "type": "String",
+          "default": "resultList"
+        },
+        {
+          "name": "decisionRefTenantId",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "ExternalCapable",
+      "extends": ["camunda:ServiceTaskLike"],
+      "properties": [
+        {
+          "name": "type",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "topic",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "TaskPriorized",
+      "extends": ["bpmn:Process", "camunda:ExternalCapable"],
+      "properties": [
+        {
+          "name": "taskPriority",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "Properties",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["*"]
+      },
+      "properties": [
+        {
+          "name": "values",
+          "type": "Property",
+          "isMany": true
+        }
+      ]
+    },
+    {
+      "name": "Property",
+      "superClass": ["Element"],
+      "properties": [
+        {
+          "name": "id",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "name",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "value",
+          "type": "String",
+          "isAttr": true
+        }
+      ]
+    },
+    {
+      "name": "Connector",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["camunda:ServiceTaskLike"]
+      },
+      "properties": [
+        {
+          "name": "inputOutput",
+          "type": "InputOutput"
+        },
+        {
+          "name": "connectorId",
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "InputOutput",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:FlowNode", "camunda:Connector"]
+      },
+      "properties": [
+        {
+          "name": "inputOutput",
+          "type": "InputOutput"
+        },
+        {
+          "name": "connectorId",
+          "type": "String"
+        },
+        {
+          "name": "inputParameters",
+          "isMany": true,
+          "type": "InputParameter"
+        },
+        {
+          "name": "outputParameters",
+          "isMany": true,
+          "type": "OutputParameter"
+        }
+      ]
+    },
+    {
+      "name": "InputOutputParameter",
+      "properties": [
+        {
+          "name": "name",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "value",
+          "isBody": true,
+          "type": "String"
+        },
+        {
+          "name": "definition",
+          "type": "InputOutputParameterDefinition"
+        }
+      ]
+    },
+    {
+      "name": "InputOutputParameterDefinition",
+      "isAbstract": true
+    },
+    {
+      "name": "List",
+      "superClass": ["InputOutputParameterDefinition"],
+      "properties": [
+        {
+          "name": "items",
+          "isMany": true,
+          "type": "InputOutputParameterDefinition"
+        }
+      ]
+    },
+    {
+      "name": "Map",
+      "superClass": ["InputOutputParameterDefinition"],
+      "properties": [
+        {
+          "name": "entries",
+          "isMany": true,
+          "type": "Entry"
+        }
+      ]
+    },
+    {
+      "name": "Entry",
+      "properties": [
+        {
+          "name": "key",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "value",
+          "isBody": true,
+          "type": "String"
+        },
+        {
+          "name": "definition",
+          "type": "InputOutputParameterDefinition"
+        }
+      ]
+    },
+    {
+      "name": "Value",
+      "superClass": ["InputOutputParameterDefinition"],
+      "properties": [
+        {
+          "name": "id",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "name",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "value",
+          "isBody": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "Script",
+      "superClass": ["InputOutputParameterDefinition"],
+      "properties": [
+        {
+          "name": "scriptFormat",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "resource",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "value",
+          "isBody": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "Field",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": [
+          "camunda:ServiceTaskLike",
+          "camunda:ExecutionListener",
+          "camunda:TaskListener"
+        ]
+      },
+      "properties": [
+        {
+          "name": "name",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "expression",
+          "type": "String"
+        },
+        {
+          "name": "stringValue",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "string",
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "InputParameter",
+      "superClass": ["InputOutputParameter"]
+    },
+    {
+      "name": "OutputParameter",
+      "superClass": ["InputOutputParameter"]
+    },
+    {
+      "name": "Collectable",
+      "isAbstract": true,
+      "extends": ["bpmn:MultiInstanceLoopCharacteristics"],
+      "superClass": ["camunda:AsyncCapable"],
+      "properties": [
+        {
+          "name": "collection",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "elementVariable",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "FailedJobRetryTimeCycle",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["camunda:AsyncCapable", "bpmn:MultiInstanceLoopCharacteristics"]
+      },
+      "properties": [
+        {
+          "name": "body",
+          "isBody": true,
+          "type": "String"
+        }
+      ]
+    },
+    {
+      "name": "ExecutionListener",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": [
+          "bpmn:Task",
+          "bpmn:ServiceTask",
+          "bpmn:UserTask",
+          "bpmn:BusinessRuleTask",
+          "bpmn:ScriptTask",
+          "bpmn:ReceiveTask",
+          "bpmn:ManualTask",
+          "bpmn:ExclusiveGateway",
+          "bpmn:SequenceFlow",
+          "bpmn:ParallelGateway",
+          "bpmn:InclusiveGateway",
+          "bpmn:EventBasedGateway",
+          "bpmn:StartEvent",
+          "bpmn:IntermediateCatchEvent",
+          "bpmn:IntermediateThrowEvent",
+          "bpmn:EndEvent",
+          "bpmn:BoundaryEvent",
+          "bpmn:CallActivity",
+          "bpmn:SubProcess",
+          "bpmn:Process"
+        ]
+      },
+      "properties": [
+        {
+          "name": "expression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "class",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "delegateExpression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "event",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "script",
+          "type": "Script"
+        },
+        {
+          "name": "fields",
+          "type": "Field",
+          "isMany": true
+        }
+      ]
+    },
+    {
+      "name": "TaskListener",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "expression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "class",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "delegateExpression",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "event",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "script",
+          "type": "Script"
+        },
+        {
+          "name": "fields",
+          "type": "Field",
+          "isMany": true
+        },
+        {
+          "name": "id",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "eventDefinitions",
+          "type": "bpmn:TimerEventDefinition",
+          "isMany": true
+        }
+      ]
+    },
+    {
+      "name": "FormProperty",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "id",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "name",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "type",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "required",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "readable",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "writable",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "variable",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "expression",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "datePattern",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "default",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "values",
+          "type": "Value",
+          "isMany": true
+        }
+      ]
+    },
+    {
+      "name": "FormData",
+      "superClass": ["Element"],
+      "meta": {
+        "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"]
+      },
+      "properties": [
+        {
+          "name": "fields",
+          "type": "FormField",
+          "isMany": true
+        },
+        {
+          "name": "businessKey",
+          "type": "String",
+          "isAttr": true
+        }
+      ]
+    },
+    {
+      "name": "FormField",
+      "superClass": ["Element"],
+      "properties": [
+        {
+          "name": "id",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "label",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "type",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "datePattern",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "defaultValue",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "properties",
+          "type": "Properties"
+        },
+        {
+          "name": "validation",
+          "type": "Validation"
+        },
+        {
+          "name": "values",
+          "type": "Value",
+          "isMany": true
+        }
+      ]
+    },
+    {
+      "name": "Validation",
+      "superClass": ["Element"],
+      "properties": [
+        {
+          "name": "constraints",
+          "type": "Constraint",
+          "isMany": true
+        }
+      ]
+    },
+    {
+      "name": "Constraint",
+      "superClass": ["Element"],
+      "properties": [
+        {
+          "name": "name",
+          "type": "String",
+          "isAttr": true
+        },
+        {
+          "name": "config",
+          "type": "String",
+          "isAttr": true
+        }
+      ]
+    },
+    {
+      "name": "ConditionalEventDefinition",
+      "isAbstract": true,
+      "extends": ["bpmn:ConditionalEventDefinition"],
+      "properties": [
+        {
+          "name": "variableName",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "variableEvents",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    }
+  ],
+  "emumerations": []
+}

+ 130 - 0
src/layout/components/Breadcrumb/src/Breadcrumb.vue

@@ -0,0 +1,130 @@
+<script lang="tsx">
+import { ElBreadcrumb, ElBreadcrumbItem } from 'element-plus'
+import { ref, watch, computed, unref, defineComponent, TransitionGroup } from 'vue'
+import { useRouter } from 'vue-router'
+import { usePermissionStore } from '@/store/modules/permission'
+import { filterBreadcrumb } from './helper'
+import { filter, treeToList } from '@/utils/tree'
+import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router'
+
+import { Icon } from '@/components/Icon'
+import { useAppStore } from '@/store/modules/app'
+import { useDesign } from '@/hooks/web/useDesign'
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('breadcrumb')
+
+const appStore = useAppStore()
+
+// 面包屑图标
+const breadcrumbIcon = computed(() => appStore.getBreadcrumbIcon)
+
+export default defineComponent({
+  name: 'Breadcrumb',
+  setup() {
+    const { currentRoute } = useRouter()
+
+    const { t } = useI18n()
+
+    const levelList = ref<AppRouteRecordRaw[]>([])
+
+    const permissionStore = usePermissionStore()
+
+    const menuRouters = computed(() => {
+      const routers = permissionStore.getRouters
+      return filterBreadcrumb(routers)
+    })
+
+    const getBreadcrumb = () => {
+      const currentPath = currentRoute.value.matched.slice(-1)[0].path
+
+      levelList.value = filter<AppRouteRecordRaw>(unref(menuRouters), (node: AppRouteRecordRaw) => {
+        return node.path === currentPath
+      })
+    }
+
+    const renderBreadcrumb = () => {
+      const breadcrumbList = treeToList<AppRouteRecordRaw[]>(unref(levelList))
+      return breadcrumbList.map((v) => {
+        const disabled = !v.redirect || v.redirect === 'noredirect'
+        const meta = v.meta as RouteMeta
+        return (
+          <ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}>
+            {meta?.icon && breadcrumbIcon.value ? (
+              <div class="flex items-center">
+                <Icon icon={meta.icon} class="mr-[2px]" svgClass="inline-block"></Icon>
+                {t(v?.meta?.title)}
+              </div>
+            ) : (
+              t(v?.meta?.title)
+            )}
+          </ElBreadcrumbItem>
+        )
+      })
+    }
+
+    watch(
+      () => currentRoute.value,
+      (route: RouteLocationNormalizedLoaded) => {
+        if (route.path.startsWith('/redirect/')) {
+          return
+        }
+        getBreadcrumb()
+      },
+      {
+        immediate: true
+      }
+    )
+
+    return () => (
+      <ElBreadcrumb separator="/" class={`${prefixCls} flex items-center h-full ml-[10px]`}>
+        <TransitionGroup appear enter-active-class="animate__animated animate__fadeInRight">
+          {renderBreadcrumb()}
+        </TransitionGroup>
+      </ElBreadcrumb>
+    )
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$elNamespace}-breadcrumb;
+
+.#{$prefix-cls} {
+  :deep(&__item) {
+    display: flex;
+    .#{$prefix-cls}__inner {
+      display: flex;
+      align-items: center;
+      color: var(--top-header-text-color);
+
+      &:hover {
+        color: var(--el-color-primary);
+      }
+    }
+  }
+
+  :deep(&__item):not(:last-child) {
+    .#{$prefix-cls}__inner {
+      color: var(--top-header-text-color);
+
+      &:hover {
+        color: var(--el-color-primary);
+      }
+    }
+  }
+
+  :deep(&__item):last-child {
+    .#{$prefix-cls}__inner {
+      display: flex;
+      align-items: center;
+      color: var(--el-text-color-placeholder);
+
+      &:hover {
+        color: var(--el-text-color-placeholder);
+      }
+    }
+  }
+}
+</style>

+ 96 - 0
src/views/Profile/components/BasicInfo.vue

@@ -0,0 +1,96 @@
+<template>
+  <Form ref="formRef" :labelWidth="200" :rules="rules" :schema="schema">
+    <template #sex="form">
+      <el-radio-group v-model="form['sex']">
+        <el-radio :label="1">{{ t('profile.user.man') }}</el-radio>
+        <el-radio :label="2">{{ t('profile.user.woman') }}</el-radio>
+      </el-radio-group>
+    </template>
+  </Form>
+  <div style="text-align: center">
+    <XButton :title="t('common.save')" type="primary" @click="submit()" />
+    <XButton :title="t('common.reset')" type="danger" @click="init()" />
+  </div>
+</template>
+<script lang="ts" setup>
+import type { FormRules } from 'element-plus'
+import { FormSchema } from '@/types/form'
+import type { FormExpose } from '@/components/Form'
+import {
+  getUserProfile,
+  updateUserProfile,
+  UserProfileUpdateReqVO
+} from '@/api/system/user/profile'
+import { useUserStore } from '@/store/modules/user'
+
+defineOptions({ name: 'BasicInfo' })
+
+const { t } = useI18n()
+const message = useMessage() // 消息弹窗
+const userStore = useUserStore() 
+// 表单校验
+const rules = reactive<FormRules>({
+  nickname: [{ required: true, message: t('profile.rules.nickname'), trigger: 'blur' }],
+  email: [
+    { required: true, message: t('profile.rules.mail'), trigger: 'blur' },
+    {
+      type: 'email',
+      message: t('profile.rules.truemail'),
+      trigger: ['blur', 'change']
+    }
+  ],
+  mobile: [
+    { required: true, message: t('profile.rules.phone'), trigger: 'blur' },
+    {
+      pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
+      message: t('profile.rules.truephone'),
+      trigger: 'blur'
+    }
+  ]
+})
+const schema = reactive<FormSchema[]>([
+  {
+    field: 'nickname',
+    label: t('profile.user.nickname'),
+    component: 'Input'
+  },
+  {
+    field: 'mobile',
+    label: t('profile.user.mobile'),
+    component: 'Input'
+  },
+  {
+    field: 'email',
+    label: t('profile.user.email'),
+    component: 'Input'
+  },
+  {
+    field: 'sex',
+    label: t('profile.user.sex'),
+    component: 'InputNumber',
+    value: 0
+  }
+])
+const formRef = ref<FormExpose>() // 表单 Ref
+const submit = () => {
+  const elForm = unref(formRef)?.getElFormRef()
+  if (!elForm) return
+  elForm.validate(async (valid) => {
+    if (valid) {
+      const data = unref(formRef)?.formModel as UserProfileUpdateReqVO
+      await updateUserProfile(data)
+      message.success(t('common.updateSuccess'))
+      const profile = await init()
+      userStore.setUserNicknameAction(profile.nickname)
+    }
+  })
+}
+const init = async () => {
+  const res = await getUserProfile()
+  unref(formRef)?.setValues(res)
+  return res
+}
+onMounted(async () => {
+  await init()
+})
+</script>

+ 163 - 0
src/views/ai/chat/manager/ChatConversationList.vue

@@ -0,0 +1,163 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="聊天编号" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入聊天编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="对话编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column label="对话标题" align="center" prop="title" width="180" fixed="left" />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="角色" align="center" prop="roleName" width="180" />
+      <el-table-column label="模型标识" align="center" prop="model" width="180" />
+      <el-table-column label="消息数" align="center" prop="messageCount" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="温度参数" align="center" prop="temperature" />
+      <el-table-column label="回复 Token 数" align="center" prop="maxTokens" width="120" />
+      <el-table-column label="上下文数量" align="center" prop="maxContexts" width="120" />
+      <el-table-column label="操作" align="center" width="180" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:chat-conversation:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import * as UserApi from '@/api/system/user'
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ChatConversationVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined,
+  title: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ChatConversationApi.getChatConversationPage(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 handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ChatConversationApi.deleteChatConversationByAdmin(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 124 - 0
src/views/bpm/category/CategoryForm.vue

@@ -0,0 +1,124 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="分类名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入分类名" />
+      </el-form-item>
+      <el-form-item label="分类标志" prop="code">
+        <el-input v-model="formData.code" placeholder="请输入分类标志" />
+      </el-form-item>
+      <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-form-item label="分类排序" prop="sort">
+        <el-input-number
+          v-model="formData.sort"
+          placeholder="请输入分类排序"
+          class="!w-1/1"
+          :precision="0"
+        />
+      </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 { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { CategoryApi, CategoryVO } from '@/api/bpm/category'
+
+/** BPM 流程分类 表单 */
+defineOptions({ name: 'CategoryForm' })
+
+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,
+  code: undefined,
+  status: undefined,
+  sort: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '分类名不能为空', trigger: 'blur' }],
+  code: [{ 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 CategoryApi.getCategory(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 CategoryVO
+    if (formType.value === 'create') {
+      await CategoryApi.createCategory(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CategoryApi.updateCategory(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    code: undefined,
+    status: undefined,
+    sort: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 287 - 0
src/views/crm/business/BusinessForm.vue

@@ -0,0 +1,287 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="1280">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="商机名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入商机名称" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <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-col :span="8">
+          <el-form-item label="客户名称" prop="customerId">
+            <el-select
+              :disabled="formData.customerDefault"
+              v-model="formData.customerId"
+              placeholder="请选择客户"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in customerList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="商机状态组" prop="statusTypeId">
+            <el-select
+              v-model="formData.statusTypeId"
+              placeholder="请选择商机状态组"
+              clearable
+              class="w-1/1"
+              :disabled="formType !== 'create'"
+            >
+              <el-option
+                v-for="item in statusTypeList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="预计成交日期" prop="dealTime">
+            <el-date-picker
+              v-model="formData.dealTime"
+              type="date"
+              value-format="x"
+              placeholder="选择预计成交日期"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="备注" prop="remark">
+            <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <!-- 子表的表单 -->
+      <ContentWrap>
+        <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px">
+          <el-tab-pane label="产品清单" name="product">
+            <BusinessProductForm
+              ref="productFormRef"
+              :products="formData.products"
+              :disabled="disabled"
+            />
+          </el-tab-pane>
+        </el-tabs>
+      </ContentWrap>
+      <el-row>
+        <el-col :span="8">
+          <el-form-item label="产品总金额" prop="totalProductPrice">
+            <el-input
+              disabled
+              v-model="formData.totalProductPrice"
+              :formatter="erpPriceTableColumnFormatter"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="整单折扣(%)" prop="discountPercent">
+            <el-input-number
+              v-model="formData.discountPercent"
+              placeholder="请输入整单折扣"
+              controls-position="right"
+              :min="0"
+              :precision="2"
+              class="!w-1/1"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="折扣后金额" prop="price">
+            <el-input
+              disabled
+              v-model="formData.totalPrice"
+              placeholder="请输入商机金额"
+              :formatter="erpPriceTableColumnFormatter"
+            />
+          </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 * as BusinessApi from '@/api/crm/business'
+import * as BusinessStatusApi from '@/api/crm/business/status'
+import * as CustomerApi from '@/api/crm/customer'
+import * as UserApi from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+import BusinessProductForm from './components/BusinessProductForm.vue'
+import { erpPriceMultiply, erpPriceTableColumnFormatter } from '@/utils'
+
+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,
+  customerId: undefined,
+  ownerUserId: undefined,
+  statusTypeId: undefined,
+  dealTime: undefined,
+  discountPercent: 0,
+  totalProductPrice: undefined,
+  totalPrice: undefined,
+  remark: undefined,
+  products: [],
+  contactId: undefined,
+  customerDefault: false
+})
+const formRules = reactive({
+  name: [{ required: true, message: '商机名称不能为空', trigger: 'blur' }],
+  customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }],
+  statusTypeId: [{ required: true, message: '商机状态组不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const statusTypeList = ref([]) // 商机状态类型列表
+const customerList = ref([]) // 客户列表的数据
+
+/** 子表的表单 */
+const subTabsName = ref('product')
+const productFormRef = ref()
+
+/** 计算 discountPrice、totalPrice 价格 */
+watch(
+  () => formData.value,
+  (val) => {
+    if (!val) {
+      return
+    }
+    const totalProductPrice = val.products.reduce((prev, curr) => prev + curr.totalPrice, 0)
+    const discountPrice =
+      val.discountPercent != null
+        ? erpPriceMultiply(totalProductPrice, val.discountPercent / 100.0)
+        : 0
+    const totalPrice = totalProductPrice - discountPrice
+    // 赋值
+    formData.value.totalProductPrice = totalProductPrice
+    formData.value.totalPrice = totalPrice
+  },
+  { deep: true }
+)
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number, customerId?: number, contactId?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await BusinessApi.getBusiness(id)
+    } finally {
+      formLoading.value = false
+    }
+  } else {
+    if (customerId) {
+      formData.value.customerId = customerId
+      formData.value.customerDefault = true // 默认客户的选择,不允许变
+    }
+    // 自动关联 contactId 联系人编号
+    if (contactId) {
+      formData.value.contactId = contactId
+    }
+  }
+  // 获得客户列表
+  customerList.value = await CustomerApi.getCustomerSimpleList()
+  // 加载商机状态类型列表
+  statusTypeList.value = await BusinessStatusApi.getBusinessStatusTypeSimpleList()
+  // 获得用户列表
+  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
+  await productFormRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as BusinessApi.BusinessVO
+    if (formType.value === 'create') {
+      await BusinessApi.createBusiness(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await BusinessApi.updateBusiness(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    customerId: undefined,
+    ownerUserId: undefined,
+    statusTypeId: undefined,
+    dealTime: undefined,
+    discountPercent: 0,
+    totalProductPrice: undefined,
+    totalPrice: undefined,
+    remark: undefined,
+    products: [],
+    contactId: undefined,
+    customerDefault: false
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 108 - 0
src/views/crm/business/BusinessUpdateStatusForm.vue

@@ -0,0 +1,108 @@
+<template>
+  <Dialog title="变更商机状态" v-model="dialogVisible" width="400">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="商机阶段" prop="status">
+        <el-select v-model="formData.status" placeholder="请选择商机阶段" class="w-1/1">
+          <el-option
+            v-for="item in statusList"
+            :key="item.id"
+            :label="item.name + '(赢单率:' + item.percent + '%)'"
+            :value="item.id"
+          />
+          <el-option
+            v-for="item in BusinessStatusApi.DEFAULT_STATUSES"
+            :key="item.endStatus"
+            :label="item.name + '(赢单率:' + item.percent + '%)'"
+            :value="-item.endStatus"
+          />
+        </el-select>
+      </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 BusinessApi from '@/api/crm/business'
+import * as BusinessStatusApi from '@/api/crm/business/status'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const formData = ref({
+  id: undefined,
+  statusId: undefined,
+  endStatus: undefined,
+  status: undefined
+})
+const formRules = reactive({
+  status: [{ required: true, message: '商机阶段不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const statusList = ref([]) // 商机状态列表
+
+/** 打开弹窗 */
+const open = async (business: BusinessApi.BusinessVO) => {
+  dialogVisible.value = true
+  resetForm()
+  formData.value = {
+    id: business.id,
+    statusId: business.statusId,
+    endStatus: business.endStatus,
+    status: business.endStatus != null ? -business.endStatus : business.statusId
+  }
+  // 加载状态列表
+  formLoading.value = true
+  try {
+    statusList.value = await BusinessStatusApi.getBusinessStatusSimpleList(business.statusTypeId)
+  } 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 {
+    await BusinessApi.updateBusinessStatus({
+      id: formData.value.id,
+      statusId: formData.value.status > 0 ? formData.value.status : undefined,
+      endStatus: formData.value.status < 0 ? -formData.value.status : undefined
+    })
+    message.success('更新商机状态成功')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    statusId: undefined,
+    endStatus: undefined,
+    status: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 186 - 0
src/views/crm/business/components/BusinessList.vue

@@ -0,0 +1,186 @@
+<template>
+  <!-- 操作栏 -->
+  <el-row justify="end">
+    <el-button @click="openForm">
+      <Icon class="mr-5px" icon="ep:opportunity" />
+      创建商机
+    </el-button>
+    <el-button
+      @click="openBusinessModal"
+      v-hasPermi="['crm:contact:create-business']"
+      v-if="queryParams.contactId"
+    >
+      <Icon class="mr-5px" icon="ep:circle-plus" />关联
+    </el-button>
+    <el-button
+      @click="deleteContactBusinessList"
+      v-hasPermi="['crm:contact:delete-business']"
+      v-if="queryParams.contactId"
+    >
+      <Icon class="mr-5px" icon="ep:remove" />解除关联
+    </el-button>
+  </el-row>
+
+  <!-- 列表 -->
+  <ContentWrap class="mt-10px">
+    <el-table
+      ref="businessRef"
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+    >
+      <el-table-column type="selection" width="55" v-if="queryParams.contactId" />
+      <el-table-column label="商机名称" fixed="left" align="center" prop="name">
+        <template #default="scope">
+          <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="商机金额"
+        align="center"
+        prop="price"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column label="客户名称" align="center" prop="customerName" />
+      <el-table-column label="商机组" align="center" prop="statusTypeName" />
+      <el-table-column label="商机阶段" align="center" prop="statusName" />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加 -->
+  <BusinessForm ref="formRef" @success="getList" />
+  <!-- 关联商机选择弹框 -->
+  <BusinessListModal
+    ref="businessModalRef"
+    :customer-id="props.customerId"
+    @success="createContactBusinessList"
+  />
+</template>
+<script setup lang="ts">
+import * as BusinessApi from '@/api/crm/business'
+import * as ContactApi from '@/api/crm/contact'
+import BusinessForm from './../BusinessForm.vue'
+import { BizTypeEnum } from '@/api/crm/permission'
+import BusinessListModal from './BusinessListModal.vue'
+import { erpPriceTableColumnFormatter } from '@/utils'
+
+const message = useMessage() // 消息
+
+defineOptions({ name: 'CrmBusinessList' })
+const props = defineProps<{
+  bizType: number // 业务类型
+  bizId: number // 业务编号
+  customerId?: number // 关联联系人与商机时,需要传入 customerId 进行筛选
+  contactId?: number // 特殊:联系人编号;在【联系人】详情中,可以传递联系人编号,默认新建的商机关联到该联系人
+}>()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  customerId: undefined as unknown, // 允许 undefined + number
+  contactId: undefined as unknown // 允许 undefined + number
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // 置空参数
+    queryParams.customerId = undefined
+    queryParams.contactId = undefined
+    // 执行查询
+    let data = { list: [], total: 0 }
+    switch (props.bizType) {
+      case BizTypeEnum.CRM_CUSTOMER:
+        queryParams.customerId = props.bizId
+        data = await BusinessApi.getBusinessPageByCustomer(queryParams)
+        break
+      case BizTypeEnum.CRM_CONTACT:
+        queryParams.contactId = props.bizId
+        data = await BusinessApi.getBusinessPageByContact(queryParams)
+        break
+      default:
+        return
+    }
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 添加操作 */
+const formRef = ref()
+const openForm = () => {
+  formRef.value.open('create', null, props.customerId, props.contactId)
+}
+
+/** 打开联系人详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmBusinessDetail', params: { id } })
+}
+
+/** 打开联系人与商机的关联弹窗 */
+const businessModalRef = ref()
+const openBusinessModal = () => {
+  businessModalRef.value.open()
+}
+const createContactBusinessList = async (businessIds: number[]) => {
+  const data = {
+    contactId: props.bizId,
+    businessIds: businessIds
+  } as ContactApi.ContactBusinessReqVO
+  businessRef.value.getSelectionRows().forEach((row: BusinessApi.BusinessVO) => {
+    data.businessIds.push(row.id)
+  })
+  await ContactApi.createContactBusinessList(data)
+  // 刷新列表
+  message.success('关联商机成功')
+  handleQuery()
+}
+
+/** 解除联系人与商机的关联 */
+const businessRef = ref()
+const deleteContactBusinessList = async () => {
+  const data = {
+    contactId: props.bizId,
+    businessIds: businessRef.value.getSelectionRows().map((row: BusinessApi.BusinessVO) => row.id)
+  } as ContactApi.ContactBusinessReqVO
+  if (data.businessIds.length === 0) {
+    return message.error('未选择商机')
+  }
+  await ContactApi.deleteContactBusinessList(data)
+  // 刷新列表
+  message.success('取关商机成功')
+  handleQuery()
+}
+
+/** 监听打开的 bizId + bizType,从而加载最新的列表 */
+watch(
+  () => [props.bizId, props.bizType],
+  () => {
+    handleQuery()
+  },
+  { immediate: true, deep: true }
+)
+</script>

+ 156 - 0
src/views/crm/business/components/BusinessListModal.vue

@@ -0,0 +1,156 @@
+<template>
+  <Dialog title="关联商机" v-model="dialogVisible">
+    <!-- 搜索工作栏 -->
+    <ContentWrap>
+      <el-form
+        class="-mb-15px"
+        :model="queryParams"
+        ref="queryFormRef"
+        :inline="true"
+        label-width="68px"
+      >
+        <el-form-item label="商机名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            placeholder="请输入商机名称"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+          <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+          <el-button type="primary" @click="openForm()" v-hasPermi="['crm:business:create']">
+            <Icon icon="ep:plus" class="mr-5px" /> 新增
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表 -->
+    <ContentWrap class="mt-10px">
+      <el-table
+        v-loading="loading"
+        ref="businessRef"
+        :data="list"
+        :stripe="true"
+        :show-overflow-tooltip="true"
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column label="商机名称" fixed="left" align="center" prop="name">
+          <template #default="scope">
+            <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)">
+              {{ scope.row.name }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="商机金额"
+          align="center"
+          prop="totalPrice"
+          :formatter="erpPriceTableColumnFormatter"
+        />
+        <el-table-column label="客户名称" align="center" prop="customerName" />
+        <el-table-column label="商机组" align="center" prop="statusTypeName" />
+        <el-table-column label="商机阶段" align="center" prop="statusName" />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+
+    <!-- 表单弹窗:添加 -->
+    <BusinessForm ref="formRef" @success="getList" />
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as BusinessApi from '@/api/crm/business'
+import BusinessForm from '../BusinessForm.vue'
+import { erpPriceTableColumnFormatter } from '@/utils'
+
+const message = useMessage() // 消息弹窗
+const props = defineProps<{
+  customerId: number
+}>()
+defineOptions({ name: 'BusinessListModal' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryFormRef = ref() // 搜索的表单
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  customerId: props.customerId
+})
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  queryParams.customerId = props.customerId // 解决 props.customerId 没更新到 queryParams 上的问题
+  await getList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await BusinessApi.getBusinessPageByCustomer(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 formRef = ref()
+const openForm = () => {
+  formRef.value.open('create')
+}
+
+/** 关联商机提交 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const businessRef = ref()
+const submitForm = async () => {
+  const businessIds = businessRef.value
+    .getSelectionRows()
+    .map((row: BusinessApi.BusinessVO) => row.id)
+  if (businessIds.length === 0) {
+    return message.error('未选择商机')
+  }
+  dialogVisible.value = false
+  emit('success', businessIds, businessRef.value.getSelectionRows())
+}
+
+/** 打开商机详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmBusinessDetail', params: { id } })
+}
+</script>

+ 183 - 0
src/views/crm/business/components/BusinessProductForm.vue

@@ -0,0 +1,183 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="formData"
+    :rules="formRules"
+    v-loading="formLoading"
+    label-width="0px"
+    :inline-message="true"
+    :disabled="disabled"
+  >
+    <el-table :data="formData" class="-mt-10px">
+      <el-table-column label="序号" type="index" align="center" width="60" />
+      <el-table-column label="产品名称" min-width="180">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!">
+            <el-select
+              v-model="row.productId"
+              clearable
+              filterable
+              @change="onChangeProduct($event, row)"
+              placeholder="请选择产品"
+            >
+              <el-option
+                v-for="item in productList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="条码" min-width="150">
+        <template #default="{ row }">
+          <el-form-item class="mb-0px!">
+            <el-input disabled v-model="row.productNo" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="单位" min-width="80">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" />
+        </template>
+      </el-table-column>
+      <el-table-column label="价格(元)" min-width="120">
+        <template #default="{ row }">
+          <el-form-item class="mb-0px!">
+            <el-input disabled v-model="row.productPrice" :formatter="erpPriceInputFormatter" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="售价(元)" fixed="right" min-width="140">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.businessPrice`" class="mb-0px!">
+            <el-input-number
+              v-model="row.businessPrice"
+              controls-position="right"
+              :min="0.001"
+              :precision="2"
+              class="!w-100%"
+            />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="数量" prop="count" fixed="right" min-width="120">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!">
+            <el-input-number
+              v-model="row.count"
+              controls-position="right"
+              :min="0.001"
+              :precision="3"
+              class="!w-100%"
+            />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="合计" prop="totalPrice" fixed="right" min-width="140">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!">
+            <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" />
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" width="60">
+        <template #default="{ $index }">
+          <el-button @click="handleDelete($index)" link>—</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-form>
+  <el-row justify="center" class="mt-3" v-if="!disabled">
+    <el-button @click="handleAdd" round>+ 添加产品</el-button>
+  </el-row>
+</template>
+<script setup lang="ts">
+import * as ProductApi from '@/api/crm/product'
+import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils'
+import { DICT_TYPE } from '@/utils/dict'
+
+const props = defineProps<{
+  products: undefined
+  disabled: false
+}>()
+const formLoading = ref(false) // 表单的加载中
+const formData = ref([])
+const formRules = reactive({
+  productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
+  businessPrice: [{ required: true, message: '合同价格不能为空', trigger: 'blur' }],
+  count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }]
+})
+const formRef = ref([]) // 表单 Ref
+const productList = ref<ProductApi.ProductVO[]>([]) // 产品列表
+
+/** 初始化设置产品项 */
+watch(
+  () => props.products,
+  async (val) => {
+    formData.value = val
+  },
+  { immediate: true }
+)
+
+/** 监听合同产品变化,计算合同产品总价 */
+watch(
+  () => formData.value,
+  (val) => {
+    if (!val || val.length === 0) {
+      return
+    }
+    // 循环处理
+    val.forEach((item) => {
+      if (item.businessPrice != null && item.count != null) {
+        item.totalPrice = erpPriceMultiply(item.businessPrice, item.count)
+      } else {
+        item.totalPrice = undefined
+      }
+    })
+  },
+  { deep: true }
+)
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  const row = {
+    id: undefined,
+    productId: undefined,
+    productUnit: undefined, // 产品单位
+    productNo: undefined, // 产品条码
+    productPrice: undefined, // 产品价格
+    businessPrice: undefined,
+    count: 1
+  }
+  formData.value.push(row)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (index: number) => {
+  formData.value.splice(index, 1)
+}
+
+/** 处理产品变更 */
+const onChangeProduct = (productId, row) => {
+  const product = productList.value.find((item) => item.id === productId)
+  if (product) {
+    row.productUnit = product.unit
+    row.productNo = product.no
+    row.productPrice = product.price
+    row.businessPrice = product.price
+  }
+}
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+defineExpose({ validate })
+
+/** 初始化 */
+onMounted(async () => {
+  productList.value = await ProductApi.getProductSimpleList()
+})
+</script>

+ 37 - 0
src/views/crm/business/detail/BusinessDetailsHeader.vue

@@ -0,0 +1,37 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ business.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="客户名称">{{ business.customerName }}</el-descriptions-item>
+      <el-descriptions-item label="商机金额(元)">
+        {{ erpPriceInputFormatter(business.totalPrice) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="商机组">{{ business.statusTypeName }}</el-descriptions-item>
+      <el-descriptions-item label="负责人">{{ business.ownerUserName }}</el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(business.createTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as BusinessApi from '@/api/crm/business'
+import { formatDate } from '@/utils/formatTime'
+import { erpPriceInputFormatter } from '@/utils'
+
+const { business } = defineProps<{ business: BusinessApi.BusinessVO }>()
+</script>

+ 61 - 0
src/views/crm/business/detail/BusinessDetailsInfo.vue

@@ -0,0 +1,61 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-collapse-item name="basicInfo">
+        <template #title>
+          <span class="text-base font-bold">基本信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="商机姓名">{{ business.name }}</el-descriptions-item>
+          <el-descriptions-item label="客户名称">{{ business.customerName }}</el-descriptions-item>
+          <el-descriptions-item label="商机金额(元)">
+            {{ erpPriceInputFormatter(business.totalPrice) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="预计成交日期">
+            {{ formatDate(business.dealTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="下次联系时间">
+            {{ formatDate(business.contactNextTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="商机状态组">
+            {{ business.statusTypeName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="商机阶段">{{ business.statusName }}</el-descriptions-item>
+          <el-descriptions-item label="备注">{{ business.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="负责人">{{ business.ownerUserName }}</el-descriptions-item>
+          <el-descriptions-item label="最后跟进时间">
+            {{ formatDate(business.contactLastTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ business.creatorName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ formatDate(business.createTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="更新时间">
+            {{ formatDate(business.updateTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import * as BusinessApi from '@/api/crm/business'
+import { formatDate } from '@/utils/formatTime'
+import { erpPriceInputFormatter } from '@/utils'
+
+const { business } = defineProps<{
+  business: BusinessApi.BusinessVO
+}>()
+
+// 展示的折叠面板
+const activeNames = ref(['basicInfo', 'systemInfo'])
+</script>

+ 66 - 0
src/views/crm/business/detail/BusinessProductList.vue

@@ -0,0 +1,66 @@
+<template>
+  <ContentWrap>
+    <el-table :data="business.products" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column
+        align="center"
+        label="产品名称"
+        fixed="left"
+        prop="productName"
+        min-width="160"
+      >
+        <template #default="scope">
+          {{ scope.row.productName }}
+        </template>
+      </el-table-column>
+      <el-table-column label="产品条码" align="center" prop="productNo" min-width="120" />
+      <el-table-column align="center" label="产品单位" prop="productUnit" min-width="160">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="产品价格(元)"
+        align="center"
+        prop="productPrice"
+        min-width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="商机价格(元)"
+        align="center"
+        prop="businessPrice"
+        min-width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        align="center"
+        label="数量"
+        prop="count"
+        min-width="100px"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+      <el-table-column
+        label="合计金额(元)"
+        align="center"
+        prop="totalPrice"
+        min-width="140"
+        :formatter="erpPriceTableColumnFormatter"
+      />
+    </el-table>
+    <el-row class="mt-10px" justify="end">
+      <el-col :span="3"> 整单折扣:{{ erpPriceInputFormatter(business.discountPercent) }}% </el-col>
+      <el-col :span="4">
+        产品总金额:{{ erpPriceInputFormatter(business.totalProductPrice) }} 元
+      </el-col>
+    </el-row>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import * as BusinessApi from '@/api/crm/business'
+import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils'
+import { DICT_TYPE } from '@/utils/dict'
+
+const { business } = defineProps<{
+  business: BusinessApi.BusinessVO
+}>()
+</script>

+ 194 - 0
src/views/crm/business/status/BusinessStatusForm.vue

@@ -0,0 +1,194 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="状态组名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入状态组名" />
+      </el-form-item>
+      <el-form-item label="应用部门" prop="deptIds">
+        <template #label>
+          <Tooltip message="不选择部门时,默认全公司生效" title="应用部门" />
+        </template>
+        <el-tree
+          ref="treeRef"
+          :data="deptList"
+          :props="defaultProps"
+          :check-strictly="!checkStrictly"
+          node-key="id"
+          placeholder="请选择归属部门"
+          show-checkbox
+        />
+      </el-form-item>
+      <el-form-item label="阶段设置" prop="statuses">
+        <el-table
+          border
+          style="width: 100%"
+          :data="formData.statuses.concat(BusinessStatusApi.DEFAULT_STATUSES)"
+        >
+          <el-table-column align="center" label="阶段" width="70">
+            <template #default="scope">
+              <el-text v-if="!scope.row.defaultStatus">阶段 {{ scope.$index + 1 }}</el-text>
+              <el-text v-else>结束</el-text>
+            </template>
+          </el-table-column>
+          <el-table-column align="center" label="阶段名称" width="160" prop="name">
+            <template #default="{ row }">
+              <el-input v-if="!row.endStatus" v-model="row.name" placeholder="请输入状态名称" />
+              <el-text v-else>{{ row.name }}</el-text>
+            </template>
+          </el-table-column>
+          <el-table-column width="140" align="center" label="赢单率(%)" prop="percent">
+            <template #default="{ row }">
+              <el-input-number
+                v-if="!row.endStatus"
+                v-model="row.percent"
+                placeholder="请输入赢单率"
+                controls-position="right"
+                :min="0"
+                :max="100"
+                :precision="2"
+                class="!w-1/1"
+              />
+              <el-text v-else>{{ row.percent }}</el-text>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="110" align="center">
+            <template #default="scope">
+              <el-button
+                v-if="!scope.row.endStatus"
+                link
+                type="primary"
+                @click="addStatus(scope.$index)"
+              >
+                添加
+              </el-button>
+              <el-button
+                v-if="!scope.row.endStatus"
+                link
+                type="danger"
+                @click="deleteStatusArea(scope.$index)"
+                :disabled="formData.statuses.length <= 1"
+              >
+                删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </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 BusinessStatusApi from '@/api/crm/business/status'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+
+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: '',
+  deptIds: [],
+  statuses: []
+})
+const formRules = reactive({
+  name: [{ required: true, message: '状态组名不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const deptList = ref<Tree[]>([]) // 树形结构
+const treeRef = ref() // 菜单树组件 Ref
+const checkStrictly = ref(true) // 是否严格模式,即父子不关联
+
+/** 打开弹窗 */
+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 BusinessStatusApi.getBusinessStatus(id)
+      treeRef.value.setCheckedKeys(formData.value.deptIds)
+      if (formData.value.statuses.length == 0) {
+        addStatus()
+      }
+    } finally {
+      formLoading.value = false
+    }
+  } else {
+    addStatus()
+  }
+  // 加载部门树
+  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
+}
+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 BusinessStatusApi.BusinessStatusTypeVO
+    data.deptIds = treeRef.value.getCheckedKeys(false)
+    if (formType.value === 'create') {
+      await BusinessStatusApi.createBusinessStatus(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await BusinessStatusApi.updateBusinessStatus(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  checkStrictly.value = true
+  formData.value = {
+    id: undefined,
+    name: '',
+    deptIds: [],
+    statuses: []
+  }
+  treeRef.value?.setCheckedNodes([])
+  formRef.value?.resetFields()
+}
+
+/** 添加状态 */
+const addStatus = () => {
+  const data = formData.value
+  data.statuses.push({
+    name: '',
+    percent: undefined
+  })
+}
+
+/** 删除状态 */
+const deleteStatusArea = (index: number) => {
+  const data = formData.value
+  data.statuses.splice(index, 1)
+}
+</script>

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

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

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

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

+ 87 - 0
src/views/infra/codegen/components/BasicInfoForm.vue

@@ -0,0 +1,87 @@
+<template>
+  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
+    <el-row>
+      <el-col :span="12">
+        <el-form-item label="表名称" prop="tableName">
+          <el-input v-model="formData.tableName" placeholder="请输入仓库名称" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="表描述" prop="tableComment">
+          <el-input v-model="formData.tableComment" placeholder="请输入" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item prop="className">
+          <template #label>
+            <span>
+              实体类名称
+              <el-tooltip
+                content="默认去除表名的前缀。如果存在重复,则需要手动添加前缀,避免 MyBatis 报 Alias 重复的问题。"
+                placement="top"
+              >
+                <Icon class="" icon="ep:question-filled" />
+              </el-tooltip>
+            </span>
+          </template>
+          <el-input v-model="formData.className" placeholder="请输入" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="作者" prop="author">
+          <el-input v-model="formData.author" placeholder="请输入" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24">
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="formData.remark" :rows="3" type="textarea" />
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+<script lang="ts" setup>
+import * as CodegenApi from '@/api/infra/codegen'
+import { PropType } from 'vue'
+
+defineOptions({ name: 'InfraCodegenBasicInfoForm' })
+
+const props = defineProps({
+  table: {
+    type: Object as PropType<Nullable<CodegenApi.CodegenTableVO>>,
+    default: () => null
+  }
+})
+
+const formRef = ref()
+const formData = ref({
+  tableName: '',
+  tableComment: '',
+  className: '',
+  author: '',
+  remark: ''
+})
+const rules = reactive({
+  tableName: [required],
+  tableComment: [required],
+  className: [required],
+  author: [required]
+})
+
+/** 监听 table 属性,复制给 formData 属性 */
+watch(
+  () => props.table,
+  (table) => {
+    if (!table) return
+    formData.value = table
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+defineExpose({
+  validate: async () => unref(formRef)?.validate()
+})
+</script>

+ 123 - 0
src/views/mall/product/brand/BrandForm.vue

@@ -0,0 +1,123 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="品牌名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入品牌名称" />
+      </el-form-item>
+      <el-form-item label="品牌图片" prop="picUrl">
+        <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" />
+      </el-form-item>
+      <el-form-item label="品牌排序" prop="sort">
+        <el-input-number v-model="formData.sort" controls-position="right" :min="0" />
+      </el-form-item>
+      <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-form-item label="品牌描述">
+        <el-input v-model="formData.description" type="textarea" placeholder="请输入品牌描述" />
+      </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 lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as ProductBrandApi from '@/api/mall/product/brand'
+
+defineOptions({ name: 'ProductBrandForm' })
+
+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: '',
+  picUrl: '',
+  status: CommonStatusEnum.ENABLE,
+  description: ''
+})
+const formRules = reactive({
+  name: [{ required: true, message: '品牌名称不能为空', trigger: 'blur' }],
+  picUrl: [{ 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 ProductBrandApi.getBrand(id)
+    } 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 as ProductBrandApi.BrandVO
+    if (formType.value === 'create') {
+      await ProductBrandApi.createBrand(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductBrandApi.updateBrand(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    picUrl: '',
+    status: CommonStatusEnum.ENABLE,
+    description: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 135 - 0
src/views/mall/product/category/CategoryForm.vue

@@ -0,0 +1,135 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="上级分类" prop="parentId">
+        <el-select v-model="formData.parentId" placeholder="请选择上级分类">
+          <el-option :key="0" label="顶级分类" :value="0" />
+          <el-option
+            v-for="item in categoryList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="分类名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入分类名称" />
+      </el-form-item>
+      <el-form-item label="移动端分类图" prop="picUrl">
+        <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" />
+        <div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div>
+      </el-form-item>
+      <el-form-item label="分类排序" prop="sort">
+        <el-input-number v-model="formData.sort" controls-position="right" :min="0" />
+      </el-form-item>
+      <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-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as ProductCategoryApi from '@/api/mall/product/category'
+
+defineOptions({ name: 'ProductCategory' })
+
+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: '',
+  picUrl: '',
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = reactive({
+  parentId: [{ required: true, message: '请选择上级分类', trigger: 'blur' }],
+  name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
+  picUrl: [{ required: true, message: '分类图片不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const categoryList = ref<any[]>([]) // 分类树
+
+/** 打开弹窗 */
+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 ProductCategoryApi.getCategory(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得分类树
+  categoryList.value = await ProductCategoryApi.getCategoryList({ parentId: 0 })
+}
+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 ProductCategoryApi.CategoryVO
+    if (formType.value === 'create') {
+      await ProductCategoryApi.createCategory(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductCategoryApi.updateCategory(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    picUrl: '',
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 90 - 0
src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue

@@ -0,0 +1,90 @@
+<template>
+  <Dialog v-model="dialogVisible" title="助力列表">
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+        <el-table-column label="用户编号" prop="userId" min-width="80px" />
+        <el-table-column label="用户头像" prop="avatar" min-width="80px">
+          <template #default="scope">
+            <el-avatar :src="scope.row.avatar" />
+          </template>
+        </el-table-column>
+        <el-table-column label="用户昵称" prop="nickname" min-width="100px" />
+        <el-table-column
+          label="砍价金额"
+          prop="reducePrice"
+          min-width="100px"
+          :formatter="fenToYuanFormat"
+        />
+        <el-table-column
+          label="助力时间"
+          align="center"
+          prop="createTime"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </ContentWrap>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as BargainHelpApi from '@/api/mall/promotion/bargain/bargainHelp'
+import { fenToYuanFormat } from '@/utils/formatter'
+
+/** 助力列表 */
+defineOptions({ name: 'BargainRecordListDialog' })
+
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  recordId: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 打开弹窗 */
+const dialogVisible = ref(false) // 弹窗的是否展示
+const open = async (recordId: any) => {
+  dialogVisible.value = true
+  queryParams.recordId = recordId
+  resetQuery()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await BargainHelpApi.getBargainHelpPage(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()
+}
+</script>

BIN
src/views/mall/promotion/kefu/components/asserts/bizui.png


BIN
src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png


BIN
src/views/mall/promotion/kefu/components/asserts/bukesiyi.png


+ 152 - 0
src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue

@@ -0,0 +1,152 @@
+<template>
+  <Dialog v-model="dialogVisible" title="推广人列表" width="75%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        class="-mb-15px"
+        :model="queryParams"
+        ref="queryFormRef"
+        :inline="true"
+        label-width="85px"
+      >
+        <el-form-item label="用户类型" prop="level">
+          <el-radio-group v-model="queryParams.level" @change="handleQuery">
+            <el-radio-button checked>全部</el-radio-button>
+            <el-radio-button label="1">一级推广人</el-radio-button>
+            <el-radio-button label="2">二级推广人</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-select
+            v-model="queryParams.status"
+            placeholder="请选择状态"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_RECORD_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"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+          <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+        <el-table-column label="订单编号" align="center" prop="bizId" min-width="80px" />
+        <el-table-column label="用户编号" align="center" prop="sourceUserId" min-width="80px" />
+        <el-table-column label="头像" align="center" prop="sourceUserAvatar" width="70px">
+          <template #default="scope">
+            <el-avatar :src="scope.row.sourceUserAvatar" />
+          </template>
+        </el-table-column>
+        <el-table-column label="昵称" align="center" prop="sourceUserNickname" min-width="80px" />
+        <el-table-column
+          label="佣金"
+          align="center"
+          prop="price"
+          min-width="100px"
+          :formatter="fenToYuanFormat"
+        />
+        <el-table-column label="状态" align="center" prop="status" min-width="85">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.BROKERAGE_RECORD_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="创建时间"
+          align="center"
+          prop="createTime"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </ContentWrap>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as BrokerageRecordApi from '@/api/mall/trade/brokerage/record'
+import { BrokerageRecordBizTypeEnum } from '@/utils/constants'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+/** 推广订单列表 */
+defineOptions({ name: 'BrokerageOrderListDialog' })
+
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: null,
+  bizType: BrokerageRecordBizTypeEnum.ORDER.type,
+  level: '',
+  createTime: [],
+  status: null
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 打开弹窗 */
+const dialogVisible = ref(false) // 弹窗的是否展示
+const open = async (userId: any) => {
+  dialogVisible.value = true
+  queryParams.userId = userId
+  resetQuery()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await BrokerageRecordApi.getBrokerageRecordPage(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()
+}
+</script>

+ 137 - 0
src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue

@@ -0,0 +1,137 @@
+<template>
+  <Dialog v-model="dialogVisible" title="推广人列表" width="75%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        class="-mb-15px"
+        :model="queryParams"
+        ref="queryFormRef"
+        :inline="true"
+        label-width="85px"
+      >
+        <el-form-item label="用户类型" prop="level">
+          <el-radio-group v-model="queryParams.level" @change="handleQuery">
+            <el-radio-button checked>全部</el-radio-button>
+            <el-radio-button label="1">一级推广人</el-radio-button>
+            <el-radio-button label="2">二级推广人</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="绑定时间" prop="bindUserTime">
+          <el-date-picker
+            v-model="queryParams.bindUserTime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+          <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+        <el-table-column label="用户编号" align="center" prop="id" min-width="80px" />
+        <el-table-column label="头像" align="center" prop="avatar" width="70px">
+          <template #default="scope">
+            <el-avatar :src="scope.row.avatar" />
+          </template>
+        </el-table-column>
+        <el-table-column label="昵称" align="center" prop="nickname" min-width="80px" />
+        <el-table-column
+          label="推广人数"
+          align="center"
+          prop="brokerageUserCount"
+          min-width="80px"
+        />
+        <el-table-column
+          label="推广订单数量"
+          align="center"
+          prop="brokerageOrderCount"
+          min-width="110px"
+        />
+        <el-table-column label="推广资格" align="center" prop="brokerageEnabled" min-width="80px">
+          <template #default="scope">
+            <el-tag v-if="scope.row.brokerageEnabled">有</el-tag>
+            <el-tag v-else type="info">无</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="绑定时间"
+          align="center"
+          prop="bindUserTime"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </ContentWrap>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as BrokerageUserApi from '@/api/mall/trade/brokerage/user'
+
+/** 推广人列表 */
+defineOptions({ name: 'BrokerageUserListDialog' })
+
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  bindUserId: null,
+  level: '',
+  bindUserTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 打开弹窗 */
+const dialogVisible = ref(false) // 弹窗的是否展示
+const open = async (bindUserId: any) => {
+  dialogVisible.value = true
+  queryParams.bindUserId = bindUserId
+  resetQuery()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await BrokerageUserApi.getBrokerageUserPage(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()
+}
+</script>

+ 73 - 0
src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue

@@ -0,0 +1,73 @@
+<template>
+  <Dialog title="审核" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="驳回原因" prop="auditReason">
+        <el-input v-model="formData.auditReason" type="textarea" placeholder="请输入驳回原因" />
+      </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 BrokerageWithdrawApi from '@/api/mall/trade/brokerage/withdraw'
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined,
+  auditReason: undefined
+})
+const formRules = reactive({
+  auditReason: [{ required: true, message: '驳回原因不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  formData.value.id = 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 BrokerageWithdrawApi.BrokerageWithdrawVO
+    await BrokerageWithdrawApi.rejectBrokerageWithdraw(data)
+    message.success('驳回成功')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    auditReason: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 116 - 0
src/views/mp/components/wx-msg/card.scss

@@ -0,0 +1,116 @@
+.avue-card {
+  &__item {
+    margin-bottom: 16px;
+    border: 1px solid #e8e8e8;
+    background-color: #fff;
+    box-sizing: border-box;
+    color: rgba(0, 0, 0, 0.65);
+    font-size: 14px;
+    font-variant: tabular-nums;
+    line-height: 1.5;
+    list-style: none;
+    font-feature-settings: 'tnum';
+    cursor: pointer;
+    height: 200px;
+
+    &:hover {
+      border-color: rgba(0, 0, 0, 0.09);
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
+    }
+
+    &--add {
+      border: 1px dashed #000;
+      width: 100%;
+      color: rgba(0, 0, 0, 0.45);
+      background-color: #fff;
+      border-color: #d9d9d9;
+      border-radius: 2px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 16px;
+
+      i {
+        margin-right: 10px;
+      }
+
+      &:hover {
+        color: #40a9ff;
+        background-color: #fff;
+        border-color: #40a9ff;
+      }
+    }
+  }
+
+  &__body {
+    display: flex;
+    padding: 24px;
+  }
+
+  &__detail {
+    flex: 1;
+  }
+
+  &__avatar {
+    width: 48px;
+    height: 48px;
+    border-radius: 48px;
+    overflow: hidden;
+    margin-right: 12px;
+
+    img {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  &__title {
+    color: rgba(0, 0, 0, 0.85);
+    margin-bottom: 12px;
+    font-size: 16px;
+
+    &:hover {
+      color: #1890ff;
+    }
+  }
+
+  &__info {
+    color: rgba(0, 0, 0, 0.45);
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 3;
+    overflow: hidden;
+    height: 64px;
+  }
+
+  &__menu {
+    display: flex;
+    justify-content: space-around;
+    height: 50px;
+    background: #f7f9fa;
+    color: rgba(0, 0, 0, 0.45);
+    text-align: center;
+    line-height: 50px;
+
+    &:hover {
+      color: #1890ff;
+    }
+  }
+}
+
+/** joolun 额外加的 */
+.avue-comment__main {
+  flex: unset !important;
+  border-radius: 5px !important;
+  margin: 0 8px !important;
+}
+
+.avue-comment__header {
+  border-top-left-radius: 5px;
+  border-top-right-radius: 5px;
+}
+
+.avue-comment__body {
+  border-bottom-right-radius: 5px;
+  border-bottom-left-radius: 5px;
+}