云
2026-08-12 93ad309dc5b948bf659eed262811eb2b1dc262eb
feat(crm): 添加销售目标管理功能

- 在字典枚举中新增销售目标相关的类型定义
- 添加销售目标API接口定义和实现
- 实现销售目标的增删改查功能页面
- 添加销售目标达成分析页面,支持多维度指标展示
- 实现销售目标统计图表功能
- 配置开发环境代理地址到本地服务
已添加7个文件
已修改2个文件
1226 ■■■■■ 文件已修改
src/api/crm/salesTarget/index.ts 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/chart-options.ts 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/data.ts 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/analysis/index.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/data.ts 351 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/index.vue 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/salesTarget/modules/form.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/salesTarget/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmSalesTargetApi {
  /** é”€å”®ç›®æ ‡ä¿¡æ¯ */
  export interface SalesTarget {
    id?: number;
    name?: string;
    periodType?: number;
    periodValue?: string;
    startTime?: string;
    endTime?: string;
    targetDimension?: number;
    targetUserId?: number;
    targetUserName?: string;
    targetDeptId?: number;
    targetDeptName?: string;
    targetItemId?: number;
    targetItemName?: string;
    contractTarget?: number;
    receivableTarget?: number;
    businessTarget?: number;
    customerTarget?: number;
    contractCountTarget?: number;
    actualContractPrice?: number;
    actualReceivablePrice?: number;
    actualBusinessPrice?: number;
    actualCustomerCount?: number;
    actualContractCount?: number;
    contractRate?: number;
    receivableRate?: number;
    businessRate?: number;
    customerRate?: number;
    contractCountRate?: number;
    status?: number;
    remark?: string;
    creator?: string;
    creatorName?: string;
    createTime?: string;
    updateTime?: string;
  }
  /** é”€å”®ç›®æ ‡åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    name?: string;
    periodType?: number;
    periodValue?: string;
    targetDimension?: number;
    status?: number;
  }
  /** é”€å”®ç›®æ ‡è¾¾æˆåˆ†æžæŸ¥è¯¢å‚æ•° */
  export interface AnalysisParams {
    periodType?: number;
    periodValue?: string;
    targetDimension?: number;
    status?: number;
  }
}
/** æŸ¥è¯¢é”€å”®ç›®æ ‡åˆ†é¡µ */
export function getSalesTargetPage(params: CrmSalesTargetApi.PageParams) {
  return requestClient.get<PageResult<CrmSalesTargetApi.SalesTarget>>(
    '/crm/sales-target/page',
    { params },
  );
}
/** æŸ¥è¯¢é”€å”®ç›®æ ‡è¯¦æƒ… */
export function getSalesTarget(id: number) {
  return requestClient.get<CrmSalesTargetApi.SalesTarget>(
    `/crm/sales-target/get?id=${id}`,
  );
}
/** æ–°å¢žé”€å”®ç›®æ ‡ */
export function createSalesTarget(data: CrmSalesTargetApi.SalesTarget) {
  return requestClient.post('/crm/sales-target/create', data);
}
/** ä¿®æ”¹é”€å”®ç›®æ ‡ */
export function updateSalesTarget(data: CrmSalesTargetApi.SalesTarget) {
  return requestClient.put('/crm/sales-target/update', data);
}
/** å˜æ›´é”€å”®ç›®æ ‡çŠ¶æ€ï¼ˆä½œåºŸ/恢复等) */
export function changeSalesTargetStatus(data: {
  id: number;
  status: number;
}) {
  return requestClient.put('/crm/sales-target/change-status', data);
}
/** åˆ é™¤é”€å”®ç›®æ ‡ */
export function deleteSalesTarget(id: number) {
  return requestClient.delete(`/crm/sales-target/delete?id=${id}`);
}
/** å¯¼å‡ºé”€å”®ç›®æ ‡ */
export function exportSalesTarget(params: Record<string, unknown>) {
  return requestClient.download('/crm/sales-target/export-excel', { params });
}
/** æŸ¥è¯¢é”€å”®ç›®æ ‡è¾¾æˆåˆ†æž */
export function getSalesTargetAnalysis(
  params: CrmSalesTargetApi.AnalysisParams,
) {
  return requestClient.get<CrmSalesTargetApi.SalesTarget[]>(
    '/crm/sales-target/get-achievement-analysis',
    { params },
  );
}
src/packages/constants/src/dict-enum.ts
@@ -124,6 +124,9 @@
  CRM_PRODUCT_UNIT: 'crm_product_unit', // CRM äº§å“å•位
  CRM_RECEIVABLE_RETURN_TYPE: 'crm_receivable_return_type', // CRM å›žæ¬¾çš„还款方式
  CRM_INVOICE_TYPE: 'crm_invoice_type', // CRM å‘票类型
  CRM_SALES_TARGET_PERIOD_TYPE: 'crm_sales_target_period_type', // CRM é”€å”®ç›®æ ‡å‘¨æœŸç±»åž‹
  CRM_SALES_TARGET_DIMENSION: 'crm_sales_target_dimension', // CRM é”€å”®ç›®æ ‡ç»´åº¦
  CRM_SALES_TARGET_STATUS: 'crm_sales_target_status', // CRM é”€å”®ç›®æ ‡çŠ¶æ€
} as const;
/** ========== AFTERSALES - å”®åŽæœåŠ¡æ¨¡å— ========== */
src/views/crm/salesTarget/analysis/chart-options.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
import type { EChartsOption } from '@vben/plugins/echarts';
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
/** è¾¾æˆåˆ†æžæŒ‡æ ‡ Tab å®šä¹‰ */
export const ANALYSIS_TABS = [
  { key: 'contract', label: '合同金额', unit: '元' },
  { key: 'receivable', label: '回款金额', unit: '元' },
  { key: 'business', label: '商机金额', unit: '元' },
  { key: 'customer', label: '新增客户', unit: '个' },
  { key: 'contractCount', label: '新增合同', unit: '个' },
] as const;
export type AnalysisTabKey = (typeof ANALYSIS_TABS)[number]['key'];
type MetricValue = {
  target: number;
  actual: number;
  rate: number | null;
};
const METRICS: Record<
  AnalysisTabKey,
  { label: string; unit: string; pick: (item: CrmSalesTargetApi.SalesTarget) => MetricValue }
> = {
  contract: {
    label: '合同金额',
    unit: '元',
    pick: (item) => ({
      target: item.contractTarget ?? 0,
      actual: item.actualContractPrice ?? 0,
      rate: item.contractRate ?? null,
    }),
  },
  receivable: {
    label: '回款金额',
    unit: '元',
    pick: (item) => ({
      target: item.receivableTarget ?? 0,
      actual: item.actualReceivablePrice ?? 0,
      rate: item.receivableRate ?? null,
    }),
  },
  business: {
    label: '商机金额',
    unit: '元',
    pick: (item) => ({
      target: item.businessTarget ?? 0,
      actual: item.actualBusinessPrice ?? 0,
      rate: item.businessRate ?? null,
    }),
  },
  customer: {
    label: '新增客户',
    unit: '个',
    pick: (item) => ({
      target: item.customerTarget ?? 0,
      actual: item.actualCustomerCount ?? 0,
      rate: item.customerRate ?? null,
    }),
  },
  contractCount: {
    label: '新增合同',
    unit: '个',
    pick: (item) => ({
      target: item.contractCountTarget ?? 0,
      actual: item.actualContractCount ?? 0,
      rate: item.contractCountRate ?? null,
    }),
  },
};
function formatNumber(value: number | null | undefined): string {
  return value == null ? '-' : value.toLocaleString('zh-CN');
}
/** ç”ŸæˆæŒ‡æ ‡å¯¹æ¯”柱状图(目标 vs å®žé™…)配置 */
export function getChartOptions(
  tabKey: AnalysisTabKey,
  list: CrmSalesTargetApi.SalesTarget[],
): EChartsOption {
  const metric = METRICS[tabKey];
  const valueName = `${metric.label}(${metric.unit})`;
  return {
    dataset: {
      dimensions: ['name', 'target', 'actual', 'rate'],
      source: list.map((item) => {
        const value = metric.pick(item);
        return {
          name: item.name ?? '--',
          target: value.target,
          actual: value.actual,
          rate: value.rate,
        };
      }),
    },
    grid: { left: 20, right: 20, bottom: 20, containLabel: true },
    legend: { top: 10 },
    series: [
      { name: '目标', type: 'bar', barMaxWidth: 24 },
      { name: '实际', type: 'bar', barMaxWidth: 24 },
    ],
    toolbox: {
      feature: {
        saveAsImage: { show: true, name: `${metric.label}达成对比` },
      },
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: { type: 'shadow' },
      formatter: (params: unknown) => {
        const list = params as Array<{
          data: { name: string; target: number; actual: number; rate: number | null };
        }>;
        const row = list[0]?.data;
        if (!row) {
          return '';
        }
        return [
          `<b>${row.name}</b>`,
          `目标:${formatNumber(row.target)}`,
          `实际:${formatNumber(row.actual)}`,
          `达成率:${row.rate == null ? '-' : `${row.rate}%`}`,
        ].join('<br/>');
      },
    },
    xAxis: { type: 'category', name: '目标名称' },
    yAxis: { type: 'value', name: valueName },
  } as EChartsOption;
}
src/views/crm/salesTarget/analysis/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,166 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
/** è¾¾æˆåˆ†æžçš„æŸ¥è¯¢è¡¨å• */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'periodType',
      label: '周期类型',
      component: 'Select',
      rules: 'required',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
        placeholder: '请选择周期类型',
      },
      defaultValue: 1,
    },
    {
      fieldName: 'periodValue',
      label: '周期值',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '如 2026 / 2026-Q1 / 2026-07',
      },
      defaultValue: new Date().getFullYear().toString(),
    },
    {
      fieldName: 'targetDimension',
      label: '目标维度',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_DIMENSION, 'number'),
        placeholder: '全部',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_STATUS, 'number'),
        placeholder: '全部',
        allowClear: true,
      },
    },
  ];
}
/** è¾¾æˆåˆ†æžçš„表格字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'name',
      title: '目标名称',
      minWidth: 200,
      fixed: 'left',
    },
    {
      field: 'targetUserName',
      title: '目标对象',
      minWidth: 160,
      slots: { default: 'target' },
    },
    {
      field: 'contractTarget',
      title: '合同金额目标',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'actualContractPrice',
      title: '实际合同金额',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'contractRate',
      title: '合同达成率(%)',
      minWidth: 120,
      slots: { default: 'contractRate' },
    },
    {
      field: 'receivableTarget',
      title: '回款金额目标',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'actualReceivablePrice',
      title: '实际回款金额',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'receivableRate',
      title: '回款达成率(%)',
      minWidth: 120,
      slots: { default: 'receivableRate' },
    },
    {
      field: 'businessTarget',
      title: '商机金额目标',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'actualBusinessPrice',
      title: '实际商机金额',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'businessRate',
      title: '商机达成率(%)',
      minWidth: 120,
      slots: { default: 'businessRate' },
    },
    {
      field: 'customerTarget',
      title: '新增客户目标',
      minWidth: 120,
    },
    {
      field: 'actualCustomerCount',
      title: '实际新增客户',
      minWidth: 120,
    },
    {
      field: 'customerRate',
      title: '客户达成率(%)',
      minWidth: 120,
      slots: { default: 'customerRate' },
    },
    {
      field: 'contractCountTarget',
      title: '新增合同目标',
      minWidth: 120,
    },
    {
      field: 'actualContractCount',
      title: '实际新增合同',
      minWidth: 120,
    },
    {
      field: 'contractCountRate',
      title: '合同数达成率(%)',
      minWidth: 120,
      slots: { default: 'contractCountRate' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      fixed: 'right',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_SALES_TARGET_STATUS },
      },
    },
  ];
}
src/views/crm/salesTarget/analysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
import { onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSalesTargetAnalysis } from '#/api/crm/salesTarget';
import { $t } from '#/locales';
import {
  ANALYSIS_TABS,
  getChartOptions,
  type AnalysisTabKey,
} from './chart-options';
import { useGridColumns, useGridFormSchema } from './data';
const activeTab = ref<string>('contract');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** æŒ‰å½“前表单值 + å½“前指标 Tab åŠ è½½å›¾è¡¨ä¸Žè¡¨æ ¼ */
async function handleLoad() {
  const formValues = await formApi.getValues();
  const res = await getSalesTargetAnalysis(formValues);
  await renderEcharts(getChartOptions(activeTab.value as AnalysisTabKey, res));
  await gridApi.grid.reloadData(res);
}
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-4',
  handleSubmit: handleLoad,
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      autoLoad: false, // å›¾è¡¨ä¸Žè¡¨æ ¼ç»Ÿä¸€èµ° handleLoad æ‰‹åŠ¨åŠ è½½ï¼Œé¿å…ç©ºå‚æ•°è¯·æ±‚
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<CrmSalesTargetApi.SalesTarget>,
});
/** æŒ‡æ ‡ Tab åˆ‡æ¢ */
function handleTabChange(key: string | number) {
  activeTab.value = String(key);
  handleLoad();
}
onMounted(async () => {
  // é‡ç½®è¡¨å•为默认周期(当前年份),默认加载一次达成分析
  await formApi.resetForm();
  await handleLoad();
});
</script>
<template>
  <Page auto-content-height>
    <ContentWrap>
      <QueryForm />
      <Tabs
        v-model:active-key="activeTab"
        class="w-full"
        @change="handleTabChange"
      >
        <Tabs.TabPane
          v-for="item in ANALYSIS_TABS"
          :key="item.key"
          :tab="item.label"
          :force-render="true"
        />
      </Tabs>
      <EchartsUI class="mb-4 w-full" height="380px" ref="chartRef" />
      <Grid>
        <template #target="{ row }">
          <span>
            {{
              row.targetUserName ||
              row.targetDeptName ||
              row.targetItemName ||
              '--'
            }}
          </span>
        </template>
        <template #contractRate="{ row }">
          <span :class="row.contractRate == null ? '' : 'font-medium'">
            {{ row.contractRate == null ? '--' : `${row.contractRate}%` }}
          </span>
        </template>
        <template #receivableRate="{ row }">
          <span :class="row.receivableRate == null ? '' : 'font-medium'">
            {{ row.receivableRate == null ? '--' : `${row.receivableRate}%` }}
          </span>
        </template>
        <template #businessRate="{ row }">
          <span :class="row.businessRate == null ? '' : 'font-medium'">
            {{ row.businessRate == null ? '--' : `${row.businessRate}%` }}
          </span>
        </template>
        <template #customerRate="{ row }">
          <span :class="row.customerRate == null ? '' : 'font-medium'">
            {{ row.customerRate == null ? '--' : `${row.customerRate}%` }}
          </span>
        </template>
        <template #contractCountRate="{ row }">
          <span :class="row.contractCountRate == null ? '' : 'font-medium'">
            {{
              row.contractCountRate == null
                ? '--'
                : `${row.contractCountRate}%`
            }}
          </span>
        </template>
      </Grid>
    </ContentWrap>
  </Page>
</template>
src/views/crm/salesTarget/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,351 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { markRaw } from 'vue';
/** ç›®æ ‡ç»´åº¦æžšä¸¾ */
export const SALES_TARGET_DIMENSION = {
  USER: 1, // ä¸ªäºº
  DEPT: 2, // éƒ¨é—¨
  ITEM: 3, // äº§å“
} as const;
/** å‘¨æœŸç±»åž‹æžšä¸¾ */
export const SALES_TARGET_PERIOD_TYPE = {
  YEAR: 1, // å¹´
  QUARTER: 2, // å­£åº¦
  MONTH: 3, // æœˆ
} as const;
/** å‘¨æœŸå€¼è¾“入占位提示 */
const PERIOD_VALUE_PLACEHOLDER: Record<number, string> = {
  [SALES_TARGET_PERIOD_TYPE.YEAR]: '请输入年份,如 2026',
  [SALES_TARGET_PERIOD_TYPE.QUARTER]: '请输入季度,如 2026-Q1',
  [SALES_TARGET_PERIOD_TYPE.MONTH]: '请输入月份,如 2026-07',
};
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'name',
      label: '目标名称',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '请输入目标名称,如 2026 å¹´åº¦é”€å”®ç›®æ ‡',
        allowClear: true,
      },
    },
    {
      fieldName: 'periodType',
      label: '周期类型',
      component: 'Select',
      rules: 'required',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
        placeholder: '请选择周期类型',
      },
    },
    {
      fieldName: 'periodValue',
      label: '周期值',
      component: 'Input',
      rules: 'required',
      dependencies: {
        triggerFields: ['periodType'],
        async componentProps(values) {
          const placeholder =
            PERIOD_VALUE_PLACEHOLDER[values.periodType] ?? '请输入周期值';
          return { placeholder };
        },
      },
    },
    {
      fieldName: 'targetDimension',
      label: '目标维度',
      component: 'Select',
      rules: 'required',
      componentProps: (_values, form) => ({
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_DIMENSION, 'number'),
        placeholder: '请选择目标维度',
        onChange: () => {
          form.setFieldValue('targetUserId', undefined);
          form.setFieldValue('targetDeptId', undefined);
          form.setFieldValue('targetItemId', undefined);
        },
      }),
    },
    {
      fieldName: 'targetUserId',
      label: '目标用户',
      component: 'ApiSelect',
      componentProps: {
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
        placeholder: '请选择目标用户',
        allowClear: true,
      },
      dependencies: {
        triggerFields: ['targetDimension'],
        show: (values) => values.targetDimension === SALES_TARGET_DIMENSION.USER,
      },
    },
    {
      fieldName: 'targetDeptId',
      label: '目标部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: async () => {
          const data = await getSimpleDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        treeDefaultExpandAll: true,
        placeholder: '请选择目标部门',
        allowClear: true,
      },
      dependencies: {
        triggerFields: ['targetDimension'],
        show: (values) => values.targetDimension === SALES_TARGET_DIMENSION.DEPT,
      },
    },
    {
      fieldName: 'targetItemId',
      label: '目标产品',
      component: markRaw(MdmItemSelect),
      componentProps: {
        placeholder: '请选择目标产品',
      },
      dependencies: {
        triggerFields: ['targetDimension'],
        show: (values) => values.targetDimension === SALES_TARGET_DIMENSION.ITEM,
      },
    },
    {
      fieldName: 'contractTarget',
      label: '合同金额目标(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入合同金额目标',
      },
    },
    {
      fieldName: 'receivableTarget',
      label: '回款金额目标(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入回款金额目标',
      },
    },
    {
      fieldName: 'businessTarget',
      label: '商机金额目标(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入商机金额目标',
      },
    },
    {
      fieldName: 'customerTarget',
      label: '新增客户数目标',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 0,
        placeholder: '请输入新增客户数目标',
      },
    },
    {
      fieldName: 'contractCountTarget',
      label: '新增合同数目标',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 0,
        placeholder: '请输入新增合同数目标',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '目标名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入目标名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'periodType',
      label: '周期类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE, 'number'),
        placeholder: '请选择周期类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'periodValue',
      label: '周期值',
      component: 'Input',
      componentProps: {
        placeholder: '如 2026 / 2026-Q1 / 2026-07',
        allowClear: true,
      },
    },
    {
      fieldName: 'targetDimension',
      label: '目标维度',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_DIMENSION, 'number'),
        placeholder: '请选择目标维度',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.CRM_SALES_TARGET_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'name',
      title: '目标名称',
      minWidth: 200,
      fixed: 'left',
    },
    {
      field: 'periodType',
      title: '周期类型',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_SALES_TARGET_PERIOD_TYPE },
      },
    },
    {
      field: 'periodValue',
      title: '周期值',
      minWidth: 120,
    },
    {
      field: 'targetUserName',
      title: '目标对象',
      minWidth: 160,
      slots: { default: 'target' },
    },
    {
      field: 'contractTarget',
      title: '合同金额',
      minWidth: 200,
      slots: { default: 'contract' },
    },
    {
      field: 'receivableTarget',
      title: '回款金额',
      minWidth: 200,
      slots: { default: 'receivable' },
    },
    {
      field: 'businessTarget',
      title: '商机金额',
      minWidth: 200,
      slots: { default: 'business' },
    },
    {
      field: 'customerTarget',
      title: '新增客户',
      minWidth: 170,
      slots: { default: 'customer' },
    },
    {
      field: 'contractCountTarget',
      title: '新增合同',
      minWidth: 170,
      slots: { default: 'contractCount' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_SALES_TARGET_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 160,
      formatter: 'formatDateTime',
    },
    {
      field: 'creatorName',
      title: '创建人',
      minWidth: 120,
    },
    {
      title: '操作',
      width: 180,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/crm/salesTarget/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,221 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  changeSalesTargetStatus,
  deleteSalesTarget,
  exportSalesTarget,
  getSalesTargetPage,
} from '#/api/crm/salesTarget';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const formValues = await gridApi.formApi.getValues();
  const data = await exportSalesTarget(formValues);
  downloadFileFromBlobPart({ fileName: '销售目标.xls', source: data });
}
/** æ–°å¢žé”€å”®ç›®æ ‡ */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑销售目标 */
function handleEdit(row: CrmSalesTargetApi.SalesTarget) {
  formModalApi.setData({ salesTarget: row }).open();
}
/** ä½œåºŸ/恢复销售目标 */
async function handleChangeStatus(row: CrmSalesTargetApi.SalesTarget) {
  const nextStatus = row.status === 3 ? 0 : 3; // ä½œåºŸ <-> æ¢å¤
  const actionText = row.status === 3 ? '恢复' : '作废';
  const hideLoading = message.loading({
    content: `${actionText}中...`,
    duration: 0,
  });
  try {
    await changeSalesTargetStatus({ id: row.id!, status: nextStatus });
    message.success(`${actionText}成功`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** åˆ é™¤é”€å”®ç›®æ ‡ */
async function handleDelete(row: CrmSalesTargetApi.SalesTarget) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name!]),
    duration: 0,
  });
  try {
    await deleteSalesTarget(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.name!]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getSalesTargetPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
      height: 64, // æ”¾å¤§è¡Œé«˜ï¼Œå®¹çº³ç›®æ ‡/实际/达成率三行内容
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<CrmSalesTargetApi.SalesTarget>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增销售目标',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:sales-target:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['crm:sales-target:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #target="{ row }">
        <span>
          {{
            row.targetUserName ||
            row.targetDeptName ||
            row.targetItemName ||
            '--'
          }}
        </span>
      </template>
      <template #contract="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.contractTarget ?? '-' }}</div>
          <div>实际:{{ row.actualContractPrice ?? '-' }}</div>
          <div>达成率:{{ row.contractRate ?? '-' }}%</div>
        </div>
      </template>
      <template #receivable="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.receivableTarget ?? '-' }}</div>
          <div>实际:{{ row.actualReceivablePrice ?? '-' }}</div>
          <div>达成率:{{ row.receivableRate ?? '-' }}%</div>
        </div>
      </template>
      <template #business="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.businessTarget ?? '-' }}</div>
          <div>实际:{{ row.actualBusinessPrice ?? '-' }}</div>
          <div>达成率:{{ row.businessRate ?? '-' }}%</div>
        </div>
      </template>
      <template #customer="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.customerTarget ?? '-' }}</div>
          <div>实际:{{ row.actualCustomerCount ?? '-' }}</div>
          <div>达成率:{{ row.customerRate ?? '-' }}%</div>
        </div>
      </template>
      <template #contractCount="{ row }">
        <div class="text-xs leading-5">
          <div>目标:{{ row.contractCountTarget ?? '-' }}</div>
          <div>实际:{{ row.actualContractCount ?? '-' }}</div>
          <div>达成率:{{ row.contractCountRate ?? '-' }}%</div>
        </div>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['crm:sales-target:update'],
              onClick: handleEdit.bind(null, row),
              ifShow: row.status !== 3,
            },
            {
              label: row.status === 3 ? '恢复' : '作废',
              type: 'link',
              auth: ['crm:sales-target:update'],
              popConfirm: {
                title:
                  row.status === 3 ? '确认恢复该目标?' : '确认作废该目标?',
                confirm: handleChangeStatus.bind(null, row),
              },
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['crm:sales-target:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name!]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/crm/salesTarget/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { CrmSalesTargetApi } from '#/api/crm/salesTarget';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import {
  createSalesTarget,
  getSalesTarget,
  updateSalesTarget,
} from '#/api/crm/salesTarget';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
type SalesTargetFormModalData = {
  salesTarget?: Pick<CrmSalesTargetApi.SalesTarget, 'id'>;
};
const emit = defineEmits(['success']);
const formData = ref<Partial<CrmSalesTargetApi.SalesTarget>>();
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: 150,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as CrmSalesTargetApi.SalesTarget;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updateSalesTarget(data)
        : createSalesTarget(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;
    }
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData() as null | SalesTargetFormModalData;
    if (!data?.salesTarget?.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getSalesTarget(data.salesTarget.id);
      if (formData.value) {
        await formApi.setValues(formData.value as any);
      }
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
vite.config.ts
@@ -75,7 +75,7 @@
        proxy: {
          "/admin-api": {
            changeOrigin: true,
            target: "http://36.213.180.217:9500",
            target: "http://192.168.0.10:48080",
            ws: true,
          },
        },