Merge remote-tracking branch 'yudao/dev' into dev

This commit is contained in:
jason
2025-12-29 00:12:30 +08:00
89 changed files with 4805 additions and 4392 deletions

View File

@@ -3,15 +3,31 @@
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type {
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import {
defineAsyncComponent,
defineComponent,
h,
ref,
render,
unref,
watch,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isEmpty } from '@vben/utils';
import { notification } from 'ant-design-vue';
@@ -22,9 +38,6 @@ const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
);
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'),
);
@@ -68,7 +81,14 @@ const TimeRangePicker = defineAsyncComponent(() =>
const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
@@ -104,12 +124,223 @@ const withDefaultPlaceholder = <T extends Component>(
});
};
const withPreviewUpload = () => {
return defineComponent({
name: Upload.name,
emits: ['change', 'update:modelValue'],
setup: (
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const handleChange = async (event: UploadChangeParam) => {
fileList.value = event.fileList;
emit('change', event);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
const handlePreview = async (file: UploadFile) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
}
// 否则渲染默认上传按钮
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
// 可以监听到表单API设置的值
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
);
},
});
};
const createDefaultSlotsWithUpload = (
listType: string,
placeholder: string,
) => {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
}
};
const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'webp',
]);
if (file.url) {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
};
// 如果当前文件不是图片,直接打开
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
console.warn('无法打开文件没有可用的URL或预览地址');
}
return;
}
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
const getBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
};
// 从fileList中过滤出所有图片文件
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 为所有没有预览地址的图片生成预览
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
visible: visible.value,
// 设置初始显示的图片索引
current: imageFiles.findIndex((f) => f.uid === file.uid),
onVisibleChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
// 渲染所有图片文件
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiCascader'
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
@@ -143,21 +374,13 @@ async function initComponentAdapter() {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiCascader: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiCascader',
},
'select',
{
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
),
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
}),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
@@ -187,6 +410,7 @@ async function initComponentAdapter() {
},
),
AutoComplete,
Cascader,
Checkbox,
CheckboxGroup,
DatePicker,
@@ -221,6 +445,7 @@ async function initComponentAdapter() {
TimeRangePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
PreviewUpload: withPreviewUpload(),
FileUpload,
ImageUpload,
};

View File

@@ -84,9 +84,10 @@ setupVbenVxeTable({
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Image, { src: row[column.field] });
return h(Image, { src: row[column.field], ...props });
},
});

View File

@@ -30,6 +30,7 @@ export namespace BpmModelApi {
deploymentTime: number;
suspensionState: number;
formType?: number;
formCustomCreatePath?: string;
formCustomViewPath?: string;
formFields?: string[];
}

View File

@@ -51,12 +51,12 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize,
});
// 计算当前绑定的值,优先使用 modelValue
/** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
// 判断是否使用 modelValue
/** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
@@ -82,19 +82,21 @@ watch(
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
fileList.value = value
.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
})
.filter(Boolean) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
@@ -107,6 +109,7 @@ watch(
},
);
/** 处理文件删除 */
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
@@ -120,17 +123,17 @@ async function handleRemove(file: UploadFile) {
}
}
// 处理文件预览
/** 处理文件预览 */
function handlePreview(file: UploadFile) {
emit('preview', file);
}
// 处理文件数量超限
/** 处理文件数量超限 */
function handleExceed() {
message.error($t('ui.upload.maxNumber', [maxNumber.value]));
}
// 处理上传错误
/** 处理上传错误 */
function handleUploadError(error: any) {
console.error('上传错误:', error);
message.error($t('ui.upload.uploadError'));
@@ -138,6 +141,11 @@ function handleUploadError(error: any) {
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) {
const fileContent = await file.text();
emit('returnText', fileContent);
@@ -171,7 +179,8 @@ async function beforeUpload(file: File) {
return true;
}
async function customRequest(info: UploadRequestOption<any>) {
/** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
@@ -196,7 +205,11 @@ async function customRequest(info: UploadRequestOption<any>) {
}
}
// 处理上传成功
/**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
@@ -228,6 +241,10 @@ function handleUploadSuccess(res: any, file: File) {
}
}
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)

View File

@@ -55,12 +55,12 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize,
});
// 计算当前绑定的值,优先使用 modelValue
/** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
// 判断是否使用 modelValue
/** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
@@ -89,19 +89,21 @@ watch(
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
fileList.value = value
.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
})
.filter(Boolean) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
@@ -114,6 +116,7 @@ watch(
},
);
/** 将文件转换为 Base64 格式 */
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
@@ -125,6 +128,7 @@ function getBase64<T extends ArrayBuffer | null | string>(file: File) {
});
}
/** 处理图片预览 */
async function handlePreview(file: UploadFile) {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
@@ -138,6 +142,7 @@ async function handlePreview(file: UploadFile) {
);
}
/** 处理文件删除 */
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
@@ -151,11 +156,17 @@ async function handleRemove(file: UploadFile) {
}
}
/** 关闭预览弹窗 */
function handleCancel() {
previewOpen.value = false;
previewTitle.value = '';
}
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) {
// 检查文件数量限制
if (fileList.value!.length >= props.maxNumber) {
@@ -186,7 +197,8 @@ async function beforeUpload(file: File) {
return true;
}
async function customRequest(info: UploadRequestOption<any>) {
/** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
@@ -211,7 +223,11 @@ async function customRequest(info: UploadRequestOption<any>) {
}
}
// 处理上传成功
/**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
@@ -243,14 +259,18 @@ function handleUploadSuccess(res: any, file: File) {
}
}
// 处理上传错误
/** 处理上传错误 */
function handleUploadError(error: any) {
console.error('上传错误:', error);
message.error('上传错误!!!');
message.error($t('ui.upload.uploadError'));
// 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)

View File

@@ -6,7 +6,7 @@ import type { FileUploadProps } from './typing';
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Col, Input, Row, Textarea } from 'ant-design-vue';
import { Input, Textarea } from 'ant-design-vue';
import FileUpload from './file-upload.vue';
@@ -30,6 +30,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
});
/** 处理文件内容返回 */
function handleReturnText(text: string) {
modelValue.value = text;
emits('change', modelValue.value);
@@ -37,6 +38,7 @@ function handleReturnText(text: string) {
emits('update:modelValue', modelValue.value);
}
/** 计算输入框属性 */
const inputProps = computed(() => {
return {
...props.inputProps,
@@ -44,6 +46,7 @@ const inputProps = computed(() => {
};
});
/** 计算文本域属性 */
const textareaProps = computed(() => {
return {
...props.textareaProps,
@@ -51,6 +54,7 @@ const textareaProps = computed(() => {
};
});
/** 计算文件上传属性 */
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
@@ -58,17 +62,17 @@ const fileUploadProps = computed(() => {
});
</script>
<template>
<Row>
<Col :span="18">
<Input readonly v-if="inputType === 'input'" v-bind="inputProps" />
<Textarea readonly v-else :row="4" v-bind="textareaProps" />
</Col>
<Col :span="6">
<FileUpload
class="ml-4"
v-bind="fileUploadProps"
@return-text="handleReturnText"
/>
</Col>
</Row>
<div class="w-full">
<Input v-if="inputType === 'input'" readonly v-bind="inputProps">
<template #suffix>
<FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
</template>
</Input>
<div v-else class="relative w-full">
<Textarea readonly :rows="4" v-bind="textareaProps" />
<div class="absolute bottom-2 right-2">
<FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
</div>
</div>
</div>
</template>

View File

@@ -12,28 +12,21 @@ export enum UploadResultStatus {
export type UploadListType = 'picture' | 'picture-card' | 'text';
export interface FileUploadProps {
// 根据后缀,或者其他
accept?: string[];
accept?: string[]; // 根据后缀,或者其他
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
) => Promise<AxiosResponse>;
directory?: string; // 上传的目录
disabled?: boolean;
drag?: boolean; // 是否支持拖拽上传
helpText?: string;
listType?: UploadListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
maxNumber?: number; // 最大数量的文件Infinity不限制
modelValue?: string | string[]; // v-model 支持
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
maxSize?: number; // 文件最大多少MB
multiple?: boolean; // 是否支持多选
resultField?: string; // support xxx.xxx.xx
showDescription?: boolean; // 是否显示下面的描述
value?: string | string[];
}

View File

@@ -22,6 +22,14 @@ enum UPLOAD_TYPE {
SERVER = 'server',
}
/**
* 上传类型钩子函数
* @param acceptRef 接受的文件类型
* @param helpTextRef 帮助文本
* @param maxNumberRef 最大文件数量
* @param maxSizeRef 最大文件大小
* @returns 文件类型限制和帮助文本的计算属性
*/
export function useUploadType({
acceptRef,
helpTextRef,
@@ -78,7 +86,11 @@ export function useUploadType({
return { getAccept, getStringAccept, getHelpText };
}
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
/**
* 上传钩子函数
* @param directory 上传目录
* @returns 上传 URL 和自定义上传方法
*/
export function useUpload(directory?: string) {
// 后端上传地址
const uploadUrl = getUploadUrl();

View File

@@ -100,7 +100,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
},
});
/** 解绑账号 */

View File

@@ -3,6 +3,7 @@ import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { confirm, Page, useVbenForm } from '@vben/common-ui';
import { BpmCandidateStrategyEnum, BpmNodeIdEnum } from '@vben/constants';
@@ -13,7 +14,7 @@ import { Button, Card, message, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getProcessDefinition } from '#/api/bpm/definition';
import { createLeave, updateLeave } from '#/api/bpm/oa/leave';
import { createLeave, getLeave, updateLeave } from '#/api/bpm/oa/leave';
import { getApprovalDetail as getApprovalDetailApi } from '#/api/bpm/processInstance';
import { $t } from '#/locales';
import { router } from '#/router';
@@ -22,6 +23,7 @@ import ProcessInstanceTimeline from '#/views/bpm/processInstance/detail/modules/
import { useFormSchema } from './data';
const { closeCurrentTab } = useTabs();
const { query } = useRoute();
const formLoading = ref(false); // 表单的加载中1修改时的数据加载2提交的按钮禁用
@@ -35,7 +37,7 @@ const processDefinitionId = ref('');
const formData = ref<BpmOALeaveApi.Leave>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['请假'])
? '重新发起请假'
: $t('ui.actionTitle.create', ['请假']);
});
@@ -157,6 +159,34 @@ function selectUserConfirm(id: string, userList: any[]) {
startUserSelectAssignees.value[id] = userList?.map((item: any) => item.id);
}
/** 获取请假数据,用于重新发起时自动填充 */
async function getDetail(id: number) {
try {
formLoading.value = true;
const data = await getLeave(id);
if (!data) {
message.error('重新发起请假失败,原因:请假数据不存在');
return;
}
formData.value = {
...formData.value,
id: data.id,
type: data.type,
reason: data.reason,
startTime: data.startTime,
endTime: data.endTime,
} as BpmOALeaveApi.Leave;
await formApi.setValues({
type: data.type,
reason: data.reason,
startTime: data.startTime,
endTime: data.endTime,
});
} finally {
formLoading.value = false;
}
}
/** 审批相关:预测流程节点会因为输入的参数值而产生新的预测结果值,所以需重新预测一次, formData.value可改成实际业务中的特定字段 */
watch(
formData.value as object,
@@ -190,6 +220,11 @@ onMounted(async () => {
processDefinitionId.value = processDefinitionDetail.id;
startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks;
// 如果是重新发起,则加载请假数据
if (query.id) {
await getDetail(Number(query.id));
}
await getApprovalDetail();
});
</script>

View File

@@ -168,7 +168,7 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
{
title: '操作',
width: 220,
width: 240,
fixed: 'right',
slots: { default: 'actions' },
},

View File

@@ -33,6 +33,16 @@ function handleCreate() {
});
}
/** 重新发起请假 */
function handleReCreate(row: BpmOALeaveApi.Leave) {
router.push({
name: 'OALeaveCreate',
query: {
id: row.id,
},
});
}
/** 取消请假 */
function handleCancel(row: BpmOALeaveApi.Leave) {
prompt({
@@ -161,9 +171,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
ifShow: row.result === BpmProcessInstanceStatus.RUNNING,
ifShow: row.status === BpmProcessInstanceStatus.RUNNING,
onClick: handleCancel.bind(null, row),
},
{
label: '重新发起',
type: 'link',
icon: ACTION_ICON.ADD,
ifShow: row.status !== BpmProcessInstanceStatus.RUNNING,
onClick: handleReCreate.bind(null, row),
},
]"
/>
</template>

View File

@@ -169,6 +169,7 @@ async function initProcessInfo(row: any, formVariables?: any) {
path: row.formCustomCreatePath,
});
// 返回选择流程
// TODO @jason这里为啥要有个 cancel 事件哈?目前看 vue3 + element-plus 貌似不需要呀;
emit('cancel');
}
}

View File

@@ -5,7 +5,11 @@ import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import { h } from 'vue';
import { DocAlert, Page, prompt } from '@vben/common-ui';
import { BpmProcessInstanceStatus, DICT_TYPE } from '@vben/constants';
import {
BpmModelFormType,
BpmProcessInstanceStatus,
DICT_TYPE,
} from '@vben/constants';
import { Button, message, Textarea } from 'ant-design-vue';
@@ -37,23 +41,34 @@ function handleDetail(row: BpmProcessInstanceApi.ProcessInstance) {
}
/** 重新发起流程 */
async function handleCreate(row: BpmProcessInstanceApi.ProcessInstance) {
// 如果是【业务表单】,不支持重新发起
async function handleCreate(row?: BpmProcessInstanceApi.ProcessInstance) {
if (row?.id) {
const processDefinitionDetail = await getProcessDefinition(
row.processDefinitionId,
);
if (processDefinitionDetail.formType === 20) {
message.error(
'重新发起流程失败,原因:该流程使用业务表单,不支持重新发起',
);
if (processDefinitionDetail?.formType === BpmModelFormType.CUSTOM) {
if (!processDefinitionDetail.formCustomCreatePath) {
message.error('未配置业务表单的提交路由,无法重新发起');
return;
}
await router.push({
path: processDefinitionDetail.formCustomCreatePath,
query: {
id: row.businessKey,
},
});
return;
} else if (processDefinitionDetail?.formType === BpmModelFormType.NORMAL) {
await router.push({
name: 'BpmProcessInstanceCreate',
query: { processInstanceId: row.id },
});
return;
}
}
// 跳转发起流程界面
await router.push({
name: 'BpmProcessInstanceCreate',
query: { processInstanceId: row?.id },
query: row?.id ? { processInstanceId: row.id } : {},
});
}

View File

@@ -1,17 +1,34 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
...extra,
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
// 客户转化率分析
case 'conversionStat': {
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '客户转化率',
@@ -40,12 +57,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户转化率分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: {
type: 'value',
name: '转化率(%)',
@@ -59,14 +71,13 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'customerSummary': {
return {
grid: {
bottom: '5%',
containLabel: true,
grid: getGrid({
bottom: '8%',
left: '5%',
right: '5%',
top: '5 %',
},
legend: {},
top: 80,
}),
legend: getLegend(),
series: [
{
name: '新增客户数',
@@ -92,12 +103,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -134,13 +140,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
};
});
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '成交周期(天)',
@@ -166,12 +167,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -208,13 +204,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
};
});
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '成交周期(天)',
@@ -240,12 +231,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -277,13 +263,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
const customerDealCycleByDate = res.customerDealCycleByDate;
const customerDealCycleByUser = res.customerDealCycleByUser;
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '成交周期(天)',
@@ -309,12 +290,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -342,15 +318,13 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
// 客户跟进次数分析
case 'followUpSummary': {
return {
grid: {
left: 20,
grid: getGrid({
right: 30, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
}),
legend: getLegend(),
series: [
{
name: '跟进客户数',
@@ -376,12 +350,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户跟进次数分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -412,20 +381,21 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
// 客户跟进方式分析
case 'followUpType': {
return {
title: {
text: '客户跟进方式分析',
left: 'center',
},
legend: {
orient: 'vertical',
legend: getLegend({
left: 'left',
},
tooltip: {
}),
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{b} : {c}% ',
},
}),
toolbox: {
feature: {
saveAsImage: { show: true, name: '客户跟进方式分析' }, // 保存为图片
@@ -458,13 +428,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'poolSummary': {
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '进入公海客户数',
@@ -490,12 +455,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '公海客户分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',

View File

@@ -1,5 +1,26 @@
import { erpCalculatePercentage } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
...extra,
});
export function getChartOptions(
activeTabName: any,
active: boolean,
@@ -9,26 +30,19 @@ export function getChartOptions(
case 'businessInversionRateSummary': {
return {
color: ['#6ca2ff', '#6ac9d7', '#ff7474'],
tooltip: {
trigger: 'axis',
axisPointer: {
// 坐标轴指示器,坐标轴触发有效
type: 'shadow', // 默认为直线,可选为:'line' | 'shadow'
},
},
legend: {
tooltip: getTooltip(),
legend: getLegend({
data: ['赢单转化率', '商机总数', '赢单商机数'],
bottom: '0px',
itemWidth: 14,
},
grid: {
}),
grid: getGrid({
top: '40px',
left: '40px',
right: '40px',
bottom: '40px',
containLabel: true,
borderColor: '#fff',
},
}),
xAxis: [
{
type: 'category',
@@ -117,13 +131,11 @@ export function getChartOptions(
}
case 'businessSummary': {
return {
grid: {
grid: getGrid({
left: 30,
right: 30, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
}),
legend: getLegend(),
series: [
{
name: '新增商机数量',
@@ -149,12 +161,7 @@ export function getChartOptions(
saveAsImage: { show: true, name: '新增商机分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -211,10 +218,11 @@ export function getChartOptions(
title: {
text: '销售漏斗',
},
tooltip: {
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{a} <br/>{b}',
},
}),
toolbox: {
feature: {
dataView: { readOnly: false },
@@ -222,9 +230,9 @@ export function getChartOptions(
saveAsImage: {},
},
},
legend: {
legend: getLegend({
data: ['客户', '商机', '赢单'],
},
}),
series: [
{
name: '销售漏斗',

View File

@@ -1,14 +1,30 @@
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
...extra,
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'ContractCountPerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '当月合同数量(个)',
@@ -65,12 +81,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -131,13 +142,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'ContractPricePerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '当月合同金额(元)',
@@ -260,13 +266,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'ReceivablePricePerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '当月回款金额(元)',

View File

@@ -13,6 +13,71 @@ function areaReplace(areaName: string) {
.replace('省', '');
}
const getPieTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'item',
...extra,
});
const getPieLegend = (extra: Record<string, any> = {}) => ({
orient: 'vertical',
left: 'left',
...extra,
});
const getPieSeries = (name: string, data: any[]) => ({
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,
});
const getPiePanel = ({
data,
legendExtra,
seriesName,
title,
tooltipExtra,
}: {
data: any[];
legendExtra?: Record<string, any>;
seriesName: string;
title: string;
tooltipExtra?: Record<string, any>;
}) => ({
title: {
text: title,
left: 'center',
},
tooltip: getPieTooltip(tooltipExtra),
legend: getPieLegend(legendExtra),
toolbox: {
feature: {
saveAsImage: { show: true, name: title },
},
},
series: [getPieSeries(seriesName, data)],
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'area': {
@@ -111,326 +176,62 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
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,
};
}),
},
],
},
left: getPiePanel({
title: '全部客户',
seriesName: '全部客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId),
value: r.customerCount,
})),
}),
right: getPiePanel({
title: '成交客户',
seriesName: '成交客户',
data: res.map((r: any) => ({
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,
};
}),
},
],
},
left: getPiePanel({
title: '全部客户',
seriesName: '全部客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.customerCount,
})),
}),
right: getPiePanel({
title: '成交客户',
seriesName: '成交客户',
data: res.map((r: any) => ({
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,
};
}),
},
],
},
left: getPiePanel({
title: '全部客户',
seriesName: '全部客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.customerCount,
})),
}),
right: getPiePanel({
title: '成交客户',
seriesName: '成交客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.dealCount,
})),
}),
};
}
default: {

View File

@@ -1,5 +1,25 @@
import { cloneDeep } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = () => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'contactCountRank': {
@@ -8,15 +28,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '新增联系人数排行',
@@ -34,12 +47,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '新增联系人数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '新增联系人数(个)',
@@ -56,15 +64,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '签约合同排行',
@@ -82,12 +83,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '签约合同排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '签约合同数(个)',
@@ -104,15 +100,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '合同金额排行',
@@ -130,12 +119,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '合同金额排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '合同金额(元)',
@@ -152,15 +136,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '新增客户数排行',
@@ -178,12 +155,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '新增客户数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '新增客户数(个)',
@@ -226,12 +198,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '跟进次数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '跟进次数(次)',
@@ -274,12 +241,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '跟进客户数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '跟进客户数(个)',
@@ -322,12 +284,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '产品销量排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '产品销量',
@@ -370,12 +327,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '回款金额排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '回款金额(元)',

View File

@@ -75,6 +75,7 @@ function handleAuthInfoDialogClose() {
<Card class="h-full">
<template #title>
<div class="flex items-center">
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 -->
<IconifyIcon class="mr-2 text-primary" icon="ep:info-filled" />
<span>设备信息</span>
</div>
@@ -141,6 +142,7 @@ function handleAuthInfoDialogClose() {
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 -->
<IconifyIcon class="mr-2 text-primary" icon="ep:location" />
<span>设备位置</span>
</div>
@@ -160,6 +162,7 @@ function handleAuthInfoDialogClose() {
v-else
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 -->
<IconifyIcon class="mr-2" icon="ep:warning" />
<span>暂无位置信息</span>
</div>

View File

@@ -15,8 +15,8 @@ import {
Tooltip,
} from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getDevicePage } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
interface Props {
products: any[];

View File

@@ -16,8 +16,8 @@ import {
Tooltip,
} from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getProductPage } from '#/api/iot/product/product';
import { DictTag } from '#/components/dict-tag';
interface Props {
categoryList: any[];

View File

@@ -73,7 +73,6 @@ export function useFormSchema(): VbenFormSchema[] {
fieldName: 'spuIds',
label: '活动商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
},
];

View File

@@ -1,30 +1,52 @@
<script lang="ts" setup>
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
import type {
PropertyAndValues,
RuleConfig,
SpuProperty,
} from '#/views/mall/product/spu/components';
import { computed, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import {
convertToInteger,
erpCalculatePercentage,
formatToFraction,
yuanToFen,
} from '@vben/utils';
import { message } from 'ant-design-vue';
import { Button, InputNumber, message } from 'ant-design-vue';
import { VxeColumn } from '#/adapter/vxe-table';
import { getSpuDetailList } from '#/api/mall/product/spu';
import {
createDiscountActivity,
getDiscountActivity,
updateDiscountActivity,
} from '#/api/mall/promotion/discount/discountActivity';
import { $t } from '#/locales';
import { SpuShowcase } from '#/views/mall/product/spu/components';
import {
getPropertyList,
SpuAndSkuList,
SpuSkuSelect,
} from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
defineOptions({ name: 'DiscountActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<
Partial<MallDiscountActivityApi.DiscountActivity> & {
spuIds?: number[];
}
>({});
/** 折扣类型枚举 */
const PromotionDiscountTypeEnum = {
PRICE: { type: 1 }, // 满减
PERCENT: { type: 2 }, // 折扣
};
// ================= 表单相关 =================
const formData = ref<Partial<MallDiscountActivityApi.DiscountActivity>>({});
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['限时折扣活动'])
@@ -44,27 +66,203 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
// ================= 商品选择相关 =================
/** SKU 扩展类型 */
interface SkuExtension extends MallSpuApi.Sku {
productConfig: MallDiscountActivityApi.DiscountProduct;
}
/** SPU 扩展类型 */
interface SpuExtension extends MallSpuApi.Spu {
skus?: SkuExtension[];
}
const spuSelectRef = ref(); // 商品选择组件 Ref
const spuAndSkuListRef = ref(); // SKU 列表组件 Ref
const spuList = ref<SpuExtension[]>([]); // 选择的 SPU 列表
const spuPropertyList = ref<SpuProperty<SpuExtension>[]>([]); // SPU 属性列表
const spuIdList = ref<number[]>([]); // 已选择的 SPU ID 列表
/** SKU 校验规则配置 */
const ruleConfig: RuleConfig[] = [
{
name: 'productConfig.discountPrice',
rule: (arg) => arg > 0,
message: '商品优惠金额不能为 0 ',
},
];
/** 打开商品选择弹窗 */
function openSpuSelect() {
spuSelectRef.value?.open();
}
/** 选择商品后的回调 */
function handleSpuSelected(spuId: number, skuIds?: number[]) {
getSpuDetails(spuId, skuIds);
}
/** 获取 SPU 详情 */
async function getSpuDetails(
spuId: number,
skuIdArr?: number[],
products?: MallDiscountActivityApi.DiscountProduct[],
type?: string,
) {
// 如果已经包含该 SPU 则跳过
if (spuIdList.value.includes(spuId)) {
if (type !== 'load') {
message.error('数据重复选择!');
}
return;
}
spuIdList.value.push(spuId);
const res = (await getSpuDetailList([spuId])) as SpuExtension[];
if (res.length === 0) {
return;
}
const spu = res[0]!;
// 筛选 SKU
const selectSkus =
skuIdArr === undefined
? spu.skus
: spu.skus?.filter((sku) => skuIdArr.includes(sku.id!));
// 为每个 SKU 添加折扣配置
selectSkus?.forEach((sku) => {
let config: MallDiscountActivityApi.DiscountProduct = {
skuId: sku.id!,
spuId: spu.id!,
discountType: 1,
discountPercent: 0,
discountPrice: 0,
};
// 编辑时,使用已有的配置
if (products !== undefined) {
const product = products.find((item) => item.skuId === sku.id);
if (product) {
// 转换为元显示
config = {
...product,
discountPercent: Number(formatToFraction(product.discountPercent)),
discountPrice: Number(formatToFraction(product.discountPrice)),
};
}
}
(sku as SkuExtension).productConfig = config;
});
spu.skus = selectSkus as SkuExtension[];
spuPropertyList.value.push({
spuId: spu.id!,
spuDetail: spu,
propertyList: getPropertyList(spu) as PropertyAndValues[],
});
spuList.value.push(spu);
}
/** 删除 SPU */
function handleDeleteSpu(spuId: number) {
const spuIndex = spuIdList.value.indexOf(spuId);
if (spuIndex !== -1) {
spuIdList.value.splice(spuIndex, 1);
}
const propertyIndex = spuPropertyList.value.findIndex(
(item) => item.spuId === spuId,
);
if (propertyIndex !== -1) {
spuPropertyList.value.splice(propertyIndex, 1);
}
const listIndex = spuList.value.findIndex((item) => item.id === spuId);
if (listIndex !== -1) {
spuList.value.splice(listIndex, 1);
}
}
/** 处理 SKU 优惠金额变动 */
function handleSkuDiscountPriceChange(row: SkuExtension) {
if (row.productConfig.discountPrice <= 0) {
return;
}
// 设置优惠类型:满减
row.productConfig.discountType = PromotionDiscountTypeEnum.PRICE.type;
// 计算折扣百分比
const price = typeof row.price === 'number' ? row.price : Number(row.price);
const percent = erpCalculatePercentage(
price - yuanToFen(row.productConfig.discountPrice),
price,
);
row.productConfig.discountPercent =
typeof percent === 'number' ? percent : Number(percent);
}
/** 处理 SKU 折扣百分比变动 */
function handleSkuDiscountPercentChange(row: SkuExtension) {
if (
row.productConfig.discountPercent <= 0 ||
row.productConfig.discountPercent >= 100
) {
return;
}
// 设置优惠类型:折扣
row.productConfig.discountType = PromotionDiscountTypeEnum.PERCENT.type;
// 计算优惠金额
const price = typeof row.price === 'number' ? row.price : Number(row.price);
row.productConfig.discountPrice = Number(
formatToFraction(price - price * (row.productConfig.discountPercent / 100)),
);
}
/** 重置表单 */
async function resetForm() {
spuList.value = [];
spuPropertyList.value = [];
spuIdList.value = [];
formData.value = {};
await nextTick();
await formApi.resetForm();
}
// ================= 弹窗相关 =================
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as MallDiscountActivityApi.DiscountActivity;
// 确保必要的默认值
if (!data.products) {
data.products = [];
// 校验是否选择了商品
if (spuList.value.length === 0) {
message.warning('请选择活动商品');
return;
}
modalApi.lock();
try {
// 获取折扣商品配置
const products = structuredClone(
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
) as MallDiscountActivityApi.DiscountProduct[];
// 转换金额为分
products.forEach((item) => {
item.discountPercent = convertToInteger(item.discountPercent);
item.discountPrice = convertToInteger(item.discountPrice);
});
const data = structuredClone(
await formApi.getValues(),
) as MallDiscountActivityApi.DiscountActivity;
data.products = products;
// 提交请求
await (formData.value?.id
? updateDiscountActivity(data)
: createDiscountActivity(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -74,19 +272,45 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = {};
await resetForm();
return;
}
// 加载数据
const data = modalApi.getData<MallDiscountActivityApi.DiscountActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDiscountActivity(data.id);
// 设置到 values
await formApi.setValues(formData.value);
const activityData = await getDiscountActivity(data.id);
formData.value = activityData;
// 加载商品详情
if (activityData.products && activityData.products.length > 0) {
// 按 spuId 分组
const spuProductsMap = new Map<
number,
MallDiscountActivityApi.DiscountProduct[]
>();
for (const product of activityData.products) {
const spuId = product.spuId;
if (!spuProductsMap.has(spuId)) {
spuProductsMap.set(spuId, []);
}
spuProductsMap.get(spuId)!.push(product);
}
// 加载每个 SPU 的详情
for (const [spuId, products] of spuProductsMap) {
const skuIdArr = products.map((p) => p.skuId);
await getSpuDetails(spuId, skuIdArr, products, 'load');
}
}
// 设置表单值
await formApi.setValues(activityData);
} finally {
modalApi.unlock();
}
@@ -95,12 +319,59 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<Modal class="w-[70%]" :title="getTitle">
<Form>
<!-- 自定义插槽商品选择 -->
<template #spuIds>
<SpuShowcase v-model="formData.spuIds" />
<div class="w-full">
<Button class="mb-4" @click="openSpuSelect">选择商品</Button>
<SpuAndSkuList
ref="spuAndSkuListRef"
:deletable="true"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list-p="spuPropertyList"
@delete="handleDeleteSpu"
>
<!-- 扩展列限时折扣活动特有配置 -->
<template #default>
<VxeColumn align="center" min-width="168" title="优惠金额(元)">
<template #default="{ row }">
<InputNumber
v-model:value="row.productConfig.discountPrice"
:max="Number(formatToFraction(row.price))"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
@change="handleSkuDiscountPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="折扣百分比(%)">
<template #default="{ row }">
<InputNumber
v-model:value="row.productConfig.discountPercent"
:max="100"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
@change="handleSkuDiscountPercentChange(row)"
/>
</template>
</VxeColumn>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
</Modal>
<!-- 商品选择弹窗 -->
<SpuSkuSelect
ref="spuSelectRef"
:is-select-sku="true"
@select="handleSpuSelected"
/>
</template>

View File

@@ -138,6 +138,7 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
placeholder: [
$t('utils.rangePicker.beginTime'),
$t('utils.rangePicker.endTime'),
@@ -217,13 +218,15 @@ export function useFormSchema(): VbenFormSchema[] {
},
rules: 'required',
},
// TODO @puhui9991新增时一直报“请输入优惠设置”2修改老数据出现报“请求参数类型错误:50.00”;
{
fieldName: 'rules',
label: '优惠设置',
component: 'Input',
formItemClass: 'items-start',
rules: 'required',
rules: z
.array(z.any())
.min(1, { message: '请添加至少一条优惠规则' })
.default([]),
},
{
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues

View File

@@ -8,10 +8,9 @@ import {
PromotionConditionTypeEnum,
PromotionProductScopeEnum,
} from '@vben/constants';
import { convertToInteger, formatToFraction } from '@vben/utils';
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import {
@@ -53,6 +52,8 @@ const [Form, formApi] = useVbenForm({
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
// 在验证前同步 formData.rules 到表单中
await formApi.setFieldValue('rules', formData.value.rules || []);
const { valid } = await formApi.validate();
if (!valid) {
return;
@@ -61,18 +62,24 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
try {
const values = await formApi.getValues();
const data = { ...formData.value, ...values };
// 使用 formData.value 作为基础,确保 rules 来自 formData
const data = { ...values, ...formData.value };
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
data.startTime = data.startAndEndTime[0];
data.endTime = data.startAndEndTime[1];
delete data.startAndEndTime;
}
data.rules?.forEach((item: any) => {
// 深拷贝 rules 避免修改原始数据
const rules = cloneDeep(
data.rules,
) as unknown as MallRewardActivityApi.RewardRule[];
rules.forEach((item: any) => {
item.discountPrice = convertToInteger(item.discountPrice || 0);
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
item.limit = convertToInteger(item.limit || 0);
}
});
data.rules = rules;
await (data.id
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
@@ -97,9 +104,10 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
const result = await getReward(data.id);
// valueFormat: 'x' 配置下,直接使用时间戳
result.startAndEndTime = [
result.startTime ? dayjs(result.startTime) : undefined,
result.endTime ? dayjs(result.endTime) : undefined,
result.startTime ? String(result.startTime) : undefined,
result.endTime ? String(result.endTime) : undefined,
] as any[];
result.rules?.forEach((item: any) => {
item.discountPrice = formatToFraction(item.discountPrice || 0);

View File

@@ -7,6 +7,7 @@ import { provide, ref } from 'vue';
import { useAccess } from '@vben/access';
import { confirm, DocAlert, Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { Button, message, Tabs } from 'ant-design-vue';
@@ -23,7 +24,6 @@ import {
import { UploadType } from './modules/upload';
import UploadFile from './modules/UploadFile.vue';
import UploadVideo from './modules/UploadVideo.vue';
import {$t} from '@vben/locales';
defineOptions({ name: 'MpMaterial' });
@@ -84,7 +84,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
rowConfig: {
keyField: 'id',
isHover: true,
height: type.value === UploadType.Image ? 220 : 'auto',
},
cellConfig: {
height: type.value === UploadType.Image ? 220 : undefined,
},
toolbarConfig: {
refresh: true,
@@ -101,7 +103,9 @@ async function onTabChange() {
rowConfig: {
keyField: 'id',
isHover: true,
height: type.value === UploadType.Image ? 220 : 'auto',
},
cellConfig: {
height: type.value === UploadType.Image ? 220 : undefined,
},
});
await gridApi.reload();

View File

@@ -43,7 +43,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as PayChannelApi.Channel;
data.config = JSON.stringify(data.config);
data.config = JSON.stringify(data.config || {});
try {
await (data.id ? updateChannel(data) : createChannel(data));
// 关闭并提示