feat(crm): 添加销售目标管理功能
- 在字典枚举中新增销售目标相关的类型定义
- 添加销售目标API接口定义和实现
- 实现销售目标的增删改查功能页面
- 添加销售目标达成分析页面,支持多维度指标展示
- 实现销售目标统计图表功能
- 配置开发环境代理地址到本地服务
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| | |
| | | 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 - å®åæå¡æ¨¡å ========== */ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | proxy: { |
| | | "/admin-api": { |
| | | changeOrigin: true, |
| | | target: "http://36.213.180.217:9500", |
| | | target: "http://192.168.0.10:48080", |
| | | ws: true, |
| | | }, |
| | | }, |