From 93ad309dc5b948bf659eed262811eb2b1dc262eb Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期三, 12 八月 2026 16:23:43 +0800
Subject: [PATCH] feat(crm): 添加销售目标管理功能

---
 vite.config.ts                                      |    2 
 src/views/crm/salesTarget/index.vue                 |  221 ++++++++++
 src/views/crm/salesTarget/modules/form.vue          |   94 ++++
 src/views/crm/salesTarget/analysis/index.vue        |  146 ++++++
 src/packages/constants/src/dict-enum.ts             |    3 
 src/views/crm/salesTarget/data.ts                   |  351 ++++++++++++++++
 src/api/crm/salesTarget/index.ts                    |  113 +++++
 src/views/crm/salesTarget/analysis/chart-options.ts |  130 ++++++
 src/views/crm/salesTarget/analysis/data.ts          |  166 +++++++
 9 files changed, 1,225 insertions(+), 1 deletions(-)

diff --git a/src/api/crm/salesTarget/index.ts b/src/api/crm/salesTarget/index.ts
new file mode 100644
index 0000000..22e5330
--- /dev/null
+++ b/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 },
+  );
+}
diff --git a/src/packages/constants/src/dict-enum.ts b/src/packages/constants/src/dict-enum.ts
index b7c45f8..8731055 100644
--- a/src/packages/constants/src/dict-enum.ts
+++ b/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 - 鍞悗鏈嶅姟妯″潡 ========== */
diff --git a/src/views/crm/salesTarget/analysis/chart-options.ts b/src/views/crm/salesTarget/analysis/chart-options.ts
new file mode 100644
index 0000000..c3b3466
--- /dev/null
+++ b/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;
+}
diff --git a/src/views/crm/salesTarget/analysis/data.ts b/src/views/crm/salesTarget/analysis/data.ts
new file mode 100644
index 0000000..aef38c3
--- /dev/null
+++ b/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 },
+      },
+    },
+  ];
+}
diff --git a/src/views/crm/salesTarget/analysis/index.vue b/src/views/crm/salesTarget/analysis/index.vue
new file mode 100644
index 0000000..c20e701
--- /dev/null
+++ b/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>
diff --git a/src/views/crm/salesTarget/data.ts b/src/views/crm/salesTarget/data.ts
new file mode 100644
index 0000000..2d70bb7
--- /dev/null
+++ b/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, // 瀛e害
+  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' },
+    },
+  ];
+}
diff --git a/src/views/crm/salesTarget/index.vue b/src/views/crm/salesTarget/index.vue
new file mode 100644
index 0000000..101f658
--- /dev/null
+++ b/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>
diff --git a/src/views/crm/salesTarget/modules/form.vue b/src/views/crm/salesTarget/modules/form.vue
new file mode 100644
index 0000000..5e09e17
--- /dev/null
+++ b/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>
diff --git a/vite.config.ts b/vite.config.ts
index 15a09bb..5b47f2d 100644
--- a/vite.config.ts
+++ b/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,
           },
         },

--
Gitblit v1.9.3