gaoluyang
昨天 cd8b52bdf89a4d1101f72e0ece9f4df22a3d3f7a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
<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>