背景
根据上篇文章# 内部审批系统开发总结(1)-基于logicflow引擎和bpmn规范设定流程可以设置流程审批的流程和控制表单在不同审批节点的不同展示表单控件组成的审批表单,这篇主要针对自研的基于拖拽生成表单的功能进行总结,其中包含当前业务场景下需要的控件类型和格式,不同控件的属性配置和拖拽操作区域
功能组成
根据场景的需求,功能开发中需要以下几点:
- 控件类型和控件对应渲染的样式
- 控件的json的格式定义和配置属性定义
- 拖拽区域和配置组件的联动
组件json数据格式
对于一个表单控件的json,根据业务场景和产品文档要求,可以概括为左侧菜单栏组件名称的label属性和icon属性、key、id、组件渲染样式的render属性、组件自身的属性的props属性和记录控件回填数据的model属性配置构成。因为vue3支持jsx的语法,此处为了配置方便,把render和props属性都放在一个object中去配置。本质上来说,所有的类似拖拽的自动化生成都是操作一堆json,所以此处可以认为定义组件标准就是打好根基。 这里简单按照单行文本框组件的实现举例。
jsx
import {Input, Tooltip} from "ant-design-vue";
import {createCheckProp, createInputProp} from "@/views/formDesign/config/createRightProps.js";
import * as commonBlockRulesConfigObj from "@/views/formDesign/config/rules.js";
export const INPUT = {
label: '单行输入框',
icon: 'icon-a-02danhangwenben',
render: ({props,model,isDesigning}) => <div>
{isDesigning ? <b style={{ color: props.color, fontSize: props.size }}>{props.title.text || '单行文本框'}</b> : null}
{props.disabled ?
<Tooltip title={model?.default?.value || ''}>
<div>
<Input placeholder={props.placeholder.text || ''} {...model.default} maxlength={200} disabled={props.disabled}></Input>
</div>
</Tooltip>
: <Input placeholder={props.placeholder.text || ''} {...model.default} maxlength={200} disabled={props.disabled}></Input>
}
</div>,
key: 'input',
props: {
title: createInputProp('标题内容','单行文本框', 'title'),
placeholder: createInputProp('提示文字',''),
check: createCheckProp('校验','必填'),
rules: {
title: commonBlockRulesConfigObj.commonTitleRules,
}
},
model: {
default: '',
}
}
此处是根据组件的props来渲染页面右侧菜单的控件属性配置部分,本质就是不断根据propsTypeConfigFn()里面单行文本框控件的json里面的props来渲染配置,比如组件的title,是否必填的校验规则,审批时placeholder的提示文字等等
jsx
const propTypeConfigFn = {
input: (component,propName,propConfig) => {
// debugger
const key = propConfig?.id || '';
let customRuleConfig = [];
if (key) {
customRuleConfig = component?.props?.['rules']?.[key] || null;
}
const checkRuleRes = checkValueByCustomRule(state.editData.props[propName]['text'], customRuleConfig);
return (
<>
<Input value={state.editData.props[propName]['text']}
onInput={(e) => {
state.editData.props[propName]['text'] = e.target.value;
const checkRuleRes = checkValueByCustomRule(e.target.value, customRuleConfig);
updateBlockConfigRuleStatus({
id: `${state.editData.id}_${key}`,
checkRes: checkRuleRes.res,
})
apply()
}}
></Input>
{ !checkRuleRes.res && <div className={'customErrorMsg'}>{ checkRuleRes?.message || '' }</div> }
</>
)
},
return () => {
let content = []
if (!props.block) {
content.push(<div class={"no-focus-tip"}>
选择组件来设置属性
</div>)
} else {
let component = config.componentMap[props.block.key];
if (component && component.props) {
content.push(<h3 className={"bold"}>属性</h3>)
content.push(Object.entries(component.props).map(([propName, propConfig]) => {
return <div class={"prop-config-block"}>
{
((propName === 'isVisibleCheck' || propName === 'isEditCheck') && appManageStore.formType === 2) ? null :
propConfig.label ?
<span className={"prop-config-label"}>
{propConfig.label}
{
component.key === 'select' && propName === 'options' ?
<div>
<optionsAuthConfig
id={state.editData.id}
blocks={props.data.blocks.filter(v => v.key !== 'instance_id')}
options={state.editData.props[propName]['options']}
auth={state.editData.props['auth']}
// onUpdateAuthList={updateOptionsAuthList}
onUpdateAuthList={(payload) =>{
state.editData.props['updateAuthFn'](payload,state.editData)
apply()
}}
/>
</div>
: null
}
</span> : null
}
{
((propName === 'isVisibleCheck' || propName === 'isEditCheck') && appManageStore.formType === 2) ? null :
propConfig.type ?
propTypeConfigFn[propConfig.type](component,propName,propConfig) :null
}
</div>
}))
}
}
return <div style="padding:30px" ref={formParamsRef}>
{content}
</div>
}
拖动展示控件区域
对于实例中间的表单控件生成区域,需要监听dragenter,dragover,dragleave三种拖拽事件,更新控件的位置。同时在选中组件之后还有复制和删除操作,也需要监听copyBlock和deleteBlock的事件
jsx
import { computed, defineComponent, inject, onMounted, ref, onUnmounted, toRaw } from "vue";
import emitter from "@/utils/bus.js";
import {CopyFilled,DeleteFilled} from "@ant-design/icons-vue";
export default defineComponent({
props: {
block: { type: Object },
formData: { type: Object },
isDesigning: {type: Boolean,default: false},
disabled: {type: Boolean,default: false},
copyBlock: Function,
deleteBlock: Function,
handleSelectChange: {
type: Function,
default: () => {}
},
containerFormRef: { type: Object, default: () => null },
},
setup(props) {
const config = inject('config');
const blockRef = ref(null)
onMounted(() => {
let { offsetWidth, offsetHeight } = blockRef.value;
props.block.width = offsetWidth;
props.block.height = offsetHeight;
emitter.on('start', () => {
blockRef.value.addEventListener('dragenter',dragenter);
blockRef.value.addEventListener('dragover',dragover)
blockRef.value.addEventListener('dragleave',dragleave)
})
emitter.on('end', () => {
blockRef.value.removeEventListener('dragenter',dragenter);
blockRef.value.removeEventListener('dragover',dragover)
blockRef.value.removeEventListener('dragleave',dragleave)
// props.block.markLine = false;
})
})
onUnmounted(() => {
emitter.off('start');
emitter.off('end');
})
const dragenter = (e) => {}
const dragover = (e) => {
// e.offsetX是鼠标距离block-div的left值
// e.offsetY是鼠标距离block-div的top值 (主要用这个)
// props.block.height是当前鼠标所在block的高度
// 判断鼠标距离当前block的top <= props.block.height/2 时 显示当前block的topMarkLine
// 判断鼠标距离当前block的top > props.block.height/2 时 显示当前block的bottomMarkLine
if (e.offsetY <= props.block.height/2) {
props.block.topMarkLine = true;
props.block.bottomMarkLine = false;
} else if (e.offsetY > props.block.height/2) {
props.block.bottomMarkLine = true;
props.block.topMarkLine = false;
}
}
const dragleave = (e) => {
// 鼠标划出当前block-div的时候 markLine都隐藏
props.block.topMarkLine = false;
props.block.bottomMarkLine = false;
}
// 复制block
const copyBlock = () => {
props.copyBlock()
}
// 删除block
const deleteBlock = () => {
props.deleteBlock()
}
// 判断下拉单选框有值 且 authType为2 则触发props.handleSelectChange 方法
const checkSelectAuth = (e, auth) => {
props.handleSelectChange(e, auth)
}
return () => {
const component = config.componentMap[props.block.key];
// 获取render函数
const RenderComponent = component.render({
props: {
...props.block.props,
disabled: props.disabled
},
model: Object.keys(component.model || {}).reduce((prev, modelName) => {
let propName = props.block.model[modelName];
prev[modelName] = {
value: component.key !== 'select' ? props.formData[propName] : props.formData[propName] || undefined,
"onUpdate:value": (v, otherParams = null)=> {
props.formData[propName] = v;
if (props.containerFormRef) {
toRaw(props.containerFormRef).validateFields([propName]);
}
function settingFormDataGlobalLoading(v, otherParams) {
// 记录每个组件uploading的状态
props.formData[`${propName}Loading`] = otherParams.loading;
const formDataUploading = Object.keys(props.formData).filter(item => item !== 'uploadLoading' && item.includes(`Loading`)).reduce((preItem, currItem) => {
if (!preItem && props.formData[currItem]) {
preItem = true;
}
return preItem;
}, false);
// 进行整体控制
props.formData['uploadLoading'] = formDataUploading;
return formDataUploading;
}
if (otherParams) {
settingFormDataGlobalLoading(v, otherParams)
}
},
}
/**
* 这里判断一下props.block是否是下拉单选框 且 authType = 2 且 prev.value有值
* 满足条件的话 触发props.handleSelectChange 方法
*/
// debugger
if (prev.default.value && props.block.key === 'select' && props.block.props.auth.authType === 2) {
checkSelectAuth(prev.default.value, props.block.props.auth)
}
return prev;
}, {}),
isDesigning: props.isDesigning,
selectChangeFns: props.handleSelectChange,
});
return <div class={["editor-block",props.isDesigning ? 'isDesigning': '']} ref={blockRef}>
<div class={"editor-block-content"}>{RenderComponent}</div>
<div class={"editor-block-operate"}>
<div class={"operate-icon"} onClick={() => copyBlock()}><CopyFilled /></div>
<div class={"operate-icon"} onClick={() => deleteBlock()}><DeleteFilled /></div>
</div>
</div>
}
}
})
对于中间拖动的部分可以直接抽取一个hook,作为中间区域拖动动作的统一逻辑
js
import emitter from "@/utils/bus.js";
export function useBlockDragger(focusData, lastSelectBlock, data,containerRef) {
let curBlockIndex = null; // 当前鼠标选中的block在data中的索引值
// block的拖拽-start
const blockDragstart = (e,block) => {
containerRef.value.addEventListener('drop', drop)
containerRef.value.addEventListener('dragover', dragover)
emitter.emit('start');
// 获取一下当前鼠标选中的block在data中的索引值 用来去判断进行拖拽比对(交换位置or位置不变)
curBlockIndex = data.value.blocks.findIndex(item => item.id === block.id)
}
// block的拖拽-end
const blockDragend = (e) => {
// containerRef.value.removeEventListener('drop', dropFn)
containerRef.value.removeEventListener('drop', drop)
containerRef.value.removeEventListener('dragover', dragover)
emitter.emit('end');
}
// 注意drop想要触发一定要dropover事件中阻止默认事件
const dragover = (e) => {
e.preventDefault()
}
const drop = () => {
// drop完 先计算得到物料插入到data中的index位置
let insertIndex = -1; // 默认为-1 就是插入到data的队尾
for (let i = 0; i < data.value.blocks.length; i++) {
if (data.value.blocks[i]['topMarkLine']) {
insertIndex = i;
break;
} else if (data.value.blocks[i]['bottomMarkLine']) {
insertIndex = i+1;
break;
}
}
// drop完 当前所有data的markLine都隐藏
let blocks = data.value.blocks.map(item => {
return {
...item,
topMarkLine: false,
bottomMarkLine: false,
}
}); // 内部已经渲染的组件
// 这里判断insertIndex的值 如果等于curBlockIndex或者等于curBlockIndex+1 则说明位置不变 那就相当于不操作
if (insertIndex === curBlockIndex || insertIndex === curBlockIndex+1){
data.value = {...data.value, blocks};
return;
}
// 特殊情况 当curBlock是最后一个元素的时候 insertIndex = -1 的情况也位置不变
if (curBlockIndex === blocks.length-1 && insertIndex === -1){
data.value = {...data.value, blocks};
return;
}
// 如果insertIndex的值不为上述三种情况,则进行下面的换位置逻辑
/*A元素(当前元素)拿出来插入insertIndex的位置*/
// 这里记得用myJSON 因为考虑到日期选择器的props里有函数 一般的JSON.parse和JSON.stringify无法处理函数
// let block_temp = myJSON.parse(myJSON.stringify(blocks[curBlockIndex]));
// 使用myJSON 进行深拷贝时 对关联控件会报错 导致配置的onChanges函数失效(尚未找到原因,故改为以下方式逻辑)
let block_temp = {
...blocks[curBlockIndex],
topMarkLine: false,
bottomMarkLine: false,
props: {...blocks[curBlockIndex]?.props}, // 保持原有渲染组件的props
model: {...blocks[curBlockIndex]?.model} // 保持原有渲染组件的model
}
blocks.splice(curBlockIndex,1,undefined); // 先删除当前选中的元素,替换成undefined占位
blocks.splice(insertIndex,0,block_temp); // 再把block_temp里存储的当前元素插入指定的位置
blocks = blocks.filter(i => i); // 去掉undefined元素
// 更新data视图
data.value = {...data.value, blocks};
// currentComponent = null;
}
return {
blockDragstart,
blockDragend
}
}
从上面代码可以看出来,其实更多的逻辑还是中间展示区域的列表部分,但是从左侧物料列表拖拽到中间内容的逻辑,我们可以单独抽取出来一个menu的拖动添加控件的hook,本质是不断给blocks的数组插入
js
import emitter from '@/utils/bus.js';
import { randomString } from "@/utils/common.js";
export function useMenuDragger(containerRef,data){
let currentComponent = null;
// enter over leave drop 都是绑定在目标区域的事件
const dragenter = (e) => {
e.dataTransfer.dropEffect = 'move'; // h5拖动的图标
}
const dragover = (e) => {
//鼠标松开的一瞬间,事件dragleave和事件drop不可共存,二者同时存在时,始终只能触发一个事件,而浏览器默认触发dragleave
// 若想触发drop事件,则需要在dragover事件中阻止dragleave事件的默认行为。
e.preventDefault();
}
const dragleave = (e) => {
e.dataTransfer.dropEffect = 'none';
}
const drop = (e) => {
addComponent()
}
const addComponent = (component) => {
// debugger
if (!currentComponent && component) {
currentComponent = component
}
// drop完 先计算得到物料插入到data中的index位置
let insertIndex = -1; // 默认为-1 就是插入到data的队尾
for (let i = 0; i < data.value.blocks.length; i++) {
if (data.value.blocks[i]['topMarkLine']) {
insertIndex = i;
break;
} else if (data.value.blocks[i]['bottomMarkLine']) {
insertIndex = i+1;
break;
}
}
// drop完 当前所有data的markLine都隐藏
let blocks = data.value.blocks.map(item => {
return {
...item,
topMarkLine: false,
bottomMarkLine: false,
props: {...item.props}, // 保持原有渲染组件的props
model: {...item.model}, // 保持原有渲染组件的model
timestamp: new Date().getTime()+item.id,
}
}); // 内部已经渲染的组件
let model = {}
// 对于没有数据绑定的物料(标题、分割线、按钮等) 保持model为空对象 存在数据绑定的物料 需要给model进行赋值
if (currentComponent.model && Object.keys(currentComponent.model).length > 1) {
Object.keys(currentComponent.model).forEach(item => {
if (item === 'default') {
model[item] = currentComponent.key +'_'+ data.value.appid + '_' + randomString(6)
} else {
model[item] = model['default']+ '_' + currentComponent.model[item]
}
})
} else if (currentComponent.model && Object.keys(currentComponent.model).length === 1){
model = {
default: currentComponent.key +'_'+ data.value.appid + '_' + randomString(6)
}
}
blocks.splice(insertIndex === -1 ? blocks.length : insertIndex, 0, {
key:currentComponent.key,
id: currentComponent.key +'_'+ randomString(6) ,
props: {...currentComponent.props},
model: model,
timestamp: new Date().getTime()+currentComponent.key+ '_'+ randomString(6),
})
data.value = {...data.value, blocks};
currentComponent = null;
}
// 作用在被拖拽对象的事件
const dragstart = (e, component) => {
// dragenter进入元素中 添加一个移动的标识
// dragover 在目标元素经过 必须要阻止默认行为 否则不能触发drop
// dragleave 离开元素的时候 需要增加一个禁用标识
// drop 松手的时候 根据拖拽的组件 添加一个组件
containerRef.value.addEventListener('dragenter', dragenter)
containerRef.value.addEventListener('dragover', dragover)
containerRef.value.addEventListener('dragleave', dragleave)
containerRef.value.addEventListener('drop', drop)
currentComponent = component
emitter.emit('start'); // 工作区域内的block 开始监听事件
}
const dragend = (e)=>{
containerRef.value.removeEventListener('dragenter', dragenter)
containerRef.value.removeEventListener('dragover', dragover)
containerRef.value.removeEventListener('dragleave', dragleave)
containerRef.value.removeEventListener('drop', drop)
emitter.emit('end'); // 工作区域内的block remove监听事件
}
return {
dragstart,
dragend,
addComponent
}
}
分享给外部平台
开发阶段的后期,不同项目组的"营销渠道"平台需要接入审批流转表单的页面,一些登录认证的操作可以在后端同步处理,但是审批页面的表单数据需要回传给其他平台,最终采用了postMessage的方案进行打通
vue
<script setup>
import { registerMaterial as config} from "@/views/formDesign/editor-material.jsx";
import EditorBlock from "@/views/formDesign/editor-block.jsx";
import '@/styles/formDesign/editor.less';
import {computed, provide, reactive, ref, onMounted, toRaw, onBeforeMount, watch} from "vue";
import {cloneDeep} from "lodash-es";
import {myJSON} from "@/utils/common.js";
import {message, Modal} from "ant-design-vue";
import useStore from "@/store/index.js";
import dayjs from "dayjs";
import {getCreaterOrgId} from "@/utils/processCenter.js";
import {briefFormContent, compileFormContent} from "@/views/formDesign/utils/formContent.js";
import {getOrderNo} from "@/api/form.js";
import {calculateEval} from "@/utils/numConfig.js";
import { useRouter, useRoute } from "vue-router"
const router = useRouter();
const route = useRoute();
provide('config', config); // 将将组件的配置直接传下去
const submitType = 'first-submit';
const props = defineProps({
id:{
type: Number,
default: 0,
},
// 自定义样式,解决抽屉拉出来,出现双滚动条的问题
customStyle: {
type: Object,
default: () => ({
formContainer: {},
formContent: {},
}),
},
})
const {appManageStore,projectManageStore,processManageStore, departmentManageStore} = useStore()
const countersignAuth = ref(false)
const submitLoading = ref(false);
const state = reactive({
data: {
blocks: [],
},
org: projectManageStore.curUserDepartments?.[0]?.id || undefined,
contentLoading: true,
})
onBeforeMount(() => {
const {
token = ''
} = router.currentRoute.value.query
if (token) {
projectManageStore.updateToken(token);
}
})
onMounted(async () => {
const {
formId,
} = route.query
// 请求当前表单配置信息
await getFormConfigInfo();
await departmentManageStore.getAllEmployeeList()
await departmentManageStore.getDepartmentTreeInfo()
await appManageStore.updateCurForm({
descInfo: toRaw(appManageStore.curForm),
status: toRaw(appManageStore.curFormStatus),
name: toRaw(appManageStore.curFormName),
type: toRaw(appManageStore.formType),
id: formId,
})
const contentStr = toRaw(appManageStore.curForm);
await initStateData(contentStr)
await handleNumberCalculationConfigWatch(state.data)
await handleFormAndProcessConfig();
state.org = projectManageStore.curUserDepartments?.[0]?.id || undefined;
})
window.addEventListener('message', async (e) => {
const {
data
} = e;
const {
isOriginCRM,
data: {
content = '',
orgId = '',
}
} = data;
if (isOriginCRM) {
if (!content) return;
const {
formId,
} = route.query
// 请求当前表单配置信息
await departmentManageStore.getAllEmployeeList()
await departmentManageStore.getDepartmentTreeInfo()
await getFormConfigInfo();
const contentObj = myJSON.parse(content);
const {
blocks = [],
processConfig = [],
...otherContent
} = contentObj;
const wholePreContentObj = myJSON.parse(toRaw(appManageStore.curForm));
const wholeContentObj = {
...wholePreContentObj,
...otherContent
}
const customStr = myJSON.stringify(wholeContentObj);
// 解析formData
await initStateData(customStr)
await handleNumberCalculationConfigWatch(state.data)
await handleFormAndProcessConfig();
state.orgId = orgId && parseInt(orgId) >= 0 ? parseInt(orgId) : (projectManageStore?.curUserDepartments?.[0]?.id || undefined);
}
})
async function getFormConfigInfo() {
const { formId } = route.query
const res = await appManageStore.updateCurProcessStartNodeConfig({
formId,
});
return res;
}
async function initStateData(data) {
state.data = cloneDeep(await compileFormContent(myJSON.parse(data)))
state.data.blocks = state.data.blocks.filter(v => v.key !== 'instance_id')
}
function handleNumberCalculationConfigWatch (data){
let formData = data.formData
let blockIds = data.blocks.map(m => m.id)
let paramsConfig = {
curformId: appManageStore.curFormId,
blocks: state.data.blocks,
formData: state.data.formData,
}
for(let i = 0; i < data.blocks.length;i++) {
let block = data.blocks[i]
if (block.key === 'number' && block?.props?.calculationConfig?.value) {
let calculationConfig = block.props.calculationConfig.value
let blockValueKey = block.model.default
let dependKeys = Object.keys(calculationConfig.vars).map(itemkey => {
if (itemkey.indexOf('__') > -1) {
return itemkey.split('__')
} else {
return itemkey
}
}).flat().filter(v => blockIds.includes(v))
dependKeys = [...new Set(dependKeys)] // 去重
const watchArr = dependKeys.map(key => {
let formDataKey = data.blocks.filter(v => v.id === key)[0]?.['model']?.['default'] || undefined
if (formDataKey) {
return () => formData[formDataKey]
}
})
watch(watchArr, async (result) => {
let params = {}
dependKeys.forEach((key,index) => {
params[key] = result[index]
})
formData[blockValueKey] = await calculateEval(calculationConfig, params, paramsConfig)
})
}
}
}
async function handleFormAndProcessConfig () {
state.contentLoading = true
const taskAuth = toRaw(appManageStore.curFormProcessStartNodeConfig);
if (taskAuth !== '{}') {
(myJSON.parse(taskAuth)?.tableAuthInfoList || []).filter(i=> i.key !== 'actionAll' && i.key !== 'instance_id').forEach((item,index) => {
state.data.blocks?.[index]
? state.data.blocks[index].visible = item?.authInfo?.isVisible ? true : false
: null
state.data.blocks?.[index]
? state.data.blocks[index].edit = item?.authInfo?.isEdit ? true : false
: null
})
} else {
if (appManageStore.formType !== 1) {
message.info('不存在启用的流程信息!')
}
state.data.blocks.forEach(i => {
if (i.key === 'number' && appManageStore.formType === 1) {
i.visible = i?.props?.isVisibleCheck?.value === undefined ? true : i?.props?.isVisibleCheck?.value
i.edit = i?.props?.isEditCheck?.value === undefined ? true : i?.props?.isEditCheck?.value
} else {
i.visible = true;
i.edit = true
}
})
}
state.contentLoading = false
}
// 获取实例id并且将id数据放入到对应的formData中
async function handleInstanceId(data) {
let instanceId = ''
if (submitType !== 'approve-submit') {
let res = await getOrderNo({
formId: appManageStore.curFormId || 0
})
data.formData['instance_id'] = res?.data || ''
instanceId = res?.data || ''
let instanceIndex = data.blocks.findIndex(v => v.key === 'instance_id')
if (instanceIndex === -1) {
data.blocks.push({
key: 'instance_id',
id: 'instance_id',
model: {
default: 'instance_id'
},
title: `${appManageStore.curFormName || '-'}实例编号`
})
}
}
return {
content: data,
instanceId,
}
}
// 把formData里relatedData数据中的成员、地址字段进行格式化
const handleRelatedData = (result) => {
for(let key in result.formData) {
if (key.indexOf('relatedData') > -1) {
for(let relatedKey in result.formData[key]) {
if (relatedKey.indexOf('memberSingular') > -1) {
result.formData[key][relatedKey] = (result.formData[key][relatedKey].id || 0 ) + ''
} else if (relatedKey.indexOf('memberMultiple') > -1) {
if (Object.prototype.toString.call(result.formData[key][relatedKey]) === '[object Array]' &&
result.formData[key][relatedKey].length > 0) {
result.formData[key][relatedKey] = result.formData[key][relatedKey].map(m => m.id).join(',')
}
} else if(relatedKey.indexOf('address') > -1 && relatedKey.split('_').length < 4) {
// debugger
if (Object.prototype.toString.call(result.formData[key][relatedKey]) === '[object Array]') {
result.formData[key][relatedKey] = result.formData[key][relatedKey].slice(0,3)
} else if (typeof result.formData[key][relatedKey] === 'string' ) {
result.formData[key][relatedKey] = result.formData[key][relatedKey].split('-').slice(0,3)
}
} else if (relatedKey.indexOf('department') > -1) {
result.formData[key][relatedKey] = (result.formData[key][relatedKey]?.value || '');
}
}
}
}
return result
}
const FormRef = ref();
// 获取需要校验的表单项
const getValidateFormItems = () => {
if (appManageStore.formType === 2) {
return state.data.blocks
.filter(v => v.key !== 'instance_id' && v.visible && v.edit && v.props.check.value)
.map(k => k.model.default).filter(m => m)
}
return null
}
async function submit() {
/**
* form表单每次点击提交的时候 只校验符合条件的form-item
* 条件: 当前节点的processConfig里 visible === true && edit === true 的表单项
*/
function filterValidateFormItems(){
return state.data.blocks
.filter(v => v.key !== 'instance_id' && v.visible && v.edit && v?.props?.check?.value)
.map(k => k.model.default).filter(m => m)
}
let shouldValidateFormItems = filterValidateFormItems()
FormRef.value.validateFields(shouldValidateFormItems).then(async (values) => {
submitLoading.value = true;
try {
// 这里请求一下实例id并将id数据放入formData中
let data = myJSON.parse(myJSON.stringify(state.data))
let {content,instanceId} = await handleInstanceId(data)
content = handleRelatedData(content)
content = briefFormContent(content)
content.blocks = []
const orgId = getCreaterOrgId(appManageStore.formType, submitType, state.org) // 流程发起人的部门id
// 直接postMessage传递给父弹窗,包含content和orgId信息
window.parent.postMessage({
isOriginCRM: true,
data:{
content: myJSON.stringify(content),
orgId,
},
}, '*')
} catch (e) {
} finally {
submitLoading.value = false;
}
})
}
function handleResetEditForm() {
FormRef.value.resetFields()
resetImgFile()
resetAddressComp();
resetDepartmentCompData();
window.parent.postMessage({
isOriginCRM: true,
data:{
isClose: true,
},
}, '*')
}
const resetImgFile = () => {
let imgFileKeysArr = Object.keys(FormRef.value.getFieldsValue()).filter(v => v.indexOf('file') > -1 || v.indexOf('img') > -1)
imgFileKeysArr.forEach(item => {
state.data.formData[item] = ''
})
}
const resetDepartmentCompData = () => {
const depCompKeyInState = Object.keys(state.data.formData).filter(v => v.indexOf('department') > -1);
let depKeysArr = Object.keys(FormRef.value.getFieldsValue()).filter(v => v.indexOf('department') > -1)
depKeysArr.forEach(item => {
state.data.formData[item] = []
})
depCompKeyInState.forEach((item) => {
state.data.formData[item] = [];
})
}
const resetAddressComp = () => {
let addressCompKey = Object.keys(FormRef.value.getFieldsValue()).filter(v => v.indexOf('address') > -1);
const addressCompKeyInState = Object.keys(state.data.formData).filter(v => v.indexOf('address') > -1);
addressCompKeyInState.forEach((item) => {
state.data.formData[item] = '';
})
addressCompKey.forEach(item => {
state.data.formData[item] = ''
})
}
const handleSelectChangeFn = (e,auth) => {
state.data.blocks = handleSelectChangeAuthFn(e,auth, state.data.blocks || [])
}
const handleSelectChangeAuthFn = (e,auth,blocks) => {
/**
* 展示规则
* 当对应字段在流程字段权限处配置为isVisible为true时 按照自定义选择器字段权限展示(true则显示 false则隐藏)
* 当对应字段在流程字段权限出配置为isVisible为false时 则无视自定义选择器字段权限 均不展示
*/
if (auth.authType === 2) {
// 按照下拉选择器自定义配置权限进行展示
let visibleBlockIds = auth.authList[e] || []
const taskAuth = toRaw(appManageStore.curFormProcessStartNodeConfig);
let blocksAuth = submitType === 'approve-submit'
? (taskAuth?.tableAuthInfoList || []).filter(i=> i.key !== 'actionAll' && i.key !== 'instance_id')
: (myJSON.parse(taskAuth)?.tableAuthInfoList || []).filter(i=> i.key !== 'actionAll' && i.key !== 'instance_id')
// 找到当前选择器在blocks中的index
let curSelectIndexInBlocks = blocks.findIndex(b => b.id === auth.selectId)
for (let i = 0 ; i < blocks.length ; i++ ){
if (i < curSelectIndexInBlocks) {
continue
}
if (blocks[i]['id'] === auth.selectId) {
blocks[i].visible = true
} else {
// 说明是发起流程时候
if (appManageStore.formType === 1) {
// 普通表单 - 无流程配置的字段权限 故按照自定义权限配置走
blocks[i].visible = !visibleBlockIds.includes(blocks[i]['id'])
} else if (appManageStore.formType === 2 && blocksAuth.length > 0 && blocks.length === blocksAuth.length) {
// 流程表单 - 有流程配置的字段权限 需要逻辑判断处理一下
// 这里的逻辑是 如果blocksAuth[i].visible已经为true 那么说明流程字段权限配置就是true 这个时候就按照自定义权限配置走
// 而如果blocksAuth[i].visible为false 那么说明流程字段权限配置这里不展示 则无视自定义权限
blocks[i].visible = blocksAuth[i]['authInfo']['isVisible']
? !visibleBlockIds.includes(blocks[i]['id']) : false
}
}
}
return blocks
} else {
return blocks
}
}
const generateRules = (item) => {
if (item.key !== 'relatedData') {
return {required: item.props.check && item.props.check.value ? true : false,message:`请输入${item.props.title.text}!`}
} else {
return [
{required: item.props.check && item.props.check.value ? true : false,message:`请输入${item.props.title.text}!`},
{validator: (rule, value, callback) => validateObject(rule, value, callback, item)}
]
}
}
function validateObject (rule, value, callback, item) {
if (value && Object.keys(value).length === 0) {
callback(new Error(`请输入${item?.props?.title?.text||'-'}`));
} else {
callback();
}
}
</script>
<template>
<div class="form-container">
<a-spin :spinning="state.contentLoading" style="min-height: calc(100vh - 200px)">
<a-form
ref="FormRef"
:model="state.data.formData"
class="form-container-content"
:style="props?.customStyle?.formContent || {}"
layout='horizontal'
>
<a-form-item v-for="item in state.data.blocks.filter(v => v.id !== 'instance_id')"
class="label"
:key="item.id"
:class="{'hiddenEle': !item.visible, 'label': true}"
:label="item.model.default ? item?.props?.title?.text : ''"
:name="item.model.default"
:rules="generateRules(item)"
>
<EditorBlock
:containerFormRef="FormRef"
:class="'canEdit'"
:block="item"
:formData="state.data.formData"
:handleSelectChange="handleSelectChangeFn"
></EditorBlock>
</a-form-item>
</a-form>
</a-spin>
<div class="form-container-footer">
<!--在流程表单初次发流程实例的时候 判断当前登录人是否是归属多部门,是的话要加上一个select选择框 -->
<div class="choose-department" v-if="projectManageStore.curUserDepartments.length > 1">
<span class="mr-10">部门选择</span>
<a-select
v-model:value="state.org"
:options="projectManageStore.curUserDepartments.map(v => ({label: v.name,value: v.id}))"
></a-select>
</div>
<!--对于流程表单:要看一下流程配置里是否配置了提交按钮且用户对当前表单的权限里有canAdd-->
<!--对于普通表单:用户对当前表单的权限里有canAdd-->
<a-button
class="btn-item"
:loading="state.data.formData?.['uploadLoading'] || submitLoading"
@click="handleResetEditForm"
>
取消
</a-button>
<a-button
class="btn-item"
type="primary"
:loading="state.data.formData?.['uploadLoading'] || submitLoading"
@click="submit(false)"
>
提交
</a-button>
</div>
</div>
</template>
<style scoped lang="less">
.form-container {
height: calc(100vh);
background: #FFFFFF;
padding: 20px 20px 0 20px;
overflow: hidden !important;
.ant-spin-nested-loading {
height: calc(100% - 64px);
:deep(.ant-spin-container){
height: 100%;
}
}
.disableEdit {
pointer-events: visible;
}
:deep(.ant-form-item-label) > label {
white-space: pre-wrap;
min-width: 160px;
width: 100%;
word-break: break-all;
justify-content: flex-end;
height: fit-content;
display: flex;
align-items: center;
}
.form-container-content {
height: 100%;
overflow-y: scroll !important;
padding: 0 20px;
.hiddenEle {
display: none;
height: 0;
margin: 0;
}
.disableEle {
background: #f5f6f8;
}
:deep(.label) {
}
}
.form-container-footer {
padding: 10px 20px;
display: flex;
justify-content: center;
align-items: center;
border-top: 1px solid #E5E6EB;
position: sticky;
bottom: 0;
background-color: #FFFFFF;
z-index: 3;
.btn-item {
& + .btn-item {
margin-left: 20px;
}
}
.choose-department {
position: absolute;
left: 20px;
}
}
}
</style>
其他方面总结
本项目还有h5移动端的部分,应用了vite + nutui,但是移动端只是负责表单填写和流转展示,并没有核心的两篇文章,故单独做总结。但是移动端正式部署后,管理员配置了企业微信的应用,但是没有接入OAuth,直接从企业微信一级入口进入。