Pre Merge pull request !155 from xingyu/dev

pull/155/MERGE
xingyu 2025-06-24 07:38:49 +00:00 committed by Gitee
commit edc4b5ca4d
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
59 changed files with 5008 additions and 488 deletions

View File

@ -24,6 +24,7 @@ import {
ImagePreviewGroup,
Popconfirm,
Switch,
Tag,
} from 'ant-design-vue';
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: ''} },
vxeUI.renderer.add('CellDict', {
renderTableDefault(renderOpts, params) {

View File

@ -1,5 +1,3 @@
import type { PageParam } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmStatisticsCustomerApi {
@ -93,10 +91,84 @@ export namespace CrmStatisticsCustomerApi {
customerDealCycle: 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[]>(
'/crm/statistics-customer/get-customer-summary-by-date',
{ 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[]>(
'/crm/statistics-customer/get-customer-summary-by-user',
{ 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[]>(
'/crm/statistics-customer/get-follow-up-summary-by-date',
{ 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[]>(
'/crm/statistics-customer/get-follow-up-summary-by-user',
{ 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[]>(
'/crm/statistics-customer/get-follow-up-summary-by-type',
{ 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[]>(
'/crm/statistics-customer/get-contract-summary',
{ 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[]>(
'/crm/statistics-customer/get-pool-summary-by-date',
{ 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[]>(
'/crm/statistics-customer/get-pool-summary-by-user',
{ 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[]>(
'/crm/statistics-customer/get-customer-deal-cycle-by-date',
{ 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[]>(
'/crm/statistics-customer/get-customer-deal-cycle-by-user',
{ 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[]>(
'/crm/statistics-customer/get-customer-deal-cycle-by-area',
{ params },
@ -184,7 +276,9 @@ export function getCustomerDealCycleByArea(params: PageParam) {
}
/** 获取客户成交周期(按产品) */
export function getCustomerDealCycleByProduct(params: PageParam) {
export function getCustomerDealCycleByProduct(
params: CrmStatisticsCustomerApi.CustomerSummaryParams,
) {
return requestClient.get<
CrmStatisticsCustomerApi.CustomerDealCycleByProduct[]
>('/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';
@ -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>(
'/crm/statistics-funnel/get-funnel-summary',
{ 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>>(
'/crm/statistics-funnel/get-business-summary-by-end-status',
{ 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[]>(
'/crm/statistics-funnel/get-business-summary-by-date',
{ params },
@ -50,7 +84,7 @@ export function getBusinessSummaryByDate(params: PageParam) {
}
/** 获取商机转化率分析(按日期) */
export function getBusinessInversionRateSummaryByDate(params: PageParam) {
export function getBusinessInversionRateSummaryByDate(params: any) {
return requestClient.get<
CrmStatisticsFunnelApi.BusinessInversionRateSummaryByDate[]
>('/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>>(
'/crm/statistics-funnel/get-business-page-by-date',
{ params },

View File

@ -1,5 +1,3 @@
import type { PageParam } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmStatisticsPerformanceApi {
@ -10,10 +8,17 @@ export namespace CrmStatisticsPerformanceApi {
lastMonthCount: 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[]>(
'/crm/statistics-performance/get-contract-price-performance',
{ 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[]>(
'/crm/statistics-performance/get-receivable-price-performance',
{ 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[]>(
'/crm/statistics-performance/get-contract-count-performance',
{ 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) {
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) {
return requestClient.get<CrmStatisticsRankApi.Rank[]>(

View File

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

View File

@ -63,6 +63,7 @@ const [Modal, modalApi] = useVbenModal({
});
// TODO xingyu modalApi ? trigger-node-config.vue conditionDialog
// useVbenModal
defineExpose({ modalApi });
</script>
<template>

View File

@ -5,7 +5,10 @@ import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useTaskStatusClass, useWatchNode } from '../../helpers';
import ProcessInstanceModal from './modules/process-instance-modal.vue';
defineOptions({ name: 'EndEventNode' });
const props = defineProps({
@ -20,15 +23,26 @@ const currentNode = useWatchNode(props);
const readonly = inject<Boolean>('readonly');
const processInstance = inject<Ref<any>>('processInstance', ref({}));
const processInstanceInfos = ref<any[]>([]); //
const [Modal, modalApi] = useVbenModal({
connectedComponent: ProcessInstanceModal,
destroyOnClose: true,
});
function nodeClick() {
if (readonly && processInstance && processInstance.value) {
console.warn(
'TODO 只读模式,弹窗显示审批信息',
processInstance.value,
processInstanceInfos.value,
);
const processInstanceInfo = [
{
startUser: processInstance.value.startUser,
createTime: processInstance.value.startTime,
endTime: processInstance.value.endTime,
status: processInstance.value.status,
durationInMillis: processInstance.value.durationInMillis,
},
];
modalApi
.setData(processInstanceInfo)
.setState({ title: '流程信息' })
.open();
}
}
</script>
@ -42,5 +56,6 @@ function nodeClick() {
<span class="node-fixed-name" title="结束">结束</span>
</div>
</div>
<!-- TODO 审批信息 -->
<!-- 流程信息弹窗 -->
<Modal />
</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 { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
@ -15,6 +16,7 @@ import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import StartUserNodeConfig from '../nodes-config/start-user-node-config.vue';
import TaskListModal from './modules/task-list-modal.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'StartUserNode' });
@ -27,7 +29,6 @@ const props = defineProps({
});
//
// const emits = defineEmits<{
defineEmits<{
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();
@ -44,24 +45,25 @@ const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
const nodeSetting = ref();
//
const selectTasks = ref<any[] | undefined>([]); //
const [Modal, modalApi] = useVbenModal({
connectedComponent: TaskListModal,
destroyOnClose: true,
});
function nodeClick() {
if (readonly) {
//
if (tasks && tasks.value) {
console.warn(
'TODO 只读模式,弹窗显示任务信息',
tasks.value,
selectTasks.value,
//
const nodeTasks = tasks.value.filter(
(task) => task.taskDefinitionKey === currentNode.value.id,
);
//
modalApi
.setData(nodeTasks)
.setState({ title: currentNode.value.name })
.open();
}
} else {
console.warn(
'TODO 编辑模式,打开节点配置、把当前节点传递给配置组件',
nodeSetting.value,
);
nodeSetting.value.showStartUserNodeConfig(currentNode.value);
}
}
@ -122,5 +124,6 @@ function nodeClick() {
ref="nodeSetting"
:flow-node="currentNode"
/>
<!-- 审批记录 TODO -->
<!-- 审批记录弹窗 -->
<Modal />
</template>

View File

@ -5,6 +5,7 @@ import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
@ -14,6 +15,26 @@ import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
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';
defineOptions({ name: 'UserTaskNode' });
@ -42,11 +63,23 @@ const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
);
const nodeSetting = ref();
const [Modal, modalApi] = useVbenModal({
connectedComponent: TaskListModal,
destroyOnClose: true,
});
function nodeClick() {
if (readonly) {
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 {
//
@ -64,8 +97,6 @@ function findReturnTaskNodes(
//
emits('findParentNode', matchNodeList, BpmNodeTypeEnum.USER_TASK_NODE);
}
// const selectTasks = ref<any[] | undefined>([]); //
</script>
<template>
<div class="node-wrapper">
@ -138,5 +169,6 @@ function findReturnTaskNodes(
:flow-node="currentNode"
@find-return-task-nodes="findReturnTaskNodes"
/>
<!-- TODO 审批记录 -->
<!-- 审批记录弹窗 -->
<Modal />
</template>

View File

@ -249,7 +249,7 @@ onMounted(() => {
/>
</div>
</div>
<!-- TODO 这个好像暂时没有用到保存失败弹窗 -->
<Modal
v-model:open="errorDialogVisible"
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 SimpleProcessViewer } from './components/simple-process-viewer.vue';
export type { SimpleFlowNode } from './consts';
export { parseFormFields } from './helpers';

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { onMounted, ref } from 'vue';
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 { deleteMusic, getMusicPage, updateMusic } from '#/api/ai/music';
@ -101,9 +101,9 @@ onMounted(async () => {
</template>
<template #userId="{ row }">
<span>{{
userList.find((item) => item.id === row.userId)?.nickname
}}</span>
<span>
{{ userList.find((item) => item.id === row.userId)?.nickname }}
</span>
</template>
<template #content="{ row }">
<Button
@ -141,11 +141,6 @@ onMounted(async () => {
:disabled="row.status !== AiMusicStatusEnum.SUCCESS"
/>
</template>
<template #tags="{ row }">
<Tag v-for="tag in row.tags" :key="tag" class="ml-1">
{{ tag }}
</Tag>
</template>
<template #actions="{ row }">
<TableAction
:actions="[

View File

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

View File

@ -21,7 +21,7 @@ const props = defineProps<{
type: 'copy' | 'create' | 'edit';
}>();
//
/** 流程表单详情 */
const flowFormConfig = ref();
const [FormModal, formModalApi] = useVbenModal({
@ -31,7 +31,7 @@ const [FormModal, formModalApi] = useVbenModal({
const designerRef = ref<InstanceType<typeof FcDesigner>>();
//
/** 表单设计器配置 */
const designerConfig = ref({
switchType: [], // ,
autoActive: true, //
@ -80,7 +80,7 @@ const currentFormId = computed(() => {
});
//
async function loadFormConfig(id: number | string) {
async function loadFormConfig(id: number) {
try {
const formDetail = await getFormDetail(id);
flowFormConfig.value = formDetail;
@ -106,8 +106,7 @@ async function initializeDesigner() {
}
}
// TODO @ziye使 /** */
//
/** 保存表单 */
function handleSave() {
formModalApi
.setData({
@ -118,7 +117,7 @@ function handleSave() {
.open();
}
//
/** 返回列表页 */
function onBack() {
router.push({
path: '/bpm/manager/form',
@ -137,7 +136,11 @@ onMounted(() => {
<Page auto-content-height>
<FormModal @success="onBack" />
<FcDesigner class="my-designer" ref="designerRef" :config="designerConfig">
<FcDesigner
class="h-full min-h-[500px]"
ref="designerRef"
:config="designerConfig"
>
<template #handle>
<Button size="small" type="primary" @click="handleSave">
<IconifyIcon icon="mdi:content-save" />
@ -147,10 +150,3 @@ onMounted(() => {
</FcDesigner>
</Page>
</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',
title: '流程图标',
minWidth: 100,
slots: { default: 'icon' },
cellRender: {
name: 'CellImage',
props: {
width: 24,
height: 24,
},
},
},
{
field: 'startUsers',
@ -47,7 +53,9 @@ export function useGridColumns(): VxeTableGridOptions<BpmProcessDefinitionApi.Pr
field: 'version',
title: '流程版本',
minWidth: 80,
slots: { default: 'version' },
cellRender: {
name: 'CellTag',
},
},
{
field: 'deploymentTime',

View File

@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
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 { getProcessDefinitionPage } from '#/api/bpm/definition';
@ -93,16 +93,6 @@ onMounted(() => {
<DocAlert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
</template>
<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 v-if="!row.startUsers?.length"></template>
<template v-else-if="row.startUsers.length === 1">
@ -135,9 +125,6 @@ onMounted(() => {
</Button>
<span v-else></span>
</template>
<template #version="{ row }">
<Tag>v{{ row.version }}</Tag>
</template>
<template #actions="{ row }">
<TableAction
:actions="[

View File

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

View File

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

View File

@ -1,9 +1,180 @@
<script setup lang="ts">
defineOptions({ name: 'ProcessInstanceSimpleViewer' });
</script>
<script lang="ts" setup>
import type { SimpleFlowNode } from '#/components/simple-process-design';
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;
// UserTaskGateway
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>
<div>
<h1>Simple BPM Viewer</h1>
<div v-loading="loading">
<SimpleProcessViewer
:flow-node="simpleModel"
:tasks="tasks"
:process-instance="processInstance"
/>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -2,10 +2,10 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
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 { confirm, Page } from '@vben/common-ui';
import { Page, prompt } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
@ -29,7 +29,6 @@ const processDefinitionId = query.processDefinitionId as string;
const formFields = ref<any[]>([]);
const userList = ref<any[]>([]); //
const gridReady = ref(false); //
const cancelReason = ref(''); //
//
let Grid: any = null;
@ -81,26 +80,19 @@ const handleDetail = (row: BpmProcessInstanceApi.ProcessInstance) => {
/** 取消按钮操作 */
const handleCancel = async (row: BpmProcessInstanceApi.ProcessInstance) => {
cancelReason.value = ''; //
confirm({
prompt({
content: '请输入取消原因:',
title: '取消流程',
content: h('div', [
h('p', '请输入取消原因:'),
h(Input, {
value: cancelReason.value,
'onUpdate:value': (val: string) => {
cancelReason.value = val;
},
placeholder: '请输入取消原因',
}),
]),
beforeClose: async ({ isConfirm }) => {
if (!isConfirm) return;
if (!cancelReason.value.trim()) {
icon: 'question',
component: Input,
modelPropName: 'value',
async beforeClose(scope) {
if (!scope.isConfirm) return;
if (!scope.value) {
message.warning('请输入取消原因');
return false;
}
await cancelProcessInstanceByAdmin(row.id, cancelReason.value);
await cancelProcessInstanceByAdmin(row.id, scope.value);
return true;
},
}).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>
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>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/customer/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/customer/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Grid>
<template #top>
<Tabs v-model:active-key="activeTabName" @change="handleTabChange">
<Tabs.TabPane
v-for="item in customerSummaryTabs"
:key="item.key"
:tab="item.tab"
:force-render="true"
/>
</Tabs>
<EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
</template>
</Grid>
</Page>
</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>
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>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/funnel/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/funnel/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Grid>
<template #top>
<Tabs v-model:active-key="activeTabName" @change="handleTabChange">
<Tabs.TabPane
v-for="item in customerSummaryTabs"
:key="item.key"
:tab="item.tab"
:force-render="true"
/>
</Tabs>
<ButtonGroup>
<Button
:type="active ? 'primary' : 'default'"
v-if="activeTabName === 'funnel'"
@click="handleActive(true)"
>
客户视角
</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>
</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>
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>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/performance/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/performance/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Grid>
<template #top>
<Tabs v-model:active-key="activeTabName" @change="handleTabChange">
<Tabs.TabPane
v-for="item in customerSummaryTabs"
:key="item.key"
:tab="item.tab"
:force-render="true"
/>
</Tabs>
<EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
</template>
</Grid>
</Page>
</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>
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>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/portrait/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/portrait/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Grid>
<template #top>
<Tabs v-model:active-key="activeTabName" @change="handleTabChange">
<Tabs.TabPane
v-for="item in customerSummaryTabs"
:key="item.key"
:tab="item.tab"
:force-render="true"
/>
</Tabs>
<div class="mt-5 flex">
<EchartsUI class="m-4 w-1/2" ref="leftChartRef" />
<EchartsUI class="m-4 w-1/2" ref="rightChartRef" />
</div>
</template>
</Grid>
</Page>
</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>
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>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/rank/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/rank/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Grid>
<template #top>
<Tabs v-model:active-key="activeTabName" @change="handleTabChange">
<Tabs.TabPane
v-for="item in customerSummaryTabs"
:key="item.key"
:tab="item.tab"
:force-render="true"
/>
</Tabs>
<EchartsUI class="mb-20 h-full w-full" ref="chartRef" />
</template>
</Grid>
</Page>
</template>

View File

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

View File

@ -6,7 +6,7 @@ import { useAccess } from '@vben/access';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
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 {
@ -167,10 +167,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
/>
</template>
<template #avatar="{ row }">
<Avatar :src="row.avatar" />
</template>
<template #brokerageEnabled="{ row }">
<Switch
v-model:checked="row.brokerageEnabled"

View File

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

View File

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

View File

@ -1,5 +1,4 @@
<script lang="ts" setup>
// TODO @xingyu @form-create/ant-design-vue vben ~
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { PayAppApi } from '#/api/pay/app';

View File

@ -1,7 +1,15 @@
import type { VbenFormSchema } from '#/adapter/form';
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 { DictTag } from '#/components/dict-tag';
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 { 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 { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
import { useDescription } from '#/components/description';
import { useBaseDetailSchema, useChannelDetailSchema } from '../data';
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({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
@ -42,89 +60,8 @@ const [Modal, modalApi] = useVbenModal({
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions bordered :column="2" size="middle" class="mx-4">
<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>
<BaseDescription :data="formData" />
<Divider />
<Descriptions bordered :column="2" size="middle" class="mx-4">
<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>
<ChannelDescription :data="formData" />
</Modal>
</template>

View File

@ -1,47 +1,67 @@
import dayjs from 'dayjs';
import { isEmpty } from '.';
import { formatDate } from './date';
/** 时间段选择器拓展 */
export function rangePickerExtend() {
return {
// 显示格式
format: 'YYYY-MM-DD HH:mm:ss',
placeholder: ['开始时间', '结束时间'],
ranges: {
: [dayjs().startOf('day'), dayjs().endOf('day')],
7: [
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day'),
],
30: [
dayjs().subtract(30, 'day').startOf('day'),
dayjs().endOf('day'),
],
: [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
: [dayjs().startOf('week'), dayjs().endOf('day')],
: [dayjs().startOf('month'), dayjs().endOf('day')],
},
showTime: {
defaultValue: [
dayjs('00:00:00', 'HH:mm:ss'),
dayjs('23:59:59', 'HH:mm:ss'),
],
format: 'HH:mm:ss',
},
transformDateFunc: (dates: any) => {
if (dates && dates.length === 2) {
// 格式化为后台支持的时间格式
return [dates.createTime[0], dates.createTime[1]].join(',');
/**
* @param {Date | number | string} time
* @param {string} fmt yyyy-MM-ddyyyy-MM-dd HH:mm:ss
*/
export function formatTime(time: Date | number | string, fmt: string) {
if (time) {
const date = new Date(time);
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'H+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds(),
'q+': Math.floor((date.getMonth() + 3) / 3),
S: date.getMilliseconds(),
};
const yearMatch = fmt.match(/y+/);
if (yearMatch) {
fmt = fmt.replace(
yearMatch[0],
`${date.getFullYear()}`.slice(4 - yearMatch[0].length),
);
}
for (const k in o) {
const match = fmt.match(new RegExp(`(${k})`));
if (match) {
fmt = fmt.replace(
match[0],
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,
),
);
}
return {};
},
// 如果需要10位时间戳秒级可以使用 valueFormat: 'X'
valueFormat: 'YYYY-MM-DD HH:mm:ss',
};
}
return fmt;
} else {
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'
? new Date(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
*
@ -105,22 +143,12 @@ export function formatPast(
* @returns {string}
*/
export function formatPast2(ms: number): string {
if (isEmpty(ms)) {
return '';
}
// 定义时间单位常量,便于维护
const SECOND = 1000;
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);
// 根据时间长短返回不同格式
const day = Math.floor(ms / (24 * 60 * 60 * 1000));
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(
ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60,
);
if (day > 0) {
return `${day}${hour} 小时 ${minute} 分钟`;
}
@ -134,43 +162,138 @@ export function formatPast2(ms: number): string {
}
/**
* @param {Date | number | string} time
* @param {string} fmt yyyy-MM-ddyyyy-MM-dd HH:mm:ss
* 00:00:00
* @param param
* @returns 00:00:00
*/
export function formatTime(time: Date | number | string, fmt: string) {
if (time) {
const date = new Date(time);
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'H+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds(),
'q+': Math.floor((date.getMonth() + 3) / 3),
S: date.getMilliseconds(),
};
const yearMatch = fmt.match(/y+/);
if (yearMatch) {
fmt = fmt.replace(
yearMatch[0],
`${date.getFullYear()}`.slice(4 - yearMatch[0].length),
);
}
for (const k in o) {
const match = fmt.match(new RegExp(`(${k})`));
if (match) {
fmt = fmt.replace(
match[0],
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,
),
);
}
}
return fmt;
} else {
return '';
}
export function beginOfDay(param: Date): Date {
return new Date(
param.getFullYear(),
param.getMonth(),
param.getDate(),
0,
0,
0,
);
}
/**
* 23:59:59
* @param param
* @returns 23:59:59
*/
export function endOfDay(param: Date): Date {
return new Date(
param.getFullYear(),
param.getMonth(),
param.getDate(),
23,
59,
59,
);
}
/**
*
* @param param1 1
* @param param2 2
*/
export function betweenDay(param1: Date, param2: Date): number {
param1 = convertDate(param1);
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>
<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-row">
<div class="mt-2 flex flex-col">
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="p-2">项目地址:</p>
<p class="w-24 p-2">项目地址:</p>
<VbenButton
variant="link"
@click="
@ -50,7 +50,7 @@ const [Modal, modalApi] = useVbenModal({
</VbenButtonGroup>
<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
variant="link"
@click="
@ -74,7 +74,7 @@ const [Modal, modalApi] = useVbenModal({
</VbenButtonGroup>
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="p-2">开发文档:</p>
<p class="w-24 p-2">开发文档:</p>
<VbenButton
variant="link"
@click="openWindow('https://doc.iocoder.cn/quick-start/')"
@ -86,13 +86,17 @@ const [Modal, modalApi] = useVbenModal({
</VbenButton>
</VbenButtonGroup>
</div>
<p class="mt-2 flex justify-center">
<span>
<img src="/wx-xingyu.png" alt="数舵科技" />
</span>
</p>
<div class="mt-2 flex justify-start">
<p class="w-24 p-2">软件外包:</p>
<img
src="/wx-xingyu.png"
alt="数舵科技"
class="cursor-pointer"
@click="openWindow('https://shuduokeji.com')"
/>
</div>
<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% 免费使用
</p>
</div>

View File

@ -1,6 +1,9 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import {
Button,
DropdownMenu,
@ -47,15 +50,17 @@ async function handleChange(id: number | undefined) {
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<DropdownMenuTrigger>
<Button
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>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56 p-0 pb-1">
<DropdownMenuContent class="w-40 p-0 pb-1">
<DropdownMenuGroup>
<DropdownMenuItem
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"
@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>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

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

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,7 @@ import {
} from '@vueuse/core';
import echarts from './echarts';
import chinaMap from './map/china.json';
type EchartsUIType = typeof EchartsUI | undefined;
@ -32,6 +33,18 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
const { height, width } = useWindowSize();
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 => {
if (!isDark.value) {
return {};