Explorar el Código

新增客户跟进

puhui999 hace 1 año
padre
commit
d29dfef7c7

+ 5 - 7
src/api/crm/business/index.ts

@@ -1,10 +1,3 @@
-/*
- * @Author: zyna
- * @Date: 2023-12-02 13:08:56
- * @LastEditTime: 2023-12-17 16:28:20
- * @FilePath: \yudao-ui-admin-vue3\src\api\crm\business\index.ts
- * @Description: 
- */
 import request from '@/config/axios'
 
 export interface BusinessVO {
@@ -67,3 +60,8 @@ export const exportBusiness = async (params) => {
 export const getBusinessPageByContact = async (params) => {
   return await request.get({ url: `/crm/business/page-by-contact`, params })
 }
+
+// 获得 CRM 商机列表
+export const getBusinessListByIds = async (val: number[]) => {
+  return await request.get({ url: '/crm/business/list-by-ids', params: { ids: val } })
+}

+ 6 - 1
src/api/crm/contact/index.ts

@@ -71,6 +71,11 @@ export const getSimpleContactList = async () => {
   return await request.get({ url: `/crm/contact/simple-all-list` })
 }
 
+// 获得 CRM 联系人列表
+export const getContactListByIds = async (val: number[]) => {
+  return await request.get({ url: '/crm/contact/list-by-ids', params: { ids: val } })
+}
+
 // 批量新增联系人商机关联
 export const createContactBusinessList = async (data: ContactBusinessReqVO) => {
   return await request.post({ url: `/crm/contact/create-business-list`, data })
@@ -84,4 +89,4 @@ export const deleteContactBusinessList = async (data: ContactBusinessReqVO) => {
 // 查询联系人操作日志
 export const getOperateLogPage = async (params: any) => {
   return await request.get({ url: '/crm/contact/operate-log-page', params })
-}
+}

+ 54 - 0
src/api/crm/followup/index.ts

@@ -0,0 +1,54 @@
+import request from '@/config/axios'
+
+// 跟进记录 VO
+export interface FollowUpRecordVO {
+  // 编号
+  id: number
+  // 数据类型
+  bizType: number
+  // 数据编号
+  bizId: number
+  // 跟进类型
+  type: number
+  // 跟进内容
+  content: string
+  // 下次联系时间
+  nextTime: Date
+  // 关联的商机编号数组
+  businessIds: number[]
+  // 关联的联系人编号数组
+  contactIds: number[]
+}
+
+// 跟进记录 API
+export const FollowUpRecordApi = {
+  // 查询跟进记录分页
+  getFollowUpRecordPage: async (params: any) => {
+    return await request.get({ url: `/crm/follow-up-record/page`, params })
+  },
+
+  // 查询跟进记录详情
+  getFollowUpRecord: async (id: number) => {
+    return await request.get({ url: `/crm/follow-up-record/get?id=` + id })
+  },
+
+  // 新增跟进记录
+  createFollowUpRecord: async (data: FollowUpRecordVO) => {
+    return await request.post({ url: `/crm/follow-up-record/create`, data })
+  },
+
+  // 修改跟进记录
+  updateFollowUpRecord: async (data: FollowUpRecordVO) => {
+    return await request.put({ url: `/crm/follow-up-record/update`, data })
+  },
+
+  // 删除跟进记录
+  deleteFollowUpRecord: async (id: number) => {
+    return await request.delete({ url: `/crm/follow-up-record/delete?id=` + id })
+  },
+
+  // 导出跟进记录 Excel
+  exportFollowUpRecord: async (params) => {
+    return await request.download({ url: `/crm/follow-up-record/export-excel`, params })
+  }
+}

+ 2 - 2
src/components/OperateLogV2/src/OperateLogV2.vue

@@ -1,6 +1,6 @@
 <template>
   <!-- TODO @puhui999:左边不用有空隙哈 -->
-  <div class="p-20px">
+  <div class="pt-20px">
     <el-timeline>
       <el-timeline-item
         v-for="(log, index) in logList"
@@ -58,7 +58,7 @@ const getUserTypeColor = (type: number) => {
 <style lang="scss" scoped>
 // 时间线样式调整
 :deep(.el-timeline) {
-  margin: 10px 0 0 160px;
+  margin: 10px 0 0 110px;
 
   .el-timeline-item__wrapper {
     position: relative;

+ 4 - 3
src/utils/dict.ts

@@ -1,8 +1,8 @@
 /**
  * 数据字典工具类
  */
-import { useDictStoreWithOut } from '@/store/modules/dict'
-import { ElementPlusInfoType } from '@/types/elementPlus'
+import {useDictStoreWithOut} from '@/store/modules/dict'
+import {ElementPlusInfoType} from '@/types/elementPlus'
 
 const dictStore = useDictStoreWithOut()
 
@@ -205,5 +205,6 @@ export enum DICT_TYPE {
   CRM_CUSTOMER_SOURCE = 'crm_customer_source',
   CRM_PRODUCT_STATUS = 'crm_product_status',
   CRM_PERMISSION_LEVEL = 'crm_permission_level', // CRM 数据权限的级别
-  CRM_PRODUCT_UNIT = 'crm_product_unit' // 产品单位
+  CRM_PRODUCT_UNIT = 'crm_product_unit', // 产品单位
+  CRM_FOLLOW_UP_TYPE = 'crm_follow_up_type' // 跟进方式
 }

+ 12 - 11
src/views/crm/customer/detail/index.vue

@@ -1,7 +1,5 @@
 <template>
   <CustomerDetailsHeader :customer="customer" :loading="loading">
-    <!-- @puhui999:返回是不是可以去掉哈,貌似用途可能不大 -->
-    <el-button @click="close">返回</el-button>
     <!-- TODO puhui999: 按钮数据权限收尾统一完善,需要按权限分级和客户状态来动态显示匹配的按钮 -->
     <el-button v-hasPermi="['crm:customer:update']" type="primary" @click="openForm">
       编辑
@@ -12,8 +10,10 @@
     <el-button>更改成交状态</el-button>
     <el-button v-if="customer.lockStatus" @click="handleUnlock">解锁</el-button>
     <el-button v-if="!customer.lockStatus" @click="handleLock">锁定</el-button>
-    <el-button v-if="!customer.ownerUserId" type="primary" @click="receive">领取客户</el-button>
-    <el-button v-if="customer.ownerUserId" @click="putPool">客户放入公海</el-button>
+    <el-button v-if="!customer.ownerUserId" type="primary" @click="handleReceive">
+      领取客户
+    </el-button>
+    <el-button v-if="customer.ownerUserId" @click="handlePutPool">客户放入公海</el-button>
   </CustomerDetailsHeader>
   <el-col>
     <el-tabs>
@@ -23,6 +23,9 @@
       <el-tab-pane label="操作日志">
         <OperateLogV2 :log-list="logList" />
       </el-tab-pane>
+      <el-tab-pane label="跟进">
+        <FollowUpList :biz-id="customerId" :biz-type="BizTypeEnum.CRM_CUSTOMER" />
+      </el-tab-pane>
       <el-tab-pane label="联系人" lazy>
         <ContactList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" />
       </el-tab-pane>
@@ -58,6 +61,7 @@ import BusinessList from '@/views/crm/business/components/BusinessList.vue' // 
 import ReceivableList from '@/views/crm/receivable/components/ReceivableList.vue' // 回款列表
 import ReceivablePlanList from '@/views/crm/receivable/plan/components/ReceivablePlanList.vue' // 回款计划列表
 import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限)
+import FollowUpList from '@/views/crm/followup/index.vue'
 import { BizTypeEnum } from '@/api/crm/permission'
 import type { OperateLogV2VO } from '@/api/system/operatelog'
 
@@ -67,7 +71,7 @@ const customerId = ref(0) // 客户编号
 const loading = ref(true) // 加载中
 const message = useMessage() // 消息弹窗
 const { delView } = useTagsViewStore() // 视图操作
-const { currentRoute, push } = useRouter() // 路由
+const { currentRoute } = useRouter() // 路由
 
 /** 获取详情 */
 const customer = ref<CustomerApi.CustomerVO>({} as CustomerApi.CustomerVO) // 客户详情
@@ -106,9 +110,8 @@ const handleUnlock = async () => {
   await getCustomer()
 }
 
-// TODO @puhui999:下面两个方法的命名,也用 handleXXX 风格哈
 /** 领取客户 */
-const receive = async () => {
+const handleReceive = async () => {
   await message.confirm(`确定领取客户【${customer.value.name}】 吗?`)
   await CustomerApi.receive([unref(customerId.value)])
   message.success(`领取客户【${customer.value.name}】成功`)
@@ -116,7 +119,7 @@ const receive = async () => {
 }
 
 /** 客户放入公海 */
-const putPool = async () => {
+const handlePutPool = async () => {
   await message.confirm(`确定将客户【${customer.value.name}】放入公海吗?`)
   await CustomerApi.putPool(unref(customerId.value))
   message.success(`客户【${customer.value.name}】放入公海成功`)
@@ -135,15 +138,13 @@ const getOperateLog = async () => {
 
 const close = () => {
   delView(unref(currentRoute))
-  // TODO 先返回到客户列表
-  push({ name: 'CrmCustomer' })
 }
 
 /** 初始化 */
 const { params } = useRoute()
 onMounted(() => {
   if (!params.id) {
-    ElMessage.warning('参数错误,客户不能为空!')
+    message.warning('参数错误,客户不能为空!')
     close()
     return
   }

+ 10 - 28
src/views/crm/customer/index.vue

@@ -100,14 +100,10 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <!-- TODO @puhui999:是不是就 3 重呀,我负责的,我参与的,我下属的 -->
     <el-tabs v-model="activeName" @tab-click="handleClick">
-      <el-tab-pane label="客户列表" name="1" />
-      <el-tab-pane label="我负责的" name="2" />
-      <el-tab-pane label="我关注的" name="3" />
-      <el-tab-pane label="我参与的" name="4" />
-      <el-tab-pane label="下属负责的" name="5" />
-      <el-tab-pane label="客户公海" name="6" />
+      <el-tab-pane label="我负责的" name="1" />
+      <el-tab-pane label="我参与的" name="2" />
+      <el-tab-pane label="下属负责的" name="3" />
     </el-tabs>
     <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
       <el-table-column align="center" label="编号" prop="id" />
@@ -149,7 +145,9 @@
           <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" />
         </template>
       </el-table-column>
-      <!-- TODO @puhui999:距进入公海天数 -->
+      <el-table-column align="center" label="距离进入公海" prop="poolDay">
+        <template #default="scope"> {{ scope.row.poolDay }}天</template>
+      </el-table-column>
       <el-table-column
         :formatter="dateFormatter"
         align="center"
@@ -251,43 +249,27 @@ const activeName = ref('1') // 列表 tab
 
 enum CrmSceneTypeEnum {
   OWNER = 1,
-  FOLLOW = 2,
-  INVOLVED = 3,
-  SUBORDINATE = 4
+  INVOLVED = 2,
+  SUBORDINATE = 3
 }
 
 const handleClick = (tab: TabsPaneContext) => {
   switch (tab.paneName) {
     case '1':
-      resetQuery()
-      break
-    case '2':
       resetQuery(() => {
         queryParams.value.sceneType = CrmSceneTypeEnum.OWNER
       })
       break
-    case '3':
-      resetQuery(() => {
-        queryParams.value.sceneType = CrmSceneTypeEnum.FOLLOW
-      })
-      break
-    // TODO @puhui999:这个貌似报错?
-    case '4':
+    case '2':
       resetQuery(() => {
         queryParams.value.sceneType = CrmSceneTypeEnum.INVOLVED
       })
       break
-    case '5':
+    case '3':
       resetQuery(() => {
         queryParams.value.sceneType = CrmSceneTypeEnum.SUBORDINATE
       })
       break
-    // TODO @puhui999:公海单独一个菜单哈。
-    case '6':
-      resetQuery(() => {
-        queryParams.value.pool = true
-      })
-      break
   }
 }
 

+ 136 - 0
src/views/crm/followup/FollowUpRecordForm.vue

@@ -0,0 +1,136 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="50%">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="跟进类型" prop="type">
+            <el-select v-model="formData.type" placeholder="请选择跟进类型">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_FOLLOW_UP_TYPE)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="下次联系时间" prop="nextTime">
+            <el-date-picker
+              v-model="formData.nextTime"
+              placeholder="选择下次联系时间"
+              type="date"
+              value-format="x"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="跟进内容" prop="content">
+            <Editor v-model="formData.content" height="300px" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="关联联系人" prop="contactIds">
+            <el-button @click="submitForm">
+              <Icon class="mr-5px" icon="ep:plus" />
+              选择添加联系人
+            </el-button>
+            <contact-list v-model:contactIds="formData.contactIds" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="关联商机" prop="businessIds">
+            <el-button @click="submitForm">
+              <Icon class="mr-5px" icon="ep:plus" />
+              选择添加商机
+            </el-button>
+            <business-list v-model:businessIds="formData.businessIds" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { FollowUpRecordApi, FollowUpRecordVO } from '@/api/crm/followup'
+import { BusinessList, ContactList } from './components'
+
+/** 跟进记录 表单 */
+defineOptions({ name: 'FollowUpRecordForm' })
+
+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<FollowUpRecordVO>({} as FollowUpRecordVO)
+const formRules = reactive({
+  type: [{ required: true, message: '跟进类型不能为空', trigger: 'change' }],
+  content: [{ required: true, message: '跟进内容不能为空', trigger: 'blur' }],
+  nextTime: [{ required: true, message: '下次联系时间不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (bizType: number, bizId: number, type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  formData.value.bizType = bizType
+  formData.value.bizId = bizId
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await FollowUpRecordApi.getFollowUpRecord(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 FollowUpRecordVO
+    if (formType.value === 'create') {
+      await FollowUpRecordApi.createFollowUpRecord(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await FollowUpRecordApi.updateFollowUpRecord(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formRef.value?.resetFields()
+  formData.value = {} as FollowUpRecordVO
+}
+</script>

+ 71 - 0
src/views/crm/followup/components/BusinessList.vue

@@ -0,0 +1,71 @@
+<template>
+  <el-table :data="list" :show-overflow-tooltip="true" :stripe="true" height="200">
+    <el-table-column align="center" label="商机名称" prop="name" />
+    <el-table-column align="center" label="客户名称" prop="customerName" />
+    <el-table-column align="center" label="商机金额" prop="price" />
+    <el-table-column
+      :formatter="dateFormatter"
+      align="center"
+      label="预计成交日期"
+      prop="dealTime"
+      width="120px"
+    />
+    <el-table-column align="center" label="商机状态类型" prop="statusTypeName" width="120" />
+    <el-table-column align="center" label="商机状态" prop="statusName" />
+    <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="ownerUserName" width="120" />
+    <el-table-column align="center" label="创建人" prop="creatorName" width="120" />
+    <el-table-column align="center" label="备注" prop="remark" />
+    <el-table-column align="center" fixed="right" label="操作" width="130">
+      <template #default="scope">
+        <el-button link type="danger" @click="handleDelete(scope.row.id)"> 移除</el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import * as BusinessApi from '@/api/crm/business'
+
+defineOptions({ name: 'BusinessList' })
+const props = withDefaults(defineProps<{ businessIds: number[] }>(), {
+  businessIds: () => []
+})
+const list = ref<BusinessApi.BusinessVO[]>([] as BusinessApi.BusinessVO[])
+watch(
+  () => props.businessIds,
+  (val) => {
+    if (!val || val.length === 0) {
+      return
+    }
+    list.value = BusinessApi.getBusinessListByIds(val) as unknown as BusinessApi.BusinessVO[]
+  }
+)
+const emits = defineEmits<{
+  (e: 'update:businessIds', businessIds: number[]): void
+}>()
+const handleDelete = (id: number) => {
+  const index = list.value.findIndex((item) => item.id === id)
+  if (index !== -1) {
+    list.value.splice(index, 1)
+  }
+  emits(
+    'update:businessIds',
+    list.value.map((item) => item.id)
+  )
+}
+</script>

+ 79 - 0
src/views/crm/followup/components/BusinessListSelectForm.vue

@@ -0,0 +1,79 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="50%">
+    <el-row>
+      <el-col :span="12">
+        <el-form-item label="跟进类型" prop="type">
+          <el-select v-model="formData.type" placeholder="请选择跟进类型">
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.CRM_FOLLOW_UP_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="下次联系时间" prop="nextTime">
+          <el-date-picker
+            v-model="formData.nextTime"
+            placeholder="选择下次联系时间"
+            type="date"
+            value-format="x"
+          />
+        </el-form-item>
+      </el-col>
+    </el-row>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+/** 跟进记录 表单 */
+defineOptions({ name: 'BusinessListSelectForm' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = 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 FollowUpRecordApi.getFollowUpRecord(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formRef.value?.resetFields()
+}
+</script>

+ 92 - 0
src/views/crm/followup/components/ContactList.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-table :data="list" :show-overflow-tooltip="true" :stripe="true" height="200">
+    <el-table-column align="center" fixed="left" label="姓名" prop="name" width="140" />
+    <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120" />
+    <el-table-column align="center" label="手机" prop="mobile" width="120" />
+    <el-table-column align="center" label="电话" prop="telephone" width="120" />
+    <el-table-column align="center" label="邮箱" prop="email" width="120" />
+    <el-table-column align="center" label="职位" prop="post" width="120" />
+    <el-table-column align="center" label="地址" prop="detailAddress" width="120" />
+    <el-table-column
+      :formatter="dateFormatter"
+      align="center"
+      label="下次联系时间"
+      prop="contactNextTime"
+      width="180px"
+    />
+    <el-table-column align="center" label="关键决策人" prop="master" width="100">
+      <template #default="scope">
+        <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="直属上级" prop="parentName" width="140" />
+    <el-table-column
+      :formatter="dateFormatter"
+      align="center"
+      label="最后跟进时间"
+      prop="contactLastTime"
+      width="180px"
+    />
+    <el-table-column align="center" label="性别" prop="sex">
+      <template #default="scope">
+        <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" />
+    <el-table-column align="center" label="创建人" prop="creatorName" width="120" />
+    <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="remark" />
+    <el-table-column align="center" fixed="right" label="操作" width="130">
+      <template #default="scope">
+        <el-button link type="danger" @click="handleDelete(scope.row.id)"> 移除</el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+import * as ContactApi from '@/api/crm/contact'
+
+defineOptions({ name: 'ContactList' })
+const props = withDefaults(defineProps<{ contactIds: number[] }>(), {
+  contactIds: () => []
+})
+const list = ref<ContactApi.ContactVO[]>([] as ContactApi.ContactVO[])
+watch(
+  () => props.contactIds,
+  (val) => {
+    if (!val || val.length === 0) {
+      return
+    }
+    list.value = ContactApi.getContactListByIds(val) as unknown as ContactApi.ContactVO[]
+  }
+)
+const emits = defineEmits<{
+  (e: 'update:contactIds', contactIds: number[]): void
+}>()
+const handleDelete = (id: number) => {
+  const index = list.value.findIndex((item) => item.id === id)
+  if (index !== -1) {
+    list.value.splice(index, 1)
+  }
+  emits(
+    'update:contactIds',
+    list.value.map((item) => item.id)
+  )
+}
+</script>

+ 4 - 0
src/views/crm/followup/components/index.ts

@@ -0,0 +1,4 @@
+import BusinessList from './BusinessList.vue'
+import ContactList from './ContactList.vue'
+
+export { BusinessList, ContactList }

+ 135 - 0
src/views/crm/followup/index.vue

@@ -0,0 +1,135 @@
+<template>
+  <!-- 操作栏 -->
+  <el-row class="mb-10px" justify="end">
+    <el-button @click="openForm('create')">
+      <Icon class="mr-5px" icon="ep:edit" />
+      写跟进
+    </el-button>
+  </el-row>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="跟进人" prop="creatorName" />
+      <el-table-column align="center" label="跟进类型" prop="type">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_FOLLOW_UP_TYPE" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="跟进内容" prop="content" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="nextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="关联联系人" prop="contactIds" />
+      <el-table-column align="center" label="关联商机" prop="businessIds" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['crm:follow-up-record:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['crm:follow-up-record:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <FollowUpRecordForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import { DICT_TYPE } from '@/utils/dict'
+import { FollowUpRecordApi, FollowUpRecordVO } from '@/api/crm/followup'
+import FollowUpRecordForm from './FollowUpRecordForm.vue'
+
+/** 跟进记录 列表 */
+defineOptions({ name: 'FollowUpRecord' })
+const props = defineProps<{
+  bizType: number
+  bizId: number
+}>()
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<FollowUpRecordVO[]>([]) // 列表的数据
+// 列表的总页数
+const total = ref(0)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  bizType: 0,
+  bizId: 0
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await FollowUpRecordApi.getFollowUpRecordPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 添加/修改操作 */
+const formRef = ref<InstanceType<typeof FollowUpRecordForm>>()
+const openForm = (type: string, id?: number) => {
+  formRef.value?.open(props.bizType, props.bizId, type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await FollowUpRecordApi.deleteFollowUpRecord(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+watch(
+  () => props.bizId,
+  () => {
+    queryParams.bizType = props.bizType
+    queryParams.bizId = props.bizId
+    getList()
+  }
+)
+</script>