gaoluyang
11 小时以前 f6f66196f3ca377eda0afd320e64005c28204c76
src/views/crm/statistics/customer/index.vue
@@ -8,12 +8,18 @@
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';
@@ -43,7 +49,9 @@
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useGridColumns(activeTabName.value),
    height: 'auto',
    // height: 'auto' 时表格跟父容器高度走,本页图表占了上面一大块,表格就被压得很矮;
    // 这里给个固定高度,8 个页签共用这一个表格,一改全高。嫌高/嫌矮改这一处即可。
    height: '600px',
    keepSource: true,
    pagerConfig: {
      enabled: false,
@@ -58,6 +66,56 @@
  } 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;
@@ -65,9 +123,44 @@
    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(() => {