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