diff --git a/.env.local b/.env.local-dev similarity index 100% rename from .env.local rename to .env.local-dev diff --git a/package.json b/package.json index 58460358..8fa4e871 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "private": false, "scripts": { "i": "pnpm install", - "dev": "vite --mode local-dev", + "local-server": "vite --mode local-dev", "dev-server": "vite --mode dev", "ts:check": "vue-tsc --noEmit", - "build:local-dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev", - "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev", + "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev", + "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev", "build:test": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage", "build:prod": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode prod", diff --git a/src/api/crm/statistics/funnel.ts b/src/api/crm/statistics/funnel.ts new file mode 100644 index 00000000..0ba322b8 --- /dev/null +++ b/src/api/crm/statistics/funnel.ts @@ -0,0 +1,45 @@ +import request from '@/config/axios' + +export interface CrmStatisticFunnelRespVO { + customerCount: number // 客户数 + businessCount: number // 商机数 + winCount: number // 赢单数 +} + +export interface CrmStatisticsBusinessSummaryByDateRespVO { + time: string // 时间 + businessCreateCount: number // 商机数 + businessDealCount: number // 商机金额 +} + +// 客户分析 API +export const StatisticFunnelApi = { + // 1. 获取销售漏斗统计数据 + getFunnelSummary: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-funnel-summary', + params + }) + }, + // 2. 获取商机结束状态统计 + getBusinessEndStatusSummary: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-end-status-summary', + params + }) + }, + // 3. 获取新增商机分析(按日期) + getBusinessSummaryByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-summary-by-date', + params + }) + }, + // 4. 获取商机列表(按日期) + getBusinessPageByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-page-by-date', + params + }) + } +} diff --git a/src/components/DictSelect/src/DictSelect.vue b/src/components/DictSelect/src/DictSelect.vue index 54279cec..2d59e23c 100644 --- a/src/components/DictSelect/src/DictSelect.vue +++ b/src/components/DictSelect/src/DictSelect.vue @@ -33,7 +33,6 @@ import { getBoolDictOptions, getIntDictOptions, getStrDictOptions } from '@/util // 接受父组件参数 interface Props { - modelValue?: any // 值 dictType: string // 字典类型 valueType: string // 字典值类型 } diff --git a/src/utils/dict.ts b/src/utils/dict.ts index 6b06aa4c..631a40b0 100644 --- a/src/utils/dict.ts +++ b/src/utils/dict.ts @@ -197,14 +197,15 @@ export enum DICT_TYPE { // ========== CRM - 客户管理模块 ========== CRM_AUDIT_STATUS = 'crm_audit_status', // CRM 审批状态 CRM_BIZ_TYPE = 'crm_biz_type', // CRM 业务类型 + CRM_BUSINESS_END_STATUS_TYPE = 'crm_business_end_status_type', // CRM 商机结束状态类型 CRM_RECEIVABLE_RETURN_TYPE = 'crm_receivable_return_type', // CRM 回款的还款方式 - CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry', - CRM_CUSTOMER_LEVEL = 'crm_customer_level', - CRM_CUSTOMER_SOURCE = 'crm_customer_source', - CRM_PRODUCT_STATUS = 'crm_product_status', + CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry', // CRM 客户所属行业 + CRM_CUSTOMER_LEVEL = 'crm_customer_level', // CRM 客户级别 + CRM_CUSTOMER_SOURCE = 'crm_customer_source', // CRM 客户来源 + CRM_PRODUCT_STATUS = 'crm_product_status', // CRM 商品状态 CRM_PERMISSION_LEVEL = 'crm_permission_level', // CRM 数据权限的级别 - CRM_PRODUCT_UNIT = 'crm_product_unit', // 产品单位 - CRM_FOLLOW_UP_TYPE = 'crm_follow_up_type', // 跟进方式 + CRM_PRODUCT_UNIT = 'crm_product_unit', // CRM 产品单位 + CRM_FOLLOW_UP_TYPE = 'crm_follow_up_type', // CRM 跟进方式 // ========== ERP - 企业资源计划模块 ========== ERP_AUDIT_STATUS = 'erp_audit_status', // ERP 审批状态 diff --git a/src/views/crm/business/index.vue b/src/views/crm/business/index.vue index 793f187c..84e447c0 100644 --- a/src/views/crm/business/index.vue +++ b/src/views/crm/business/index.vue @@ -5,35 +5,43 @@ <ContentWrap> <!-- 搜索工作栏 --> <el-form - class="-mb-15px" - :model="queryParams" ref="queryFormRef" :inline="true" + :model="queryParams" + class="-mb-15px" label-width="68px" > <el-form-item label="商机名称" prop="name"> <el-input v-model="queryParams.name" - placeholder="请输入商机名称" - clearable - @keyup.enter="handleQuery" class="!w-240px" + clearable + placeholder="请输入商机名称" + @keyup.enter="handleQuery" /> </el-form-item> <el-form-item> - <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> - <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> - <el-button type="primary" @click="openForm('create')" v-hasPermi="['crm:business:create']"> - <Icon icon="ep:plus" class="mr-5px" /> 新增 + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['crm:business:create']" type="primary" @click="openForm('create')"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 </el-button> <el-button - type="success" - plain - @click="handleExport" - :loading="exportLoading" v-hasPermi="['crm:business:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" > - <Icon icon="ep:download" class="mr-5px" /> 导出 + <Icon class="mr-5px" icon="ep:download" /> + 导出 </el-button> </el-form-item> </el-form> @@ -46,8 +54,8 @@ <el-tab-pane label="我参与的" name="2" /> <el-tab-pane label="下属负责的" name="3" /> </el-tabs> - <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> - <el-table-column align="center" label="商机名称" fixed="left" prop="name" width="160"> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160"> <template #default="scope"> <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> {{ scope.row.name }} @@ -66,17 +74,17 @@ </template> </el-table-column> <el-table-column - label="商机金额(元)" + :formatter="erpPriceTableColumnFormatter" align="center" + label="商机金额(元)" prop="totalPrice" width="140" - :formatter="erpPriceTableColumnFormatter" /> <el-table-column - label="预计成交日期" - align="center" - prop="dealTime" :formatter="dateFormatter" + align="center" + label="预计成交日期" + prop="dealTime" width="180px" /> <el-table-column align="center" label="备注" prop="remark" width="200" /> @@ -97,49 +105,49 @@ width="180px" /> <el-table-column - label="更新时间" - align="center" - prop="updateTime" :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" width="180px" /> <el-table-column - label="创建时间" - align="center" - prop="createTime" :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" width="180px" /> <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> <el-table-column - label="商机状态组" align="center" - prop="statusTypeName" fixed="right" + label="商机状态组" + prop="statusTypeName" width="140" /> <el-table-column - label="商机阶段" align="center" - prop="statusName" fixed="right" + label="商机阶段" + prop="statusName" width="120" /> - <el-table-column label="操作" align="center" fixed="right" width="130px"> + <el-table-column align="center" fixed="right" label="操作" width="130px"> <template #default="scope"> <el-button + v-hasPermi="['crm:business:update']" link type="primary" @click="openForm('update', scope.row.id)" - v-hasPermi="['crm:business:update']" > 编辑 </el-button> <el-button + v-hasPermi="['crm:business:delete']" link type="danger" @click="handleDelete(scope.row.id)" - v-hasPermi="['crm:business:delete']" > 删除 </el-button> @@ -148,9 +156,9 @@ </el-table> <!-- 分页 --> <Pagination - :total="total" - v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" @pagination="getList" /> </ContentWrap> @@ -159,7 +167,7 @@ <BusinessForm ref="formRef" @success="getList" /> </template> -<script setup lang="ts"> +<script lang="ts" setup> import { dateFormatter } from '@/utils/formatTime' import download from '@/utils/download' import * as BusinessApi from '@/api/crm/business' @@ -216,7 +224,7 @@ const handleTabClick = (tab: TabsPaneContext) => { } /** 打开客户详情 */ -const { currentRoute, push } = useRouter() +const { push } = useRouter() const openDetail = (id: number) => { push({ name: 'CrmBusinessDetail', params: { id } }) } diff --git a/src/views/crm/statistics/funnel/components/BusinessSummary.vue b/src/views/crm/statistics/funnel/components/BusinessSummary.vue new file mode 100644 index 00000000..60dd8198 --- /dev/null +++ b/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) +/** 将传进来的值赋值给 formData */ +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.businessDealCount + ) + } + + // 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> diff --git a/src/views/crm/statistics/funnel/components/FunnelBusiness.vue b/src/views/crm/statistics/funnel/components/FunnelBusiness.vue new file mode 100644 index 00000000..7579cb64 --- /dev/null +++ b/src/views/crm/statistics/funnel/components/FunnelBusiness.vue @@ -0,0 +1,135 @@ +<!-- 销售漏斗分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-row> + <el-col :span="24"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-col> + </el-row> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="序号" type="index" width="80" /> + <el-table-column align="center" label="阶段" prop="endStatus" width="200"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE" :value="scope.row.endStatus" /> + </template> + </el-table-column> + <el-table-column align="center" label="商机数" min-width="200" prop="businessCount" /> + <el-table-column align="center" label="商机总金额(元)" min-width="200" prop="totalPrice" /> + </el-table> + </el-card> +</template> +<script lang="ts" setup> +import { CrmStatisticFunnelRespVO, StatisticFunnelApi } from '@/api/crm/statistics/funnel' +import { EChartsOption } from 'echarts' +import { DICT_TYPE } from '@/utils/dict' +import echarts from '@/plugins/echarts' +import { FunnelChart } from 'echarts/charts' + +echarts?.use([FunnelChart]) +defineOptions({ name: 'FunnelBusiness' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticFunnelRespVO[]>([]) // 列表的数据 + +/** 销售漏斗 */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '销售漏斗' + }, + tooltip: { + trigger: 'item', + formatter: '{a} <br/>{b}' + }, + toolbox: { + feature: { + dataView: { readOnly: false }, + restore: {}, + saveAsImage: {} + } + }, + legend: { + data: ['客户', '商机', '赢单'] + }, + series: [ + { + name: '销售漏斗', + type: 'funnel', + left: '10%', + top: 60, + bottom: 60, + width: '80%', + min: 0, + max: 100, + minSize: '0%', + maxSize: '100%', + sort: 'descending', + gap: 2, + label: { + show: true, + position: 'inside' + }, + labelLine: { + length: 10, + lineStyle: { + width: 1, + type: 'solid' + } + }, + itemStyle: { + borderColor: '#fff', + borderWidth: 1 + }, + emphasis: { + label: { + fontSize: 20 + } + }, + data: [ + { value: 60, name: '客户-0个' }, + { value: 40, name: '商机-0个' }, + { value: 20, name: '赢单-0个' } + ] + } + ] +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + // 1. 加载漏斗数据 + const data = (await StatisticFunnelApi.getFunnelSummary( + props.queryParams + )) as CrmStatisticFunnelRespVO + // 2.1 更新 Echarts 数据 + if ( + !!data && + echartsOption.series && + echartsOption.series[0] && + echartsOption.series[0]['data'] + ) { + // tips:写死 value 值是为了保持漏斗顺序不变 + const list: { value: number; name: string }[] = [] + list.push({ value: 60, name: `客户-${data.customerCount || 0}个` }) + list.push({ value: 40, name: `商机-${data.businessCount || 0}个` }) + list.push({ value: 20, name: `赢单-${data.winCount || 0}个` }) + echartsOption.series[0]['data'] = list + } + // 2.2 获取商机结束状态统计 + list.value = await StatisticFunnelApi.getBusinessEndStatusSummary(props.queryParams) + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/funnel/index.vue b/src/views/crm/statistics/funnel/index.vue new file mode 100644 index 00000000..b8cddf8d --- /dev/null +++ b/src/views/crm/statistics/funnel/index.vue @@ -0,0 +1,165 @@ +<!-- 数据统计 - 客户画像 --> +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="时间范围" prop="orderDate"> + <el-date-picker + v-model="queryParams.times" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + :shortcuts="defaultShortcuts" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + @change="handleQuery" + /> + </el-form-item> + <el-form-item label="时间间隔" prop="interval"> + <el-select + v-model="queryParams.interval" + class="!w-240px" + placeholder="间隔类型" + @change="handleQuery" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.DATE_INTERVAL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="queryParams.deptId" + :data="deptList" + :props="defaultProps" + check-strictly + class="!w-240px" + node-key="id" + placeholder="请选择归属部门" + @change="(queryParams.userId = undefined), handleQuery()" + /> + </el-form-item> + <el-form-item label="员工" prop="userId"> + <el-select + v-model="queryParams.userId" + class="!w-240px" + clearable + placeholder="员工" + @change="handleQuery" + > + <el-option + v-for="(user, index) in userListByDeptId" + :key="index" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 查询 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 客户统计 --> + <el-col> + <el-tabs v-model="activeTab"> + <el-tab-pane label="销售漏斗分析" lazy name="funnelRef"> + <FunnelBusiness ref="funnelRef" :query-params="queryParams" /> + </el-tab-pane> + <el-tab-pane label="新增商机分析" lazy name="businessSummaryRef"> + <BusinessSummary ref="businessSummaryRef" :query-params="queryParams" /> + </el-tab-pane> + <el-tab-pane label="商机转化率分析" lazy name="sourceRef" /> + </el-tabs> + </el-col> +</template> + +<script lang="ts" setup> +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' +import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime' +import { defaultProps, handleTree } from '@/utils/tree' +import FunnelBusiness from './components/FunnelBusiness.vue' +import BusinessSummary from './components/BusinessSummary.vue' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +defineOptions({ name: 'CrmStatisticsFunnel' }) + +const queryParams = reactive({ + interval: 2, // WEEK, 周 + deptId: useUserStore().getUser.deptId, + userId: undefined, + times: [ + // 默认显示最近一周的数据 + formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))), + formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))) + ] +}) + +const queryFormRef = ref() // 搜索的表单 +const deptList = ref<Tree[]>([]) // 部门树形结构 +const userList = ref<UserApi.UserVO[]>([]) // 全量用户清单 + +/** 根据选择的部门筛选员工清单 */ +const userListByDeptId = computed(() => + queryParams.deptId + ? userList.value.filter((u: UserApi.UserVO) => u.deptId === queryParams.deptId) + : [] +) + +const activeTab = ref('funnelRef') // 活跃标签 +const funnelRef = ref() // 销售漏斗 +const businessSummaryRef = ref() // 新增商机分析 +const sourceRef = ref() // 客户来源 + +/** 搜索按钮操作 */ +const handleQuery = () => { + switch (activeTab.value) { + case 'funnelRef': + funnelRef.value?.loadData?.() + break + case 'businessSummaryRef': + businessSummaryRef.value?.loadData?.() + break + case 'sourceRef': + sourceRef.value?.loadData?.() + break + } +} + +/** 当 activeTab 改变时,刷新当前活动的 tab */ +watch(activeTab, () => { + handleQuery() +}) + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 */ +onMounted(async () => { + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) + userList.value = handleTree(await UserApi.getSimpleUserList()) +}) +</script> diff --git a/src/views/crm/statistics/portrait/components/CustomerAddress.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue similarity index 88% rename from src/views/crm/statistics/portrait/components/CustomerAddress.vue rename to src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue index f31c7963..8ccd52c8 100644 --- a/src/views/crm/statistics/portrait/components/CustomerAddress.vue +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue @@ -25,8 +25,7 @@ import { StatisticsPortraitApi } from '@/api/crm/statistics/portrait' -// TODO @puhui999:address 换成 area 会更合适哈, -defineOptions({ name: 'CustomerAddress' }) +defineOptions({ name: 'PortraitCustomerArea' }) const props = defineProps<{ queryParams: any }>() // 搜索参数 // 注册地图 @@ -107,22 +106,21 @@ const loadData = async () => { areaStatisticsList.value = areaList.map((item: CrmStatisticCustomerAreaRespVO) => { return { ...item, - areaName: item.areaName // TODO @puhui999:这里最好注释下原因哈 - .replace('维吾尔自治区', '') - .replace('壮族自治区', '') - .replace('回族自治区', '') - .replace('自治区', '') - .replace('省', '') + areaName: item.areaName // TODO @puhui999:这里最好注释下原因哈, 🤣 我从 mall copy 过来的 + // .replace('维吾尔自治区', '') + // .replace('壮族自治区', '') + // .replace('回族自治区', '') + // .replace('自治区', '') + // .replace('省', '') } }) - builderLeftMap() - builderRightMap() + buildLeftMap() + buildRightMap() loading.value = false } defineExpose({ loadData }) -// TODO @puhui999:builder 改成 build 更合理哈 -const builderLeftMap = () => { +const buildLeftMap = () => { let min = 0 let max = 0 echartsOption.series![0].data = areaStatisticsList.value.map((item) => { @@ -134,7 +132,7 @@ const builderLeftMap = () => { echartsOption.visualMap!['max'] = max } -const builderRightMap = () => { +const buildRightMap = () => { let min = 0 let max = 0 echartsOption2.series![0].data = areaStatisticsList.value.map((item) => { diff --git a/src/views/crm/statistics/portrait/components/CustomerIndustry.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue similarity index 94% rename from src/views/crm/statistics/portrait/components/CustomerIndustry.vue rename to src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue index d1f3c173..d4269932 100644 --- a/src/views/crm/statistics/portrait/components/CustomerIndustry.vue +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue @@ -39,10 +39,10 @@ import { } from '@/api/crm/statistics/portrait' import { EChartsOption } from 'echarts' import { DICT_TYPE, getDictLabel } from '@/utils/dict' -import { getSumValue } from '@/utils' +import { erpCalculatePercentage, getSumValue } from '@/utils' import { isEmpty } from '@/utils/is' -defineOptions({ name: 'CustomerIndustry' }) +defineOptions({ name: 'PortraitCustomerIndustry' }) const props = defineProps<{ queryParams: any }>() // 搜索参数 const loading = ref(false) // 加载中 @@ -185,8 +185,9 @@ const calculateProportion = (sourceList: CrmStatisticCustomerIndustryRespVO[]) = const sumDealCount = getSumValue(list.map((item) => item.dealCount)) list.forEach((item) => { item.industryPortion = - item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2) - item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2) + item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount) + item.dealPortion = + item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount) }) } diff --git a/src/views/crm/statistics/portrait/components/CustomerLevel.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue similarity index 93% rename from src/views/crm/statistics/portrait/components/CustomerLevel.vue rename to src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue index 2f81c0fc..653feef8 100644 --- a/src/views/crm/statistics/portrait/components/CustomerLevel.vue +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue @@ -39,10 +39,10 @@ import { } from '@/api/crm/statistics/portrait' import { EChartsOption } from 'echarts' import { DICT_TYPE, getDictLabel } from '@/utils/dict' -import { getSumValue } from '@/utils' +import { erpCalculatePercentage, getSumValue } from '@/utils' import { isEmpty } from '@/utils/is' -defineOptions({ name: 'CustomerSource' }) +defineOptions({ name: 'PortraitCustomerLevel' }) const props = defineProps<{ queryParams: any }>() // 搜索参数 const loading = ref(false) // 加载中 @@ -184,10 +184,10 @@ const calculateProportion = (levelList: CrmStatisticCustomerLevelRespVO[]) => { const sumCustomerCount = getSumValue(list.map((item) => item.customerCount)) const sumDealCount = getSumValue(list.map((item) => item.dealCount)) list.forEach((item) => { - // TODO @puhui999:可以使用 erpCalculatePercentage 方法 item.levelPortion = - item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2) - item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2) + item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount) + item.dealPortion = + item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount) }) } diff --git a/src/views/crm/statistics/portrait/components/CustomerSource.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue similarity index 94% rename from src/views/crm/statistics/portrait/components/CustomerSource.vue rename to src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue index af1708fc..ade6445d 100644 --- a/src/views/crm/statistics/portrait/components/CustomerSource.vue +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue @@ -40,9 +40,9 @@ import { import { EChartsOption } from 'echarts' import { DICT_TYPE, getDictLabel } from '@/utils/dict' import { isEmpty } from '@/utils/is' -import { getSumValue } from '@/utils' +import { erpCalculatePercentage, getSumValue } from '@/utils' -defineOptions({ name: 'CustomerSource' }) +defineOptions({ name: 'PortraitCustomerSource' }) const props = defineProps<{ queryParams: any }>() // 搜索参数 const loading = ref(false) // 加载中 @@ -185,8 +185,9 @@ const calculateProportion = (sourceList: CrmStatisticCustomerSourceRespVO[]) => const sumDealCount = getSumValue(list.map((item) => item.dealCount)) list.forEach((item) => { item.sourcePortion = - item.customerCount === 0 ? 0 : ((item.customerCount / sumCustomerCount) * 100).toFixed(2) - item.dealPortion = item.dealCount === 0 ? 0 : ((item.dealCount / sumDealCount) * 100).toFixed(2) + item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount) + item.dealPortion = + item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount) }) } diff --git a/src/views/crm/statistics/portrait/index.vue b/src/views/crm/statistics/portrait/index.vue index 88793837..71807e17 100644 --- a/src/views/crm/statistics/portrait/index.vue +++ b/src/views/crm/statistics/portrait/index.vue @@ -60,20 +60,20 @@ <el-col> <el-tabs v-model="activeTab"> <!-- 城市分布分析 --> - <el-tab-pane label="城市分布分析" lazy name="addressRef"> - <CustomerAddress ref="addressRef" :query-params="queryParams" /> + <el-tab-pane label="城市分布分析" lazy name="areaRef"> + <PortraitCustomerArea ref="areaRef" :query-params="queryParams" /> </el-tab-pane> <!-- 客户级别分析 --> <el-tab-pane label="客户级别分析" lazy name="levelRef"> - <CustomerLevel ref="levelRef" :query-params="queryParams" /> + <PortraitCustomerLevel ref="levelRef" :query-params="queryParams" /> </el-tab-pane> <!-- 客户来源分析 --> <el-tab-pane label="客户来源分析" lazy name="sourceRef"> - <CustomerSource ref="sourceRef" :query-params="queryParams" /> + <PortraitCustomerSource ref="sourceRef" :query-params="queryParams" /> </el-tab-pane> <!-- 客户行业分析 --> <el-tab-pane label="客户行业分析" lazy name="industryRef"> - <CustomerIndustry ref="industryRef" :query-params="queryParams" /> + <PortraitCustomerIndustry ref="industryRef" :query-params="queryParams" /> </el-tab-pane> </el-tabs> </el-col> @@ -85,11 +85,10 @@ import * as UserApi from '@/api/system/user' import { useUserStore } from '@/store/modules/user' import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime' import { defaultProps, handleTree } from '@/utils/tree' -// TODO @puhui999:最好命名带上模块名,如:CrmStatisticsPortrait -import CustomerAddress from './components/CustomerAddress.vue' -import CustomerIndustry from './components/CustomerIndustry.vue' -import CustomerSource from './components/CustomerSource.vue' -import CustomerLevel from './components/CustomerLevel.vue' +import PortraitCustomerArea from './components/PortraitCustomerArea.vue' +import PortraitCustomerIndustry from './components/PortraitCustomerIndustry.vue' +import PortraitCustomerSource from './components/PortraitCustomerSource.vue' +import PortraitCustomerLevel from './components/PortraitCustomerLevel.vue' defineOptions({ name: 'CrmStatisticsPortrait' }) @@ -114,8 +113,8 @@ const userListByDeptId = computed(() => : [] ) -const activeTab = ref('addressRef') // 活跃标签 -const addressRef = ref() // 客户地区分布 +const activeTab = ref('areaRef') // 活跃标签 +const areaRef = ref() // 客户地区分布 const levelRef = ref() // 客户级别 const sourceRef = ref() // 客户来源 const industryRef = ref() // 客户行业 @@ -123,8 +122,8 @@ const industryRef = ref() // 客户行业 /** 搜索按钮操作 */ const handleQuery = () => { switch (activeTab.value) { - case 'addressRef': - addressRef.value?.loadData?.() + case 'areaRef': + areaRef.value?.loadData?.() break case 'levelRef': levelRef.value?.loadData?.()