未验证 提交 c5b39f2c 编写于 作者: W wwsheng009 提交者: GitHub

feat: 增加表单设计器 (#2533)

上级 4c0f2038
......@@ -73,7 +73,8 @@
"vxe-table": "^4.3.9",
"vxe-table-plugin-export-xlsx": "^3.0.4",
"xe-utils": "^3.5.7",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@commitlint/cli": "^16.2.3",
......
import type { AppRouteModule } from '/@/router/types';
import { LAYOUT } from '/@/router/constant';
const permission: AppRouteModule = {
path: '/form-designer',
name: 'Form-designer',
component: LAYOUT,
meta: {
orderNo: 10000,
icon: 'icon:add-circle',
title: '表单设计',
},
children: [
{
path: 'design',
name: 'Design',
meta: {
title: '表单设计',
},
component: () => import('/@/views/form-design/index.vue'),
},
{
path: 'example1',
name: 'Example1',
meta: {
title: '示例',
},
component: () => import('/@/views/form-design/examples/baseForm.vue'),
},
],
};
export default permission;
此差异已折叠。
<template>
<!-- <component :is="layoutTag" v-bind="schema.colProps"> -->
<template v-if="['Grid'].includes(schema.component)">
<Row class="grid-row">
<Col
class="grid-col"
v-for="(colItem, index) in schema.columns"
:key="index"
:span="colItem.span"
>
<FormRender
v-for="(item, k) in colItem.children"
:key="k"
:schema="item"
:formData="formData"
:formConfig="formConfig"
:setFormModel="setFormModel"
/>
</Col>
</Row>
</template>
<VFormItem
v-else
:formConfig="formConfig"
:schema="schema"
:formData="formData"
:setFormModel="setFormModel"
@change="$emit('change', { schema: schema, value: $event })"
@submit="$emit('submit', schema)"
@reset="$emit('reset')"
>
<template
v-if="schema.componentProps && schema.componentProps.slotName"
#[schema.componentProps!.slotName]
>
<slot :name="schema.componentProps!.slotName"></slot>
</template>
</VFormItem>
<!-- </component> -->
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { IVFormComponent, IFormConfig } from '../../../typings/v-form-component';
import VFormItem from '../../VFormItem/index.vue';
import { Row, Col } from 'ant-design-vue';
export default defineComponent({
name: 'FormRender',
components: {
VFormItem,
Row,
Col,
},
props: {
formData: {
type: Object,
default: () => ({}),
},
schema: {
type: Object as PropType<IVFormComponent>,
default: () => ({}),
},
formConfig: {
type: Object as PropType<IFormConfig>,
default: () => [] as IFormConfig[],
},
setFormModel: {
type: Function as PropType<(key: string, value: any) => void>,
default: null,
},
},
emits: ['change', 'submit', 'reset'],
setup(_props) {},
});
</script>
<style>
.v-form-render-item {
overflow: hidden;
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/29
* @Description: 表单渲染器,根据json生成表单
-->
<template>
<div class="v-form-container">
<Form class="v-form-model" ref="eFormModel" :model="formModel" v-bind="formModelProps">
<Row>
<!-- <component :is="wrapperComp"> -->
<FormRender
v-for="(schema, index) of noHiddenList"
:key="index"
:schema="schema"
:formConfig="formConfig"
:formData="formModelNew"
@change="handleChange"
:setFormModel="setFormModel"
@submit="handleSubmit"
@reset="resetFields"
>
<template v-if="schema && schema.componentProps" #[`schema.componentProps!.slotName`]>
<slot
:name="schema.componentProps!.slotName"
v-bind="{ formModel: formModel, field: schema.field, schema }"
></slot>
</template>
</FormRender>
<!-- </component> -->
</Row>
</Form>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, provide, ref, unref } from 'vue';
import FormRender from './components/FormRender.vue';
import { IFormConfig, AForm } from '../../typings/v-form-component';
import { Form, Row, Col } from 'ant-design-vue';
import { useFormInstanceMethods } from '../../hooks/useFormInstanceMethods';
import { IProps, IVFormMethods, useVFormMethods } from '../../hooks/useVFormMethods';
import { useVModel } from '@vueuse/core';
import { omit } from 'lodash-es';
export default defineComponent({
name: 'VFormCreate',
components: {
FormRender,
Form,
Row,
},
props: {
fApi: {
type: Object,
},
formModel: {
type: Object,
default: () => ({}),
},
formConfig: {
type: Object as PropType<IFormConfig>,
required: true,
},
},
emits: ['submit', 'change', 'update:fApi', 'update:formModel'],
setup(props, context) {
const wrapperComp = props.formConfig.layout == 'vertical' ? Col : Row;
const { emit } = context;
const eFormModel = ref<AForm | null>(null);
const formModelNew = computed({
get: () => props.formModel,
set: (value) => emit('update:formModel', value),
});
const noHiddenList = computed(() => {
return (
props.formConfig.schemas &&
props.formConfig.schemas.filter((item) => item.hidden !== true)
);
});
const fApi = useVModel(props, 'fApi', emit);
const { submit, validate, clearValidate, resetFields, validateField } =
useFormInstanceMethods(props, formModelNew, context, eFormModel);
const { linkOn, ...methods } = useVFormMethods(
{ formConfig: props.formConfig, formData: props.formModel } as unknown as IProps,
context,
eFormModel,
{
submit,
validate,
validateField,
resetFields,
clearValidate,
},
);
fApi.value = methods;
const handleChange = (_event) => {
const { schema, value } = _event;
const { field } = unref(schema);
linkOn[field!]?.forEach((formItem) => {
// console.log('handleChange', formItem, field, value);
formItem.update?.(value, formItem, fApi.value as IVFormMethods);
});
};
/**
* 获取表单属性
*/
const formModelProps = computed(
() => omit(props.formConfig, ['disabled', 'labelWidth', 'schemas']) as Recordable,
);
const handleSubmit = () => {
submit();
};
provide('formModel', formModelNew);
const setFormModel = (key, value) => {
formModelNew.value[key] = value;
};
provide<(key: String, value: any) => void>('setFormModelMethod', setFormModel);
// 把祖先组件的方法项注入到子组件中,子组件可通过inject获取
return {
eFormModel,
submit,
validate,
validateField,
resetFields,
clearValidate,
handleChange,
formModelProps,
handleSubmit,
setFormModel,
formModelNew,
wrapperComp,
noHiddenList,
};
},
});
</script>
<style lang="less" scoped>
.v-form-model {
overflow: hidden;
}
</style>
<!--
* @Author: ypt
* @Date: 2021/12/7
* @Description: 渲染代码
-->
<template>
<Modal
title="代码"
:footer="null"
:visible="visible"
@cancel="visible = false"
wrapClassName="v-code-modal"
style="top: 20px"
width="850px"
:destroyOnClose="true"
>
<PreviewCode :editorJson="editorVueJson" fileFormat="vue" />
</Modal>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from 'vue';
import { formatRules, removeAttrs } from '../../../utils';
import PreviewCode from './PreviewCode.vue';
import { IFormConfig } from '../../../typings/v-form-component';
import { Modal } from 'ant-design-vue';
const codeVueFront = `<template>
<div>
<v-form-create
:formConfig="formConfig"
:formData="formData"
v-model="fApi"
/>
<a-button @click="submit">提交</a-button>
</div>
</template>
<script>
export default {
name: 'Demo',
data () {
return {
fApi:{},
formData:{},
formConfig: `;
/* eslint-disable */
let codeVueLast = `
}
},
methods: {
async submit() {
const data = await this.fApi.submit()
console.log(data)
}
}
}
<\/script>`;
//
export default defineComponent({
name: 'CodeModal',
components: { PreviewCode, Modal },
setup() {
const state = reactive({
visible: false,
jsonData: {} as IFormConfig,
});
const showModal = (formConfig: IFormConfig) => {
formConfig.schemas && formatRules(formConfig.schemas);
state.visible = true;
state.jsonData = formConfig;
};
const editorVueJson = computed(() => {
return codeVueFront + JSON.stringify(removeAttrs(state.jsonData), null, '\t') + codeVueLast;
});
return { ...toRefs(state), editorVueJson, showModal };
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/11/26
* @Description: 组件属性控件
-->
<template>
<div class="properties-content">
<div class="properties-body" v-if="formConfig.currentItem">
<Empty class="hint-box" v-if="!formConfig.currentItem.key" description="未选择组件" />
<Form label-align="left" layout="vertical">
<!-- 循环遍历渲染组件属性 -->
<div v-if="formConfig.currentItem && formConfig.currentItem.componentProps">
<FormItem v-for="item in inputOptions" :key="item.name" :label="item.label">
<!-- 处理数组属性,placeholder -->
<div v-if="item.children">
<component
v-for="(child, index) of item.children"
:key="index"
v-bind="child.componentProps"
:is="child.component"
v-model:value="formConfig.currentItem.componentProps[item.name][index]"
/>
</div>
<!-- 如果不是数组,则正常处理属性值 -->
<component
v-else
class="component-prop"
v-bind="item.componentProps"
:is="item.component"
v-model:value="formConfig.currentItem.componentProps[item.name]"
/>
</FormItem>
<!-- </Row> -->
<FormItem label="控制属性">
<Col v-for="item in controlOptions" :key="item.name">
<Checkbox
v-if="showControlAttrs(item.includes)"
v-bind="item.componentProps"
v-model:checked="formConfig.currentItem.componentProps[item.name]"
>
{{ item.label }}
</Checkbox>
</Col>
</FormItem>
</div>
<FormItem label="关联字段">
<Select
mode="multiple"
v-model:value="formConfig.currentItem['link']"
:options="linkOptions"
/>
</FormItem>
<FormItem
label="选项"
v-if="
[
'Select',
'CheckboxGroup',
'RadioGroup',
'TreeSelect',
'Cascader',
'AutoComplete',
].includes(formConfig.currentItem.component)
"
>
<FormOptions />
</FormItem>
<FormItem label="栅格" v-if="['Grid'].includes(formConfig.currentItem.component)">
<FormOptions />
</FormItem>
</Form>
</div>
</div>
</template>
<script lang="ts">
import {
Empty,
Input,
Form,
FormItem,
Switch,
Checkbox,
Select,
InputNumber,
RadioGroup,
} from 'ant-design-vue';
import RadioButtonGroup from '/@/components/Form/src/components/RadioButtonGroup.vue';
import { Col, Row } from 'ant-design-vue';
import { computed, defineComponent, ref, watch } from 'vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import {
baseComponentControlAttrs,
baseComponentAttrs,
baseComponentCommonAttrs,
componentPropsFuncs,
} from '../../VFormDesign/config/componentPropsConfig';
import FormOptions from './FormOptions.vue';
import { formItemsForEach, remove } from '../../../utils';
import { IBaseFormAttrs } from '../config/formItemPropsConfig';
export default defineComponent({
name: 'ComponentProps',
components: {
FormOptions,
Empty,
Input,
Form,
FormItem,
Switch,
Checkbox,
Select,
InputNumber,
RadioGroup,
RadioButtonGroup,
Col,
Row,
},
setup() {
// 让compuated属性自动更新
// const dummyUpdate = ref(0);
const allOptions = ref([] as Omit<IBaseFormAttrs, 'tag'>[]);
const showControlAttrs = (includes: string[] | undefined) => {
if (!includes) return true;
return includes.includes(formConfig.value.currentItem!.component);
};
const { formConfig } = useFormDesignState();
if (formConfig.value.currentItem) {
formConfig.value.currentItem.componentProps =
formConfig.value.currentItem.componentProps || {};
}
watch(
() => formConfig.value.currentItem?.field,
(_newValue, oldValue) => {
formConfig.value.schemas &&
formItemsForEach(formConfig.value.schemas, (item) => {
if (item.link) {
const index = item.link.findIndex((linkItem) => linkItem === oldValue);
index !== -1 && remove(item.link, index);
}
});
},
);
watch(
() => formConfig.value.currentItem && formConfig.value.currentItem.component,
() => {
allOptions.value = [];
baseComponentControlAttrs.forEach((item) => {
item.category = 'control';
if (!item.includes) {
// 如果属性没有include,所有的控件都适用
allOptions.value.push(item);
} else if (item.includes.includes(formConfig.value.currentItem!.component)) {
// 如果有include,检查是否包含了当前控件类型
allOptions.value.push(item);
}
});
baseComponentCommonAttrs.forEach((item) => {
item.category = 'input';
if (item.includes) {
if (item.includes.includes(formConfig.value.currentItem!.component)) {
allOptions.value.push(item);
}
} else if (item.exclude) {
if (!item.exclude.includes(formConfig.value.currentItem!.component)) {
allOptions.value.push(item);
}
} else {
allOptions.value.push(item);
}
});
baseComponentAttrs[formConfig.value.currentItem!.component] &&
baseComponentAttrs[formConfig.value.currentItem!.component].forEach(async (item) => {
if (item.component) {
if (['Switch', 'Checkbox', 'Radio'].includes(item.component)) {
item.category = 'control';
allOptions.value.push(item);
} else {
item.category = 'input';
allOptions.value.push(item);
}
}
});
},
{
immediate: true,
},
);
// 控制性的选项
const controlOptions = computed(() => {
return allOptions.value.filter((item) => {
return item.category == 'control';
});
});
// 非控制性选择
const inputOptions = computed(() => {
return allOptions.value.filter((item) => {
return item.category == 'input';
});
});
watch(
() => formConfig.value.currentItem!.componentProps,
() => {
const func = componentPropsFuncs[formConfig.value.currentItem!.component];
if (func) {
func(formConfig.value.currentItem!.componentProps, allOptions.value);
}
},
{
immediate: true,
deep: true,
},
);
const linkOptions = computed(() => {
return (
formConfig.value.schemas &&
formConfig.value.schemas
.filter((item) => item.key !== formConfig.value.currentItem!.key)
.map(({ label, field }) => ({ label: label + '/' + field, value: field }))
);
});
return {
formConfig,
showControlAttrs,
linkOptions,
controlOptions,
inputOptions,
};
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/11/24
* @Description: 表单项属性
-->
<template>
<div class="properties-content">
<div class="properties-body" v-if="formConfig.currentItem">
<Empty class="hint-box" v-if="!formConfig.currentItem.key" description="未选择控件" />
<Form v-else label-align="left" layout="vertical">
<div v-for="item of baseItemColumnProps" :key="item.name">
<FormItem :label="item.label" v-if="showProps(item.exclude)">
<component
v-if="formConfig.currentItem.colProps"
class="component-props"
v-bind="item.componentProps"
:is="item.component"
v-model:value="formConfig.currentItem.colProps[item.name]"
/>
</FormItem>
</div>
</Form>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { baseItemColumnProps } from '../config/formItemPropsConfig';
import { Empty, Input, Form, FormItem, Switch, Checkbox, Select, Slider } from 'ant-design-vue';
import RuleProps from './RuleProps.vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { isArray } from 'lodash-es';
export default defineComponent({
name: 'FormItemProps',
components: {
RuleProps,
Empty,
Input,
Form,
FormItem,
Switch,
Checkbox,
Select,
Slider,
},
// props: {} as PropsOptions,
setup() {
const { formConfig } = useFormDesignState();
const showProps = (exclude: string[] | undefined) => {
if (!exclude) {
return true;
}
return isArray(exclude) ? !exclude.includes(formConfig.value.currentItem!.component) : true;
};
return {
baseItemColumnProps,
formConfig,
showProps,
};
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/11/24
* @Description: 表单项属性,控件属性面板
-->
<template>
<div class="properties-content">
<div class="properties-body" v-if="formConfig.currentItem?.itemProps">
<Empty class="hint-box" v-if="!formConfig.currentItem.key" description="未选择控件" />
<Form v-else label-align="left" layout="vertical">
<div v-for="item of baseFormItemProps" :key="item.name">
<FormItem :label="item.label" v-if="showProps(item.exclude)">
<component
class="component-props"
v-bind="item.componentProps"
:is="item.component"
v-model:value="formConfig.currentItem[item.name]"
/>
</FormItem>
</div>
<div v-for="item of advanceFormItemProps" :key="item.name">
<FormItem :label="item.label" v-if="showProps(item.exclude)">
<component
class="component-props"
v-bind="item.componentProps"
:is="item.component"
v-model:value="formConfig.currentItem.itemProps[item.name]"
/>
</FormItem> </div
><div v-for="item of advanceFormItemColProps" :key="item.name">
<FormItem :label="item.label" v-if="showProps(item.exclude)">
<component
class="component-props"
v-bind="item.componentProps"
:is="item.component"
v-model:value="formConfig.currentItem.itemProps[item.name]['span']"
/>
</FormItem>
</div>
<FormItem label="控制属性" v-if="controlPropsList.length">
<Col v-for="item of controlPropsList" :key="item.name">
<Checkbox v-model:checked="formConfig.currentItem.itemProps[item.name]">
{{ item.label }}
</Checkbox>
</Col>
</FormItem>
<FormItem label="是否必选" v-if="!['Grid'].includes(formConfig.currentItem.component)">
<Switch v-model:checked="formConfig.currentItem.itemProps['required']" />
<Input
v-if="formConfig.currentItem.itemProps['required']"
v-model:value="formConfig.currentItem.itemProps['message']"
placeholder="请输入必选提示"
/>
</FormItem>
<FormItem
v-if="!['Grid'].includes(formConfig.currentItem.component)"
label="校验规则"
:class="{ 'form-rule-props': !!formConfig.currentItem.itemProps['rules'] }"
>
<RuleProps />
</FormItem>
</Form>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, watch } from 'vue';
import {
baseFormItemControlAttrs,
baseFormItemProps,
advanceFormItemProps,
advanceFormItemColProps,
} from '../../VFormDesign/config/formItemPropsConfig';
import {
Empty,
Input,
Form,
FormItem,
Switch,
Checkbox,
Select,
Slider,
Col,
RadioGroup,
} from 'ant-design-vue';
import RuleProps from './RuleProps.vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { isArray } from 'lodash-es';
export default defineComponent({
name: 'FormItemProps',
components: {
RuleProps,
Empty,
Input,
Form,
FormItem,
Switch,
Checkbox,
Select,
Slider,
Col,
RadioGroup,
},
// props: {} as PropsOptions,
setup() {
const { formConfig } = useFormDesignState();
watch(
() => formConfig.value,
() => {
if (formConfig.value.currentItem) {
formConfig.value.currentItem.itemProps = formConfig.value.currentItem.itemProps || {};
formConfig.value.currentItem.itemProps.labelCol =
formConfig.value.currentItem.itemProps.labelCol || {};
formConfig.value.currentItem.itemProps.wrapperCol =
formConfig.value.currentItem.itemProps.wrapperCol || {};
}
},
{ deep: true, immediate: true },
);
const showProps = (exclude: string[] | undefined) => {
if (!exclude) {
return true;
}
return isArray(exclude) ? !exclude.includes(formConfig.value.currentItem!.component) : true;
};
const controlPropsList = computed(() => {
// console.log('const list2 = computed(() => {');
return baseFormItemControlAttrs.filter((item) => {
return showProps(item.exclude);
});
});
return {
baseFormItemProps,
advanceFormItemProps,
advanceFormItemColProps,
formConfig,
controlPropsList,
showProps,
};
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/11/19
* @Description: 拖拽节点控件
-->
<template>
<div
class="drag-move-box"
@click.stop="handleSelectItem"
:class="{ active: schema.key === formConfig.currentItem?.key }"
>
<div class="form-item-box">
<VFormItem :formConfig="formConfig" :schema="schema" />
</div>
<div class="show-key-box">
{{ schema.label + (schema.field ? '/' + schema.field : '') }}
</div>
<FormNodeOperate :schema="schema" :currentItem="formConfig.currentItem" />
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, PropType } from 'vue';
import { IVFormComponent } from '../../../typings/v-form-component';
import FormNodeOperate from './FormNodeOperate.vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import VFormItem from '../../VFormItem/index.vue';
// import VFormItem from '../../VFormItem/vFormItem.vue';
export default defineComponent({
name: 'FormNode',
components: {
VFormItem,
FormNodeOperate,
},
props: {
schema: {
type: Object as PropType<IVFormComponent>,
required: true,
},
},
setup(props) {
const { formConfig, formDesignMethods } = useFormDesignState();
const state = reactive({});
// 获取 formDesignMethods
const handleSelectItem = () => {
// 调用 formDesignMethods
formDesignMethods.handleSetSelectItem(props.schema);
};
return {
...toRefs(state),
handleSelectItem,
formConfig,
};
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/11/11
* @Description: 节点操作复制删除控件
-->
<template>
<div class="copy-delete-box">
<a class="copy" :class="activeClass" @click.stop="handleCopy">
<Icon icon="ant-design:copy-outlined" />
</a>
<a class="delete" :class="activeClass" @click.stop="handleDelete">
<Icon icon="ant-design:delete-outlined" />
</a>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import { IVFormComponent } from '../../../typings/v-form-component';
import { remove } from '../../../utils';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import Icon from '/@/components/Icon/index';
export default defineComponent({
name: 'FormNodeOperate',
components: {
Icon,
},
props: {
schema: {
type: Object,
default: () => ({}),
},
currentItem: {
type: Object,
default: () => ({}),
},
},
setup(props) {
const { formConfig, formDesignMethods } = useFormDesignState();
const activeClass = computed(() => {
return props.schema.key === props.currentItem.key ? 'active' : 'unactivated';
});
/**
* 删除当前项
*/
const handleDelete = () => {
const traverse = (schemas: IVFormComponent[]) => {
schemas.some((formItem, index) => {
const { component, key } = formItem;
// 处理栅格和标签页布局
['Grid', 'Tabs'].includes(component) &&
formItem.columns?.forEach((item) => traverse(item.children));
if (key === props.currentItem.key) {
let params: IVFormComponent =
schemas.length === 1
? { component: '' }
: schemas.length - 1 > index
? schemas[index + 1]
: schemas[index - 1];
formDesignMethods.handleSetSelectItem(params);
remove(schemas, index);
return true;
}
});
};
traverse(formConfig.value!.schemas);
};
const handleCopy = () => {
formDesignMethods.handleCopy();
};
return { activeClass, handleDelete, handleCopy };
},
});
</script>
<template>
<div>
<div v-if="['Grid'].includes(formConfig.currentItem!.component)">
<div v-for="(item, index) of formConfig.currentItem!['columns']" :key="index">
<div class="options-box">
<Input v-model:value="item.span" class="options-value" />
<a class="options-delete" @click="deleteGridOptions(index)">
<!-- <a-icon type="delete" /> -->
<Icon icon="ant-design:delete-outlined" />
</a>
</div>
</div>
<a @click="addGridOptions">
<Icon icon="ant-design:file-add-outlined" />
添加栅格
</a>
</div>
<div v-else>
<div v-for="(item, index) of formConfig.currentItem!.componentProps![key]" :key="index">
<div class="options-box">
<Input v-model:value="item.label" />
<Input v-model:value="item.value" class="options-value" />
<a class="options-delete" @click="deleteOptions(index)">
<!-- <a-icon type="delete" /> -->
<Icon icon="ant-design:delete-outlined" />
</a>
</div>
</div>
<a @click="addOptions">
<Icon icon="ant-design:file-add-outlined" />
添加选项
</a>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { remove } from '../../../utils';
import message from '../../../utils/message';
import { Input } from 'ant-design-vue';
import Icon from '/@/components/Icon/index';
export default defineComponent({
name: 'FormOptions',
components: { Input, Icon },
// props: {},
setup() {
const state = reactive({});
const { formConfig } = useFormDesignState();
const key = formConfig.value.currentItem?.component === 'TreeSelect' ? 'treeData' : 'options';
const addOptions = () => {
if (!formConfig.value.currentItem?.componentProps?.[key])
formConfig.value.currentItem!.componentProps![key] = [];
const len = formConfig.value.currentItem?.componentProps?.[key].length + 1;
formConfig.value.currentItem!.componentProps![key].push({
label: `选项${len}`,
value: '' + len,
});
};
const deleteOptions = (index: number) => {
remove(formConfig.value.currentItem?.componentProps?.[key], index);
};
const addGridOptions = () => {
formConfig.value.currentItem?.['columns']?.push({
span: 12,
children: [],
});
};
const deleteGridOptions = (index: number) => {
if (index === 0) return message.warning('请至少保留一个栅格');
remove(formConfig.value.currentItem!['columns']!, index);
};
return {
...toRefs(state),
formConfig,
addOptions,
deleteOptions,
key,
deleteGridOptions,
addGridOptions,
};
},
});
</script>
<style lang="less" scoped>
.options-box {
display: flex;
align-items: center;
margin-bottom: 5px;
.options-value {
margin: 0 8px;
}
.options-delete {
width: 30px;
height: 30px;
flex-shrink: 0;
line-height: 30px;
text-align: center;
border-radius: 50%;
background: #f5f5f5;
color: #666;
&:hover {
background: #ff4d4f;
}
}
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/23
* @Description: 右侧属性面板控件 表单属性面板
-->
<template>
<div class="properties-content">
<Form class="properties-body" label-align="left" layout="vertical">
<!-- <e-upload v-model="fileList"></e-upload>-->
<FormItem label="表单布局">
<RadioGroup button-style="solid" v-model:value="formConfig.layout">
<RadioButton value="horizontal">水平</RadioButton>
<RadioButton value="vertical" :disabled="formConfig.labelLayout === 'Grid'">
垂直
</RadioButton>
<RadioButton value="inline" :disabled="formConfig.labelLayout === 'Grid'">
行内
</RadioButton>
</RadioGroup>
</FormItem>
<!-- <Row> -->
<FormItem label="标签布局">
<RadioGroup
buttonStyle="solid"
v-model:value="formConfig.labelLayout"
@change="lableLayoutChange"
>
<RadioButton value="flex">固定</RadioButton>
<RadioButton value="Grid" :disabled="formConfig.layout !== 'horizontal'">
栅格
</RadioButton>
</RadioGroup>
</FormItem>
<!-- </Row> -->
<FormItem label="标签宽度(px)" v-show="formConfig.labelLayout === 'flex'">
<InputNumber
:style="{ width: '100%' }"
v-model:value="formConfig.labelWidth"
:min="0"
:step="1"
/>
</FormItem>
<div v-if="formConfig.labelLayout === 'Grid'">
<FormItem label="labelCol">
<Slider v-model:value="formConfig.labelCol!.span" :max="24" />
</FormItem>
<FormItem label="wrapperCol">
<Slider v-model:value="formConfig.wrapperCol!.span" :max="24" />
</FormItem>
<FormItem label="标签对齐">
<RadioGroup button-style="solid" v-model:value="formConfig.labelAlign">
<RadioButton value="left">靠左</RadioButton>
<RadioButton value="right">靠右</RadioButton>
</RadioGroup>
</FormItem>
<FormItem label="控件大小">
<RadioGroup button-style="solid" v-model:value="formConfig.size">
<RadioButton value="default">默认</RadioButton>
<RadioButton value="small"></RadioButton>
<RadioButton value="large"></RadioButton>
</RadioGroup>
</FormItem>
</div>
<FormItem label="表单属性">
<Col
><Checkbox v-model:checked="formConfig.colon" v-if="formConfig.layout == 'horizontal'"
>label后面显示冒号</Checkbox
></Col
>
<Col><Checkbox v-model:checked="formConfig.disabled">禁用</Checkbox></Col>
<Col><Checkbox v-model:checked="formConfig.hideRequiredMark">隐藏必选标记</Checkbox></Col>
</FormItem>
</Form>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { InputNumber, Slider, Checkbox, Col, RadioChangeEvent } from 'ant-design-vue';
// import RadioButtonGroup from '/@/components/RadioButtonGroup.vue';
import { Form, FormItem, Radio } from 'ant-design-vue';
export default defineComponent({
name: 'FormProps',
components: {
InputNumber,
Slider,
Checkbox,
// RadioButtonGroup,
RadioGroup: Radio.Group,
RadioButton: Radio.Button,
Form,
FormItem,
Col,
},
setup() {
// const labelColspan = computed(()=>)
const { formConfig } = useFormDesignState();
formConfig.value = formConfig.value || {
labelCol: { span: 24 },
wrapperCol: { span: 24 },
};
const lableLayoutChange = (e: RadioChangeEvent) => {
if (e.target.value === 'Grid') {
formConfig.value.layout = 'horizontal';
}
};
return { formConfig, lableLayoutChange };
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/12/7
* @Description: 导入JSON模板
-->
<template>
<Modal
title="JSON数据"
:visible="visible"
@ok="handleImportJson"
@cancel="handleCancel"
cancelText="关闭"
:destroyOnClose="true"
wrapClassName="v-code-modal"
style="top: 20px"
:width="850"
>
<p class="hint-box">导入格式如下:</p>
<div class="v-json-box">
<!-- <CodeEditor style="height: 100%" ref="myEditor" v-model="json"></CodeEditor> -->
<CodeEditor v-model:value="json" ref="myEditor" :mode="MODE.JSON" />
</div>
<template #footer>
<a-button @click="handleCancel">取消</a-button>
<Upload
class="upload-button"
:beforeUpload="beforeUpload"
:showUploadList="false"
accept="application/json"
>
<a-button type="primary">导入json文件</a-button>
</Upload>
<a-button type="primary" @click="handleImportJson">确定</a-button>
</template>
</Modal>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue';
// import message from '../../../utils/message';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
// import { codemirror } from 'vue-codemirror-lite';
import { IFormConfig } from '../../../typings/v-form-component';
import { formItemsForEach, generateKey } from '../../../utils';
import { CodeEditor, MODE } from '/@/components/CodeEditor';
import { useMessage } from '/@/hooks/web/useMessage';
import { Upload, Modal } from 'ant-design-vue';
export default defineComponent({
name: 'ImportJsonModal',
components: {
CodeEditor,
Upload,
Modal,
},
setup() {
const { createMessage } = useMessage();
const state = reactive({
visible: false,
json: `{
"schemas": [
{
"component": "input",
"label": "输入框",
"field": "input_2",
"span": 24,
"props": {
"type": "text"
}
}
],
"layout": "horizontal",
"labelLayout": "flex",
"labelWidth": 100,
"labelCol": {},
"wrapperCol": {}
}`,
jsonData: {
schemas: {},
config: {},
},
handleSetSelectItem: null,
});
const { formDesignMethods } = useFormDesignState();
const handleCancel = () => {
state.visible = false;
};
const showModal = () => {
state.visible = true;
};
const handleImportJson = () => {
// 导入JSON
console.log(state.json);
try {
const editorJsonData = JSON.parse(state.json) as IFormConfig;
editorJsonData.schemas &&
formItemsForEach(editorJsonData.schemas, (formItem) => {
generateKey(formItem);
});
formDesignMethods.setFormConfig({
...editorJsonData,
activeKey: 1,
currentItem: { component: '' },
});
handleCancel();
createMessage.success('导入成功');
} catch {
createMessage.error('导入失败,数据格式不对');
}
};
const beforeUpload = (e: File) => {
// 通过json文件导入
const reader = new FileReader();
reader.readAsText(e);
reader.onload = function () {
state.json = this.result as string;
handleImportJson();
};
return false;
};
return {
handleImportJson,
beforeUpload,
handleCancel,
showModal,
...toRefs(state),
MODE,
};
},
});
</script>
<style lang="less" scoped>
.upload-button {
margin: 0 10px;
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/23
* @Description: 渲染JSON数据
-->
<template>
<Modal
title="JSON数据"
:footer="null"
:visible="visible"
@cancel="handleCancel"
:destroyOnClose="true"
wrapClassName="v-code-modal"
style="top: 20px"
width="850px"
>
<PreviewCode :editorJson="editorJson" />
</Modal>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from 'vue';
import PreviewCode from './PreviewCode.vue';
import { IFormConfig } from '../../../typings/v-form-component';
import { formatRules, removeAttrs } from '../../../utils';
import { Modal } from 'ant-design-vue';
export default defineComponent({
name: 'JsonModal',
components: {
PreviewCode,
Modal,
},
emits: ['cancel'],
setup(_props, { emit }) {
const state = reactive<{
visible: boolean;
jsonData: IFormConfig;
}>({
visible: false, // 控制json数据弹框显示
jsonData: {} as IFormConfig, // json数据
});
/**
* 显示Json数据弹框
* @param jsonData
*/
const showModal = (jsonData: IFormConfig) => {
formatRules(jsonData.schemas);
state.jsonData = jsonData;
state.visible = true;
};
// 计算json数据
const editorJson = computed(() => {
return JSON.stringify(removeAttrs(state.jsonData), null, '\t');
});
// 关闭弹框
const handleCancel = () => {
state.visible = false;
emit('cancel');
};
return { ...toRefs(state), editorJson, handleCancel, showModal };
},
});
</script>
<!--
* @Author: ypt
* @Date: 2021/11/19
* @Description: 表单项布局控件
* 千万不要在template下面的第一行加注释,因为这里拖动的第一个元素
-->
<template>
<Col v-bind="colPropsComputed">
<template v-if="['Grid'].includes(schema.component)">
<div
class="grid-box"
:class="{ active: schema.key === currentItem.key }"
@click.stop="handleSetSelectItem(schema)"
>
<Row class="grid-row" v-bind="schema.componentProps">
<Col
class="grid-col"
v-for="(colItem, index) in schema.columns"
:key="index"
:span="colItem.span"
>
<!-- <div class="draggable-box"> -->
<!-- <div class="list-main"> -->
<draggable
class="list-main draggable-box"
:component-data="{ name: 'list', tag: 'div', type: 'transition-group' }"
v-bind="{
group: 'form-draggable',
ghostClass: 'moving',
animation: 180,
handle: '.drag-move',
}"
item-key="key"
v-model="colItem.children"
@start="$emit('dragStart', $event, colItem.children)"
@add="$emit('handleColAdd', $event, colItem.children)"
>
<!-- <transition-group tag="div" name="list" class="list-main"> -->
<template #item="{ element }">
<LayoutItem
class="drag-move"
:schema="element"
:current-item="currentItem"
@handle-copy="$emit('handle-copy')"
@handle-delete="$emit('handle-delete')"
/>
</template>
<!-- </transition-group> -->
</draggable>
<!-- </div> -->
<!-- </div> -->
</Col>
</Row>
<FormNodeOperate :schema="schema" :currentItem="currentItem" />
</div>
</template>
<FormNode
v-else
:key="schema.key"
:schema="schema"
:current-item="currentItem"
@handle-copy="$emit('handle-copy')"
@handle-delete="$emit('handle-delete')"
/>
</Col>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, reactive, toRefs } from 'vue';
import draggable from 'vuedraggable';
import FormNode from './FormNode.vue';
import FormNodeOperate from './FormNodeOperate.vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { IVFormComponent } from '../../../typings/v-form-component';
import { Row, Col } from 'ant-design-vue';
export default defineComponent({
name: 'LayoutItem',
components: {
FormNode,
FormNodeOperate,
draggable,
Row,
Col,
},
props: {
schema: {
type: Object as PropType<IVFormComponent>,
required: true,
},
currentItem: {
type: Object,
required: true,
},
},
emits: ['dragStart', 'handleColAdd', 'handle-copy', 'handle-delete'],
setup(props) {
const {
formDesignMethods: { handleSetSelectItem },
formConfig,
} = useFormDesignState();
const state = reactive({});
const colPropsComputed = computed(() => {
const { colProps = {} } = props.schema;
return colProps;
});
const list1 = computed(() => props.schema.columns);
// 计算布局元素,水平模式下为ACol,非水平模式下为div
const layoutTag = computed(() => {
return formConfig.value.layout === 'horizontal' ? 'Col' : 'div';
});
return {
...toRefs(state),
colPropsComputed,
handleSetSelectItem,
layoutTag,
list1,
};
},
});
</script>
<style lang="less">
@import url(../styles/variable.less);
.layout-width {
width: 100%;
}
.hidden-item {
background-color: rgb(240, 191, 195);
//opacity: 0.5;
}
</style>
<template>
<div>
<div class="v-json-box">
<CodeEditor :value="editorJson" ref="myEditor" :mode="MODE.JSON" />
</div>
<div class="copy-btn-box">
<a-button
@click="handleCopyJson"
type="primary"
class="copy-btn"
data-clipboard-action="copy"
:data-clipboard-text="editorJson"
>
复制数据
</a-button>
<a-button @click="handleExportJson" type="primary">导出代码</a-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, unref } from 'vue';
import { CodeEditor, MODE } from '/@/components/CodeEditor';
import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard';
import { useMessage } from '/@/hooks/web/useMessage';
export default defineComponent({
name: 'PreviewCode',
components: {
CodeEditor,
},
props: {
fileFormat: {
type: String,
default: 'json',
},
editorJson: {
type: String,
default: '',
},
},
setup(props) {
const state = reactive({
visible: false,
});
const exportData = (data: string, fileName = `file.${props.fileFormat}`) => {
let content = 'data:text/csv;charset=utf-8,';
content += data;
const encodedUri = encodeURI(content);
const actions = document.createElement('a');
actions.setAttribute('href', encodedUri);
actions.setAttribute('download', fileName);
actions.click();
};
const handleExportJson = () => {
exportData(props.editorJson);
};
const { clipboardRef, copiedRef } = useCopyToClipboard();
const { createMessage } = useMessage();
const handleCopyJson = () => {
// 复制数据
const value = props.editorJson;
if (!value) {
createMessage.warning('代码为空!');
return;
}
clipboardRef.value = value;
if (unref(copiedRef)) {
createMessage.warning('复制成功!');
}
};
return {
...toRefs(state),
exportData,
handleCopyJson,
handleExportJson,
MODE,
};
},
});
</script>
<style lang="less" scoped>
// modal复制按钮样式
.copy-btn-box {
padding-top: 8px;
text-align: center;
.copy-btn {
margin-right: 8px;
}
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/25
* @Description: 正则校验选项组件
-->
<template>
<div class="rule-props-content">
<Form v-if="formConfig.currentItem && formConfig.currentItem['rules']">
<div
v-for="(item, index) of formConfig.currentItem['rules']"
:key="index"
class="rule-props-item"
>
<Icon
icon="ant-design:close-circle-filled"
class="rule-props-item-close"
@click="removeRule(index)"
/>
<FormItem label="正则" :labelCol="{ span: 6 }" :wrapperCol="{ span: 16 }">
<AutoComplete
v-model:value="item.pattern"
placeholder="请输入正则表达式"
:dataSource="patternDataSource"
/>
</FormItem>
<FormItem label="文案" :labelCol="{ span: 6 }" :wrapperCol="{ span: 16 }">
<Input v-model:value="item.message" placeholder="请输入提示文案" />
</FormItem>
</div>
</Form>
<a @click="addRules">
<Icon icon="ant-design:file-add-outlined" />
添加正则
</a>
</div>
</template>
<script lang="ts">
import { ref, defineComponent } from 'vue';
import { remove } from '../../../utils';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { isArray } from 'lodash-es';
import { Form, FormItem, AutoComplete, Input } from 'ant-design-vue';
import Icon from '/@/components/Icon';
export default defineComponent({
name: 'RuleProps',
components: {
Form,
FormItem,
AutoComplete,
Input,
Icon,
},
setup() {
// 获取祖先组件的状态
const { formConfig } = useFormDesignState();
// 抽离 currentItem
/**
* 添加正则校验,判断当前组件的rules是不是数组,如果不是数组,使用set方法重置成数组,然后添加正则校验
*/
const addRules = () => {
if (!isArray(formConfig.value.currentItem!.rules))
formConfig.value.currentItem!['rules'] = [];
formConfig.value.currentItem!.rules?.push({ pattern: '', message: '' });
};
/**
* 删除正则校验,当正则规则为0时,删除rules属性
* @param index {number} 需要删除的规则下标
*/
const removeRule = (index: number) => {
remove(formConfig.value.currentItem!.rules as Array<any>, index);
if (formConfig.value.currentItem!.rules?.length === 0)
delete formConfig.value.currentItem!['rules'];
};
const patternDataSource = ref([
{
value: '/^(?:(?:\\+|00)86)?1[3-9]\\d{9}$/',
text: '手机号码',
},
{
value: '/^((ht|f)tps?:\\/\\/)?[\\w-]+(\\.[\\w-]+)+:\\d{1,5}\\/?$/',
text: '网址带端口号',
},
{
value:
'/^(((ht|f)tps?):\\/\\/)?[\\w-]+(\\.[\\w-]+)+([\\w.,@?^=%&:/~+#-\\(\\)]*[\\w@?^=%&/~+#-\\(\\)])?$/',
text: '网址带参数',
},
{
value: '/^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/',
text: '统一社会信用代码',
},
{
value: '/^(s[hz]|S[HZ])(000[\\d]{3}|002[\\d]{3}|300[\\d]{3}|600[\\d]{3}|60[\\d]{4})$/',
text: '股票代码',
},
{
value: '/^([a-f\\d]{32}|[A-F\\d]{32})$/',
text: 'md5格式(32位)',
},
{
value: '/^[a-f\\d]{4}(?:[a-f\\d]{4}-){4}[a-f\\d]{12}$/i',
text: 'GUID/UUID',
},
{
value: '/^\\d+(?:\\.\\d+){2}$/',
text: '版本号(x.y.z)格式',
},
{
value:
'/^https?:\\/\\/(.+\\/)+.+(\\.(swf|avi|flv|mpg|rm|mov|wav|asf|3gp|mkv|rmvb|mp4))$/i',
text: '视频链接地址',
},
{
value: '/^https?:\\/\\/(.+\\/)+.+(\\.(gif|png|jpg|jpeg|webp|svg|psd|bmp|tif))$/i',
text: '图片链接地址',
},
{
value: '/^-?\\d+(,\\d{3})*(\\.\\d{1,2})?$/',
text: '数字/货币金额(支持负数、千分位分隔符)',
},
{
value:
'/(?:^[1-9]([0-9]+)?(?:\\.[0-9]{1,2})?$)|(?:^(?:0)$)|(?:^[0-9]\\.[0-9](?:[0-9])?$)/',
text: '数字/货币金额',
},
{
value: '/^[1-9]\\d{9,29}$/',
text: '银行卡号',
},
{
value: '/^(?:[\u4e00-\u9fa5·]{2,16})$/',
text: '中文姓名',
},
{
value: '/(^[a-zA-Z][a-zA-Z\\s]{0,20}[a-zA-Z]$)/',
text: '英文姓名',
},
{
value:
'/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z](?:((\\d{5}[A-HJK])|([A-HJK][A-HJ-NP-Z0-9][0-9]{4}))|[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳])$/',
text: '车牌号(新能源)',
},
{
value:
'/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]$/',
text: '车牌号(非新能源)',
},
{
value:
'/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$/',
text: '车牌号(新能源+非新能源)',
},
{
value:
'/^(([^<>()[\\]\\\\.,;:\\s@"]+(\\.[^<>()[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/',
text: 'email(邮箱)',
},
{
value: '/^(?:(?:\\d{3}-)?\\d{8}|^(?:\\d{4}-)?\\d{7,8})(?:-\\d+)?$/',
text: '座机',
},
{
value:
'/^[1-9]\\d{5}(?:18|19|20)\\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\\d|30|31)\\d{3}[\\dXx]$/',
text: '身份证号',
},
{
value:
'/(^[EeKkGgDdSsPpHh]\\d{8}$)|(^(([Ee][a-fA-F])|([DdSsPp][Ee])|([Kk][Jj])|([Mm][Aa])|(1[45]))\\d{7}$)/',
text: '护照',
},
{
value:
'/^(?:[\u3400-\u4DB5\u4E00-\u9FEA\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0])+$/',
text: '中文汉字',
},
{
value: '/^\\d+\\.\\d+$/',
text: '小数',
},
{
value: '/^\\d{1,}$/',
text: '数字',
},
{
value: '/^[1-9][0-9]{4,10}$/',
text: 'qq号',
},
{
value: '/^[A-Za-z0-9]+$/',
text: '数字字母组合',
},
{
value: '/^[a-zA-Z]+$/',
text: '英文字母',
},
{
value: '/^[a-z]+$/',
text: '小写英文字母',
},
{
value: '/^[A-Z]+$/',
text: '大写英文字母',
},
{
value: '/^[a-zA-Z0-9_-]{4,16}$/',
text: '用户名校验,4到16位(字母,数字,下划线,减号)',
},
{
value: '/^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/',
text: '16进制颜色',
},
{
value: '/^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/',
text: '微信号',
},
{
value: '/^(0[1-7]|1[0-356]|2[0-7]|3[0-6]|4[0-7]|5[1-7]|6[1-7]|7[0-5]|8[013-6])\\d{4}$/',
text: '邮政编码(中国)',
},
{
value: '/^[^A-Za-z]*$/',
text: '不能包含字母',
},
{
value: '/^\\+?[1-9]\\d*$/',
text: '正整数,不包含0',
},
{
value: '/^-[1-9]\\d*$/',
text: '负整数,不包含0',
},
{
value: '/^-?[0-9]\\d*$/',
text: '整数',
},
{
value: '/^(-?\\d+)(\\.\\d+)?$/',
text: '浮点数',
},
{
value: '/^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$/',
text: 'email(支持中文邮箱)',
},
]);
return { addRules, removeRule, formConfig, patternDataSource };
},
});
</script>
<style lang="less" scoped>
:deep(.icon) {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.rule-props-content {
:deep(.ant-form-item) {
margin-bottom: 0;
}
.rule-props-item {
position: relative;
background-color: #f0eded;
padding: 3px 2px;
border-radius: 5px;
margin-bottom: 5px;
:deep(.ant-form-item) {
border: 0 !important;
}
&-close {
position: absolute;
top: -5px;
right: -5px;
color: #ccc;
cursor: pointer;
border-radius: 7px;
background-color: #a3a0a0;
z-index: 999;
&:hover {
color: #00c;
}
}
}
}
</style>
import { IAnyObject } from '../../../typings/base-type';
import { baseComponents, customComponents } from '../../../core/formItemConfig';
export const globalConfigState: { span: number } = {
span: 24,
};
export interface IBaseFormAttrs {
name: string; // 字段名
label: string; // 字段标签
component?: string; // 属性控件
componentProps?: IAnyObject; // 传递给控件的属性
exclude?: string[]; // 需要排除的控件
includes?: string[]; // 符合条件的组件
on?: IAnyObject;
children?: IBaseFormAttrs[];
category?: 'control' | 'input';
}
export interface IBaseFormItemControlAttrs extends IBaseFormAttrs {
target?: 'props' | 'options'; // 绑定到对象下的某个目标key中
}
export const baseItemColumnProps: IBaseFormAttrs[] = [
{
name: 'span',
label: '栅格数',
component: 'Slider',
on: {
change(value: number) {
globalConfigState.span = value;
},
},
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'offset',
label: '栅格左侧的间隔格数',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'order',
label: '栅格顺序,flex 布局模式下有效',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'pull',
label: '栅格向左移动格数',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'push',
label: '栅格向右移动格数',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'xs',
label: '<576px 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'sm',
label: '≥576px 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'md',
label: '≥768p 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'lg',
label: '≥992px 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'xl',
label: '≥1200px 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: 'xxl',
label: '≥1600px 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
{
name: '≥2000px',
label: '≥1600px 响应式栅格',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
},
];
// 控件属性面板的配置项
export const advanceFormItemColProps: IBaseFormAttrs[] = [
{
name: 'labelCol',
label: '标签col',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
exclude: ['Grid'],
},
{
name: 'wrapperCol',
label: '控件-span',
component: 'Slider',
componentProps: {
max: 24,
min: 0,
marks: { 12: '' },
},
exclude: ['Grid'],
},
];
// 控件属性面板的配置项
export const baseFormItemProps: IBaseFormAttrs[] = [
{
// 动态的切换控件的类型
name: 'component',
label: '控件-FormItem',
component: 'Select',
componentProps: {
options: baseComponents
.concat(customComponents)
.map((item) => ({ value: item.component, label: item.label })),
},
},
{
name: 'label',
label: '标签',
component: 'Input',
componentProps: {
type: 'Input',
placeholder: '请输入标签',
},
exclude: ['Grid'],
},
{
name: 'field',
label: '字段标识',
component: 'Input',
componentProps: {
type: 'InputTextArea',
placeholder: '请输入字段标识',
},
exclude: ['Grid'],
},
{
name: 'helpMessage',
label: 'helpMessage',
component: 'Input',
componentProps: {
placeholder: '请输入提示信息',
},
exclude: ['Grid'],
},
];
// 控件属性面板的配置项
export const advanceFormItemProps: IBaseFormAttrs[] = [
{
name: 'labelAlign',
label: '标签对齐',
component: 'RadioGroup',
componentProps: {
options: [
{
label: '靠左',
value: 'left',
},
{
label: '靠右',
value: 'right',
},
],
},
exclude: ['Grid'],
},
{
name: 'help',
label: 'help',
component: 'Input',
componentProps: {
placeholder: '请输入提示信息',
},
exclude: ['Grid'],
},
{
name: 'extra',
label: '额外消息',
component: 'Input',
componentProps: {
type: 'InputTextArea',
placeholder: '请输入额外消息',
},
exclude: ['Grid'],
},
{
name: 'validateTrigger',
label: 'validateTrigger',
component: 'Input',
componentProps: {
type: 'InputTextArea',
placeholder: '请输入validateTrigger',
},
exclude: ['Grid'],
},
{
name: 'validateStatus',
label: '校验状态',
component: 'RadioGroup',
componentProps: {
options: [
{
label: '默认',
value: '',
},
{
label: '成功',
value: 'success',
},
{
label: '警告',
value: 'warning',
},
{
label: '错误',
value: 'error',
},
{
label: '校验中',
value: 'validating',
},
],
},
exclude: ['Grid'],
},
];
export const baseFormItemControlAttrs: IBaseFormItemControlAttrs[] = [
{
name: 'required',
label: '必填项',
component: 'Checkbox',
exclude: ['alert'],
},
{
name: 'hidden',
label: '隐藏',
component: 'Checkbox',
exclude: ['alert'],
},
{
name: 'hiddenLabel',
component: 'Checkbox',
exclude: ['Grid'],
label: '隐藏标签',
},
{
name: 'colon',
label: 'label后面显示冒号',
component: 'Checkbox',
componentProps: {},
exclude: ['Grid'],
},
{
name: 'hasFeedback',
label: '输入反馈',
component: 'Checkbox',
componentProps: {},
includes: ['Input'],
},
{
name: 'autoLink',
label: '自动关联',
component: 'Checkbox',
componentProps: {},
includes: ['Input'],
},
{
name: 'validateFirst',
label: '检验证错误停止',
component: 'Checkbox',
componentProps: {},
includes: ['Input'],
},
];
<template>
<!-- <div class="v-form-design-container"> -->
<!-- <header class="v-form-design-header">{{ title }}</header> -->
<Layout>
<LayoutSider
class="left"
theme="light"
collapsible
collapsedWidth="0"
width="270"
:zeroWidthTriggerStyle="{ 'margin-top': '-70px' }"
breakpoint="md"
>
<CollapseContainer title="基础控件">
<CollapseItem
:list="baseComponents"
:handleListPush="handleListPushDrag"
@add-attrs="handleAddAttrs"
@handle-list-push="handleListPush"
/>
</CollapseContainer>
<CollapseContainer title="自定义控件">
<CollapseItem
:list="customComponents"
@add-attrs="handleAddAttrs"
:handleListPush="handleListPushDrag"
@handle-list-push="handleListPush"
/>
</CollapseContainer>
<CollapseContainer title="布局控件">
<CollapseItem
:list="layoutComponents"
:handleListPush="handleListPushDrag"
@add-attrs="handleAddAttrs"
@handle-list-push="handleListPush"
/>
</CollapseContainer>
</LayoutSider>
<LayoutContent>
<Toolbar
@handle-open-json-modal="handleOpenModal(jsonModal!)"
@handle-open-import-json-modal="handleOpenModal(importJsonModal!)"
@handle-preview="handleOpenModal(eFormPreview!)"
@handle-preview2="handleOpenModal(eFormPreview2!)"
@handle-open-code-modal="handleOpenModal(codeModal!)"
@handle-clear-form-items="handleClearFormItems"
/>
<FormComponentPanel
:current-item="formConfig.currentItem"
:data="formConfig"
@handle-set-select-item="handleSetSelectItem"
/>
</LayoutContent>
<LayoutSider
class="right"
collapsible
:reverseArrow="true"
theme="light"
collapsedWidth="0"
width="270"
:zeroWidthTriggerStyle="{ 'margin-top': '-70px' }"
breakpoint="lg"
>
<!-- <div class="right" onselectstart="return false"> -->
<PropsPanel ref="propsPanel" :activeKey="formConfig.activeKey">
<template v-for="item of formConfig.schemas" #[`${item.component}Props`]="data">
<slot
:name="`${item.component}Props`"
v-bind="{ formItem: data, props: data.componentProps }"
></slot>
</template>
</PropsPanel>
<!-- </div> -->
</LayoutSider>
</Layout>
<JsonModal ref="jsonModal" />
<CodeModal ref="codeModal" />
<ImportJsonModal ref="importJsonModal" />
<VFormPreview ref="eFormPreview" :formConfig="formConfig" />
<VFormPreview2 ref="eFormPreview2" :formConfig="formConfig" />
<!-- </div> -->
</template>
<script lang="ts" setup>
import CollapseItem from './modules/CollapseItem.vue';
import FormComponentPanel from './modules/FormComponentPanel.vue';
import JsonModal from './components/JsonModal.vue';
import VFormPreview from '../VFormPreview/index.vue';
import VFormPreview2 from '../VFormPreview/useForm.vue';
import Toolbar from './modules/Toolbar.vue';
import PropsPanel from './modules/PropsPanel.vue';
import ImportJsonModal from './components/ImportJsonModal.vue';
import CodeModal from './components/CodeModal.vue';
import 'codemirror/mode/javascript/javascript';
import { ref, provide, Ref } from 'vue';
import { Layout, LayoutContent, LayoutSider } from 'ant-design-vue';
// import zhCN from 'ant-design-vue/lib/locale-provider/zh_CN';
import { IVFormComponent, IFormConfig, PropsTabKey } from '../../typings/v-form-component';
import { formItemsForEach, generateKey } from '../../utils';
import { cloneDeep } from 'lodash-es';
import { baseComponents, customComponents, layoutComponents } from '../../core/formItemConfig';
import { useRefHistory, UseRefHistoryReturn } from '@vueuse/core';
// import { IAnyObject } from '../../typings/base-type';
import { globalConfigState } from './config/formItemPropsConfig';
import { IFormDesignMethods, IPropsPanel, IToolbarMethods } from '../../typings/form-type';
import { CollapseContainer } from '/@/components/Container/index';
defineProps({
title: {
type: String,
default: 'v-form-antd表单设计器',
},
});
// 子组件实例
const propsPanel = ref<null | IPropsPanel>(null);
const jsonModal = ref<null | IToolbarMethods>(null);
const importJsonModal = ref<null | IToolbarMethods>(null);
const eFormPreview = ref<null | IToolbarMethods>(null);
const eFormPreview2 = ref<null | IToolbarMethods>(null);
const codeModal = ref<null | IToolbarMethods>(null);
const formModel = ref({});
// endregion
const formConfig = ref<IFormConfig>({
// 表单配置
schemas: [],
layout: 'horizontal',
labelLayout: 'flex',
labelWidth: 100,
labelCol: {},
wrapperCol: {},
currentItem: {
component: '',
componentProps: {},
},
activeKey: 1,
});
// const _state = reactive<IState>({
// locale: zhCN, // 国际化
// baseComponents, // 基础控件列表
// layoutComponents, // 布局组件列表
// customComponents,
// propsPanel,
// jsonModal,
// eFormPreview,
// eFormPreview2,
// importJsonModal,
// codeModal,
// });
const setFormConfig = (config: IFormConfig) => {
//外部导入时,可能会缺少必要的信息。
config.schemas = config.schemas || [];
config.schemas.forEach((item) => {
item.colProps = item.colProps || { span: 24 };
item.componentProps = item.componentProps || {};
item.itemProps = item.itemProps || {};
});
formConfig.value = config;
};
// 获取历史记录,用于撤销和重构
const historyReturn = useRefHistory(formConfig, {
deep: true,
capacity: 20,
parse: (val: IFormConfig) => {
// 使用lodash.cloneDeep重新拷贝数据,把currentItem指向选中项
const formConfig = cloneDeep(val);
const { currentItem, schemas } = formConfig;
// 从formItems中查找选中项
const item = schemas && schemas.find((item) => item.key === currentItem?.key);
// 如果有,则赋值给当前项,如果没有,则切换属性面板
if (item) {
formConfig.currentItem = item;
}
return formConfig;
},
});
/**
* 选中表单项
* @param schema 当前选中的表单项
*/
const handleSetSelectItem = (schema: IVFormComponent) => {
formConfig.value.currentItem = schema;
handleChangePropsTabs(
schema.key ? (formConfig.value.activeKey! === 1 ? 2 : formConfig.value.activeKey!) : 1,
);
};
const setGlobalConfigState = (formItem: IVFormComponent) => {
formItem.colProps = formItem.colProps || {};
formItem.colProps.span = globalConfigState.span;
// console.log('setGlobalConfigState', formItem);
};
/**
* 添加属性
* @param schemas
* @param index
*/
const handleAddAttrs = (_formItems: IVFormComponent[], _index: number) => {
// const item = schemas[index];
// setGlobalConfigState(item);
// generateKey(item);
// handleListPush(item);
};
const handleListPushDrag = (item: IVFormComponent) => {
const formItem = cloneDeep(item);
setGlobalConfigState(formItem);
generateKey(formItem);
// if (!formConfig.value.currentItem?.key) {
// formConfig.value.schemas.push(formItem);
// handleSetSelectItem(formItem);
// return formItem;
// }
// handleCopy(formItem, false);
// handleCopy(formItem, false);
return formItem;
};
/**
* 单击控件时添加到面板中
* @param item {IVFormComponent} 当前点击的组件
*/
const handleListPush = (item: IVFormComponent) => {
// console.log('handleListPush', item);
const formItem = cloneDeep(item);
setGlobalConfigState(formItem);
generateKey(formItem);
if (!formConfig.value.currentItem?.key) {
handleSetSelectItem(formItem);
formConfig.value.schemas && formConfig.value.schemas.push(formItem);
return;
}
handleCopy(formItem, false);
};
/**
* 复制表单项,如果表单项为栅格布局,则遍历所有自表单项重新生成key
* @param {IVFormComponent} formItem
* @return {IVFormComponent}
*/
const copyFormItem = (formItem: IVFormComponent) => {
const newFormItem = cloneDeep(formItem);
if (newFormItem.component === 'Grid') {
formItemsForEach([formItem], (item) => {
generateKey(item);
});
}
return newFormItem;
};
/**
* 复制或者添加表单,isCopy为true时则复制表单
* @param item {IVFormComponent} 当前点击的组件
* @param isCopy {boolean} 是否复制
*/
const handleCopy = (
item: IVFormComponent = formConfig.value.currentItem as IVFormComponent,
isCopy = true,
) => {
const key = formConfig.value.currentItem?.key;
/**
* 遍历当表单项配置,如果是复制,则复制一份表单项,如果不是复制,则直接添加到表单项中
* @param schemas
*/
const traverse = (schemas: IVFormComponent[]) => {
// 使用some遍历,找到目标后停止遍历
schemas.some((formItem: IVFormComponent, index: number) => {
if (formItem.key === key) {
// 判断是不是复制
isCopy
? schemas.splice(index, 0, copyFormItem(formItem))
: schemas.splice(index + 1, 0, item);
const event = {
newIndex: index + 1,
};
// 添加到表单项中
handleBeforeColAdd(event, schemas, isCopy);
return true;
}
if (['Grid', 'Tabs'].includes(formItem.component)) {
// 栅格布局
formItem.columns?.forEach((item) => {
traverse(item.children);
});
}
});
};
if (formConfig.value.schemas) {
traverse(formConfig.value.schemas);
}
};
/**
* 添加到表单中
* @param newIndex {object} 事件对象
* @param schemas {IVFormComponent[]} 表单项列表
* @param isCopy {boolean} 是否复制
*/
const handleBeforeColAdd = ({ newIndex }: any, schemas: IVFormComponent[], isCopy = false) => {
const item = schemas[newIndex];
isCopy && generateKey(item);
handleSetSelectItem(item);
};
/**
* 打开模态框
* @param Modal {IToolbarMethods}
*/
const handleOpenModal = (Modal: IToolbarMethods) => {
const config = cloneDeep(formConfig.value);
Modal?.showModal(config);
};
/**
* 切换属性面板
* @param key
*/
const handleChangePropsTabs = (key: PropsTabKey) => {
formConfig.value.activeKey = key;
};
/**
* 清空表单项列表
*/
const handleClearFormItems = () => {
formConfig.value.schemas = [];
handleSetSelectItem({ component: '' });
};
const setFormModel = (key, value) => (formModel.value[key] = value);
provide('formModel', formModel);
// 把祖先组件的方法项注入到子组件中,子组件可通过inject获取
provide<(key: String, value: any) => void>('setFormModelMethod', setFormModel);
// region 注入给子组件的属性
// provide('currentItem', formConfig.value.currentItem)
// 把表单配置项注入到子组件中,子组件可通过inject获取,获取到的数据为响应式
provide<Ref<IFormConfig>>('formConfig', formConfig);
// 注入历史记录
provide<UseRefHistoryReturn<any, any>>('historyReturn', historyReturn);
// 把祖先组件的方法项注入到子组件中,子组件可通过inject获取
provide<IFormDesignMethods>('formDesignMethods', {
handleBeforeColAdd,
handleCopy,
handleListPush,
handleSetSelectItem,
handleAddAttrs,
setFormConfig,
});
// endregion
</script>
<style lang="less" scoped>
// @import url(./styles/variable.less);
</style>
<template>
<div>
<draggable
tag="ul"
:model-value="list"
v-bind="{
group: { name: 'form-draggable', pull: 'clone', put: false },
sort: false,
clone: cloneItem,
animation: 180,
ghostClass: 'moving',
}"
item-key="type"
@start="handleStart($event, list)"
@add="handleAdd"
>
<template #item="{ element, index }">
<li
class="bs-box text-ellipsis"
@dragstart="$emit('add-attrs', list, index)"
@click="$emit('handle-list-push', element)"
>
<!-- <svg v-if="element.icon.indexOf('icon-') > -1" class="icon" aria-hidden="true">
<use :xlink:href="`#${element.icon}`" />
</svg> -->
<Icon :icon="element.icon" />
{{ element.label }}</li
></template
>
</draggable>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
import { IVFormComponent } from '../../../typings/v-form-component';
import draggable from 'vuedraggable';
// import { toRefs } from '@vueuse/core';
import { Icon } from '/@/components/Icon';
export default defineComponent({
name: 'CollapseItem',
components: { draggable, Icon },
props: {
list: {
type: [Array] as PropType<IVFormComponent[]>,
default: () => [],
},
handleListPush: {
type: Function as PropType<(item: IVFormComponent) => void>,
default: null,
},
},
setup(props, { emit }) {
const state = reactive({});
const handleStart = (e: any, list1: IVFormComponent[]) => {
emit('start', list1[e.oldIndex].component);
};
const handleAdd = (e: any) => {
console.log(e);
};
// https://github.com/SortableJS/vue.draggable.next
// https://github.com/SortableJS/vue.draggable.next/blob/master/example/components/custom-clone.vue
const cloneItem = (one) => {
return props.handleListPush(one);
};
return { state, handleStart, handleAdd, cloneItem };
},
});
</script>
<style lang="less" scoped>
@import url(../styles/variable.less);
ul {
padding: 5px;
list-style: none;
display: flex;
margin-bottom: 0;
flex-wrap: wrap;
// background: #efefef;
li {
padding: 8px 12px;
transition: all 0.3s;
width: calc(50% - 6px);
margin: 2.7px;
height: 36px;
line-height: 20px;
cursor: move;
border: 1px solid @border-color;
border-radius: 3px;
&:hover {
color: @primary-color;
border: 1px solid @primary-color;
position: relative;
// z-index: 1;
box-shadow: 0 2px 6px @primary-color;
}
}
}
svg {
display: inline !important;
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/18
* @Description: 中间表单布局面板
* https://github.com/SortableJS/vue.draggable.next/issues/138
-->
<template>
<div class="form-panel v-form-container">
<Empty
class="empty-text"
v-show="formConfig.schemas.length === 0"
description="从左侧选择控件添加"
/>
<Form v-bind="formConfig">
<div class="draggable-box">
<draggable
class="list-main ant-row"
group="form-draggable"
:component-data="{ name: 'list', tag: 'div', type: 'transition-group' }"
ghostClass="moving"
:animation="180"
handle=".drag-move"
v-model="formConfig.schemas"
item-key="key"
@add="addItem"
@start="handleDragStart"
>
<template #item="{ element }">
<LayoutItem
class="drag-move"
:schema="element"
:data="formConfig"
:current-item="formConfig.currentItem || {}"
/>
</template>
</draggable>
</div>
</Form>
</div>
</template>
<script lang="ts">
import draggable from 'vuedraggable';
import { defineComponent, computed } from 'vue';
import LayoutItem from '../components/LayoutItem.vue';
import { cloneDeep } from 'lodash-es';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { Form, Empty } from 'ant-design-vue';
export default defineComponent({
name: 'FormComponentPanel',
components: {
LayoutItem,
draggable,
Form,
Empty,
},
emits: ['handleSetSelectItem'],
setup(_, { emit }) {
const { formConfig } = useFormDesignState() as Recordable;
/**
* 拖拽完成事件
* @param newIndex
*/
const addItem = ({ newIndex }: any) => {
formConfig.value.schemas = formConfig.value.schemas || [];
const schemas = formConfig.value.schemas;
schemas[newIndex] = cloneDeep(schemas[newIndex]);
emit('handleSetSelectItem', schemas[newIndex]);
};
/**
* 拖拽开始事件
* @param e {Object} 事件对象
*/
const handleDragStart = (e: any) => {
emit('handleSetSelectItem', formConfig.value.schemas[e.oldIndex]);
};
// 获取祖先组件传递的currentItem
// 计算布局元素,水平模式下为ACol,非水平模式下为div
const layoutTag = computed(() => {
return formConfig.value.layout === 'horizontal' ? 'Col' : 'div';
});
return {
addItem,
handleDragStart,
formConfig,
layoutTag,
};
},
});
</script>
<style lang="less" scoped>
@import url(../styles/variable.less);
@import url(../styles/drag.less);
.v-form-container {
// 内联布局样式
.ant-form-inline {
.list-main {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-content: flex-start;
.layout-width {
width: 100%;
}
}
.ant-form-item-control-wrapper {
min-width: 175px !important;
}
}
}
.form-panel {
position: relative;
height: 100%;
.empty-text {
color: #aaa;
height: 150px;
top: -10%;
left: 0;
right: 0;
bottom: 0;
margin: auto;
position: absolute;
z-index: 100;
}
.draggable-box {
// width: 100%;
.drag-move {
cursor: move;
min-height: 62px;
}
.list-main {
overflow: auto;
height: 100%;
// 列表动画
.list-enter-active {
transition: all 0.5s;
}
.list-leave-active {
transition: all 0.3s;
}
.list-enter,
.list-leave-to {
opacity: 0;
transform: translateX(-100px);
}
.list-enter {
height: 30px;
}
}
}
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/19
* @Description: 右侧属性配置面板
-->
<template>
<div>
<Tabs v-model:activeKey="formConfig.activeKey" :tabBarStyle="{ margin: 0 }">
<TabPane :key="1" tab="表单">
<FormProps />
</TabPane>
<TabPane :key="2" tab="控件">
<FormItemProps />
</TabPane>
<TabPane :key="3" tab="栅格">
<ComponentColumnProps />
</TabPane>
<TabPane :key="4" tab="组件">
<slot v-if="slotProps" :name="slotProps.component + 'Props'"></slot>
<ComponentProps v-else />
</TabPane>
</Tabs>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import FormProps from '../components/FormProps.vue';
import FormItemProps from '../components/FormItemProps.vue';
import ComponentProps from '../components/ComponentProps.vue';
import ComponentColumnProps from '../components/FormItemColumnProps.vue';
import { useFormDesignState } from '../../../hooks/useFormDesignState';
import { customComponents } from '../../../core/formItemConfig';
import { TabPane, Tabs } from 'ant-design-vue';
type ChangeTabKey = 1 | 2;
export interface IPropsPanel {
changeTab: (key: ChangeTabKey) => void;
}
export default defineComponent({
name: 'PropsPanel',
components: {
FormProps,
FormItemProps,
ComponentProps,
ComponentColumnProps,
Tabs,
TabPane,
},
setup() {
const { formConfig } = useFormDesignState();
const slotProps = computed(() => {
return customComponents.find(
(item) => item.component === formConfig.value.currentItem?.component,
);
});
return { formConfig, customComponents, slotProps };
},
});
</script>
<style lang="less" scoped>
@import url(../styles/variable.less);
:deep(.ant-tabs) {
box-sizing: border-box;
form {
width: 100%;
position: absolute;
height: calc(100% - 50px);
margin-right: 10px;
overflow-y: auto;
overflow-x: hidden;
}
.hint-box {
margin-top: 200px;
}
.ant-form-item,
.ant-slider-with-marks {
margin-left: 10px;
margin-right: 20px;
margin-bottom: 0;
}
.ant-form-item {
// width: 100%;
margin-bottom: 0;
.ant-form-item-label {
line-height: 2;
vertical-align: text-top;
}
}
.ant-input-number {
width: 100%;
}
}
</style>
<!--
* @Author: ypt
* @Date: 2021/11/23
* @Description: 工具栏
-->
<template>
<div class="operating-area">
<!-- 头部操作按钮区域 start -->
<!-- 操作左侧区域 start -->
<div class="left-btn-box">
<Tooltip v-for="item in toolbarsConfigs" :title="item.title" :key="item.icon">
<a @click="$emit(item.event)" class="toolbar-text">
<!-- <a-icon :type="item.icon" /> -->
<Icon :icon="item.icon" />
</a>
</Tooltip>
<Divider type="vertical" />
<Tooltip title="撤销">
<a :class="{ disabled: !canUndo }" :disabled="!canUndo" @click="undo">
<!-- <a-icon type="undo" /> -->
<Icon icon="ant-design:undo-outlined" />
</a>
</Tooltip>
<Tooltip title="重做">
<a :class="{ disabled: !canRedo }" :disabled="!canRedo" @click="redo">
<!-- <a-icon type="redo" /> -->
<Icon icon="ant-design:redo-outlined" />
</a>
</Tooltip>
</div>
</div>
<!-- 操作区域 start -->
</template>
<script lang="ts">
import { defineComponent, inject, reactive, toRefs } from 'vue';
import { UseRefHistoryReturn } from '@vueuse/core';
import { IFormConfig } from '../../../typings/v-form-component';
import { Tooltip, Divider } from 'ant-design-vue';
import Icon from '/@/components/Icon/index';
interface IToolbarsConfig {
type: string;
title: string;
icon: string;
event: string;
}
export default defineComponent({
name: 'OperatingArea',
components: {
Tooltip,
Icon,
Divider,
},
setup() {
const state = reactive<{
toolbarsConfigs: IToolbarsConfig[];
}>({
toolbarsConfigs: [
{
title: '预览',
type: 'preview',
event: 'handlePreview',
icon: 'ant-design:chrome-filled',
},
{
title: '预览2',
type: 'preview',
event: 'handlePreview2',
icon: 'ant-design:chrome-filled',
},
{
title: '导入',
type: 'importJson',
event: 'handleOpenImportJsonModal',
icon: 'ant-design:import-outlined',
},
{
title: '生成JSON',
type: 'exportJson',
event: 'handleOpenJsonModal',
icon: 'ant-design:export-outlined',
},
{
title: '生成代码',
type: 'exportCode',
event: 'handleOpenCodeModal',
icon: 'ant-design:code-filled',
},
{
title: '清空',
type: 'reset',
event: 'handleClearFormItems',
icon: 'ant-design:clear-outlined',
},
],
});
const historyRef = inject('historyReturn') as UseRefHistoryReturn<IFormConfig, IFormConfig>;
const { undo, redo, canUndo, canRedo } = historyRef;
return { ...toRefs(state), undo, redo, canUndo, canRedo };
},
});
</script>
<style lang="less" scoped>
//noinspection CssUnknownTarget
@import url('../styles/variable.less');
.operating-area {
border-bottom: 2px solid @border-color;
font-size: 16px;
text-align: left;
height: @operating-area-height;
line-height: @operating-area-height;
padding: 0 12px;
display: flex;
justify-content: space-between;
align-content: center;
padding-left: 30px;
a {
color: #666;
margin: 0 5px;
&.disabled,
&.disabled:hover {
color: #ccc;
}
&:hover {
color: @primary-color;
}
> span {
font-size: 14px;
padding-left: 2px;
}
}
}
</style>
.draggable-box {
height: 100%;
overflow: auto;
:deep(.list-main) {
overflow: hidden;
min-height: 100%;
padding: 5px;
position: relative;
background: #fafafa;
// border : 1px #ccc dashed;
.moving {
// 拖放移动中
// outline-width: 0;
min-height: 35px;
box-sizing: border-box;
overflow: hidden;
padding: 0 !important;
// margin : 3px 0;
position: relative;
&::before {
content: '';
height: 5px;
width: 100%;
background: @primary-color;
position: absolute;
top: 0;
right: 0;
}
}
.drag-move-box {
position: relative;
box-sizing: border-box;
padding: 8px;
overflow: hidden;
transition: all 0.3s;
min-height: 60px;
&:hover {
background: @primary-hover-bg-color;
}
// 选择时 start
&::before {
content: '';
height: 5px;
width: 100%;
background: @primary-color;
position: absolute;
top: 0;
right: -100%;
transition: all 0.3s;
}
&.active {
background: @primary-hover-bg-color;
outline-offset: 0;
&::before {
right: 0;
}
}
// 选择时 end
.form-item-box {
position: relative;
box-sizing: border-box;
word-wrap: break-word;
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
// z-index: 888;
}
.ant-form-item {
// 修改ant form-item的margin为padding
margin: 0;
padding-bottom: 6px;
}
}
.show-key-box {
// 显示key
position: absolute;
bottom: 2px;
right: 5px;
font-size: 14px;
// z-index: 999;
color: @primary-color;
}
.copy,
.delete {
position: absolute;
top: 0;
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
color: #fff;
// z-index: 989;
transition: all 0.3s;
&.unactivated {
opacity: 0 !important;
pointer-events: none;
}
&.active {
opacity: 1 !important;
}
}
.copy {
border-radius: 0 0 0 8px;
right: 30px;
background: @primary-color;
}
.delete {
right: 0;
background: @primary-color;
}
}
.grid-box {
position: relative;
box-sizing: border-box;
padding: 5px;
background: @layout-background-color;
width: 100%;
transition: all 0.3s;
overflow: hidden;
.form-item-box {
position: relative;
box-sizing: border-box;
.ant-form-item {
// 修改ant form-item的margin为padding
margin: 0;
padding-bottom: 15px;
}
}
.grid-row {
background: @layout-background-color;
.grid-col {
.draggable-box {
min-height: 80px;
min-width: 50px;
border: 1px #ccc dashed;
background: #fff;
.list-main {
min-height: 83px;
position: relative;
border: 1px #ccc dashed;
}
}
}
}
// 选择时 start
&::before {
content: '';
height: 5px;
width: 100%;
background: transparent;
position: absolute;
top: 0;
right: -100%;
transition: all 0.3s;
}
&.active {
background: @layout-hover-bg-color;
outline-offset: 0;
&::before {
background: @layout-color;
right: 0;
}
}
// 选择时 end
> .copy-delete-box {
> .copy,
> .delete {
position: absolute;
top: 0;
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
color: #fff;
// z-index: 989;
transition: all 0.3s;
&.unactivated {
opacity: 0 !important;
pointer-events: none;
}
&.active {
opacity: 1 !important;
}
}
> .copy {
border-radius: 0 0 0 8px;
right: 30px;
background: @layout-color;
}
> .delete {
right: 0;
background: @layout-color;
}
}
}
}
}
// 表单设计器样式
@primary-color: #13c2c2;
@layout-color: #9867f7;
@primary-background-color: fade(@primary-color, 6%);
@primary-hover-bg-color: fade(@primary-color, 20%);
@layout-background-color: fade(@layout-color, 12%);
@layout-hover-bg-color: fade(@layout-color, 24%);
@title-text-color: #fff;
@border-color: #ccc;
@left-right-width: 280px;
@header-height: 56px;
@operating-area-height: 45px;
<!--
* @Author: ypt
* @Date: 2021/11/19
* @Description:
-->
<template>
<Col v-bind="colPropsComputed">
<FormItem v-bind="{ ...formItemProps }">
<template #label v-if="!formItemProps.hiddenLabel && schema.component !== 'Divider'">
<Tooltip>
<span>{{ schema.label }}</span>
<template #title v-if="schema.helpMessage"
><span>{{ schema.helpMessage }}</span></template
>
<Icon v-if="schema.helpMessage" class="ml-5" icon="ant-design:question-circle-outlined" />
</Tooltip>
</template>
<slot
v-if="schema.componentProps && schema.componentProps?.slotName"
:name="schema.componentProps.slotName"
v-bind="schema"
></slot>
<Divider
v-else-if="schema.component == 'Divider' && schema.label && !formItemProps.hiddenLabel"
>{{ schema.label }}</Divider
>
<!-- 部分控件需要一个空div -->
<div
><component
class="v-form-item-wrapper"
:is="componentItem"
v-bind="{ ...cmpProps, ...asyncProps }"
:schema="schema"
:style="schema.width ? { width: schema.width } : {}"
@change="handleChange"
@click="handleClick(schema)"
/></div>
<span v-if="['Button'].includes(schema.component)">{{ schema.label }}</span>
</FormItem>
</Col>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, computed, PropType, unref } from 'vue';
import { componentMap } from '../../core/formItemConfig';
import { IVFormComponent, IFormConfig } from '../../typings/v-form-component';
import { asyncComputed } from '@vueuse/core';
import { handleAsyncOptions } from '../../utils';
import { omit } from 'lodash-es';
import { Tooltip, FormItem, Divider, Col } from 'ant-design-vue';
// import FormItem from '/@/components/Form/src/components/FormItem.vue';
import { Icon } from '/@/components/Icon';
import { useFormModelState } from '../../hooks/useFormDesignState';
export default defineComponent({
name: 'VFormItem',
components: {
Tooltip,
Icon,
FormItem,
Divider,
Col,
},
props: {
formData: {
type: Object,
default: () => ({}),
},
schema: {
type: Object as PropType<IVFormComponent>,
required: true,
},
formConfig: {
type: Object as PropType<IFormConfig>,
required: true,
},
},
emits: ['update:form-data', 'change'],
setup(props, { emit }) {
const state = reactive({
componentMap,
});
const { formModel: formData1, setFormModel } = useFormModelState();
const colPropsComputed = computed(() => {
const { colProps = {} } = props.schema;
return colProps;
});
const formItemProps = computed(() => {
const { formConfig } = unref(props);
let { field, required, rules, labelCol, wrapperCol } = unref(props.schema);
const { colon } = props.formConfig;
const { itemProps } = unref(props.schema);
//<editor-fold desc="布局属性">
labelCol = labelCol
? labelCol
: formConfig.layout === 'horizontal'
? formConfig.labelLayout === 'flex'
? { style: `width:${formConfig.labelWidth}px` }
: formConfig.labelCol
: {};
wrapperCol = wrapperCol
? wrapperCol
: formConfig.layout === 'horizontal'
? formConfig.labelLayout === 'flex'
? { style: 'width:auto;flex:1' }
: formConfig.wrapperCol
: {};
const style =
formConfig.layout === 'horizontal' && formConfig.labelLayout === 'flex'
? { display: 'flex' }
: {};
/**
* 将字符串正则格式化成正则表达式
*/
const newConfig = Object.assign(
{},
{
name: field,
style: { ...style },
colon,
required,
rules,
labelCol,
wrapperCol,
},
itemProps,
);
if (!itemProps?.labelCol?.span) {
newConfig.labelCol = labelCol;
}
if (!itemProps?.wrapperCol?.span) {
newConfig.wrapperCol = wrapperCol;
}
if (!itemProps?.rules) {
newConfig.rules = rules;
}
return newConfig;
}) as Recordable;
const componentItem = computed(() => componentMap.get(props.schema.component as string));
// console.log('component change:', props.schema.component, componentItem.value);
const handleClick = (schema: IVFormComponent) => {
if (schema.component === 'Button' && schema.componentProps?.handle)
emit(schema.componentProps?.handle);
};
/**
* 处理异步属性,异步属性会导致一些属性渲染错误,如defaultValue异步加载会导致渲染不出来,故而此处只处理options,treeData,同步属性在cmpProps中处理
*/
const asyncProps = asyncComputed(async () => {
let { options, treeData } = props.schema.componentProps ?? {};
if (options) options = await handleAsyncOptions(options);
if (treeData) treeData = await handleAsyncOptions(treeData);
return {
options,
treeData,
};
});
/**
* 处理同步属性
*/
const cmpProps = computed(() => {
const isCheck =
props.schema && ['Switch', 'Checkbox', 'Radio'].includes(props.schema.component);
let { field } = props.schema;
let { disabled, ...attrs } =
omit(props.schema.componentProps, ['options', 'treeData']) ?? {};
disabled = props.formConfig.disabled || disabled;
return {
...attrs,
disabled,
[isCheck ? 'checked' : 'value']: formData1.value[field!],
};
});
const handleChange = function (e) {
const isCheck = ['Switch', 'Checkbox', 'Radio'].includes(props.schema.component);
const target = e ? e.target : null;
const value = target ? (isCheck ? target.checked : target.value) : e;
setFormModel(props.schema.field!, value);
emit('change', value);
};
return {
...toRefs(state),
componentItem,
formItemProps,
handleClick,
asyncProps,
cmpProps,
handleChange,
colPropsComputed,
};
},
});
</script>
<style lang="less" scoped>
.ml-5 {
margin-left: 5px;
}
// form字段中的标签有ant-col,不能使用width:100%
:deep(.ant-col) {
width: auto;
}
.ant-form-item:not(.ant-form-item-with-help) {
margin-bottom: 20px;
}
// .w-full {
// width: 100% !important;
// }
</style>
<!--
* @Author: ypt
* @Date: 2021/11/19
* @Description:
`<FormItem`
:tableAction="tableAction"
:formActionType="formActionType"
:schema="schema2"
:formProps="getProps"
:allDefaultValues="defaultValueRef"
:formModel="formModel"
:setFormModel="setFormModel"
>
<FormItem
:tableAction="tableAction"
:formActionType="formActionType"
:schema="schemaNew"
:formProps="getProps"
:allDefaultValues="defaultValueRef"
:formModel="formModel"
>
-->
<template>
<FormItem :schema="schemaNew" :formProps="getProps">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</FormItem>
</template>
<script lang="ts">
import { computed, defineComponent, unref } from 'vue';
import { IFormConfig, IVFormComponent } from '../../typings/v-form-component';
import { FormProps, FormSchema } from '/@/components/Form';
import FormItem from '/@/components/Form/src/components/FormItem.vue';
export default defineComponent({
name: 'VFormItem',
components: {
FormItem,
},
props: {
formData: {
type: Object,
default: () => ({}),
},
schema: {
type: Object as PropType<IVFormComponent>,
required: true,
},
formConfig: {
type: Object as PropType<IFormConfig>,
required: true,
},
},
setup(props) {
const schema = computed(() => {
const schema: FormSchema = {
...unref(props.schema),
} as FormSchema;
return schema;
});
// Get the basic configuration of the form
const getProps = computed((): FormProps => {
return { ...unref(props.formConfig) } as FormProps;
});
return {
schemaNew: schema,
getProps,
};
},
});
</script>
<style lang="less" scoped></style>
此差异已折叠。
<!--
* @Author: ypt
* @Date: 2021/11/29
* @Description: 使用vbenForm的功能进行渲染
-->
<template>
<Modal
title="预览(VbenForm)"
:visible="state.visible"
@ok="handleGetData"
@cancel="handleCancel"
okText="获取数据"
cancelText="关闭"
style="top: 20px"
:destroyOnClose="true"
:width="900"
>
<BasicForm v-bind="attrs" @register="registerForm" />
<JsonModal ref="jsonModal" />
</Modal>
</template>
<script lang="ts" setup>
import { BasicForm, useForm } from '/@/components/Form/index';
import { reactive, ref, computed } from 'vue';
import { IFormConfig } from '../../typings/v-form-component';
import { IAnyObject } from '../../typings/base-type';
import JsonModal from '../VFormDesign/components/JsonModal.vue';
import { IToolbarMethods } from '../../typings/form-type';
import { Modal } from 'ant-design-vue';
const jsonModal = ref<IToolbarMethods | null>(null);
const state = reactive<{
formModel: IAnyObject;
visible: boolean;
formConfig: IFormConfig;
}>({
formModel: {},
formConfig: {} as IFormConfig,
visible: false,
});
const attrs = computed(() => {
return {
...state.formConfig,
} as Recordable;
});
/**
* 显示Json数据弹框
* @param jsonData
*/
const showModal = (jsonData: IFormConfig) => {
state.formConfig = jsonData;
state.visible = true;
};
//表单
const [registerForm, { validate }] = useForm();
const handleCancel = () => {
state.visible = false;
};
/**
* 获取表单数据
* @return {Promise<void>}
*/
const handleGetData = async () => {
let data = await validate();
console.log(data);
jsonModal.value?.showModal?.(data);
};
defineExpose({ showModal });
</script>
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
import { inject, Ref } from 'vue';
import { IFormDesignMethods } from '../typings/form-type';
import { IFormConfig } from '../typings/v-form-component';
/**
* 获取formDesign状态
*/
export function useFormDesignState() {
const formConfig = inject('formConfig') as Ref<IFormConfig>;
const formDesignMethods = inject('formDesignMethods') as IFormDesignMethods;
return { formConfig, formDesignMethods };
}
export function useFormModelState() {
const formModel = inject('formModel') as Ref<{}>;
const setFormModel = inject('setFormModelMethod') as (key: String, value: any) => void;
return { formModel, setFormModel };
}
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册