| | |
| | | |
| | | import { ContentWrap, Page } from '@vben/common-ui'; |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | import { erpCalculatePercentage } from '@vben/utils'; |
| | | |
| | | import { Tabs } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { getChartDatas } from '#/api/crm/statistics/customer'; |
| | | import { |
| | | getChartDatas, |
| | | getCustomerDealCycleByDate, |
| | | getCustomerSummaryByDate, |
| | | getDatas, |
| | | } from '#/api/crm/statistics/customer'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { getChartOptions } from './chartOptions'; |
| | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | gridOptions: { |
| | | columns: useGridColumns(activeTabName.value), |
| | | height: 'auto', |
| | | // height: 'auto' 时表格跟父容器高度走,本页图表占了上面一大块,表格就被压得很矮; |
| | | // 这里给个固定高度,8 个页签共用这一个表格,一改全高。嫌高/嫌矮改这一处即可。 |
| | | height: '600px', |
| | | keepSource: true, |
| | | pagerConfig: { |
| | | enabled: false, |
| | |
| | | } as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUserRespVO>, |
| | | }); |
| | | |
| | | /** 超级管理员(用户 ID 1)不是业务人员,统计表里不该出现他名下的那行 */ |
| | | const ADMIN_USER_ID = 1; |
| | | |
| | | function excludeAdminRows(rows: unknown[]) { |
| | | return (rows as Array<Record<string, unknown>>).filter( |
| | | (row) => Number(row.ownerUserId) !== ADMIN_USER_ID, |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 按员工统计的页签,后端会把部门里每个员工都列一行(没数据的补 0),按用户 ID 升序排 → 有数据的员工往往在最后一行, |
| | | * 600px 一屏只显示十几行,翻前几屏看到的全是 0,看着就像"没数据"。这里按主指标倒序,有数据的排前面;0 行不删。 |
| | | */ |
| | | const USER_TAB_METRIC: Record<string, string[]> = { |
| | | customerSummary: ['customerDealCount', 'customerCreateCount'], |
| | | dealCycleByUser: ['customerDealCount', 'customerDealCycle'], |
| | | followUpSummary: ['followUpRecordCount', 'followUpCustomerCount'], |
| | | poolSummary: ['customerPutCount', 'customerTakeCount'], |
| | | }; |
| | | |
| | | function sortByMetric(key: string, rows: unknown[]) { |
| | | const fields = USER_TAB_METRIC[key]; |
| | | if (!fields) { |
| | | return rows; |
| | | } |
| | | return [...(rows as Array<Record<string, number>>)].sort((a, b) => { |
| | | for (const field of fields) { |
| | | const diff = (Number(b[field]) || 0) - (Number(a[field]) || 0); |
| | | if (diff !== 0) { |
| | | return diff; |
| | | } |
| | | } |
| | | return 0; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 跟进方式分析的“占比(%)”列在 data.ts 里既没有 formatter,后端 FollowUpSummaryByTypeRespVO 也不返回该字段, |
| | | * 所以整列永远是空的;这里按跟进次数在本地补算(口径与后端其它占比一致:value / total * 100,保留两位)。 |
| | | */ |
| | | function fillFollowUpPortion(rows: unknown[]) { |
| | | // getDatas 的返回类型是各页签的联合类型,这里按调用方保证的顺序收窄成跟进方式的响应 |
| | | const list = rows as CrmStatisticsCustomerApi.FollowUpSummaryByTypeRespVO[]; |
| | | const total = list.reduce((sum, row) => sum + (row.followUpRecordCount ?? 0), 0); |
| | | return list.map((row) => ({ |
| | | ...row, |
| | | portion: erpCalculatePercentage(row.followUpRecordCount ?? 0, total), |
| | | })); |
| | | } |
| | | |
| | | /** tab 切换 */ |
| | | async function handleTabChange(key: any) { |
| | | activeTabName.value = key; |
| | |
| | | columns: useGridColumns(key), |
| | | }); |
| | | const queryParams = await formApi.getValues(); |
| | | const res = await getChartDatas(activeTabName.value, queryParams); |
| | | await renderEcharts(getChartOptions(activeTabName.value, res)); |
| | | await gridApi.grid.reloadData(res); |
| | | // 图表和表格是两个口径的接口:图表要"按日期"的时序数据,表格要"按员工/地区/产品"的明细数据。 |
| | | // 原来只调 getChartDatas 又把它的返回值喂给表格,凡是两者口径不同的标签页(客户汇总、跟进汇总、客户池、转化统计) |
| | | // 表格里就会大片空字段——图表返回的日期/数量字段在表格列里并不存在。 |
| | | const [chartData, tableData] = await Promise.all([ |
| | | getChartDatas(key, queryParams), |
| | | getDatas(key, queryParams), |
| | | ]); |
| | | // 「员工客户成交周期分析」的图表选项读的是 res.customerDealCycleByDate / res.customerDealCycleByUser 两个数组 |
| | | // (chartOptions.ts 里只有这一个页签是按对象取的),但 getChartDatas 对这个页签只返回一个数组, |
| | | // res.customerDealCycleByDate 是 undefined → 在图表里 .map 直接抛错,后面的表格 reloadData 永远不会执行, |
| | | // 表格就一直停在上一个页签的数据上(看着就是"成交周期/成交客户数没有数据")。 |
| | | // 这里按图表要的形状补齐两个"按日期"序列:成交周期用成交周期(按日期)接口,成交客户数用客户总量(按日期)接口, |
| | | // 两个接口都按查询区间分桶,time 一致(实测 2026-10-08(第 41 周) / 2026-10-12(第 42 周))。 |
| | | let chartPayload: any = chartData; |
| | | if (key === 'dealCycleByUser') { |
| | | const params = queryParams as CrmStatisticsCustomerApi.CustomerSummaryReqVO; |
| | | const [dealCycleByDate, summaryByDate] = await Promise.all([ |
| | | getCustomerDealCycleByDate(params), |
| | | getCustomerSummaryByDate(params), |
| | | ]); |
| | | chartPayload = { |
| | | customerDealCycleByDate: dealCycleByDate, |
| | | customerDealCycleByUser: summaryByDate, |
| | | }; |
| | | } |
| | | // 图表画不出来不能连累表格:容错包一层,异常只记日志 |
| | | try { |
| | | await renderEcharts(getChartOptions(key, chartPayload)); |
| | | } catch (error) { |
| | | console.error('[客户分析] 图表渲染失败', key, error); |
| | | } |
| | | const rows = sortByMetric( |
| | | key, |
| | | excludeAdminRows(Array.isArray(tableData) ? tableData : []), |
| | | ); |
| | | await gridApi.grid.reloadData( |
| | | key === 'followUpType' ? fillFollowUpPortion(rows) : rows, |
| | | ); |
| | | } |
| | | |
| | | onMounted(() => { |