<script lang="ts" setup>
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
|
|
import { onMounted, ref } from 'vue';
|
|
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,
|
getCustomerDealCycleByDate,
|
getCustomerSummaryByDate,
|
getDatas,
|
} from '#/api/crm/statistics/customer';
|
import { $t } from '#/locales';
|
|
import { getChartOptions } from './chartOptions';
|
import { customerSummaryTabs, useGridColumns, useGridFormSchema } from './data';
|
|
const activeTabName = ref('customerSummary');
|
const chartRef = ref<EchartsUIType>();
|
const { renderEcharts } = useEcharts(chartRef);
|
|
const [QueryForm, formApi] = useVbenForm({
|
commonConfig: {
|
componentProps: {
|
class: 'w-full',
|
},
|
},
|
schema: useGridFormSchema(),
|
showCollapseButton: true,
|
submitButtonOptions: {
|
content: $t('common.query'),
|
},
|
wrapperClass: 'grid-cols-1 md:grid-cols-2',
|
handleSubmit: async () => {
|
await handleTabChange(activeTabName.value);
|
},
|
});
|
|
const [Grid, gridApi] = useVbenVxeGrid({
|
gridOptions: {
|
columns: useGridColumns(activeTabName.value),
|
// height: 'auto' 时表格跟父容器高度走,本页图表占了上面一大块,表格就被压得很矮;
|
// 这里给个固定高度,8 个页签共用这一个表格,一改全高。嫌高/嫌矮改这一处即可。
|
height: '600px',
|
keepSource: true,
|
pagerConfig: {
|
enabled: false,
|
},
|
rowConfig: {
|
keyField: 'id',
|
isHover: true,
|
},
|
toolbarConfig: {
|
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;
|
gridApi.setGridOptions({
|
columns: useGridColumns(key),
|
});
|
const queryParams = await formApi.getValues();
|
// 图表和表格是两个口径的接口:图表要"按日期"的时序数据,表格要"按员工/地区/产品"的明细数据。
|
// 原来只调 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(() => {
|
handleTabChange(activeTabName.value);
|
});
|
</script>
|
|
<template>
|
<Page auto-content-height>
|
<ContentWrap>
|
<QueryForm />
|
<Tabs
|
v-model:active-key="activeTabName"
|
class="w-full"
|
@change="handleTabChange"
|
>
|
<Tabs.TabPane
|
v-for="item in customerSummaryTabs"
|
:key="item.key"
|
:tab="item.tab"
|
:force-render="true"
|
/>
|
</Tabs>
|
<EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
|
<Grid />
|
</ContentWrap>
|
</Page>
|
</template>
|