Merge pull request !155 from xingyu/dev
This commit is contained in:
xingyu
2025-06-24 07:40:36 +00:00
committed by Gitee
59 changed files with 5008 additions and 488 deletions

View File

@@ -24,6 +24,7 @@ import {
ImagePreviewGroup, ImagePreviewGroup,
Popconfirm, Popconfirm,
Switch, Switch,
Tag,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag'; import { DictTag } from '#/components/dict-tag';
@@ -113,6 +114,35 @@ setupVbenVxeTable({
}, },
}); });
// 表格配置项可以用 cellRender: { name: 'CellTag' },
vxeUI.renderer.add('CellTag', {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Tag, { color: props?.color }, () => row[column.field]);
},
});
vxeUI.renderer.add('CellTags', {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
if (!row[column.field] || row[column.field].length === 0) {
return '';
}
return h(
'div',
{ class: 'flex items-center justify-center' },
{
default: () =>
row[column.field].map((item: any) =>
h(Tag, { color: props?.color }, { default: () => item }),
),
},
);
},
});
// 表格配置项可以用 cellRender: { name: 'CellDict', props:{dictType: ''} }, // 表格配置项可以用 cellRender: { name: 'CellDict', props:{dictType: ''} },
vxeUI.renderer.add('CellDict', { vxeUI.renderer.add('CellDict', {
renderTableDefault(renderOpts, params) { renderTableDefault(renderOpts, params) {

View File

@@ -1,5 +1,3 @@
import type { PageParam } from '@vben/request';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export namespace CrmStatisticsCustomerApi { export namespace CrmStatisticsCustomerApi {
@@ -93,10 +91,84 @@ export namespace CrmStatisticsCustomerApi {
customerDealCycle: number; customerDealCycle: number;
customerDealCount: number; customerDealCount: number;
} }
export interface CustomerSummaryParams {
times: string[];
interval: number;
deptId: number;
userId: number;
userIds: number[];
}
}
export function getDatas(activeTabName: any, params: any) {
switch (activeTabName) {
case 'conversionStat': {
return getContractSummary(params);
}
case 'customerSummary': {
return getCustomerSummaryByUser(params);
}
case 'dealCycleByArea': {
return getCustomerDealCycleByArea(params);
}
case 'dealCycleByProduct': {
return getCustomerDealCycleByProduct(params);
}
case 'dealCycleByUser': {
return getCustomerDealCycleByUser(params);
}
case 'followUpSummary': {
return getFollowUpSummaryByUser(params);
}
case 'followUpType': {
return getFollowUpSummaryByType(params);
}
case 'poolSummary': {
return getPoolSummaryByUser(params);
}
default: {
return [];
}
}
}
export function getChartDatas(activeTabName: any, params: any) {
switch (activeTabName) {
case 'conversionStat': {
return getCustomerSummaryByDate(params);
}
case 'customerSummary': {
return getCustomerSummaryByDate(params);
}
case 'dealCycleByArea': {
return getCustomerDealCycleByArea(params);
}
case 'dealCycleByProduct': {
return getCustomerDealCycleByProduct(params);
}
case 'dealCycleByUser': {
return getCustomerDealCycleByUser(params);
}
case 'followUpSummary': {
return getFollowUpSummaryByDate(params);
}
case 'followUpType': {
return getFollowUpSummaryByType(params);
}
case 'poolSummary': {
return getPoolSummaryByDate(params);
}
default: {
return [];
}
}
} }
/** 客户总量分析(按日期) */ /** 客户总量分析(按日期) */
export function getCustomerSummaryByDate(params: PageParam) { export function getCustomerSummaryByDate(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.CustomerSummaryByDate[]>( return requestClient.get<CrmStatisticsCustomerApi.CustomerSummaryByDate[]>(
'/crm/statistics-customer/get-customer-summary-by-date', '/crm/statistics-customer/get-customer-summary-by-date',
{ params }, { params },
@@ -104,7 +176,9 @@ export function getCustomerSummaryByDate(params: PageParam) {
} }
/** 客户总量分析(按用户) */ /** 客户总量分析(按用户) */
export function getCustomerSummaryByUser(params: PageParam) { export function getCustomerSummaryByUser(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.CustomerSummaryByUser[]>( return requestClient.get<CrmStatisticsCustomerApi.CustomerSummaryByUser[]>(
'/crm/statistics-customer/get-customer-summary-by-user', '/crm/statistics-customer/get-customer-summary-by-user',
{ params }, { params },
@@ -112,7 +186,9 @@ export function getCustomerSummaryByUser(params: PageParam) {
} }
/** 客户跟进次数分析(按日期) */ /** 客户跟进次数分析(按日期) */
export function getFollowUpSummaryByDate(params: PageParam) { export function getFollowUpSummaryByDate(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.FollowUpSummaryByDate[]>( return requestClient.get<CrmStatisticsCustomerApi.FollowUpSummaryByDate[]>(
'/crm/statistics-customer/get-follow-up-summary-by-date', '/crm/statistics-customer/get-follow-up-summary-by-date',
{ params }, { params },
@@ -120,7 +196,9 @@ export function getFollowUpSummaryByDate(params: PageParam) {
} }
/** 客户跟进次数分析(按用户) */ /** 客户跟进次数分析(按用户) */
export function getFollowUpSummaryByUser(params: PageParam) { export function getFollowUpSummaryByUser(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.FollowUpSummaryByUser[]>( return requestClient.get<CrmStatisticsCustomerApi.FollowUpSummaryByUser[]>(
'/crm/statistics-customer/get-follow-up-summary-by-user', '/crm/statistics-customer/get-follow-up-summary-by-user',
{ params }, { params },
@@ -128,7 +206,9 @@ export function getFollowUpSummaryByUser(params: PageParam) {
} }
/** 获取客户跟进方式统计数 */ /** 获取客户跟进方式统计数 */
export function getFollowUpSummaryByType(params: PageParam) { export function getFollowUpSummaryByType(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.FollowUpSummaryByType[]>( return requestClient.get<CrmStatisticsCustomerApi.FollowUpSummaryByType[]>(
'/crm/statistics-customer/get-follow-up-summary-by-type', '/crm/statistics-customer/get-follow-up-summary-by-type',
{ params }, { params },
@@ -136,7 +216,9 @@ export function getFollowUpSummaryByType(params: PageParam) {
} }
/** 合同摘要信息(客户转化率页面) */ /** 合同摘要信息(客户转化率页面) */
export function getContractSummary(params: PageParam) { export function getContractSummary(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.CustomerContractSummary[]>( return requestClient.get<CrmStatisticsCustomerApi.CustomerContractSummary[]>(
'/crm/statistics-customer/get-contract-summary', '/crm/statistics-customer/get-contract-summary',
{ params }, { params },
@@ -144,7 +226,9 @@ export function getContractSummary(params: PageParam) {
} }
/** 获取客户公海分析(按日期) */ /** 获取客户公海分析(按日期) */
export function getPoolSummaryByDate(params: PageParam) { export function getPoolSummaryByDate(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.PoolSummaryByDate[]>( return requestClient.get<CrmStatisticsCustomerApi.PoolSummaryByDate[]>(
'/crm/statistics-customer/get-pool-summary-by-date', '/crm/statistics-customer/get-pool-summary-by-date',
{ params }, { params },
@@ -152,7 +236,9 @@ export function getPoolSummaryByDate(params: PageParam) {
} }
/** 获取客户公海分析(按用户) */ /** 获取客户公海分析(按用户) */
export function getPoolSummaryByUser(params: PageParam) { export function getPoolSummaryByUser(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.PoolSummaryByUser[]>( return requestClient.get<CrmStatisticsCustomerApi.PoolSummaryByUser[]>(
'/crm/statistics-customer/get-pool-summary-by-user', '/crm/statistics-customer/get-pool-summary-by-user',
{ params }, { params },
@@ -160,7 +246,9 @@ export function getPoolSummaryByUser(params: PageParam) {
} }
/** 获取客户成交周期(按日期) */ /** 获取客户成交周期(按日期) */
export function getCustomerDealCycleByDate(params: PageParam) { export function getCustomerDealCycleByDate(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.CustomerDealCycleByDate[]>( return requestClient.get<CrmStatisticsCustomerApi.CustomerDealCycleByDate[]>(
'/crm/statistics-customer/get-customer-deal-cycle-by-date', '/crm/statistics-customer/get-customer-deal-cycle-by-date',
{ params }, { params },
@@ -168,7 +256,9 @@ export function getCustomerDealCycleByDate(params: PageParam) {
} }
/** 获取客户成交周期(按用户) */ /** 获取客户成交周期(按用户) */
export function getCustomerDealCycleByUser(params: PageParam) { export function getCustomerDealCycleByUser(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.CustomerDealCycleByUser[]>( return requestClient.get<CrmStatisticsCustomerApi.CustomerDealCycleByUser[]>(
'/crm/statistics-customer/get-customer-deal-cycle-by-user', '/crm/statistics-customer/get-customer-deal-cycle-by-user',
{ params }, { params },
@@ -176,7 +266,9 @@ export function getCustomerDealCycleByUser(params: PageParam) {
} }
/** 获取客户成交周期(按地区) */ /** 获取客户成交周期(按地区) */
export function getCustomerDealCycleByArea(params: PageParam) { export function getCustomerDealCycleByArea(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<CrmStatisticsCustomerApi.CustomerDealCycleByArea[]>( return requestClient.get<CrmStatisticsCustomerApi.CustomerDealCycleByArea[]>(
'/crm/statistics-customer/get-customer-deal-cycle-by-area', '/crm/statistics-customer/get-customer-deal-cycle-by-area',
{ params }, { params },
@@ -184,7 +276,9 @@ export function getCustomerDealCycleByArea(params: PageParam) {
} }
/** 获取客户成交周期(按产品) */ /** 获取客户成交周期(按产品) */
export function getCustomerDealCycleByProduct(params: PageParam) { export function getCustomerDealCycleByProduct(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get< return requestClient.get<
CrmStatisticsCustomerApi.CustomerDealCycleByProduct[] CrmStatisticsCustomerApi.CustomerDealCycleByProduct[]
>('/crm/statistics-customer/get-customer-deal-cycle-by-product', { params }); >('/crm/statistics-customer/get-customer-deal-cycle-by-product', { params });

View File

@@ -1,4 +1,4 @@
import type { PageParam, PageResult } from '@vben/request'; import type { PageResult } from '@vben/request';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
@@ -25,8 +25,42 @@ export namespace CrmStatisticsFunnelApi {
} }
} }
export function getDatas(activeTabName: any, params: any) {
switch (activeTabName) {
case 'businessInversionRateSummary': {
return getBusinessPageByDate(params);
}
case 'businessSummary': {
return getBusinessPageByDate(params);
}
case 'funnel': {
return getBusinessSummaryByEndStatus(params);
}
default: {
return [];
}
}
}
export function getChartDatas(activeTabName: any, params: any) {
switch (activeTabName) {
case 'businessInversionRateSummary': {
return getBusinessInversionRateSummaryByDate(params);
}
case 'businessSummary': {
return getBusinessSummaryByDate(params);
}
case 'funnel': {
return getFunnelSummary(params);
}
default: {
return [];
}
}
}
/** 获取销售漏斗统计数据 */ /** 获取销售漏斗统计数据 */
export function getFunnelSummary(params: PageParam) { export function getFunnelSummary(params: any) {
return requestClient.get<CrmStatisticsFunnelApi.FunnelSummary>( return requestClient.get<CrmStatisticsFunnelApi.FunnelSummary>(
'/crm/statistics-funnel/get-funnel-summary', '/crm/statistics-funnel/get-funnel-summary',
{ params }, { params },
@@ -34,7 +68,7 @@ export function getFunnelSummary(params: PageParam) {
} }
/** 获取商机结束状态统计 */ /** 获取商机结束状态统计 */
export function getBusinessSummaryByEndStatus(params: PageParam) { export function getBusinessSummaryByEndStatus(params: any) {
return requestClient.get<Record<string, number>>( return requestClient.get<Record<string, number>>(
'/crm/statistics-funnel/get-business-summary-by-end-status', '/crm/statistics-funnel/get-business-summary-by-end-status',
{ params }, { params },
@@ -42,7 +76,7 @@ export function getBusinessSummaryByEndStatus(params: PageParam) {
} }
/** 获取新增商机分析(按日期) */ /** 获取新增商机分析(按日期) */
export function getBusinessSummaryByDate(params: PageParam) { export function getBusinessSummaryByDate(params: any) {
return requestClient.get<CrmStatisticsFunnelApi.BusinessSummaryByDate[]>( return requestClient.get<CrmStatisticsFunnelApi.BusinessSummaryByDate[]>(
'/crm/statistics-funnel/get-business-summary-by-date', '/crm/statistics-funnel/get-business-summary-by-date',
{ params }, { params },
@@ -50,7 +84,7 @@ export function getBusinessSummaryByDate(params: PageParam) {
} }
/** 获取商机转化率分析(按日期) */ /** 获取商机转化率分析(按日期) */
export function getBusinessInversionRateSummaryByDate(params: PageParam) { export function getBusinessInversionRateSummaryByDate(params: any) {
return requestClient.get< return requestClient.get<
CrmStatisticsFunnelApi.BusinessInversionRateSummaryByDate[] CrmStatisticsFunnelApi.BusinessInversionRateSummaryByDate[]
>('/crm/statistics-funnel/get-business-inversion-rate-summary-by-date', { >('/crm/statistics-funnel/get-business-inversion-rate-summary-by-date', {
@@ -59,7 +93,7 @@ export function getBusinessInversionRateSummaryByDate(params: PageParam) {
} }
/** 获取商机列表(按日期) */ /** 获取商机列表(按日期) */
export function getBusinessPageByDate(params: PageParam) { export function getBusinessPageByDate(params: any) {
return requestClient.get<PageResult<any>>( return requestClient.get<PageResult<any>>(
'/crm/statistics-funnel/get-business-page-by-date', '/crm/statistics-funnel/get-business-page-by-date',
{ params }, { params },

View File

@@ -1,5 +1,3 @@
import type { PageParam } from '@vben/request';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export namespace CrmStatisticsPerformanceApi { export namespace CrmStatisticsPerformanceApi {
@@ -10,10 +8,17 @@ export namespace CrmStatisticsPerformanceApi {
lastMonthCount: number; lastMonthCount: number;
lastYearCount: number; lastYearCount: number;
} }
export interface PerformanceParams {
times: string[];
deptId: number;
userId: number;
}
} }
/** 员工获得合同金额统计 */ /** 员工获得合同金额统计 */
export function getContractPricePerformance(params: PageParam) { export function getContractPricePerformance(
params: CrmStatisticsPerformanceApi.PerformanceParams,
) {
return requestClient.get<CrmStatisticsPerformanceApi.Performance[]>( return requestClient.get<CrmStatisticsPerformanceApi.Performance[]>(
'/crm/statistics-performance/get-contract-price-performance', '/crm/statistics-performance/get-contract-price-performance',
{ params }, { params },
@@ -21,7 +26,9 @@ export function getContractPricePerformance(params: PageParam) {
} }
/** 员工获得回款统计 */ /** 员工获得回款统计 */
export function getReceivablePricePerformance(params: PageParam) { export function getReceivablePricePerformance(
params: CrmStatisticsPerformanceApi.PerformanceParams,
) {
return requestClient.get<CrmStatisticsPerformanceApi.Performance[]>( return requestClient.get<CrmStatisticsPerformanceApi.Performance[]>(
'/crm/statistics-performance/get-receivable-price-performance', '/crm/statistics-performance/get-receivable-price-performance',
{ params }, { params },
@@ -29,7 +36,9 @@ export function getReceivablePricePerformance(params: PageParam) {
} }
/** 员工获得签约合同数量统计 */ /** 员工获得签约合同数量统计 */
export function getContractCountPerformance(params: PageParam) { export function getContractCountPerformance(
params: CrmStatisticsPerformanceApi.PerformanceParams,
) {
return requestClient.get<CrmStatisticsPerformanceApi.Performance[]>( return requestClient.get<CrmStatisticsPerformanceApi.Performance[]>(
'/crm/statistics-performance/get-contract-count-performance', '/crm/statistics-performance/get-contract-count-performance',
{ params }, { params },

View File

@@ -36,6 +36,26 @@ export namespace CrmStatisticsPortraitApi {
} }
} }
export function getDatas(activeTabName: any, params: any) {
switch (activeTabName) {
case 'area': {
return getCustomerArea(params);
}
case 'industry': {
return getCustomerIndustry(params);
}
case 'level': {
return getCustomerLevel(params);
}
case 'source': {
return getCustomerSource(params);
}
default: {
return [];
}
}
}
/** 获取客户行业统计数据 */ /** 获取客户行业统计数据 */
export function getCustomerIndustry(params: PageParam) { export function getCustomerIndustry(params: PageParam) {
return requestClient.get<CrmStatisticsPortraitApi.CustomerIndustry[]>( return requestClient.get<CrmStatisticsPortraitApi.CustomerIndustry[]>(

View File

@@ -11,6 +11,38 @@ export namespace CrmStatisticsRankApi {
} }
} }
export function getDatas(activeTabName: any, params: any) {
switch (activeTabName) {
case 'contactCountRank': {
return getContactsCountRank(params);
}
case 'contractCountRank': {
return getContractCountRank(params);
}
case 'contractPriceRank': {
return getContractPriceRank(params);
}
case 'customerCountRank': {
return getCustomerCountRank(params);
}
case 'followCountRank': {
return getFollowCountRank(params);
}
case 'followCustomerCountRank': {
return getFollowCustomerCountRank(params);
}
case 'productSalesRank': {
return getProductSalesRank(params);
}
case 'receivablePriceRank': {
return getReceivablePriceRank(params);
}
default: {
return [];
}
}
}
/** 获得合同排行榜 */ /** 获得合同排行榜 */
export function getContractPriceRank(params: PageParam) { export function getContractPriceRank(params: PageParam) {
return requestClient.get<CrmStatisticsRankApi.Rank[]>( return requestClient.get<CrmStatisticsRankApi.Rank[]>(

View File

@@ -121,7 +121,7 @@ const apiSelectRule = [
field: 'data', field: 'data',
title: '请求参数 JSON 格式', title: '请求参数 JSON 格式',
props: { props: {
autosize: true, autoSize: true,
type: 'textarea', type: 'textarea',
placeholder: '{"type": 1}', placeholder: '{"type": 1}',
}, },
@@ -155,7 +155,7 @@ const apiSelectRule = [
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表 info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
(data: any)=>{ label: string; value: any }[]`, (data: any)=>{ label: string; value: any }[]`,
props: { props: {
autosize: true, autoSize: true,
rows: { minRows: 2, maxRows: 6 }, rows: { minRows: 2, maxRows: 6 },
type: 'textarea', type: 'textarea',
placeholder: ` placeholder: `

View File

@@ -63,6 +63,7 @@ const [Modal, modalApi] = useVbenModal({
}); });
// TODO xingyu 暴露 modalApi 给父组件是否合适? trigger-node-config.vue 会有多个 conditionDialog 实例 // TODO xingyu 暴露 modalApi 给父组件是否合适? trigger-node-config.vue 会有多个 conditionDialog 实例
// 不用暴露啊,用 useVbenModal 就可以了
defineExpose({ modalApi }); defineExpose({ modalApi });
</script> </script>
<template> <template>

View File

@@ -5,7 +5,10 @@ import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue'; import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useTaskStatusClass, useWatchNode } from '../../helpers'; import { useTaskStatusClass, useWatchNode } from '../../helpers';
import ProcessInstanceModal from './modules/process-instance-modal.vue';
defineOptions({ name: 'EndEventNode' }); defineOptions({ name: 'EndEventNode' });
const props = defineProps({ const props = defineProps({
@@ -20,15 +23,26 @@ const currentNode = useWatchNode(props);
const readonly = inject<Boolean>('readonly'); const readonly = inject<Boolean>('readonly');
const processInstance = inject<Ref<any>>('processInstance', ref({})); const processInstance = inject<Ref<any>>('processInstance', ref({}));
const processInstanceInfos = ref<any[]>([]); // 流程的审批信息 const [Modal, modalApi] = useVbenModal({
connectedComponent: ProcessInstanceModal,
destroyOnClose: true,
});
function nodeClick() { function nodeClick() {
if (readonly && processInstance && processInstance.value) { if (readonly && processInstance && processInstance.value) {
console.warn( const processInstanceInfo = [
'TODO 只读模式,弹窗显示审批信息', {
processInstance.value, startUser: processInstance.value.startUser,
processInstanceInfos.value, createTime: processInstance.value.startTime,
); endTime: processInstance.value.endTime,
status: processInstance.value.status,
durationInMillis: processInstance.value.durationInMillis,
},
];
modalApi
.setData(processInstanceInfo)
.setState({ title: '流程信息' })
.open();
} }
} }
</script> </script>
@@ -42,5 +56,6 @@ function nodeClick() {
<span class="node-fixed-name" title="结束">结束</span> <span class="node-fixed-name" title="结束">结束</span>
</div> </div>
</div> </div>
<!-- TODO 审批信息 --> <!-- 流程信息弹窗 -->
<Modal />
</template> </template>

View File

@@ -0,0 +1,56 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { DICT_TYPE } from '#/utils';
/** 流程实例列表字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'startUser',
title: '发起人',
slots: {
default: ({ row }: { row: any }) => {
return row.startUser?.nickname;
},
},
minWidth: 100,
},
{
field: 'deptName',
title: '部门',
slots: {
default: ({ row }: { row: any }) => {
return row.startUser?.deptName;
},
},
minWidth: 100,
},
{
field: 'createTime',
title: '开始时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'endTime',
title: '结束时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'status',
title: '流程状态',
minWidth: 90,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS },
},
},
{
field: 'durationInMillis',
title: '耗时',
minWidth: 100,
formatter: 'formatPast2',
},
];
}

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { useGridColumns } from './process-instance-data';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
border: true,
height: 'auto',
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
const [Modal, modalApi] = useVbenModal({
footer: false,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
const data = modalApi.getData<any[]>();
// 填充列表数据
await gridApi.setGridOptions({ data });
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/4">
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,61 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { DICT_TYPE } from '#/utils';
/** 审批记录列表字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'assigneeUser',
title: '审批人',
slots: {
default: ({ row }: { row: any }) => {
return row.assigneeUser?.nickname || row.ownerUser?.nickname;
},
},
minWidth: 100,
},
{
field: 'deptName',
title: '部门',
slots: {
default: ({ row }: { row: any }) => {
return row.assigneeUser?.deptName || row.ownerUser?.deptName;
},
},
minWidth: 100,
},
{
field: 'createTime',
title: '开始时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'endTime',
title: '结束时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'status',
title: '审批状态',
minWidth: 90,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BPM_TASK_STATUS },
},
},
{
field: 'reason',
title: '审批建议',
minWidth: 160,
},
{
field: 'durationInMillis',
title: '耗时',
minWidth: 100,
formatter: 'formatPast2',
},
];
}

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { useGridColumns } from './task-list-data';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
border: true,
height: 'auto',
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
const [Modal, modalApi] = useVbenModal({
footer: false,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
const data = modalApi.getData<any[]>();
// 填充列表数据
await gridApi.setGridOptions({ data });
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/4">
<Grid />
</Modal>
</template>

View File

@@ -6,6 +6,7 @@ import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue'; import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue'; import { Input } from 'ant-design-vue';
@@ -15,6 +16,7 @@ import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts'; import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers'; import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import StartUserNodeConfig from '../nodes-config/start-user-node-config.vue'; import StartUserNodeConfig from '../nodes-config/start-user-node-config.vue';
import TaskListModal from './modules/task-list-modal.vue';
import NodeHandler from './node-handler.vue'; import NodeHandler from './node-handler.vue';
defineOptions({ name: 'StartUserNode' }); defineOptions({ name: 'StartUserNode' });
@@ -27,7 +29,6 @@ const props = defineProps({
}); });
// 定义事件,更新父组件。 // 定义事件,更新父组件。
// const emits = defineEmits<{
defineEmits<{ defineEmits<{
'update:modelValue': [node: SimpleFlowNode | undefined]; 'update:modelValue': [node: SimpleFlowNode | undefined];
}>(); }>();
@@ -44,24 +45,25 @@ const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
const nodeSetting = ref(); const nodeSetting = ref();
// 任务的弹窗显示,用于只读模式 const [Modal, modalApi] = useVbenModal({
const selectTasks = ref<any[] | undefined>([]); // 选中的任务数组 connectedComponent: TaskListModal,
destroyOnClose: true,
});
function nodeClick() { function nodeClick() {
if (readonly) { if (readonly) {
// 只读模式,弹窗显示任务信息 // 只读模式,弹窗显示任务信息
if (tasks && tasks.value) { if (tasks && tasks.value) {
console.warn( // 过滤出当前节点的任务
'TODO 只读模式,弹窗显示任务信息', const nodeTasks = tasks.value.filter(
tasks.value, (task) => task.taskDefinitionKey === currentNode.value.id,
selectTasks.value,
); );
// 弹窗显示任务信息
modalApi
.setData(nodeTasks)
.setState({ title: currentNode.value.name })
.open();
} }
} else { } else {
console.warn(
'TODO 编辑模式,打开节点配置、把当前节点传递给配置组件',
nodeSetting.value,
);
nodeSetting.value.showStartUserNodeConfig(currentNode.value); nodeSetting.value.showStartUserNodeConfig(currentNode.value);
} }
} }
@@ -122,5 +124,6 @@ function nodeClick() {
ref="nodeSetting" ref="nodeSetting"
:flow-node="currentNode" :flow-node="currentNode"
/> />
<!-- 审批记录 TODO --> <!-- 审批记录弹窗 -->
<Modal />
</template> </template>

View File

@@ -5,6 +5,7 @@ import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue'; import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue'; import { Input } from 'ant-design-vue';
@@ -14,6 +15,26 @@ import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts'; import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers'; import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import UserTaskNodeConfig from '../nodes-config/user-task-node-config.vue'; import UserTaskNodeConfig from '../nodes-config/user-task-node-config.vue';
import TaskListModal from './modules/task-list-modal.vue';
// // 使用useVbenVxeGrid
// const [Grid, gridApi] = useVbenVxeGrid({
// gridOptions: {
// columns: columns.value,
// keepSource: true,
// border: true,
// height: 'auto',
// data: selectTasks.value,
// rowConfig: {
// keyField: 'id',
// },
// pagerConfig: {
// enabled: false,
// },
// toolbarConfig: {
// enabled: false,
// },
// } as VxeTableGridOptions<any>,
// });
import NodeHandler from './node-handler.vue'; import NodeHandler from './node-handler.vue';
defineOptions({ name: 'UserTaskNode' }); defineOptions({ name: 'UserTaskNode' });
@@ -42,11 +63,23 @@ const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
); );
const nodeSetting = ref(); const nodeSetting = ref();
const [Modal, modalApi] = useVbenModal({
connectedComponent: TaskListModal,
destroyOnClose: true,
});
function nodeClick() { function nodeClick() {
if (readonly) { if (readonly) {
if (tasks && tasks.value) { if (tasks && tasks.value) {
// 只读模式,弹窗显示任务信息 TODO 待实现 // 过滤出当前节点的任务
console.warn('只读模式,弹窗显示任务信息待实现'); const nodeTasks = tasks.value.filter(
(task) => task.taskDefinitionKey === currentNode.value.id,
);
// 弹窗显示任务信息
modalApi
.setData(nodeTasks)
.setState({ title: currentNode.value.name })
.open();
} }
} else { } else {
// 编辑模式,打开节点配置、把当前节点传递给配置组件 // 编辑模式,打开节点配置、把当前节点传递给配置组件
@@ -64,8 +97,6 @@ function findReturnTaskNodes(
// 从父节点查找 // 从父节点查找
emits('findParentNode', matchNodeList, BpmNodeTypeEnum.USER_TASK_NODE); emits('findParentNode', matchNodeList, BpmNodeTypeEnum.USER_TASK_NODE);
} }
// const selectTasks = ref<any[] | undefined>([]); // 选中的任务数组
</script> </script>
<template> <template>
<div class="node-wrapper"> <div class="node-wrapper">
@@ -138,5 +169,6 @@ function findReturnTaskNodes(
:flow-node="currentNode" :flow-node="currentNode"
@find-return-task-nodes="findReturnTaskNodes" @find-return-task-nodes="findReturnTaskNodes"
/> />
<!-- TODO 审批记录 --> <!-- 审批记录弹窗 -->
<Modal />
</template> </template>

View File

@@ -249,7 +249,7 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
<!-- TODO 这个好像暂时没有用到保存失败弹窗 -->
<Modal <Modal
v-model:open="errorDialogVisible" v-model:open="errorDialogVisible"
title="保存失败" title="保存失败"

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../consts';
import { provide, ref, watch } from 'vue';
import { useWatchNode } from '../helpers';
import SimpleProcessModel from './simple-process-model.vue';
defineOptions({ name: 'SimpleProcessViewer' });
const props = withDefaults(
defineProps<{
flowNode: SimpleFlowNode;
// 流程实例
processInstance?: any;
// 流程任务
tasks?: any[];
}>(),
{
processInstance: undefined,
tasks: () => [] as any[],
},
);
const approveTasks = ref<any[]>(props.tasks);
const currentProcessInstance = ref(props.processInstance);
const simpleModel = useWatchNode(props);
watch(
() => props.tasks,
(newValue) => {
approveTasks.value = newValue;
},
);
watch(
() => props.processInstance,
(newValue) => {
currentProcessInstance.value = newValue;
},
);
// 提供给后代组件使用
provide('tasks', approveTasks);
provide('processInstance', currentProcessInstance);
</script>
<template>
<SimpleProcessModel :flow-node="simpleModel" :readonly="true" />
</template>

View File

@@ -4,4 +4,8 @@ export { default as HttpRequestSetting } from './components/nodes-config/modules
export { default as SimpleProcessDesigner } from './components/simple-process-designer.vue'; export { default as SimpleProcessDesigner } from './components/simple-process-designer.vue';
export { default as SimpleProcessViewer } from './components/simple-process-viewer.vue';
export type { SimpleFlowNode } from './consts';
export { parseFormFields } from './helpers'; export { parseFormFields } from './helpers';

View File

@@ -41,13 +41,13 @@ const props = defineProps({
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
/** 缓存处理后的actions */ /** 缓存处理后的 actions */
const processedActions = ref<any[]>([]); const processedActions = ref<any[]>([]);
const processedDropdownActions = ref<any[]>([]); const processedDropdownActions = ref<any[]>([]);
/** 用于比较的字符串化版本 */ /** 用于比较的字符串化版本 */
const actionsStringified = ref(''); const actionsStringField = ref('');
const dropdownActionsStringified = ref(''); const dropdownActionsStringField = ref('');
function isIfShow(action: ActionItem): boolean { function isIfShow(action: ActionItem): boolean {
const ifShow = action.ifShow; const ifShow = action.ifShow;
@@ -65,7 +65,7 @@ function isIfShow(action: ActionItem): boolean {
return isIfShow; return isIfShow;
} }
/** 处理actions的纯函数 */ /** 处理 actions 的纯函数 */
function processActions(actions: ActionItem[]): any[] { function processActions(actions: ActionItem[]): any[] {
return actions return actions
.filter((action: ActionItem) => { .filter((action: ActionItem) => {
@@ -84,7 +84,7 @@ function processActions(actions: ActionItem[]): any[] {
}); });
} }
/** 处理下拉菜单actions的纯函数 */ /** 处理下拉菜单 actions 的纯函数 */
function processDropdownActions( function processDropdownActions(
dropDownActions: ActionItem[], dropDownActions: ActionItem[],
divider: boolean, divider: boolean,
@@ -108,10 +108,10 @@ function processDropdownActions(
}); });
} }
/** 监听actions变化并更新缓存 */ /** 监听 actions 变化并更新缓存 */
watchEffect(() => { watchEffect(() => {
const rawActions = toRaw(props.actions) || []; const rawActions = toRaw(props.actions) || [];
const currentStringified = JSON.stringify( const currentStringField = JSON.stringify(
rawActions.map((a) => ({ rawActions.map((a) => ({
...a, ...a,
onClick: undefined, // 排除函数以便比较 onClick: undefined, // 排除函数以便比较
@@ -121,16 +121,16 @@ watchEffect(() => {
})), })),
); );
if (currentStringified !== actionsStringified.value) { if (currentStringField !== actionsStringField.value) {
actionsStringified.value = currentStringified; actionsStringField.value = currentStringField;
processedActions.value = processActions(rawActions); processedActions.value = processActions(rawActions);
} }
}); });
/** 监听dropDownActions变化并更新缓存 */ /** 监听 dropDownActions 变化并更新缓存 */
watchEffect(() => { watchEffect(() => {
const rawDropDownActions = toRaw(props.dropDownActions) || []; const rawDropDownActions = toRaw(props.dropDownActions) || [];
const currentStringified = JSON.stringify({ const currentStringField = JSON.stringify({
actions: rawDropDownActions.map((a) => ({ actions: rawDropDownActions.map((a) => ({
...a, ...a,
onClick: undefined, // 排除函数以便比较 onClick: undefined, // 排除函数以便比较
@@ -141,8 +141,8 @@ watchEffect(() => {
divider: props.divider, divider: props.divider,
}); });
if (currentStringified !== dropdownActionsStringified.value) { if (currentStringField !== dropdownActionsStringField.value) {
dropdownActionsStringified.value = currentStringified; dropdownActionsStringField.value = currentStringField;
processedDropdownActions.value = processDropdownActions( processedDropdownActions.value = processDropdownActions(
rawDropDownActions, rawDropDownActions,
props.divider, props.divider,
@@ -154,14 +154,14 @@ const getActions = computed(() => processedActions.value);
const getDropdownList = computed(() => processedDropdownActions.value); const getDropdownList = computed(() => processedDropdownActions.value);
/** 缓存Space组件的size计算结果 */ /** 缓存 Space 组件的 size 计算结果 */
const spaceSize = computed(() => { const spaceSize = computed(() => {
return unref(getActions)?.some((item: ActionItem) => item.type === 'link') return unref(getActions)?.some((item: ActionItem) => item.type === 'link')
? 0 ? 0
: 8; : 8;
}); });
/** 缓存PopConfirm属性 */ /** 缓存 PopConfirm 属性 */
const popConfirmPropsMap = new Map<string, any>(); const popConfirmPropsMap = new Map<string, any>();
function getPopConfirmProps(attrs: PopConfirm) { function getPopConfirmProps(attrs: PopConfirm) {
@@ -191,7 +191,7 @@ function getPopConfirmProps(attrs: PopConfirm) {
return originAttrs; return originAttrs;
} }
/** 缓存Button属性 */ /** 缓存 Button 属性 */
const buttonPropsMap = new Map<string, any>(); const buttonPropsMap = new Map<string, any>();
function getButtonProps(action: ActionItem) { function getButtonProps(action: ActionItem) {
@@ -217,7 +217,7 @@ function getButtonProps(action: ActionItem) {
return res; return res;
} }
/** 缓存Tooltip属性 */ /** 缓存 Tooltip 属性 */
const tooltipPropsMap = new Map<string, any>(); const tooltipPropsMap = new Map<string, any>();
function getTooltipProps(tooltip: any | string) { function getTooltipProps(tooltip: any | string) {
@@ -243,7 +243,7 @@ function handleMenuClick(e: any) {
} }
} }
/** 生成稳定的key */ /** 生成稳定的 key */
function getActionKey(action: ActionItem, index: number) { function getActionKey(action: ActionItem, index: number) {
return `${action.label || ''}-${action.type || ''}-${index}`; return `${action.label || ''}-${action.type || ''}-${index}`;
} }

View File

@@ -26,7 +26,7 @@ defineExpose({
> >
<Textarea <Textarea
v-model:value="formData.desc" v-model:value="formData.desc"
:autosize="{ minRows: 6, maxRows: 6 }" :auto-size="{ minRows: 6, maxRows: 6 }"
:maxlength="1200" :maxlength="1200"
:show-count="true" :show-count="true"
placeholder="一首关于糟糕分手的欢快歌曲" placeholder="一首关于糟糕分手的欢快歌曲"

View File

@@ -28,7 +28,7 @@ defineExpose({
<Title title="歌词" desc="自己编写歌词或使用Ai生成歌词两节/8行效果最佳"> <Title title="歌词" desc="自己编写歌词或使用Ai生成歌词两节/8行效果最佳">
<Textarea <Textarea
v-model:value="formData.lyric" v-model:value="formData.lyric"
:autosize="{ minRows: 6, maxRows: 6 }" :auto-size="{ minRows: 6, maxRows: 6 }"
:maxlength="1200" :maxlength="1200"
:show-count="true" :show-count="true"
placeholder="请输入您自己的歌词" placeholder="请输入您自己的歌词"
@@ -60,7 +60,7 @@ defineExpose({
> >
<Textarea <Textarea
v-model="formData.style" v-model="formData.style"
:autosize="{ minRows: 4, maxRows: 4 }" :auto-size="{ minRows: 4, maxRows: 4 }"
:maxlength="256" :maxlength="256"
show-count show-count
placeholder="输入音乐风格(英文)" placeholder="输入音乐风格(英文)"

View File

@@ -100,15 +100,6 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
minWidth: 180, minWidth: 180,
slots: { default: 'content' }, slots: { default: 'content' },
}, },
{
field: 'status',
title: '绘画状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.AI_IMAGE_STATUS },
},
},
{ {
field: 'duration', field: 'duration',
title: '时长(秒)', title: '时长(秒)',
@@ -139,9 +130,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
}, },
}, },
{ {
field: 'tags',
title: '风格标签', title: '风格标签',
minWidth: 180, minWidth: 180,
slots: { default: 'tags' }, cellRender: {
name: 'CellTags',
},
}, },
{ {
minWidth: 100, minWidth: 100,

View File

@@ -7,7 +7,7 @@ import { onMounted, ref } from 'vue';
import { confirm, DocAlert, Page } from '@vben/common-ui'; import { confirm, DocAlert, Page } from '@vben/common-ui';
import { Button, message, Switch, Tag } from 'ant-design-vue'; import { Button, message, Switch } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMusic, getMusicPage, updateMusic } from '#/api/ai/music'; import { deleteMusic, getMusicPage, updateMusic } from '#/api/ai/music';
@@ -101,9 +101,9 @@ onMounted(async () => {
</template> </template>
<template #userId="{ row }"> <template #userId="{ row }">
<span>{{ <span>
userList.find((item) => item.id === row.userId)?.nickname {{ userList.find((item) => item.id === row.userId)?.nickname }}
}}</span> </span>
</template> </template>
<template #content="{ row }"> <template #content="{ row }">
<Button <Button
@@ -141,11 +141,6 @@ onMounted(async () => {
:disabled="row.status !== AiMusicStatusEnum.SUCCESS" :disabled="row.status !== AiMusicStatusEnum.SUCCESS"
/> />
</template> </template>
<template #tags="{ row }">
<Tag v-for="tag in row.tags" :key="tag" class="ml-1">
{{ tag }}
</Tag>
</template>
<template #actions="{ row }"> <template #actions="{ row }">
<TableAction <TableAction
:actions="[ :actions="[

View File

@@ -98,7 +98,7 @@ watch(copied, (val) => {
<Textarea <Textarea
id="inputId" id="inputId"
v-model:value="compContent" v-model:value="compContent"
autosize auto-size
:bordered="false" :bordered="false"
placeholder="生成的内容……" placeholder="生成的内容……"
/> />

View File

@@ -21,7 +21,7 @@ const props = defineProps<{
type: 'copy' | 'create' | 'edit'; type: 'copy' | 'create' | 'edit';
}>(); }>();
// 流程表单详情 /** 流程表单详情 */
const flowFormConfig = ref(); const flowFormConfig = ref();
const [FormModal, formModalApi] = useVbenModal({ const [FormModal, formModalApi] = useVbenModal({
@@ -31,7 +31,7 @@ const [FormModal, formModalApi] = useVbenModal({
const designerRef = ref<InstanceType<typeof FcDesigner>>(); const designerRef = ref<InstanceType<typeof FcDesigner>>();
// 表单设计器配置 /** 表单设计器配置 */
const designerConfig = ref({ const designerConfig = ref({
switchType: [], // 是否可以切换组件类型,或者可以相互切换的字段 switchType: [], // 是否可以切换组件类型,或者可以相互切换的字段
autoActive: true, // 是否自动选中拖入的组件 autoActive: true, // 是否自动选中拖入的组件
@@ -80,7 +80,7 @@ const currentFormId = computed(() => {
}); });
// 加载表单配置 // 加载表单配置
async function loadFormConfig(id: number | string) { async function loadFormConfig(id: number) {
try { try {
const formDetail = await getFormDetail(id); const formDetail = await getFormDetail(id);
flowFormConfig.value = formDetail; flowFormConfig.value = formDetail;
@@ -106,8 +106,7 @@ async function initializeDesigner() {
} }
} }
// TODO @ziye注释使用 /** */ 风格,高亮更明显哈,方法注释; /** 保存表单 */
// 保存表单
function handleSave() { function handleSave() {
formModalApi formModalApi
.setData({ .setData({
@@ -118,7 +117,7 @@ function handleSave() {
.open(); .open();
} }
// 返回列表页 /** 返回列表页 */
function onBack() { function onBack() {
router.push({ router.push({
path: '/bpm/manager/form', path: '/bpm/manager/form',
@@ -137,7 +136,11 @@ onMounted(() => {
<Page auto-content-height> <Page auto-content-height>
<FormModal @success="onBack" /> <FormModal @success="onBack" />
<FcDesigner class="my-designer" ref="designerRef" :config="designerConfig"> <FcDesigner
class="h-full min-h-[500px]"
ref="designerRef"
:config="designerConfig"
>
<template #handle> <template #handle>
<Button size="small" type="primary" @click="handleSave"> <Button size="small" type="primary" @click="handleSave">
<IconifyIcon icon="mdi:content-save" /> <IconifyIcon icon="mdi:content-save" />
@@ -147,10 +150,3 @@ onMounted(() => {
</FcDesigner> </FcDesigner>
</Page> </Page>
</template> </template>
<style scoped>
.my-designer {
height: 100%;
min-height: 500px;
}
</style>

View File

@@ -20,7 +20,13 @@ export function useGridColumns(): VxeTableGridOptions<BpmProcessDefinitionApi.Pr
field: 'icon', field: 'icon',
title: '流程图标', title: '流程图标',
minWidth: 100, minWidth: 100,
slots: { default: 'icon' }, cellRender: {
name: 'CellImage',
props: {
width: 24,
height: 24,
},
},
}, },
{ {
field: 'startUsers', field: 'startUsers',
@@ -47,7 +53,9 @@ export function useGridColumns(): VxeTableGridOptions<BpmProcessDefinitionApi.Pr
field: 'version', field: 'version',
title: '流程版本', title: '流程版本',
minWidth: 80, minWidth: 80,
slots: { default: 'version' }, cellRender: {
name: 'CellTag',
},
}, },
{ {
field: 'deploymentTime', field: 'deploymentTime',

View File

@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui'; import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, Tag, Tooltip } from 'ant-design-vue'; import { Button, Tooltip } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProcessDefinitionPage } from '#/api/bpm/definition'; import { getProcessDefinitionPage } from '#/api/bpm/definition';
@@ -93,16 +93,6 @@ onMounted(() => {
<DocAlert title="工作流手册" url="https://doc.iocoder.cn/bpm/" /> <DocAlert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
</template> </template>
<Grid table-title="流程定义列表"> <Grid table-title="流程定义列表">
<template #icon="{ row }">
<Image
v-if="row.icon"
:src="row.icon"
:width="24"
:height="24"
class="rounded"
/>
<span v-else> 无图标 </span>
</template>
<template #startUsers="{ row }"> <template #startUsers="{ row }">
<template v-if="!row.startUsers?.length">全部可见</template> <template v-if="!row.startUsers?.length">全部可见</template>
<template v-else-if="row.startUsers.length === 1"> <template v-else-if="row.startUsers.length === 1">
@@ -135,9 +125,6 @@ onMounted(() => {
</Button> </Button>
<span v-else>暂无表单</span> <span v-else>暂无表单</span>
</template> </template>
<template #version="{ row }">
<Tag>v{{ row.version }}</Tag>
</template>
<template #actions="{ row }"> <template #actions="{ row }">
<TableAction <TableAction
:actions="[ :actions="[

View File

@@ -4,7 +4,7 @@ import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue'; import { h } from 'vue';
import dayjs from 'dayjs'; import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag'; import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils'; import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
@@ -186,12 +186,12 @@ export function useDetailFormSchema(): DescriptionItemSchema[] {
{ {
label: '开始时间', label: '开始时间',
field: 'startTime', field: 'startTime',
content: (data) => dayjs(data?.startTime).format('YYYY-MM-DD HH:mm:ss'), content: (data) => formatDateTime(data?.startTime) as string,
}, },
{ {
label: '结束时间', label: '结束时间',
field: 'endTime', field: 'endTime',
content: (data) => dayjs(data?.endTime).format('YYYY-MM-DD HH:mm:ss'), content: (data) => formatDateTime(data?.endTime) as string,
}, },
{ {
label: '原因', label: '原因',

View File

@@ -346,7 +346,12 @@ onMounted(async () => {
</Row> </Row>
</TabPane> </TabPane>
<TabPane tab="流程图" key="diagram" class="tab-pane-content"> <TabPane
tab="流程图"
key="diagram"
class="tab-pane-content"
:force-render="true"
>
<div class="h-full"> <div class="h-full">
<ProcessInstanceSimpleViewer <ProcessInstanceSimpleViewer
v-show=" v-show="

View File

@@ -1,9 +1,180 @@
<script setup lang="ts"> <script lang="ts" setup>
defineOptions({ name: 'ProcessInstanceSimpleViewer' }); import type { SimpleFlowNode } from '#/components/simple-process-design';
</script>
import { ref, watch } from 'vue';
import { SimpleProcessViewer } from '#/components/simple-process-design';
import { BpmNodeTypeEnum, BpmTaskStatusEnum } from '#/utils';
defineOptions({ name: 'BpmProcessInstanceSimpleViewer' });
const props = withDefaults(
defineProps<{
loading?: boolean; // 是否加载中
modelView?: any;
simpleJson?: string; // Simple 模型结构数据 (json 格式)
}>(),
{
loading: false,
modelView: () => ({}),
simpleJson: '',
},
);
const simpleModel = ref<any>({});
// 用户任务
const tasks = ref([]);
// 流程实例
const processInstance = ref();
/** 监控模型视图 包括任务列表、进行中的活动节点编号等 */
watch(
() => props.modelView,
async (newModelView) => {
if (newModelView) {
tasks.value = newModelView.tasks;
processInstance.value = newModelView.processInstance;
// 已经拒绝的活动节点编号集合,只包括 UserTask
const rejectedTaskActivityIds: string[] =
newModelView.rejectedTaskActivityIds;
// 进行中的活动节点编号集合, 只包括 UserTask
const unfinishedTaskActivityIds: string[] =
newModelView.unfinishedTaskActivityIds;
// 已经完成的活动节点编号集合, 包括 UserTask、Gateway 等
const finishedActivityIds: string[] =
newModelView.finishedTaskActivityIds;
// 已经完成的连线节点编号集合,只包括 SequenceFlow
const finishedSequenceFlowActivityIds: string[] =
newModelView.finishedSequenceFlowActivityIds;
setSimpleModelNodeTaskStatus(
newModelView.simpleModel,
newModelView.processInstance?.status,
rejectedTaskActivityIds,
unfinishedTaskActivityIds,
finishedActivityIds,
finishedSequenceFlowActivityIds,
);
simpleModel.value = newModelView.simpleModel || {};
}
},
);
/** 监控模型结构数据 */
watch(
() => props.simpleJson,
async (value) => {
if (value) {
simpleModel.value = JSON.parse(value);
}
},
);
const setSimpleModelNodeTaskStatus = (
simpleModel: SimpleFlowNode | undefined,
processStatus: number,
rejectedTaskActivityIds: string[],
unfinishedTaskActivityIds: string[],
finishedActivityIds: string[],
finishedSequenceFlowActivityIds: string[],
) => {
if (!simpleModel) {
return;
}
// 结束节点
if (simpleModel.type === BpmNodeTypeEnum.END_EVENT_NODE) {
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
? processStatus
: BpmTaskStatusEnum.NOT_START;
return;
}
// 审批节点
if (
simpleModel.type === BpmNodeTypeEnum.START_USER_NODE ||
simpleModel.type === BpmNodeTypeEnum.USER_TASK_NODE ||
simpleModel.type === BpmNodeTypeEnum.TRANSACTOR_NODE ||
simpleModel.type === BpmNodeTypeEnum.CHILD_PROCESS_NODE
) {
simpleModel.activityStatus = BpmTaskStatusEnum.NOT_START;
if (rejectedTaskActivityIds.includes(simpleModel.id)) {
simpleModel.activityStatus = BpmTaskStatusEnum.REJECT;
} else if (unfinishedTaskActivityIds.includes(simpleModel.id)) {
simpleModel.activityStatus = BpmTaskStatusEnum.RUNNING;
} else if (finishedActivityIds.includes(simpleModel.id)) {
simpleModel.activityStatus = BpmTaskStatusEnum.APPROVE;
}
// TODO 是不是还缺一个 cancel 的状态
}
// 抄送节点
if (simpleModel.type === BpmNodeTypeEnum.COPY_TASK_NODE) {
// 抄送节点,只有通过和未执行状态
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
? BpmTaskStatusEnum.APPROVE
: BpmTaskStatusEnum.NOT_START;
}
// 延迟器节点
if (simpleModel.type === BpmNodeTypeEnum.DELAY_TIMER_NODE) {
// 延迟器节点,只有通过和未执行状态
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
? BpmTaskStatusEnum.APPROVE
: BpmTaskStatusEnum.NOT_START;
}
// 触发器节点
if (simpleModel.type === BpmNodeTypeEnum.TRIGGER_NODE) {
// 触发器节点,只有通过和未执行状态
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
? BpmTaskStatusEnum.APPROVE
: BpmTaskStatusEnum.NOT_START;
}
// 条件节点对应 SequenceFlow
if (simpleModel.type === BpmNodeTypeEnum.CONDITION_NODE) {
// 条件节点,只有通过和未执行状态
simpleModel.activityStatus = finishedSequenceFlowActivityIds.includes(
simpleModel.id,
)
? BpmTaskStatusEnum.APPROVE
: BpmTaskStatusEnum.NOT_START;
}
// 网关节点
if (
simpleModel.type === BpmNodeTypeEnum.CONDITION_BRANCH_NODE ||
simpleModel.type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE ||
simpleModel.type === BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE ||
simpleModel.type === BpmNodeTypeEnum.ROUTER_BRANCH_NODE
) {
// 网关节点。只有通过和未执行状态
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
? BpmTaskStatusEnum.APPROVE
: BpmTaskStatusEnum.NOT_START;
simpleModel.conditionNodes?.forEach((node) => {
setSimpleModelNodeTaskStatus(
node,
processStatus,
rejectedTaskActivityIds,
unfinishedTaskActivityIds,
finishedActivityIds,
finishedSequenceFlowActivityIds,
);
});
}
setSimpleModelNodeTaskStatus(
simpleModel.childNode,
processStatus,
rejectedTaskActivityIds,
unfinishedTaskActivityIds,
finishedActivityIds,
finishedSequenceFlowActivityIds,
);
};
</script>
<template> <template>
<div> <div v-loading="loading">
<h1>Simple BPM Viewer</h1> <SimpleProcessViewer
:flow-node="simpleModel"
:tasks="tasks"
:process-instance="processInstance"
/>
</div> </div>
</template> </template>
<style lang="scss" scoped></style>

View File

@@ -2,10 +2,10 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance'; import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import { h, nextTick, onMounted, ref } from 'vue'; import { nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { confirm, Page } from '@vben/common-ui'; import { Page, prompt } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue'; import { Input, message } from 'ant-design-vue';
@@ -29,7 +29,6 @@ const processDefinitionId = query.processDefinitionId as string;
const formFields = ref<any[]>([]); const formFields = ref<any[]>([]);
const userList = ref<any[]>([]); // 用户列表 const userList = ref<any[]>([]); // 用户列表
const gridReady = ref(false); // 表格是否准备好 const gridReady = ref(false); // 表格是否准备好
const cancelReason = ref(''); // 取消原因
// 表格的列需要解析表单字段,这里定义成变量,解析表单字段后再渲染 // 表格的列需要解析表单字段,这里定义成变量,解析表单字段后再渲染
let Grid: any = null; let Grid: any = null;
@@ -81,26 +80,19 @@ const handleDetail = (row: BpmProcessInstanceApi.ProcessInstance) => {
/** 取消按钮操作 */ /** 取消按钮操作 */
const handleCancel = async (row: BpmProcessInstanceApi.ProcessInstance) => { const handleCancel = async (row: BpmProcessInstanceApi.ProcessInstance) => {
cancelReason.value = ''; // 重置取消原因 prompt({
confirm({ content: '请输入取消原因:',
title: '取消流程', title: '取消流程',
content: h('div', [ icon: 'question',
h('p', '请输入取消原因:'), component: Input,
h(Input, { modelPropName: 'value',
value: cancelReason.value, async beforeClose(scope) {
'onUpdate:value': (val: string) => { if (!scope.isConfirm) return;
cancelReason.value = val; if (!scope.value) {
},
placeholder: '请输入取消原因',
}),
]),
beforeClose: async ({ isConfirm }) => {
if (!isConfirm) return;
if (!cancelReason.value.trim()) {
message.warning('请输入取消原因'); message.warning('请输入取消原因');
return false; return false;
} }
await cancelProcessInstanceByAdmin(row.id, cancelReason.value); await cancelProcessInstanceByAdmin(row.id, scope.value);
return true; return true;
}, },
}).then(() => { }).then(() => {

View File

@@ -0,0 +1,529 @@
import { DICT_TYPE, getDictLabel } from '#/utils';
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'conversionStat': {
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '客户转化率',
type: 'line',
data: res.map((item: any) => {
return {
name: item.time,
value: item.customerCreateCount
? (
(item.customerDealCount / item.customerCreateCount) *
100
).toFixed(2)
: 0,
};
}),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '客户转化率分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: {
type: 'value',
name: '转化率(%)',
},
xAxis: {
type: 'category',
name: '日期',
data: res.map((s: any) => s.time),
},
};
}
case 'customerSummary': {
return {
grid: {
bottom: '5%',
containLabel: true,
left: '5%',
right: '5%',
top: '5 %',
},
legend: {},
series: [
{
name: '新增客户数',
type: 'bar',
yAxisIndex: 0,
data: res.map((item: any) => item.customerCreateCount),
},
{
name: '成交客户数',
type: 'bar',
yAxisIndex: 1,
data: res.map((item: any) => item.customerDealCount),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '新增客户数',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '成交客户数',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: res.map((item: any) => item.time),
},
};
}
case 'dealCycleByArea': {
const data = res.map((s: any) => {
return {
areaName: s.areaName,
customerDealCycle: s.customerDealCycle,
customerDealCount: s.customerDealCount,
};
});
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '成交周期(天)',
type: 'bar',
data: data.map((s: any) => s.customerDealCycle),
yAxisIndex: 0,
},
{
name: '成交客户数',
type: 'bar',
data: data.map((s: any) => s.customerDealCount),
yAxisIndex: 1,
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '成交周期(天)',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '成交客户数',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '区域',
data: data.map((s: any) => s.areaName),
},
};
}
case 'dealCycleByProduct': {
const data = res.map((s: any) => {
return {
productName: s.productName ?? '未知',
customerDealCycle: s.customerDealCount,
customerDealCount: s.customerDealCount,
};
});
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '成交周期(天)',
type: 'bar',
data: data.map((s: any) => s.customerDealCycle),
yAxisIndex: 0,
},
{
name: '成交客户数',
type: 'bar',
data: data.map((s: any) => s.customerDealCount),
yAxisIndex: 1,
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '成交周期(天)',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '成交客户数',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '产品名称',
data: data.map((s: any) => s.productName),
},
};
}
case 'dealCycleByUser': {
const customerDealCycleByDate = res.customerDealCycleByDate;
const customerDealCycleByUser = res.customerDealCycleByUser;
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '成交周期(天)',
type: 'bar',
data: customerDealCycleByDate.map((s: any) => s.customerDealCycle),
yAxisIndex: 0,
},
{
name: '成交客户数',
type: 'bar',
data: customerDealCycleByUser.map((s: any) => s.customerDealCount),
yAxisIndex: 1,
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '成交周期(天)',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '成交客户数',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: customerDealCycleByDate.map((s: any) => s.time),
},
};
}
case 'followUpSummary': {
return {
grid: {
left: 20,
right: 30, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '跟进客户数',
type: 'bar',
yAxisIndex: 0,
data: res.map((s: any) => s.followUpCustomerCount),
},
{
name: '跟进次数',
type: 'bar',
yAxisIndex: 1,
data: res.map((s: any) => s.followUpRecordCount),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '客户跟进次数分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '跟进客户数',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '跟进次数',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
axisTick: {
alignWithLabel: true,
},
data: res.map((s: any) => s.time),
},
};
}
case 'followUpType': {
return {
title: {
text: '客户跟进方式分析',
left: 'center',
},
legend: {
orient: 'vertical',
left: 'left',
},
tooltip: {
trigger: 'item',
formatter: '{b} : {c}% ',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '客户跟进方式分析' }, // 保存为图片
},
},
series: [
{
name: '跟进方式',
type: 'pie',
radius: '50%',
data: res.map((s: any) => {
return {
name: getDictLabel(
DICT_TYPE.CRM_FOLLOW_UP_TYPE,
s.followUpType,
),
value: s.followUpRecordCount,
};
}),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
};
}
case 'poolSummary': {
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '进入公海客户数',
type: 'bar',
yAxisIndex: 0,
data: res.map((s: any) => s.customerPutCount),
},
{
name: '公海领取客户数',
type: 'bar',
yAxisIndex: 1,
data: res.map((s: any) => s.customerTakeCount),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '公海客户分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '进入公海客户数',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '公海领取客户数',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: res.map((s: any) => s.time),
},
};
}
default: {
return {};
}
}
}

View File

@@ -0,0 +1,396 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useUserStore } from '@vben/stores';
import {
beginOfDay,
endOfDay,
erpCalculatePercentage,
formatDateTime,
handleTree,
} from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const userStore = useUserStore();
export const customerSummaryTabs = [
{
tab: '客户总量分析',
key: 'customerSummary',
},
{
tab: '客户跟进次数分析',
key: 'followUpSummary',
},
{
tab: '客户跟进方式分析',
key: 'followUpType',
},
{
tab: '客户转化率分析',
key: 'conversionStat',
},
{
tab: '公海客户分析',
key: 'poolSummary',
},
{
tab: '员工客户成交周期分析',
key: 'dealCycleByUser',
},
{
tab: '地区客户成交周期分析',
key: 'dealCycleByArea',
},
{
tab: '产品客户成交周期分析',
key: 'dealCycleByProduct',
},
];
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'times',
label: '时间范围',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
},
defaultValue: [
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
] as [Date, Date],
},
{
fieldName: 'interval',
label: '时间间隔',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.DATE_INTERVAL, 'number'),
},
defaultValue: 2,
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
},
defaultValue: userStore.userInfo?.deptId,
},
{
fieldName: 'userId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
labelField: 'nickname',
valueField: 'id',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
activeTabName: any,
): VxeTableGridOptions['columns'] {
switch (activeTabName) {
case 'conversionStat': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'customerName',
title: '客户名称',
minWidth: 100,
},
{
field: 'contractName',
title: '合同名称',
minWidth: 200,
},
{
field: 'totalPrice',
title: '合同总金额',
minWidth: 200,
formatter: 'formatAmount2',
},
{
field: 'receivablePrice',
title: '回款金额',
minWidth: 200,
formatter: 'formatAmount2',
},
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 200,
},
{
field: 'creatorUserName',
title: '创建人',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'orderDate',
title: '下单日期',
minWidth: 200,
formatter: 'formatDateTime',
},
];
}
case 'customerSummary': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'ownerUserName',
title: '员工姓名',
minWidth: 100,
},
{
field: 'customerCreateCount',
title: '新增客户数',
minWidth: 200,
},
{
field: 'customerDealCount',
title: '成交客户数',
minWidth: 200,
},
{
field: 'customerDealRate',
title: '客户成交率(%)',
minWidth: 200,
formatter: ({ row }) => {
return erpCalculatePercentage(
row.customerDealCount,
row.customerCreateCount,
);
},
},
{
field: 'contractPrice',
title: '合同总金额',
minWidth: 200,
formatter: 'formatAmount2',
},
{
field: 'receivablePrice',
title: '回款金额',
minWidth: 200,
formatter: 'formatAmount2',
},
{
field: 'creceivablePrice',
title: '未回款金额',
minWidth: 200,
formatter: ({ row }) => {
return erpCalculatePercentage(
row.receivablePrice,
row.contractPrice,
);
},
},
{
field: 'ccreceivablePrice',
title: '回款完成率(%)',
formatter: ({ row }) => {
return erpCalculatePercentage(
row.receivablePrice,
row.contractPrice,
);
},
},
];
}
case 'dealCycleByArea': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'areaName',
title: '区域',
minWidth: 200,
},
{
field: 'customerDealCycle',
title: '成交周期(天)',
minWidth: 200,
},
{
field: 'customerDealCount',
title: '成交客户数',
minWidth: 200,
},
];
}
case 'dealCycleByProduct': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'productName',
title: '产品名称',
minWidth: 200,
},
{
field: 'customerDealCycle',
title: '成交周期(天)',
minWidth: 200,
},
{
field: 'customerDealCount',
title: '成交客户数',
minWidth: 200,
},
];
}
case 'dealCycleByUser': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'ownerUserName',
title: '日期',
minWidth: 200,
},
{
field: 'customerDealCycle',
title: '成交周期(天)',
minWidth: 200,
},
{
field: 'customerDealCount',
title: '成交客户数',
minWidth: 200,
},
];
}
case 'followUpSummary': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'ownerUserName',
title: '员工姓名',
minWidth: 200,
},
{
field: 'followUpRecordCount',
title: '跟进次数',
minWidth: 200,
},
{
field: 'followUpCustomerCount',
title: '跟进客户数',
minWidth: 200,
},
];
}
case 'followUpType': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'followUpType',
title: '跟进方式',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_FOLLOW_UP_TYPE },
},
},
{
field: 'followUpRecordCount',
title: '个数',
minWidth: 200,
},
{
field: 'portion',
title: '占比(%)',
minWidth: 200,
},
];
}
case 'poolSummary': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'ownerUserName',
title: '员工姓名',
minWidth: 200,
},
{
field: 'customerPutCount',
title: '进入公海客户数',
minWidth: 200,
},
{
field: 'customerTakeCount',
title: '公海领取客户数',
minWidth: 200,
},
];
}
default: {
return [];
}
}
}

View File

@@ -1,28 +1,79 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Page } from '@vben/common-ui'; import type { EchartsUIType } from '@vben/plugins/echarts';
import { Button } from 'ant-design-vue'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getChartDatas, getDatas } from '#/api/crm/statistics/customer';
import { getChartOptions } from './chartOptions';
import { customerSummaryTabs, useGridColumns, useGridFormSchema } from './data';
const activeTabName = ref('customerSummary');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(activeTabName.value),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
const res = await getChartDatas(activeTabName.value, formValues);
renderEcharts(getChartOptions(activeTabName.value, res));
return await getDatas(activeTabName.value, formValues);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUser>,
});
async function handleTabChange(key: any) {
activeTabName.value = key;
gridApi.setGridOptions({
columns: useGridColumns(key),
});
gridApi.reload();
}
</script> </script>
<template> <template>
<Page> <Page auto-content-height>
<Button <Grid>
danger <template #top>
type="link" <Tabs v-model:active-key="activeTabName" @change="handleTabChange">
target="_blank" <Tabs.TabPane
href="https://github.com/yudaocode/yudao-ui-admin-vue3" v-for="item in customerSummaryTabs"
> :key="item.key"
该功能支持 Vue3 + element-plus 版本 :tab="item.tab"
</Button> :force-render="true"
<br /> />
<Button </Tabs>
type="link" <EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
target="_blank" </template>
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/customer/index.vue" </Grid>
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/customer/index.vue
代码pull request 贡献给我们
</Button>
</Page> </Page>
</template> </template>

View File

@@ -0,0 +1,271 @@
import { erpCalculatePercentage } from '@vben/utils';
export function getChartOptions(
activeTabName: any,
active: boolean,
res: any,
): any {
switch (activeTabName) {
case 'businessInversionRateSummary': {
return {
color: ['#6ca2ff', '#6ac9d7', '#ff7474'],
tooltip: {
trigger: 'axis',
axisPointer: {
// 坐标轴指示器,坐标轴触发有效
type: 'shadow', // 默认为直线,可选为:'line' | 'shadow'
},
},
legend: {
data: ['赢单转化率', '商机总数', '赢单商机数'],
bottom: '0px',
itemWidth: 14,
},
grid: {
top: '40px',
left: '40px',
right: '40px',
bottom: '40px',
containLabel: true,
borderColor: '#fff',
},
xAxis: [
{
type: 'category',
data: res.map((s: any) => s.time),
axisTick: {
alignWithLabel: true,
lineStyle: { width: 0 },
},
axisLabel: {
color: '#BDBDBD',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: { color: '#BDBDBD' },
},
splitLine: {
show: false,
},
},
],
yAxis: [
{
type: 'value',
name: '赢单转化率',
axisTick: {
alignWithLabel: true,
lineStyle: { width: 0 },
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}%',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: { color: '#BDBDBD' },
},
splitLine: {
show: false,
},
},
{
type: 'value',
name: '商机数',
axisTick: {
alignWithLabel: true,
lineStyle: { width: 0 },
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}个',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: { color: '#BDBDBD' },
},
splitLine: {
show: false,
},
},
],
series: [
{
name: '赢单转化率',
type: 'line',
yAxisIndex: 0,
data: res.map((s: any) =>
erpCalculatePercentage(s.businessWinCount, s.businessCount),
),
},
{
name: '商机总数',
type: 'bar',
yAxisIndex: 1,
barWidth: 15,
data: res.map((s: any) => s.businessCount),
},
{
name: '赢单商机数',
type: 'bar',
yAxisIndex: 1,
barWidth: 15,
data: res.map((s: any) => s.businessWinCount),
},
],
};
}
case 'businessSummary': {
return {
grid: {
left: 30,
right: 30, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '新增商机数量',
type: 'bar',
yAxisIndex: 0,
data: res.map((s: any) => s.businessCreateCount),
},
{
name: '新增商机金额',
type: 'bar',
yAxisIndex: 1,
data: res.map((s: any) => s.totalPrice),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '新增商机分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '新增商机数量',
min: 0,
minInterval: 1, // 显示整数刻度
},
{
type: 'value',
name: '新增商机金额',
min: 0,
minInterval: 1, // 显示整数刻度
splitLine: {
lineStyle: {
type: 'dotted', // 右侧网格线虚化, 减少混乱
opacity: 0.7,
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: res.map((s: any) => s.time),
},
};
}
case 'funnel': {
// tips写死 value 值是为了保持漏斗顺序不变
const list: { name: string; value: number }[] = [];
if (active) {
list.push(
{ value: 60, name: `客户-${res.customerCount || 0}` },
{ value: 40, name: `商机-${res.businessCount || 0}` },
{ value: 20, name: `赢单-${res.businessWinCount || 0}` },
);
} else {
list.push(
{
value: res.customerCount || 0,
name: `客户-${res.customerCount || 0}`,
},
{
value: res.businessCount || 0,
name: `商机-${res.businessCount || 0}`,
},
{
value: res.businessWinCount || 0,
name: `赢单-${res.businessWinCount || 0}`,
},
);
}
return {
title: {
text: '销售漏斗',
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}',
},
toolbox: {
feature: {
dataView: { readOnly: false },
restore: {},
saveAsImage: {},
},
},
legend: {
data: ['客户', '商机', '赢单'],
},
series: [
{
name: '销售漏斗',
type: 'funnel',
left: '10%',
top: 60,
bottom: 60,
width: '80%',
min: 0,
max: 100,
minSize: '0%',
maxSize: '100%',
sort: 'descending',
gap: 2,
label: {
show: true,
position: 'inside',
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: 'solid',
},
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
},
emphasis: {
label: {
fontSize: 20,
},
},
data: list,
},
],
};
}
default: {
return {};
}
}
}

View File

@@ -0,0 +1,266 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const userStore = useUserStore();
export const customerSummaryTabs = [
{
tab: '销售漏斗分析',
key: 'funnel',
},
{
tab: '新增商机分析',
key: 'businessSummary',
},
{
tab: '商机转化率分析',
key: 'businessInversionRateSummary',
},
];
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'times',
label: '时间范围',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
},
defaultValue: [
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
] as [Date, Date],
},
{
fieldName: 'interval',
label: '时间间隔',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.DATE_INTERVAL, 'number'),
},
defaultValue: 2,
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
},
defaultValue: userStore.userInfo?.deptId,
},
{
fieldName: 'userId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
labelField: 'nickname',
valueField: 'id',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
activeTabName: any,
): VxeTableGridOptions['columns'] {
switch (activeTabName) {
case 'businessInversionRateSummary': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'name',
title: '商机名称',
minWidth: 100,
},
{
field: 'customerName',
title: '客户名称',
minWidth: 200,
},
{
field: 'totalPrice',
title: '商机金额(元)',
minWidth: 200,
formatter: 'formatAmount2',
},
{
field: 'dealTime',
title: '预计成交日期',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 200,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 200,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'statusTypeName',
title: '商机状态组',
minWidth: 100,
},
{
field: 'statusName',
title: '商机阶段',
minWidth: 100,
},
];
}
case 'businessSummary': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'name',
title: '商机名称',
minWidth: 100,
},
{
field: 'customerName',
title: '客户名称',
minWidth: 200,
},
{
field: 'totalPrice',
title: '商机金额(元)',
minWidth: 200,
formatter: 'formatAmount2',
},
{
field: 'dealTime',
title: '预计成交日期',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 200,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 200,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 200,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'statusTypeName',
title: '商机状态组',
minWidth: 100,
},
{
field: 'statusName',
title: '商机阶段',
minWidth: 100,
},
];
}
case 'funnel': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'endStatus',
title: '阶段',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE },
},
},
{
field: 'businessCount',
title: '商机数',
minWidth: 200,
},
{
field: 'totalPrice',
title: '商机总金额(元)',
minWidth: 200,
formatter: 'formatAmount2',
},
];
}
default: {
return [];
}
}
}

View File

@@ -1,28 +1,117 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Page } from '@vben/common-ui'; import type { EchartsUIType } from '@vben/plugins/echarts';
import { Button } from 'ant-design-vue'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsFunnelApi } from '#/api/crm/statistics/funnel';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Button, ButtonGroup, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getChartDatas, getDatas } from '#/api/crm/statistics/funnel';
import { getChartOptions } from './chartOptions';
import { customerSummaryTabs, useGridColumns, useGridFormSchema } from './data';
const activeTabName = ref('funnel');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const active = ref(true);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(activeTabName.value),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const res = await getChartDatas(activeTabName.value, formValues);
renderEcharts(
getChartOptions(activeTabName.value, active.value, res),
);
return await getDatas(activeTabName.value, {
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsFunnelApi.BusinessSummaryByDate>,
});
async function handleTabChange(key: any) {
activeTabName.value = key;
gridApi.setGridOptions({
columns: useGridColumns(key),
pagerConfig: {
enabled: activeTabName.value !== 'funnelRef',
},
});
gridApi.reload();
}
function handleActive(value: boolean) {
active.value = value;
renderEcharts(
getChartOptions(
activeTabName.value,
active.value,
gridApi.formApi.getValues(),
),
);
}
</script> </script>
<template> <template>
<Page> <Page auto-content-height>
<Button <Grid>
danger <template #top>
type="link" <Tabs v-model:active-key="activeTabName" @change="handleTabChange">
target="_blank" <Tabs.TabPane
href="https://github.com/yudaocode/yudao-ui-admin-vue3" v-for="item in customerSummaryTabs"
> :key="item.key"
该功能支持 Vue3 + element-plus 版本 :tab="item.tab"
</Button> :force-render="true"
<br /> />
<Button </Tabs>
type="link" <ButtonGroup>
target="_blank" <Button
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/funnel/index" :type="active ? 'primary' : 'default'"
> v-if="activeTabName === 'funnel'"
可参考 @click="handleActive(true)"
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/funnel/index >
代码pull request 贡献给我们 客户视角
</Button> </Button>
<Button
:type="active ? 'default' : 'primary'"
v-if="activeTabName === 'funnel'"
@click="handleActive(false)"
>
动态视角
</Button>
</ButtonGroup>
<EchartsUI class="mb-20 h-2/5 w-full" ref="chartRef" />
</template>
</Grid>
</Page> </Page>
</template> </template>

View File

@@ -0,0 +1,394 @@
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'ContractCountPerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '当月合同数量(个)',
type: 'line',
data: res.map((s: any) => s.currentMonthCount),
},
{
name: '上月合同数量(个)',
type: 'line',
data: res.map((s: any) => s.lastMonthCount),
},
{
name: '去年同月合同数量(个)',
type: 'line',
data: res.map((s: any) => s.lastYearCount),
},
{
name: '环比增长率(%',
type: 'line',
yAxisIndex: 1,
data: res.map((s: any) =>
s.lastMonthCount === 0
? 'NULL'
: (
((s.currentMonthCount - s.lastMonthCount) /
s.lastMonthCount) *
100
).toFixed(2),
),
},
{
name: '同比增长率(%',
type: 'line',
yAxisIndex: 1,
data: res.map((s: any) =>
s.lastYearCount === 0
? 'NULL'
: (
((s.currentMonthCount - s.lastYearCount) /
s.lastYearCount) *
100
).toFixed(2),
),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '数量(个)',
axisTick: {
show: false,
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: {
color: '#BDBDBD',
},
},
splitLine: {
show: true,
lineStyle: {
color: '#e6e6e6',
},
},
},
{
type: 'value',
name: '',
axisTick: {
alignWithLabel: true,
lineStyle: {
width: 0,
},
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}%',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: {
color: '#BDBDBD',
},
},
splitLine: {
show: true,
lineStyle: {
color: '#e6e6e6',
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: res.map((s: any) => s.time),
},
};
}
case 'ContractPricePerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '当月合同金额(元)',
type: 'line',
data: res.map((s: any) => s.currentMonthCount),
},
{
name: '上月合同金额(元)',
type: 'line',
data: res.map((s: any) => s.lastMonthCount),
},
{
name: '去年同月合同金额(元)',
type: 'line',
data: res.map((s: any) => s.lastYearCount),
},
{
name: '环比增长率(%',
type: 'line',
yAxisIndex: 1,
data: res.map((s: any) =>
s.lastMonthCount === 0
? 'NULL'
: (
((s.currentMonthCount - s.lastMonthCount) /
s.lastMonthCount) *
100
).toFixed(2),
),
},
{
name: '同比增长率(%',
type: 'line',
yAxisIndex: 1,
data: res.map((s: any) =>
s.lastYearCount === 0
? 'NULL'
: (
((s.currentMonthCount - s.lastYearCount) /
s.lastYearCount) *
100
).toFixed(2),
),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '金额(元)',
axisTick: {
show: false,
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: {
color: '#BDBDBD',
},
},
splitLine: {
show: true,
lineStyle: {
color: '#e6e6e6',
},
},
},
{
type: 'value',
name: '',
axisTick: {
alignWithLabel: true,
lineStyle: {
width: 0,
},
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}%',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: {
color: '#BDBDBD',
},
},
splitLine: {
show: true,
lineStyle: {
color: '#e6e6e6',
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: res.map((s: any) => s.time),
},
};
}
case 'ReceivablePricePerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
series: [
{
name: '当月回款金额(元)',
type: 'line',
data: res.map((s: any) => s.currentMonthCount),
},
{
name: '上月回款金额(元)',
type: 'line',
data: res.map((s: any) => s.lastMonthCount),
},
{
name: '去年同月回款金额(元)',
type: 'line',
data: res.map((s: any) => s.lastYearCount),
},
{
name: '环比增长率(%',
type: 'line',
yAxisIndex: 1,
data: res.map((s: any) =>
s.lastMonthCount === 0
? 'NULL'
: (
((s.currentMonthCount - s.lastMonthCount) /
s.lastMonthCount) *
100
).toFixed(2),
),
},
{
name: '同比增长率(%',
type: 'line',
yAxisIndex: 1,
data: res.map((s: any) =>
s.lastYearCount === 0
? 'NULL'
: (
((s.currentMonthCount - s.lastYearCount) /
s.lastYearCount) *
100
).toFixed(2),
),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
yAxis: [
{
type: 'value',
name: '金额(元)',
axisTick: {
show: false,
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: {
color: '#BDBDBD',
},
},
splitLine: {
show: true,
lineStyle: {
color: '#e6e6e6',
},
},
},
{
type: 'value',
name: '',
axisTick: {
alignWithLabel: true,
lineStyle: {
width: 0,
},
},
axisLabel: {
color: '#BDBDBD',
formatter: '{value}%',
},
/** 坐标轴轴线相关设置 */
axisLine: {
lineStyle: {
color: '#BDBDBD',
},
},
splitLine: {
show: true,
lineStyle: {
color: '#e6e6e6',
},
},
},
],
xAxis: {
type: 'category',
name: '日期',
data: res.map((s: any) => s.time),
},
};
}
default: {
return {};
}
}
}

View File

@@ -0,0 +1,74 @@
import type { VbenFormSchema } from '#/adapter/form';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
const userStore = useUserStore();
export const customerSummaryTabs = [
{
tab: '员工合同数量统计',
key: 'ContractCountPerformance',
},
{
tab: '员工合同金额统计',
key: 'ContractPricePerformance',
},
{
tab: '员工回款金额统计',
key: 'ReceivablePricePerformance',
},
];
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'times',
label: '时间范围',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
picker: 'year',
showTime: false,
format: 'YYYY',
ranges: {},
},
defaultValue: [
formatDateTime(beginOfDay(new Date(new Date().getFullYear(), 0, 1))),
formatDateTime(endOfDay(new Date(new Date().getFullYear(), 11, 31))),
] as [Date, Date],
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
},
defaultValue: userStore.userInfo?.deptId,
},
{
fieldName: 'userId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
labelField: 'nickname',
valueField: 'id',
},
},
];
}

View File

@@ -1,28 +1,156 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Page } from '@vben/common-ui'; import type { EchartsUIType } from '@vben/plugins/echarts';
import { Button } from 'ant-design-vue'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getContractCountPerformance,
getContractPricePerformance,
getReceivablePricePerformance,
} from '#/api/crm/statistics/performance';
import { getChartOptions } from './chartOptions';
import { customerSummaryTabs, useGridFormSchema } from './data';
const activeTabName = ref('ContractCountPerformance');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
handleSubmit: async () => {
await handleTabChange(activeTabName.value);
},
},
gridOptions: {
columns: [],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
enabled: false,
},
data: [],
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUser>,
});
async function handleTabChange(key: any) {
activeTabName.value = key;
const params = (await gridApi.formApi.getValues()) as any;
let data: any[] = [];
const columnsData: any[] = [];
let tableData: any[] = [];
switch (key) {
case 'ContractCountPerformance': {
tableData = [
{ title: '当月合同数量统计(个)' },
{ title: '上月合同数量统计(个)' },
{ title: '去年当月合同数量统计(个)' },
{ title: '环比增长率(%' },
{ title: '同比增长率(%' },
];
data = await getContractCountPerformance(params);
break;
}
case 'ContractPricePerformance': {
tableData = [
{ title: '当月合同金额统计(元)' },
{ title: '上月合同金额统计(元)' },
{ title: '去年当月合同金额统计(元)' },
{ title: '环比增长率(%' },
{ title: '同比增长率(%' },
];
data = await getContractPricePerformance(params);
break;
}
case 'ReceivablePricePerformance': {
tableData = [
{ title: '当月回款金额统计(元)' },
{ title: '上月回款金额统计(元)' },
{ title: '去年当月回款金额统计(元)' },
{ title: '环比增长率(%' },
{ title: '同比增长率(%' },
];
data = await getReceivablePricePerformance(params);
break;
}
default: {
break;
}
}
const columnObj = {
title: '日期',
field: 'title',
minWidth: 200,
align: 'left',
};
columnsData.splice(0); // 清空数组
columnsData.push(columnObj);
data.forEach((item: any, index: number) => {
const columnObj = { title: item.time, field: `field${index}` };
columnsData.push(columnObj);
tableData[0][`field${index}`] = item.currentMonthCount;
tableData[1][`field${index}`] = item.lastMonthCount;
tableData[2][`field${index}`] = item.lastYearCount;
tableData[3][`field${index}`] =
item.lastMonthCount === 0
? 'NULL'
: (
((item.currentMonthCount - item.lastMonthCount) /
item.lastMonthCount) *
100
).toFixed(2);
tableData[4][`field${index}`] =
item.lastYearCount === 0
? 'NULL'
: (
((item.currentMonthCount - item.lastYearCount) /
item.lastYearCount) *
100
).toFixed(2);
});
renderEcharts(getChartOptions(key, data), true);
gridApi.grid.reloadColumn(columnsData);
gridApi.grid.reloadData(tableData);
}
onMounted(() => {
handleTabChange(activeTabName.value);
});
</script> </script>
<template> <template>
<Page> <Page auto-content-height>
<Button <Grid>
danger <template #top>
type="link" <Tabs v-model:active-key="activeTabName" @change="handleTabChange">
target="_blank" <Tabs.TabPane
href="https://github.com/yudaocode/yudao-ui-admin-vue3" v-for="item in customerSummaryTabs"
> :key="item.key"
该功能支持 Vue3 + element-plus 版本 :tab="item.tab"
</Button> :force-render="true"
<br /> />
<Button </Tabs>
type="link" <EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
target="_blank" </template>
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/performance/index" </Grid>
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/performance/index
代码pull request 贡献给我们
</Button>
</Page> </Page>
</template> </template>

View File

@@ -0,0 +1,439 @@
import { DICT_TYPE, getDictLabel } from '#/utils';
function areaReplace(areaName: string) {
if (!areaName) {
return areaName;
}
return areaName
.replace('维吾尔自治区', '')
.replace('壮族自治区', '')
.replace('回族自治区', '')
.replace('自治区', '')
.replace('省', '');
}
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'area': {
const data = res.map((item: any) => {
return {
...item,
areaName: areaReplace(item.areaName),
};
});
let leftMin = 0;
let leftMax = 0;
let rightMin = 0;
let rightMax = 0;
data.forEach((item: any) => {
leftMin = Math.min(leftMin, item.customerCount || 0);
leftMax = Math.max(leftMax, item.customerCount || 0);
rightMin = Math.min(rightMin, item.dealCount || 0);
rightMax = Math.max(rightMax, item.dealCount || 0);
});
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
showDelay: 0,
transitionDuration: 0.2,
},
visualMap: {
text: ['高', '低'],
realtime: false,
calculable: true,
top: 'middle',
inRange: {
color: ['yellow', 'lightskyblue', 'orangered'],
},
min: leftMin,
max: leftMax,
},
series: [
{
name: '客户地域分布',
type: 'map',
map: 'china',
roam: false,
selectedMode: false,
data: data.map((item: any) => {
return {
name: item.areaName,
value: item.customerCount || 0,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
showDelay: 0,
transitionDuration: 0.2,
},
visualMap: {
text: ['高', '低'],
realtime: false,
calculable: true,
top: 'middle',
inRange: {
color: ['yellow', 'lightskyblue', 'orangered'],
},
min: rightMin,
max: rightMax,
},
series: [
{
name: '客户地域分布',
type: 'map',
map: 'china',
roam: false,
selectedMode: false,
data: data.map((item: any) => {
return {
name: item.areaName,
value: item.dealCount || 0,
};
}),
},
],
},
};
}
case 'industry': {
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '全部客户' }, // 保存为图片
},
},
series: [
{
name: '全部客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(
DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
r.industryId,
),
value: r.customerCount,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '成交客户' }, // 保存为图片
},
},
series: [
{
name: '成交客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(
DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
r.industryId,
),
value: r.dealCount,
};
}),
},
],
},
};
}
case 'level': {
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '全部客户' }, // 保存为图片
},
},
series: [
{
name: '全部客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.customerCount,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '成交客户' }, // 保存为图片
},
},
series: [
{
name: '成交客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.dealCount,
};
}),
},
],
},
};
}
case 'source': {
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '全部客户' }, // 保存为图片
},
},
series: [
{
name: '全部客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.customerCount,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '成交客户' }, // 保存为图片
},
},
series: [
{
name: '成交客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.dealCount,
};
}),
},
],
},
};
}
default: {
return {};
}
}
}

View File

@@ -0,0 +1,199 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { DICT_TYPE, getRangePickerDefaultProps } from '#/utils';
const userStore = useUserStore();
export const customerSummaryTabs = [
{
tab: '城市分布分析',
key: 'area',
},
{
tab: '客户级别分析',
key: 'level',
},
{
tab: '客户来源分析',
key: 'source',
},
{
tab: '客户行业分析',
key: 'industry',
},
];
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'times',
label: '时间范围',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
format: 'YYYY-MM-DD',
picker: 'year',
},
defaultValue: [
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
] as [Date, Date],
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
},
defaultValue: userStore.userInfo?.deptId,
},
{
fieldName: 'userId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
labelField: 'nickname',
valueField: 'id',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
activeTabName: any,
): VxeTableGridOptions['columns'] {
switch (activeTabName) {
case 'industry': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'customerCount',
title: '客户个数',
minWidth: 200,
},
{
field: 'dealCount',
title: '成交个数',
minWidth: 200,
},
{
field: 'industryPortion',
title: '行业占比(%)',
minWidth: 200,
},
{
field: 'dealPortion',
title: '成交占比(%)',
minWidth: 200,
},
];
}
case 'level': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'customerCount',
title: '客户个数',
minWidth: 200,
},
{
field: 'dealCount',
title: '成交个数',
minWidth: 200,
},
{
field: 'industryPortion',
title: '行业占比(%)',
minWidth: 200,
},
{
field: 'dealPortion',
title: '成交占比(%)',
minWidth: 200,
},
];
}
case 'source': {
return [
{
type: 'seq',
title: '序号',
},
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'customerCount',
title: '客户个数',
minWidth: 200,
},
{
field: 'dealCount',
title: '成交个数',
minWidth: 200,
},
{
field: 'industryPortion',
title: '行业占比(%)',
minWidth: 200,
},
{
field: 'dealPortion',
title: '成交占比(%)',
minWidth: 200,
},
];
}
default: {
return [];
}
}
}

View File

@@ -1,28 +1,85 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Page } from '@vben/common-ui'; import type { EchartsUIType } from '@vben/plugins/echarts';
import { Button } from 'ant-design-vue'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDatas } from '#/api/crm/statistics/portrait';
import { getChartOptions } from './chartOptions';
import { customerSummaryTabs, useGridColumns, useGridFormSchema } from './data';
const activeTabName = ref('area');
const leftChartRef = ref<EchartsUIType>();
const rightChartRef = ref<EchartsUIType>();
const { renderEcharts: renderLeftEcharts } = useEcharts(leftChartRef);
const { renderEcharts: renderRightEcharts } = useEcharts(rightChartRef);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(activeTabName.value),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
const res = await getDatas(activeTabName.value, formValues);
renderLeftEcharts(getChartOptions(activeTabName.value, res).left);
renderRightEcharts(getChartOptions(activeTabName.value, res).right);
return res;
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUser>,
});
async function handleTabChange(key: any) {
activeTabName.value = key;
gridApi.setGridOptions({
columns: useGridColumns(key),
});
gridApi.reload();
}
</script> </script>
<template> <template>
<Page> <Page auto-content-height>
<Button <Grid>
danger <template #top>
type="link" <Tabs v-model:active-key="activeTabName" @change="handleTabChange">
target="_blank" <Tabs.TabPane
href="https://github.com/yudaocode/yudao-ui-admin-vue3" v-for="item in customerSummaryTabs"
> :key="item.key"
该功能支持 Vue3 + element-plus 版本 :tab="item.tab"
</Button> :force-render="true"
<br /> />
<Button </Tabs>
type="link" <div class="mt-5 flex">
target="_blank" <EchartsUI class="m-4 w-1/2" ref="leftChartRef" />
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/portrait/index" <EchartsUI class="m-4 w-1/2" ref="rightChartRef" />
> </div>
可参考 </template>
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/portrait/index </Grid>
代码pull request 贡献给我们
</Button>
</Page> </Page>
</template> </template>

View File

@@ -0,0 +1,394 @@
import { cloneDeep } from '@vben/utils';
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'contactCountRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '新增联系人数排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '新增联系人数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '新增联系人数(个)',
},
yAxis: {
type: 'category',
name: '创建人',
},
};
}
case 'contractCountRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '签约合同排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '签约合同排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '签约合同数(个)',
},
yAxis: {
type: 'category',
name: '签订人',
},
};
}
case 'contractPriceRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '合同金额排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '合同金额排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '合同金额(元)',
},
yAxis: {
type: 'category',
name: '签订人',
},
};
}
case 'customerCountRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '新增客户数排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '新增客户数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '新增客户数(个)',
},
yAxis: {
type: 'category',
name: '创建人',
},
};
}
case 'followCountRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '跟进次数排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '跟进次数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '跟进次数(次)',
},
yAxis: {
type: 'category',
name: '员工',
},
};
}
case 'followCustomerCountRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '跟进客户数排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '跟进客户数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '跟进客户数(个)',
},
yAxis: {
type: 'category',
name: '员工',
},
};
}
case 'productSalesRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '产品销量排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '产品销量排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '产品销量',
},
yAxis: {
type: 'category',
name: '员工',
},
};
}
case 'receivablePriceRank': {
return {
dataset: {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).reverse(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '回款金额排行',
type: 'bar',
},
],
toolbox: {
feature: {
dataZoom: {
yAxisIndex: false, // 数据区域缩放Y 轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '回款金额排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
name: '回款金额(元)',
},
yAxis: {
type: 'category',
name: '签订人',
nameGap: 30,
},
};
}
default: {
return {};
}
}
}

View File

@@ -0,0 +1,276 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getSimpleDeptList } from '#/api/system/dept';
import { getRangePickerDefaultProps } from '#/utils';
const userStore = useUserStore();
export const customerSummaryTabs = [
{
tab: '合同金额排行',
key: 'contractPriceRank',
},
{
tab: '回款金额排行',
key: 'receivablePriceRank',
},
{
tab: '签约合同排行',
key: 'contractCountRank',
},
{
tab: '产品销量排行',
key: 'productSalesRank',
},
{
tab: '新增客户数排行',
key: 'customerCountRank',
},
{
tab: '新增联系人数排行',
key: 'contactCountRank',
},
{
tab: '跟进次数排行',
key: 'followCountRank',
},
{
tab: '跟进客户数排行',
key: 'followCustomerCountRank',
},
];
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'times',
label: '时间范围',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
},
defaultValue: [
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
] as [Date, Date],
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
treeDefaultExpandAll: true,
},
defaultValue: userStore.userInfo?.deptId,
},
];
}
/** 列表的字段 */
export function useGridColumns(
activeTabName: any,
): VxeTableGridOptions['columns'] {
switch (activeTabName) {
case 'contactCountRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '创建人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '新增联系人数(个)',
minWidth: 200,
},
];
}
case 'contractCountRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '签约合同数(个)',
minWidth: 200,
},
];
}
case 'contractPriceRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '合同金额(元)',
minWidth: 200,
formatter: 'formatAmount2',
},
];
}
case 'customerCountRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '新增客户数(个)',
minWidth: 200,
},
];
}
case 'followCountRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '跟进次数(次)',
minWidth: 200,
},
];
}
case 'followCustomerCountRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '跟进客户数(个)',
minWidth: 200,
},
];
}
case 'productSalesRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '产品销量',
minWidth: 200,
},
];
}
case 'receivablePriceRank': {
return [
{
type: 'seq',
title: '公司排名',
},
{
field: 'nickname',
title: '签订人',
minWidth: 200,
},
{
field: 'deptName',
title: '部门',
minWidth: 200,
},
{
field: 'count',
title: '回款金额(元)',
minWidth: 200,
formatter: 'formatAmount2',
},
];
}
default: {
return [];
}
}
}

View File

@@ -1,28 +1,79 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Page } from '@vben/common-ui'; import type { EchartsUIType } from '@vben/plugins/echarts';
import { Button } from 'ant-design-vue'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDatas } from '#/api/crm/statistics/customer';
import { getChartOptions } from './chartOptions';
import { customerSummaryTabs, useGridColumns, useGridFormSchema } from './data';
const activeTabName = ref('contractPriceRank');
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(activeTabName.value),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
const res = await getDatas(activeTabName.value, formValues);
renderEcharts(getChartOptions(activeTabName.value, res));
return res;
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUser>,
});
async function handleTabChange(key: any) {
activeTabName.value = key;
gridApi.setGridOptions({
columns: useGridColumns(key),
});
gridApi.reload();
}
</script> </script>
<template> <template>
<Page> <Page auto-content-height>
<Button <Grid>
danger <template #top>
type="link" <Tabs v-model:active-key="activeTabName" @change="handleTabChange">
target="_blank" <Tabs.TabPane
href="https://github.com/yudaocode/yudao-ui-admin-vue3" v-for="item in customerSummaryTabs"
> :key="item.key"
该功能支持 Vue3 + element-plus 版本 :tab="item.tab"
</Button> :force-render="true"
<br /> />
<Button </Tabs>
type="link" <EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
target="_blank" </template>
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/rank/index" </Grid>
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/rank/index
代码pull request 贡献给我们
</Button>
</Page> </Page>
</template> </template>

View File

@@ -54,7 +54,14 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
field: 'avatar', field: 'avatar',
title: '头像', title: '头像',
width: 70, width: 70,
slots: { default: 'avatar' }, cellRender: {
name: 'CellImage',
props: {
width: 24,
height: 24,
shape: 'circle',
},
},
}, },
{ {
field: 'nickname', field: 'nickname',

View File

@@ -6,7 +6,7 @@ import { useAccess } from '@vben/access';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui'; import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import { Avatar, message, Switch } from 'ant-design-vue'; import { message, Switch } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { import {
@@ -167,10 +167,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
/> />
</template> </template>
<template #avatar="{ row }">
<Avatar :src="row.avatar" />
</template>
<template #brokerageEnabled="{ row }"> <template #brokerageEnabled="{ row }">
<Switch <Switch
v-model:checked="row.brokerageEnabled" v-model:checked="row.brokerageEnabled"

View File

@@ -9,8 +9,6 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { fenToYuan } from '@vben/utils'; import { fenToYuan } from '@vben/utils';
import { Avatar, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getBrokerageRecordPage } from '#/api/mall/trade/brokerage/record'; import { getBrokerageRecordPage } from '#/api/mall/trade/brokerage/record';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils'; import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
@@ -102,7 +100,13 @@ function useColumns(): VxeTableGridOptions['columns'] {
field: 'sourceUserAvatar', field: 'sourceUserAvatar',
title: '头像', title: '头像',
width: 70, width: 70,
slots: { default: 'avatar' }, cellRender: {
name: 'CellImage',
props: {
width: 24,
height: 24,
},
},
}, },
{ {
field: 'sourceUserNickname', field: 'sourceUserNickname',
@@ -119,7 +123,10 @@ function useColumns(): VxeTableGridOptions['columns'] {
field: 'status', field: 'status',
title: '状态', title: '状态',
minWidth: 85, minWidth: 85,
slots: { default: 'status' }, cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BROKERAGE_RECORD_STATUS },
},
}, },
{ {
field: 'createTime', field: 'createTime',
@@ -173,21 +180,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template> <template>
<Modal title="推广订单列表" class="w-3/5"> <Modal title="推广订单列表" class="w-3/5">
<Grid table-title="推广订单列表"> <Grid table-title="推广订单列表" />
<template #avatar="{ row }">
<Avatar :src="row.sourceUserAvatar" />
</template>
<template #status="{ row }">
<template
v-for="dict in getDictOptions(DICT_TYPE.BROKERAGE_RECORD_STATUS)"
:key="dict.value"
>
<Tag v-if="dict.value === row.status" :color="dict.colorType">
{{ dict.label }}
</Tag>
</template>
</template>
</Grid>
</Modal> </Modal>
</template> </template>

View File

@@ -7,7 +7,7 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { Avatar, Tag } from 'ant-design-vue'; import { Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getBrokerageUserPage } from '#/api/mall/trade/brokerage/user'; import { getBrokerageUserPage } from '#/api/mall/trade/brokerage/user';
@@ -76,7 +76,14 @@ function useColumns(): VxeTableGridOptions['columns'] {
field: 'avatar', field: 'avatar',
title: '头像', title: '头像',
width: 70, width: 70,
slots: { default: 'avatar' }, cellRender: {
name: 'CellImage',
props: {
width: 24,
height: 24,
shape: 'circle',
},
},
}, },
{ {
field: 'nickname', field: 'nickname',
@@ -144,10 +151,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template> <template>
<Modal title="推广人列表" class="w-3/5"> <Modal title="推广人列表" class="w-3/5">
<Grid table-title="推广人列表"> <Grid table-title="推广人列表">
<template #avatar="{ row }">
<Avatar :src="row.avatar" />
</template>
<template #brokerageEnabled="{ row }"> <template #brokerageEnabled="{ row }">
<Tag v-if="row.brokerageEnabled" color="success"></Tag> <Tag v-if="row.brokerageEnabled" color="success"></Tag>
<Tag v-else></Tag> <Tag v-else></Tag>

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
// TODO @xingyu是不是不引入 @form-create/ant-design-vue 组件哈;保持和 vben 一致~
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { PayAppApi } from '#/api/pay/app'; import type { PayAppApi } from '#/api/pay/app';

View File

@@ -1,7 +1,15 @@
import type { VbenFormSchema } from '#/adapter/form'; import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { fenToYuan, formatDateTime } from '@vben/utils';
import { Tag } from 'ant-design-vue';
import { getAppList } from '#/api/pay/app'; import { getAppList } from '#/api/pay/app';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils'; import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */ /** 列表的搜索表单 */
@@ -119,3 +127,128 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
}, },
]; ];
} }
/** 详情页的字段 */
export function useBaseDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'merchantRefundId',
label: '商户退款单号',
content: (data) =>
h(Tag, {}, () => {
return data?.merchantRefundId || '-';
}),
},
{
field: 'channelRefundNo',
label: '渠道退款单号',
content: (data) =>
h(Tag, {}, () => {
return data?.channelRefundNo || '-';
}),
},
{
field: 'merchantOrderId',
label: '商户支付单号',
content: (data) =>
h(Tag, {}, () => {
return data?.merchantOrderId || '-';
}),
},
{
field: 'channelOrderNo',
label: '渠道支付单号',
content: (data) =>
h(Tag, {}, () => {
return data?.channelOrderNo || '-';
}),
},
{
field: 'appId',
label: '应用编号',
},
{
field: 'appName',
label: '应用名称',
},
{
field: 'payPrice',
label: '支付金额',
content: (data) =>
h(Tag, { color: 'success' }, () => {
return fenToYuan(data.payPrice || 0);
}),
},
{
field: 'refundPrice',
label: '退款金额',
content: (data) =>
h(Tag, { color: 'red' }, () => {
return fenToYuan(data.refundPrice || 0);
}),
},
{
field: 'status',
label: '退款状态',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.PAY_REFUND_STATUS,
value: data?.status,
}),
},
{
field: 'successTime',
label: '退款时间',
content: (data) => formatDateTime(data.successTime) as string,
},
{
field: 'createTime',
label: '创建时间',
content: (data) => formatDateTime(data.createTime) as string,
},
{
field: 'updateTime',
label: '更新时间',
content: (data) => formatDateTime(data.updateTime) as string,
},
];
}
/** 详情页的字段 */
export function useChannelDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'channelCode',
label: '退款渠道',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.PAY_CHANNEL_CODE,
value: data?.channelCode,
}),
},
{
field: 'reason',
label: '退款原因',
},
{
field: 'userIp',
label: '退款 IP',
},
{
field: 'notifyUrl',
label: '通知 URL',
},
{
field: 'channelErrorCode',
label: '渠道错误码',
},
{
field: 'channelErrorMsg',
label: '渠道错误码描述',
},
{
field: 'channelNotifyData',
label: '支付通道异步回调内容',
},
];
}

View File

@@ -4,16 +4,34 @@ import type { PayRefundApi } from '#/api/pay/refund';
import { ref } from 'vue'; import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { Descriptions, Divider, Tag } from 'ant-design-vue'; import { Divider } from 'ant-design-vue';
import { getRefund } from '#/api/pay/refund'; import { getRefund } from '#/api/pay/refund';
import { DictTag } from '#/components/dict-tag'; import { useDescription } from '#/components/description';
import { DICT_TYPE } from '#/utils';
import { useBaseDetailSchema, useChannelDetailSchema } from '../data';
const formData = ref<PayRefundApi.Refund>(); const formData = ref<PayRefundApi.Refund>();
const [BaseDescription] = useDescription({
componentProps: {
bordered: false,
column: 2,
class: 'mx-4',
},
schema: useBaseDetailSchema(),
});
const [ChannelDescription] = useDescription({
componentProps: {
bordered: false,
column: 2,
class: 'mx-4',
},
schema: useChannelDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) { async onOpenChange(isOpen: boolean) {
if (!isOpen) { if (!isOpen) {
@@ -42,89 +60,8 @@ const [Modal, modalApi] = useVbenModal({
:show-cancel-button="false" :show-cancel-button="false"
:show-confirm-button="false" :show-confirm-button="false"
> >
<Descriptions bordered :column="2" size="middle" class="mx-4"> <BaseDescription :data="formData" />
<Descriptions.Item label="商户退款单号">
<Tag size="small">{{ formData?.merchantRefundId }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="渠道退款单号">
<Tag type="success" size="small" v-if="formData?.channelRefundNo">
{{ formData?.channelRefundNo }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="商户支付单号">
<Tag size="small">{{ formData?.merchantOrderId }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="渠道支付单号">
<Tag type="success" size="small">
{{ formData?.channelOrderNo }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="应用编号">
{{ formData?.appId }}
</Descriptions.Item>
<Descriptions.Item label="应用名称">
{{ formData?.appName }}
</Descriptions.Item>
<Descriptions.Item label="支付金额">
<Tag type="success" size="small">
{{ (formData?.payPrice || 0) / 100.0 }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="退款金额">
<Tag size="mini" type="danger">
{{ (formData?.refundPrice || 0) / 100.0 }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="退款状态">
<DictTag
:type="DICT_TYPE.PAY_REFUND_STATUS"
:value="formData?.status"
/>
</Descriptions.Item>
<Descriptions.Item label="退款时间">
{{ formatDateTime(formData?.successTime || '') }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDateTime(formData?.createTime || '') }}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{{ formatDateTime(formData?.updateTime || '') }}
</Descriptions.Item>
</Descriptions>
<Divider /> <Divider />
<Descriptions bordered :column="2" size="middle" class="mx-4"> <ChannelDescription :data="formData" />
<Descriptions.Item label="退款渠道">
<DictTag
:type="DICT_TYPE.PAY_CHANNEL_CODE"
:value="formData?.channelCode"
/>
</Descriptions.Item>
<Descriptions.Item label="退款原因">
{{ formData?.reason }}
</Descriptions.Item>
<Descriptions.Item label="退款 IP">
{{ formData?.userIp }}
</Descriptions.Item>
<Descriptions.Item label="通知 URL">
{{ formData?.notifyUrl }}
</Descriptions.Item>
</Descriptions>
<Divider />
<Descriptions bordered :column="2" size="middle" class="mx-4">
<Descriptions.Item label="渠道错误码">
{{ formData?.channelErrorCode }}
</Descriptions.Item>
<Descriptions.Item label="渠道错误码描述">
{{ formData?.channelErrorMsg }}
</Descriptions.Item>
</Descriptions>
<Descriptions bordered :column="1" size="middle" class="mx-4">
<Descriptions.Item label="支付通道异步回调内容">
<p class="whitespace-pre-wrap break-words">
{{ formData?.channelNotifyData }}
</p>
</Descriptions.Item>
</Descriptions>
</Modal> </Modal>
</template> </template>

View File

@@ -1,47 +1,67 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { isEmpty } from '.'; import { formatDate } from './date';
/** 时间段选择器拓展 */ /**
export function rangePickerExtend() { * @param {Date | number | string} time 需要转换的时间
return { * @param {string} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss
// 显示格式 */
format: 'YYYY-MM-DD HH:mm:ss', export function formatTime(time: Date | number | string, fmt: string) {
placeholder: ['开始时间', '结束时间'], if (time) {
ranges: { const date = new Date(time);
: [dayjs().startOf('day'), dayjs().endOf('day')], const o = {
7: [ 'M+': date.getMonth() + 1,
dayjs().subtract(7, 'day').startOf('day'), 'd+': date.getDate(),
dayjs().endOf('day'), 'H+': date.getHours(),
], 'm+': date.getMinutes(),
30: [ 's+': date.getSeconds(),
dayjs().subtract(30, 'day').startOf('day'), 'q+': Math.floor((date.getMonth() + 3) / 3),
dayjs().endOf('day'), S: date.getMilliseconds(),
], };
: [ const yearMatch = fmt.match(/y+/);
dayjs().subtract(1, 'day').startOf('day'), if (yearMatch) {
dayjs().subtract(1, 'day').endOf('day'), fmt = fmt.replace(
], yearMatch[0],
: [dayjs().startOf('week'), dayjs().endOf('day')], `${date.getFullYear()}`.slice(4 - yearMatch[0].length),
: [dayjs().startOf('month'), dayjs().endOf('day')], );
}, }
showTime: { for (const k in o) {
defaultValue: [ const match = fmt.match(new RegExp(`(${k})`));
dayjs('00:00:00', 'HH:mm:ss'), if (match) {
dayjs('23:59:59', 'HH:mm:ss'), fmt = fmt.replace(
], match[0],
format: 'HH:mm:ss', match[0].length === 1
}, ? (o[k as keyof typeof o] as any)
transformDateFunc: (dates: any) => { : `00${o[k as keyof typeof o]}`.slice(
if (dates && dates.length === 2) { `${o[k as keyof typeof o]}`.length,
// 格式化为后台支持的时间格式 ),
return [dates.createTime[0], dates.createTime[1]].join(','); );
} }
return {}; }
}, return fmt;
// 如果需要10位时间戳秒级可以使用 valueFormat: 'X' } else {
valueFormat: 'YYYY-MM-DD HH:mm:ss', return '';
}; }
}
/**
* 获取当前日期是第几周
* @param dateTime 当前传入的日期值
* @returns 返回第几周数字值
*/
export function getWeek(dateTime: Date): number {
const temptTime = new Date(dateTime);
// 周几
const weekday = temptTime.getDay() || 7;
// 周1+5天=周六
temptTime.setDate(temptTime.getDate() - weekday + 1 + 5);
let firstDay = new Date(temptTime.getFullYear(), 0, 1);
const dayOfWeek = firstDay.getDay();
let spendDay = 1;
if (dayOfWeek !== 0) spendDay = 7 - dayOfWeek + 1;
firstDay = new Date(temptTime.getFullYear(), 0, 1 + spendDay);
const d = Math.ceil((temptTime.valueOf() - firstDay.valueOf()) / 86_400_000);
return Math.ceil(d / 7);
} }
/** /**
@@ -94,10 +114,28 @@ export function formatPast(
typeof param === 'string' || typeof param === 'object' typeof param === 'string' || typeof param === 'object'
? new Date(param) ? new Date(param)
: param; : param;
return dayjs(date).format(format); return formatDate(date, format) as string;
} }
} }
/**
* 时间问候语
* @param param 当前时间new Date() 格式
* @description param 调用 `formatAxis(new Date())` 输出 `上午好`
* @returns 返回拼接后的时间字符串
*/
export function formatAxis(param: Date): string {
const hour: number = new Date(param).getHours();
if (hour < 6) return '凌晨好';
else if (hour < 9) return '早上好';
else if (hour < 12) return '上午好';
else if (hour < 14) return '中午好';
else if (hour < 17) return '下午好';
else if (hour < 19) return '傍晚好';
else if (hour < 22) return '晚上好';
else return '夜里好';
}
/** /**
* 将毫秒转换成时间字符串。例如说xx 分钟 * 将毫秒转换成时间字符串。例如说xx 分钟
* *
@@ -105,22 +143,12 @@ export function formatPast(
* @returns {string} 字符串 * @returns {string} 字符串
*/ */
export function formatPast2(ms: number): string { export function formatPast2(ms: number): string {
if (isEmpty(ms)) { const day = Math.floor(ms / (24 * 60 * 60 * 1000));
return ''; const hour = Math.floor(ms / (60 * 60 * 1000) - day * 24);
} const minute = Math.floor(ms / (60 * 1000) - day * 24 * 60 - hour * 60);
// 定义时间单位常量,便于维护 const second = Math.floor(
const SECOND = 1000; ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60,
const MINUTE = 60 * SECOND; );
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// 计算各时间单位
const day = Math.floor(ms / DAY);
const hour = Math.floor((ms % DAY) / HOUR);
const minute = Math.floor((ms % HOUR) / MINUTE);
const second = Math.floor((ms % MINUTE) / SECOND);
// 根据时间长短返回不同格式
if (day > 0) { if (day > 0) {
return `${day}${hour} 小时 ${minute} 分钟`; return `${day}${hour} 小时 ${minute} 分钟`;
} }
@@ -134,43 +162,138 @@ export function formatPast2(ms: number): string {
} }
/** /**
* @param {Date | number | string} time 需要转换的时间 * 设置起始日期时间为00:00:00
* @param {string} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss * @param param 传入日期
* @returns 带时间00:00:00的日期
*/ */
export function formatTime(time: Date | number | string, fmt: string) { export function beginOfDay(param: Date): Date {
if (time) { return new Date(
const date = new Date(time); param.getFullYear(),
const o = { param.getMonth(),
'M+': date.getMonth() + 1, param.getDate(),
'd+': date.getDate(), 0,
'H+': date.getHours(), 0,
'm+': date.getMinutes(), 0,
's+': date.getSeconds(), );
'q+': Math.floor((date.getMonth() + 3) / 3), }
S: date.getMilliseconds(),
}; /**
const yearMatch = fmt.match(/y+/); * 设置结束日期时间为23:59:59
if (yearMatch) { * @param param 传入日期
fmt = fmt.replace( * @returns 带时间23:59:59的日期
yearMatch[0], */
`${date.getFullYear()}`.slice(4 - yearMatch[0].length), export function endOfDay(param: Date): Date {
); return new Date(
} param.getFullYear(),
for (const k in o) { param.getMonth(),
const match = fmt.match(new RegExp(`(${k})`)); param.getDate(),
if (match) { 23,
fmt = fmt.replace( 59,
match[0], 59,
match[0].length === 1 );
? (o[k as keyof typeof o] as any) }
: `00${o[k as keyof typeof o]}`.slice(
`${o[k as keyof typeof o]}`.length, /**
), * 计算两个日期间隔天数
); * @param param1 日期1
} * @param param2 日期2
} */
return fmt; export function betweenDay(param1: Date, param2: Date): number {
} else { param1 = convertDate(param1);
return ''; param2 = convertDate(param2);
} // 计算差值
return Math.floor((param2.getTime() - param1.getTime()) / (24 * 3600 * 1000));
}
/**
* 日期计算
* @param param1 日期
* @param param2 添加的时间
*/
export function addTime(param1: Date, param2: number): Date {
param1 = convertDate(param1);
return new Date(param1.getTime() + param2);
}
/**
* 日期转换
* @param param 日期
*/
export function convertDate(param: Date | string): Date {
if (typeof param === 'string') {
return new Date(param);
}
return param;
}
/**
* 指定的两个日期, 是否为同一天
* @param a 日期 A
* @param b 日期 B
*/
export function isSameDay(a: dayjs.ConfigType, b: dayjs.ConfigType): boolean {
if (!a || !b) return false;
const aa = dayjs(a);
const bb = dayjs(b);
return (
aa.year() === bb.year() &&
aa.month() === bb.month() &&
aa.day() === bb.day()
);
}
/**
* 获取一天的开始时间、截止时间
* @param date 日期
* @param days 天数
*/
export function getDayRange(
date: dayjs.ConfigType,
days: number,
): [dayjs.ConfigType, dayjs.ConfigType] {
const day = dayjs(date).add(days, 'd');
return getDateRange(day, day);
}
/**
* 获取最近7天的开始时间、截止时间
*/
export function getLast7Days(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastWeekDay = dayjs().subtract(7, 'd');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastWeekDay, yesterday);
}
/**
* 获取最近30天的开始时间、截止时间
*/
export function getLast30Days(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastMonthDay = dayjs().subtract(30, 'd');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastMonthDay, yesterday);
}
/**
* 获取最近1年的开始时间、截止时间
*/
export function getLast1Year(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastYearDay = dayjs().subtract(1, 'y');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastYearDay, yesterday);
}
/**
* 获取指定日期的开始时间、截止时间
* @param beginDate 开始日期
* @param endDate 截止日期
*/
export function getDateRange(
beginDate: dayjs.ConfigType,
endDate: dayjs.ConfigType,
): [string, string] {
return [
dayjs(beginDate).startOf('d').format('YYYY-MM-DD HH:mm:ss'),
dayjs(endDate).endOf('d').format('YYYY-MM-DD HH:mm:ss'),
];
} }

View File

@@ -26,11 +26,11 @@ const [Modal, modalApi] = useVbenModal({
}); });
</script> </script>
<template> <template>
<Modal class="w-2/5" :title="$t('ui.widgets.qa')"> <Modal class="w-1/3" :title="$t('ui.widgets.qa')">
<div class="mt-2 flex flex-col"> <div class="mt-2 flex flex-col">
<div class="mt-2 flex flex-row"> <div class="mt-2 flex flex-col">
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large"> <VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="p-2">项目地址:</p> <p class="w-24 p-2">项目地址:</p>
<VbenButton <VbenButton
variant="link" variant="link"
@click=" @click="
@@ -50,7 +50,7 @@ const [Modal, modalApi] = useVbenModal({
</VbenButtonGroup> </VbenButtonGroup>
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large"> <VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="p-2">issues:</p> <p class="w-24 p-2">issues:</p>
<VbenButton <VbenButton
variant="link" variant="link"
@click=" @click="
@@ -74,7 +74,7 @@ const [Modal, modalApi] = useVbenModal({
</VbenButtonGroup> </VbenButtonGroup>
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large"> <VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="p-2">开发文档:</p> <p class="w-24 p-2">开发文档:</p>
<VbenButton <VbenButton
variant="link" variant="link"
@click="openWindow('https://doc.iocoder.cn/quick-start/')" @click="openWindow('https://doc.iocoder.cn/quick-start/')"
@@ -86,13 +86,17 @@ const [Modal, modalApi] = useVbenModal({
</VbenButton> </VbenButton>
</VbenButtonGroup> </VbenButtonGroup>
</div> </div>
<p class="mt-2 flex justify-center"> <div class="mt-2 flex justify-start">
<span> <p class="w-24 p-2">软件外包:</p>
<img src="/wx-xingyu.png" alt="数舵科技" /> <img
</span> src="/wx-xingyu.png"
</p> alt="数舵科技"
class="cursor-pointer"
@click="openWindow('https://shuduokeji.com')"
/>
</div>
<p class="mt-2 flex justify-center pt-4 text-sm italic"> <p class="mt-2 flex justify-center pt-4 text-sm italic">
本项目采用<Badge variant="destructive">MIT</Badge> 本项目采用 <Badge class="mx-2" variant="destructive">MIT</Badge>
开源协议个人与企业可100% 免费使用 开源协议个人与企业可100% 免费使用
</p> </p>
</div> </div>

View File

@@ -1,6 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@@ -47,15 +50,17 @@ async function handleChange(id: number | undefined) {
</script> </script>
<template> <template>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger>
<Button <Button
variant="outline" variant="outline"
class="hover:bg-accent ml-1 mr-2 h-8 w-24 cursor-pointer rounded-full p-1.5" class="hover:bg-accent ml-1 mr-2 h-8 w-32 cursor-pointer rounded-full p-1.5"
> >
{{ tenants.find((item) => item.id === visitTenantId)?.name }} <IconifyIcon icon="lucide:align-justify" class="mr-4" />
{{ $t('page.tenant.placeholder') }}
<!-- {{ tenants.find((item) => item.id === visitTenantId)?.name }} -->
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="w-56 p-0 pb-1"> <DropdownMenuContent class="w-40 p-0 pb-1">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
v-for="tenant in tenants" v-for="tenant in tenants"
@@ -64,7 +69,13 @@ async function handleChange(id: number | undefined) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleChange(tenant.id)" @click="handleChange(tenant.id)"
> >
{{ tenant.name }} <template v-if="tenant.id === visitTenantId">
<IconifyIcon icon="lucide:check" class="mr-2" />
{{ tenant.name }}
</template>
<template v-else>
{{ tenant.name }}
</template>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -3,13 +3,16 @@ import type {
BarSeriesOption, BarSeriesOption,
GaugeSeriesOption, GaugeSeriesOption,
LineSeriesOption, LineSeriesOption,
MapSeriesOption,
} from 'echarts/charts'; } from 'echarts/charts';
import type { import type {
DatasetComponentOption, DatasetComponentOption,
GeoComponentOption,
GridComponentOption, GridComponentOption,
// 组件类型的定义后缀都为 ComponentOption // 组件类型的定义后缀都为 ComponentOption
TitleComponentOption, TitleComponentOption,
TooltipComponentOption, TooltipComponentOption,
VisualMapComponentOption,
} from 'echarts/components'; } from 'echarts/components';
import type { ComposeOption } from 'echarts/core'; import type { ComposeOption } from 'echarts/core';
@@ -17,12 +20,14 @@ import {
BarChart, BarChart,
GaugeChart, GaugeChart,
LineChart, LineChart,
MapChart,
PieChart, PieChart,
RadarChart, RadarChart,
} from 'echarts/charts'; } from 'echarts/charts';
import { import {
// 数据集组件 // 数据集组件
DatasetComponent, DatasetComponent,
GeoComponent,
GridComponent, GridComponent,
LegendComponent, LegendComponent,
TitleComponent, TitleComponent,
@@ -30,6 +35,7 @@ import {
TooltipComponent, TooltipComponent,
// 内置数据转换器组件 (filter, sort) // 内置数据转换器组件 (filter, sort)
TransformComponent, TransformComponent,
VisualMapComponent,
} from 'echarts/components'; } from 'echarts/components';
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core';
import { LabelLayout, UniversalTransition } from 'echarts/features'; import { LabelLayout, UniversalTransition } from 'echarts/features';
@@ -40,10 +46,13 @@ export type ECOption = ComposeOption<
| BarSeriesOption | BarSeriesOption
| DatasetComponentOption | DatasetComponentOption
| GaugeSeriesOption | GaugeSeriesOption
| GeoComponentOption
| GridComponentOption | GridComponentOption
| LineSeriesOption | LineSeriesOption
| MapSeriesOption
| TitleComponentOption | TitleComponentOption
| TooltipComponentOption | TooltipComponentOption
| VisualMapComponentOption
>; >;
// 注册必须的组件 // 注册必须的组件
@@ -63,6 +72,9 @@ echarts.use([
CanvasRenderer, CanvasRenderer,
LegendComponent, LegendComponent,
ToolboxComponent, ToolboxComponent,
VisualMapComponent,
MapChart,
GeoComponent,
]); ]);
export default echarts; export default echarts;

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,7 @@ import {
} from '@vueuse/core'; } from '@vueuse/core';
import echarts from './echarts'; import echarts from './echarts';
import chinaMap from './map/china.json';
type EchartsUIType = typeof EchartsUI | undefined; type EchartsUIType = typeof EchartsUI | undefined;
@@ -32,6 +33,18 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
const { height, width } = useWindowSize(); const { height, width } = useWindowSize();
const resizeHandler: () => void = useDebounceFn(resize, 200); const resizeHandler: () => void = useDebounceFn(resize, 200);
echarts.registerMap('china', {
geoJSON: chinaMap as any,
specialAreas: {
china: {
left: 500,
top: 500,
width: 1000,
height: 1000,
},
},
});
const getOptions = computed((): EChartsOption => { const getOptions = computed((): EChartsOption => {
if (!isDark.value) { if (!isDark.value) {
return {}; return {};