feat: 迁移 CRM 数据统计与业绩目标设置

- 迁移 CRM 销售漏斗分析,补充漏斗图、阶段统计和商机转化率分析
- 迁移 CRM 员工业绩分析,补充合同汇总统计
- 迁移 CRM 产品分析,新增产品销售情况和产品分类销售分析
- 新增 CRM 业绩达成统计,支持销售目标和回款目标达成情况展示
- 新增 CRM 业绩目标设置,支持部门和员工维度的目标配置
- 补充 web-antd、web-antdv-next、web-ele 对应 API 和页面实现
- 调整统计图表配置、筛选项和表格展示,统一多端 CRM 统计交互
This commit is contained in:
YunaiV
2026-07-11 19:08:29 +08:00
parent b5029a0b67
commit 7372f9151c
8 changed files with 1558 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmPerformanceConfigApi {
/** 业绩目标设置 */
export interface PerformanceConfig {
id?: number;
objectId: number;
objectName?: string;
objectType: number;
year: number | string;
januaryTargetPrice?: number;
februaryTargetPrice?: number;
marchTargetPrice?: number;
aprilTargetPrice?: number;
mayTargetPrice?: number;
juneTargetPrice?: number;
julyTargetPrice?: number;
augustTargetPrice?: number;
septemberTargetPrice?: number;
octoberTargetPrice?: number;
novemberTargetPrice?: number;
decemberTargetPrice?: number;
bizType: number;
yearTargetPrice?: number;
createTime?: Date;
}
}
export enum PerformanceConfigObjectTypeEnum {
DEPT = 2,
USER = 3,
}
/** 查询业绩目标设置分页 */
export function getPerformanceConfigPage(params: PageParam) {
return requestClient.get<
PageResult<CrmPerformanceConfigApi.PerformanceConfig>
>('/crm/performance-config/page', { params });
}
/** 获得业绩目标设置详情 */
export function getPerformanceConfig(id: number) {
return requestClient.get<CrmPerformanceConfigApi.PerformanceConfig>(
`/crm/performance-config/get?id=${id}`,
);
}
/** 新增业绩目标设置 */
export function createPerformanceConfig(
data: CrmPerformanceConfigApi.PerformanceConfig,
) {
return requestClient.post('/crm/performance-config/create', data);
}
/** 修改业绩目标设置 */
export function updatePerformanceConfig(
data: CrmPerformanceConfigApi.PerformanceConfig,
) {
return requestClient.put('/crm/performance-config/update', data);
}
/** 删除业绩目标设置 */
export function deletePerformanceConfig(id: number) {
return requestClient.delete(`/crm/performance-config/delete?id=${id}`);
}

View File

@@ -0,0 +1,30 @@
import { requestClient } from '#/api/request';
export namespace CrmStatisticsPerformanceTargetApi {
/** 业绩目标完成情况请求 */
export interface PerformanceTargetReqVO {
deptId: number;
userId?: number;
year: number;
bizType: number;
}
/** 业绩目标完成情况响应 */
export interface PerformanceTargetRespVO {
month: number;
targetPrice: number;
currentPrice: number;
completionRate: number;
}
}
/** 获得业绩目标完成情况 */
export function getPerformanceTargetSummary(
params: CrmStatisticsPerformanceTargetApi.PerformanceTargetReqVO,
) {
return requestClient.get<
CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO[]
>('/crm/statistics-performance-target/get-performance-target-summary', {
params,
});
}

View File

@@ -0,0 +1,46 @@
import { requestClient } from '#/api/request';
export namespace CrmStatisticsProductApi {
/** 产品销售情况统计响应 */
export interface ProductSalesRespVO {
categoryId: number;
categoryName: string;
productId: number;
productName: string;
contractId: number;
contractNo: string;
contractName: string;
ownerUserId: number;
ownerUserName: string;
customerId: number;
customerName: string;
productPrice: number;
productCount: number;
productTotalPrice: number;
}
/** 产品分类销售分析响应 */
export interface ProductCategoryRespVO {
categoryId: number;
categoryName: string;
contractCount: number;
productCount: number;
productTotalPrice: number;
}
}
/** 获得产品销售情况统计 */
export function getProductSalesList(params: any) {
return requestClient.get<CrmStatisticsProductApi.ProductSalesRespVO[]>(
'/crm/statistics-product/get-product-sales-list',
{ params },
);
}
/** 获得产品分类销售分析 */
export function getProductCategorySummary(params: any) {
return requestClient.get<CrmStatisticsProductApi.ProductCategoryRespVO[]>(
'/crm/statistics-product/get-product-category-summary',
{ params },
);
}

View File

@@ -0,0 +1,263 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { handleTree } from '@vben/utils';
import { PerformanceConfigObjectTypeEnum } from '#/api/crm/performance/config';
import { BizTypeEnum } from '#/api/crm/permission';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
export const bizTypeOptions = [
{ label: '销售目标', value: BizTypeEnum.CRM_CONTRACT },
{ label: '回款目标', value: BizTypeEnum.CRM_RECEIVABLE },
];
export const objectTypeOptions = [
{ label: '部门', value: PerformanceConfigObjectTypeEnum.DEPT },
{ label: '员工', value: PerformanceConfigObjectTypeEnum.USER },
];
export const monthFields = [
{ label: '一月', prop: 'januaryTargetPrice' },
{ label: '二月', prop: 'februaryTargetPrice' },
{ label: '三月', prop: 'marchTargetPrice' },
{ label: '四月', prop: 'aprilTargetPrice' },
{ label: '五月', prop: 'mayTargetPrice' },
{ label: '六月', prop: 'juneTargetPrice' },
{ label: '七月', prop: 'julyTargetPrice' },
{ label: '八月', prop: 'augustTargetPrice' },
{ label: '九月', prop: 'septemberTargetPrice' },
{ label: '十月', prop: 'octoberTargetPrice' },
{ label: '十一月', prop: 'novemberTargetPrice' },
{ label: '十二月', prop: 'decemberTargetPrice' },
] as const;
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'year',
label: '年份',
component: 'DatePicker',
componentProps: {
picker: 'year',
format: 'YYYY',
valueFormat: 'YYYY',
placeholder: '请选择年份',
},
defaultValue: new Date().getFullYear().toString(),
},
{
fieldName: 'bizType',
label: '目标类型',
component: 'Select',
componentProps: {
allowClear: true,
options: bizTypeOptions,
placeholder: '请选择目标类型',
},
},
{
fieldName: 'objectType',
label: '对象类型',
component: 'Select',
componentProps: {
allowClear: true,
options: objectTypeOptions,
placeholder: '请选择对象类型',
},
},
{
fieldName: 'deptObjectId',
label: '部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => handleTree(await getSimpleDeptList()),
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
placeholder: '请选择部门',
allowClear: true,
},
dependencies: {
triggerFields: ['objectType'],
show: (values) =>
values.objectType === PerformanceConfigObjectTypeEnum.DEPT,
},
},
{
fieldName: 'userObjectId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
showSearch: true,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择员工',
},
dependencies: {
triggerFields: ['objectType'],
show: (values) =>
values.objectType === PerformanceConfigObjectTypeEnum.USER,
},
},
];
}
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'year',
label: '年份',
component: 'DatePicker',
componentProps: {
picker: 'year',
format: 'YYYY',
valueFormat: 'YYYY',
placeholder: '请选择年份',
},
rules: 'required',
},
{
fieldName: 'bizType',
label: '目标类型',
component: 'Select',
componentProps: {
options: bizTypeOptions,
placeholder: '请选择目标类型',
},
rules: 'required',
},
{
fieldName: 'objectType',
label: '对象类型',
component: 'Select',
componentProps: {
options: objectTypeOptions,
placeholder: '请选择对象类型',
},
rules: 'required',
},
{
fieldName: 'deptObjectId',
label: '目标对象',
component: 'ApiTreeSelect',
componentProps: {
api: async () => handleTree(await getSimpleDeptList()),
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeCheckStrictly: true,
treeDefaultExpandAll: true,
placeholder: '请选择部门',
},
dependencies: {
triggerFields: ['objectType'],
show: (values) =>
values.objectType === PerformanceConfigObjectTypeEnum.DEPT,
},
},
{
fieldName: 'userObjectId',
label: '目标对象',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
showSearch: true,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择员工',
},
dependencies: {
triggerFields: ['objectType'],
show: (values) =>
values.objectType === PerformanceConfigObjectTypeEnum.USER,
},
},
...monthFields.map((item) => ({
fieldName: item.prop,
label: item.label,
component: 'InputNumber',
componentProps: {
class: '!w-full',
min: 0,
precision: 2,
step: 1000,
},
})),
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ field: 'year', title: '年份', width: 90, fixed: 'left' },
{
field: 'objectType',
title: '对象类型',
width: 100,
fixed: 'left',
slots: { default: 'objectType' },
},
{
field: 'objectName',
title: '目标对象',
minWidth: 140,
fixed: 'left',
},
{
field: 'bizType',
title: '目标类型',
width: 100,
slots: { default: 'bizType' },
},
...monthFields.map((item) => ({
field: item.prop,
title: item.label,
formatter: 'formatAmount2',
width: 120,
})),
{
field: 'yearTargetPrice',
title: '年度目标',
formatter: 'formatAmount2',
width: 140,
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
width: 180,
},
{
title: '操作',
width: 140,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 获取目标类型名称 */
export function getBizTypeLabel(value: number) {
return bizTypeOptions.find((item) => item.value === value)?.label || '';
}
/** 获取对象类型名称 */
export function getObjectTypeLabel(value: number) {
return objectTypeOptions.find((item) => item.value === value)?.label || '';
}

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmPerformanceConfigApi } from '#/api/crm/performance/config';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deletePerformanceConfig,
getPerformanceConfigPage,
PerformanceConfigObjectTypeEnum,
} from '#/api/crm/performance/config';
import { $t } from '#/locales';
import {
getBizTypeLabel,
getObjectTypeLabel,
useGridColumns,
useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建业绩目标 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑业绩目标 */
function handleEdit(row: CrmPerformanceConfigApi.PerformanceConfig) {
formModalApi.setData(row).open();
}
/** 删除业绩目标 */
async function handleDelete(row: CrmPerformanceConfigApi.PerformanceConfig) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', ['业绩目标']),
duration: 0,
});
try {
await deletePerformanceConfig(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', ['业绩目标']));
handleRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const objectId =
formValues.objectType === PerformanceConfigObjectTypeEnum.DEPT
? formValues.deptObjectId
: formValues.objectType === PerformanceConfigObjectTypeEnum.USER
? formValues.userObjectId
: undefined;
return await getPerformanceConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
deptObjectId: undefined,
objectId,
userObjectId: undefined,
year: formValues.year ? Number(formValues.year) : undefined,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmPerformanceConfigApi.PerformanceConfig>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="业绩目标设置">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['业绩目标']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:performance-config:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #objectType="{ row }">
{{ getObjectTypeLabel(row.objectType) }}
</template>
<template #bizType="{ row }">
{{ getBizTypeLabel(row.bizType) }}
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:performance-config:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:performance-config:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', ['业绩目标']),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,132 @@
<script lang="ts" setup>
import type { CrmPerformanceConfigApi } from '#/api/crm/performance/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createPerformanceConfig,
getPerformanceConfig,
PerformanceConfigObjectTypeEnum,
updatePerformanceConfig,
} from '#/api/crm/performance/config';
import { BizTypeEnum } from '#/api/crm/permission';
import { $t } from '#/locales';
import { monthFields, useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmPerformanceConfigApi.PerformanceConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['业绩目标'])
: $t('ui.actionTitle.create', ['业绩目标']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
const data = buildSubmitData(await formApi.getValues());
if (!data.objectId) {
message.warning('请选择目标对象');
modalApi.unlock();
return;
}
try {
await (data.id
? updatePerformanceConfig(data)
: createPerformanceConfig(data));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
await formApi.resetForm();
const data =
modalApi.getData<CrmPerformanceConfigApi.PerformanceConfig>();
if (!data?.id) {
await formApi.setValues(buildFormValues());
return;
}
modalApi.lock();
try {
formData.value = await getPerformanceConfig(data.id);
await formApi.setValues(buildFormValues(formData.value));
} finally {
modalApi.unlock();
}
},
});
/** 构建表单默认值 */
function buildFormValues(data?: CrmPerformanceConfigApi.PerformanceConfig) {
const formValues: any = {
objectId: undefined,
objectType: PerformanceConfigObjectTypeEnum.DEPT,
year: new Date().getFullYear().toString(),
bizType: BizTypeEnum.CRM_CONTRACT,
...Object.fromEntries(monthFields.map((item) => [item.prop, 0])),
...data,
};
formValues.year = String(formValues.year);
if (formValues.objectType === PerformanceConfigObjectTypeEnum.USER) {
formValues.userObjectId = formValues.objectId;
} else {
formValues.deptObjectId = formValues.objectId;
}
return formValues;
}
/** 构建提交数据 */
function buildSubmitData(values: Record<string, any>) {
const data = { ...values } as CrmPerformanceConfigApi.PerformanceConfig & {
deptObjectId?: number;
userObjectId?: number;
};
data.objectId =
data.objectType === PerformanceConfigObjectTypeEnum.DEPT
? data.deptObjectId!
: data.userObjectId!;
data.year = Number(data.year);
monthFields.forEach((item) => {
data[item.prop] = Number(data[item.prop] || 0);
});
delete data.deptObjectId;
delete data.userObjectId;
return data;
}
</script>
<template>
<Modal :title="getTitle" class="w-3/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,285 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsPerformanceTargetApi } from '#/api/crm/statistics/performanceTarget';
import { onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { useUserStore } from '@vben/stores';
import { erpPriceInputFormatter, handleTree } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { BizTypeEnum } from '#/api/crm/permission';
import { getPerformanceTargetSummary } from '#/api/crm/statistics/performanceTarget';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { $t } from '#/locales';
const userStore = useUserStore();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const bizTypeOptions = [
{ label: '销售目标', value: BizTypeEnum.CRM_CONTRACT },
{ label: '回款目标', value: BizTypeEnum.CRM_RECEIVABLE },
];
/** 搜索表单 */
function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'year',
label: '选择年份',
component: 'DatePicker',
componentProps: {
picker: 'year',
format: 'YYYY',
valueFormat: 'YYYY',
placeholder: '请选择年份',
},
defaultValue: new Date().getFullYear().toString(),
},
{
fieldName: 'bizType',
label: '目标类型',
component: 'Select',
componentProps: {
options: bizTypeOptions,
placeholder: '请选择目标类型',
},
defaultValue: BizTypeEnum.CRM_CONTRACT,
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => handleTree(await getSimpleDeptList()),
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
placeholder: '请选择归属部门',
},
defaultValue: userStore.userInfo?.deptId,
},
{
fieldName: 'userId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择员工',
},
},
];
}
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 loadData();
},
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ field: 'monthName', title: '月份', minWidth: 120 },
{
field: 'targetPrice',
title: '目标金额(元)',
formatter: 'formatAmount2',
minWidth: 160,
slots: { default: 'targetPrice' },
},
{
field: 'currentPrice',
title: '完成金额(元)',
formatter: 'formatAmount2',
minWidth: 160,
slots: { default: 'currentPrice' },
},
{ field: 'completionRateText', title: '完成率', minWidth: 120 },
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
enabled: false,
},
rowConfig: {
keyField: 'month',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<
CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO
>,
});
/** 获取接口参数 */
async function getApiParams() {
const values = await formApi.getValues();
return {
...values,
year: Number(values.year),
};
}
/** 格式化月份 */
function formatMonth(year: number, month: number) {
return `${year}-${String(month).padStart(2, '0')}`;
}
/** 构建图表配置 */
function getChartOptions(
year: number,
data: CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO[],
) {
return {
grid: {
left: 20,
right: 40,
bottom: 72,
containLabel: true,
},
legend: {
bottom: 8,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'category',
name: '月份',
data: data.map((item) => formatMonth(year, item.month)),
},
yAxis: [
{
type: 'value',
name: '金额(元)',
},
{
type: 'value',
name: '完成率',
axisLabel: {
formatter: '{value}%',
},
},
],
series: [
{
name: '目标金额(元)',
type: 'bar',
data: data.map((item) => item.targetPrice),
},
{
name: '完成金额(元)',
type: 'bar',
data: data.map((item) => item.currentPrice),
},
{
name: '完成率(%',
type: 'line',
yAxisIndex: 1,
data: data.map((item) => item.completionRate),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false,
},
brush: {
type: ['lineX', 'clear'],
},
saveAsImage: { show: true, name: '业绩达成' },
},
},
};
}
/** 统计合计行 */
function buildSummaryRow(
data: CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO[],
) {
const targetTotal = data.reduce(
(sum, item) => sum + Number(item.targetPrice || 0),
0,
);
const currentTotal = data.reduce(
(sum, item) => sum + Number(item.currentPrice || 0),
0,
);
const completionRate =
targetTotal > 0 ? ((currentTotal / targetTotal) * 100).toFixed(2) : '0.00';
return {
month: 13,
monthName: '合计',
targetPrice: targetTotal,
currentPrice: currentTotal,
completionRateText: `${completionRate}%`,
};
}
/** 获取业绩目标完成情况 */
async function loadData() {
const params = await getApiParams();
const data = await getPerformanceTargetSummary(params);
const tableData = data.map((item) => ({
...item,
monthName: formatMonth(params.year, item.month),
completionRateText: `${item.completionRate || 0}%`,
}));
await renderEcharts(getChartOptions(params.year, data), true);
await gridApi.grid.reloadData([...tableData, buildSummaryRow(data)]);
}
/** 初始化 */
onMounted(() => {
loadData();
});
</script>
<template>
<Page auto-content-height>
<ContentWrap>
<QueryForm />
<EchartsUI class="mb-5 h-[500px] w-full" ref="chartRef" />
<Grid>
<template #targetPrice="{ row }">
{{ erpPriceInputFormatter(row.targetPrice) }}
</template>
<template #currentPrice="{ row }">
{{ erpPriceInputFormatter(row.currentPrice) }}
</template>
</Grid>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,585 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsProductApi } from '#/api/crm/statistics/product';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { useUserStore } from '@vben/stores';
import {
beginOfDay,
endOfDay,
formatDateTime,
handleTree,
} from '@vben/utils';
import { Button, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProductSimpleList } from '#/api/crm/product';
import { getProductCategoryList } from '#/api/crm/product/category';
import {
getProductCategorySummary,
getProductSalesList,
} from '#/api/crm/statistics/product';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { $t } from '#/locales';
const userStore = useUserStore();
const { push } = useRouter();
const activeTabName = ref('productSalesList');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
enum ProductSalesRowTypeEnum {
CATEGORY_SUMMARY = 'categorySummary',
DETAIL = 'detail',
PRODUCT_SUMMARY = 'productSummary',
}
type ProductSalesRow =
Partial<CrmStatisticsProductApi.ProductSalesRespVO> & {
categoryRowspan?: number;
index?: number;
productRowspan?: number;
rowKey?: string;
rowType: ProductSalesRowTypeEnum;
summaryLabel?: string;
};
const SUMMARY_LABEL_COLUMN_INDEX = 0;
const CATEGORY_COLUMN_INDEX = 1;
const PRODUCT_COLUMN_INDEX = 2;
const CONTRACT_NO_COLUMN_INDEX = 3;
const CUSTOMER_COLUMN_INDEX = 6;
const SUMMARY_VALUE_COLUMN_INDEX = 8;
const LINK_COLUMN_INDEXES = [
PRODUCT_COLUMN_INDEX,
CONTRACT_NO_COLUMN_INDEX,
CUSTOMER_COLUMN_INDEX,
];
/** 搜索表单 */
function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'times',
label: '时间范围',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: false,
},
defaultValue: [
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 30))),
formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
],
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => handleTree(await getSimpleDeptList()),
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
placeholder: '请选择归属部门',
},
defaultValue: userStore.userInfo?.deptId,
},
{
fieldName: 'userId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择员工',
},
},
{
fieldName: 'categoryId',
label: '产品分类',
component: 'ApiTreeSelect',
componentProps: {
api: async () => handleTree(await getProductCategoryList()),
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
placeholder: '请选择产品分类',
allowClear: true,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getProductSimpleList,
allowClear: true,
showSearch: true,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
},
},
];
}
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 xl:grid-cols-3',
handleSubmit: async () => {
await loadData();
},
});
const [ProductSalesGrid, productSalesGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ field: 'index', title: '序号', width: 90, slots: { default: 'index' } },
{ field: 'categoryName', title: '产品分类', minWidth: 140 },
{
field: 'productName',
title: '产品名称',
minWidth: 180,
slots: { default: 'productName' },
},
{
field: 'contractNo',
title: '合同编号',
minWidth: 160,
slots: { default: 'contractNo' },
},
{ field: 'contractName', title: '合同名称', minWidth: 180 },
{ field: 'ownerUserName', title: '负责人', minWidth: 120 },
{
field: 'customerName',
title: '客户名称',
minWidth: 180,
slots: { default: 'customerName' },
},
{
field: 'productPrice',
title: '销售单价(元)',
formatter: 'formatAmount2',
minWidth: 140,
},
{ field: 'productCount', title: '数量', minWidth: 120 },
{
field: 'productTotalPrice',
title: '订单产品小计(元)',
formatter: 'formatAmount2',
minWidth: 160,
},
],
cellClassName: ({ columnIndex, row }: any) =>
row.rowType === ProductSalesRowTypeEnum.DETAIL &&
LINK_COLUMN_INDEXES.includes(columnIndex)
? 'is-link-cell'
: '',
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
enabled: false,
},
rowClassName: ({ row }: any) => {
if (row.rowType === ProductSalesRowTypeEnum.PRODUCT_SUMMARY) {
return 'product-summary-row';
}
if (row.rowType === ProductSalesRowTypeEnum.CATEGORY_SUMMARY) {
return 'category-summary-row';
}
return '';
},
rowConfig: {
keyField: 'rowKey',
isHover: true,
},
spanMethod,
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<ProductSalesRow>,
});
const [ProductCategoryGrid, productCategoryGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', title: '序号', width: 80 },
{ field: 'categoryName', title: '产品分类', minWidth: 180 },
{ field: 'contractCount', title: '合同数量', minWidth: 120 },
{ field: 'productCount', title: '销售数量', minWidth: 120 },
{
field: 'productTotalPrice',
title: '销售金额(元)',
formatter: 'formatAmount2',
minWidth: 160,
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
enabled: false,
},
rowConfig: {
keyField: 'categoryId',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsProductApi.ProductCategoryRespVO>,
});
/** 转换为数值 */
function getNumber(value?: number) {
return Number(value || 0);
}
/** 获得分类分组 Key */
function getCategoryKey(item: CrmStatisticsProductApi.ProductSalesRespVO) {
return item.categoryId || `category-${item.categoryName}`;
}
/** 获得产品分组 Key */
function getProductKey(item: CrmStatisticsProductApi.ProductSalesRespVO) {
return item.productId;
}
/** 构建产品小计行 */
function buildProductSummaryRow(
rows: CrmStatisticsProductApi.ProductSalesRespVO[],
): ProductSalesRow {
return {
rowType: ProductSalesRowTypeEnum.PRODUCT_SUMMARY,
rowKey: `product-summary-${rows[0]?.productId || rows[0]?.productName}`,
summaryLabel: `${rows[0]?.productName || '产品'} 小计`,
productCount: rows.reduce((sum, item) => sum + getNumber(item.productCount), 0),
productTotalPrice: rows.reduce(
(sum, item) => sum + getNumber(item.productTotalPrice),
0,
),
};
}
/** 构建分类小计行 */
function buildCategorySummaryRow(
rows: CrmStatisticsProductApi.ProductSalesRespVO[],
): ProductSalesRow {
return {
rowType: ProductSalesRowTypeEnum.CATEGORY_SUMMARY,
rowKey: `category-summary-${rows[0]?.categoryId || rows[0]?.categoryName}`,
summaryLabel: `${rows[0]?.categoryName || '未分类'} 小计`,
productCount: rows.reduce((sum, item) => sum + getNumber(item.productCount), 0),
productTotalPrice: rows.reduce(
(sum, item) => sum + getNumber(item.productTotalPrice),
0,
),
};
}
/** 按产品分组 */
function buildProductGroups(rows: CrmStatisticsProductApi.ProductSalesRespVO[]) {
const result: CrmStatisticsProductApi.ProductSalesRespVO[][] = [];
let index = 0;
while (index < rows.length) {
const productKey = getProductKey(rows[index]!);
const productRows: CrmStatisticsProductApi.ProductSalesRespVO[] = [];
while (index < rows.length && getProductKey(rows[index]!) === productKey) {
productRows.push(rows[index]!);
index++;
}
result.push(productRows);
}
return result;
}
/** 构建列表展示数据 */
function buildList(
data: CrmStatisticsProductApi.ProductSalesRespVO[],
): ProductSalesRow[] {
const result: ProductSalesRow[] = [];
let index = 0;
let rowIndex = 1;
while (index < data.length) {
const categoryStartIndex = result.length;
const categoryKey = getCategoryKey(data[index]!);
const categoryRows: CrmStatisticsProductApi.ProductSalesRespVO[] = [];
while (index < data.length && getCategoryKey(data[index]!) === categoryKey) {
categoryRows.push(data[index]!);
index++;
}
const productGroups = buildProductGroups(categoryRows);
const productSummaryRows: ProductSalesRow[] = [];
productGroups.forEach((productRows) => {
const productStartIndex = result.length;
productRows.forEach((row) => {
const detailIndex = rowIndex++;
result.push({
...row,
rowType: ProductSalesRowTypeEnum.DETAIL,
index: detailIndex,
rowKey: `detail-${detailIndex}`,
});
});
result[productStartIndex]!.productRowspan = productRows.length;
if (productGroups.length > 1 && productRows.length > 1) {
productSummaryRows.push(buildProductSummaryRow(productRows));
}
});
if (result.length > categoryStartIndex) {
result[categoryStartIndex]!.categoryRowspan = categoryRows.length;
}
result.push(...productSummaryRows);
result.push(buildCategorySummaryRow(categoryRows));
}
return result;
}
/** 合并产品分类、产品名称单元格 */
function spanMethod({ columnIndex, row }: any) {
if (
row.rowType === ProductSalesRowTypeEnum.PRODUCT_SUMMARY ||
row.rowType === ProductSalesRowTypeEnum.CATEGORY_SUMMARY
) {
if (columnIndex === SUMMARY_LABEL_COLUMN_INDEX) {
return { rowspan: 1, colspan: SUMMARY_VALUE_COLUMN_INDEX };
}
if (
columnIndex > SUMMARY_LABEL_COLUMN_INDEX &&
columnIndex < SUMMARY_VALUE_COLUMN_INDEX
) {
return { rowspan: 0, colspan: 0 };
}
}
if (columnIndex === CATEGORY_COLUMN_INDEX) {
if (row.rowType !== ProductSalesRowTypeEnum.DETAIL) {
return { rowspan: 0, colspan: 0 };
}
return row.categoryRowspan
? { rowspan: row.categoryRowspan, colspan: 1 }
: { rowspan: 0, colspan: 0 };
}
if (columnIndex === PRODUCT_COLUMN_INDEX) {
if (row.rowType !== ProductSalesRowTypeEnum.DETAIL) {
return undefined;
}
return row.productRowspan
? { rowspan: row.productRowspan, colspan: 1 }
: { rowspan: 0, colspan: 0 };
}
}
/** 获得产品分类销售分析图表 */
function getProductCategoryChartOptions(
data: CrmStatisticsProductApi.ProductCategoryRespVO[],
) {
return {
title: {
text: '产品分类销量占比',
left: 'center',
bottom: 10,
},
legend: {
type: 'scroll',
orient: 'vertical',
left: 10,
top: 20,
bottom: 20,
data: data.map((item) => item.categoryName),
},
tooltip: {
trigger: 'item',
formatter: '{b}<br/>销售数量:{c}',
},
series: [
{
name: '销售数量',
type: 'pie',
radius: ['50%', '70%'],
center: ['55%', '48%'],
data: data.map((item) => ({
name: item.categoryName,
value: item.productCount,
})),
},
],
toolbox: {
feature: {
saveAsImage: { show: true, name: '产品分类销量占比' },
},
},
};
}
/** 加载产品销售情况统计 */
async function loadProductSalesList() {
const params = await formApi.getValues();
const data = await getProductSalesList(params);
await productSalesGridApi.grid.reloadData(buildList(data));
}
/** 加载产品分类销售分析 */
async function loadProductCategorySummary() {
const params = await formApi.getValues();
const data = await getProductCategorySummary(params);
await renderEcharts(getProductCategoryChartOptions(data), true);
await productCategoryGridApi.grid.reloadData(data);
}
/** 查询按钮操作 */
async function loadData() {
if (activeTabName.value === 'productSalesList') {
await loadProductSalesList();
return;
}
await loadProductCategorySummary();
}
/** tab 切换 */
async function handleTabChange(key: any) {
activeTabName.value = key;
await loadData();
}
/** 打开合同详情 */
function openContract(id?: number) {
if (!id) {
return;
}
push({ name: 'CrmContractDetail', params: { id } });
}
/** 打开客户详情 */
function openCustomer(id?: number) {
if (!id) {
return;
}
push({ name: 'CrmCustomerDetail', params: { id } });
}
/** 打开产品详情 */
function openProduct(id?: number) {
if (!id) {
return;
}
push({ name: 'CrmProductDetail', params: { id } });
}
/** 初始化 */
onMounted(() => {
loadData();
});
</script>
<template>
<Page auto-content-height>
<ContentWrap>
<QueryForm />
<Tabs
v-model:active-key="activeTabName"
class="w-full"
@change="handleTabChange"
>
<Tabs.TabPane key="productSalesList" tab="产品销售情况统计" />
<Tabs.TabPane key="productCategorySummary" tab="产品分类销售分析" />
</Tabs>
<ProductSalesGrid v-show="activeTabName === 'productSalesList'">
<template #index="{ row }">
<span v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL">
{{ row.index }}
</span>
<span v-else>{{ row.summaryLabel }}</span>
</template>
<template #productName="{ row }">
<Button
v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL"
type="link"
@click="openProduct(row.productId)"
>
{{ row.productName }}
</Button>
<span v-else>{{ row.summaryLabel }}</span>
</template>
<template #contractNo="{ row }">
<Button
v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL"
type="link"
@click="openContract(row.contractId)"
>
{{ row.contractNo }}
</Button>
</template>
<template #customerName="{ row }">
<Button
v-if="
row.rowType === ProductSalesRowTypeEnum.DETAIL && row.customerId
"
type="link"
@click="openCustomer(row.customerId)"
>
{{ row.customerName }}
</Button>
<span v-else-if="row.rowType === ProductSalesRowTypeEnum.DETAIL">
{{ row.customerName }}
</span>
</template>
</ProductSalesGrid>
<div v-show="activeTabName === 'productCategorySummary'">
<EchartsUI class="mb-5 h-[500px] w-full" ref="chartRef" />
<ProductCategoryGrid />
</div>
</ContentWrap>
</Page>
</template>
<style scoped>
:deep(.product-summary-row > td) {
background-color: #fff9f2 !important;
font-weight: 600;
}
:deep(.category-summary-row > td) {
background-color: #fff3e8 !important;
font-weight: 600;
}
:deep(.is-link-cell) {
color: var(--ant-color-primary);
cursor: pointer;
}
</style>