Appearance
一个强大的表单组件应该具备哪些功能
功能
- 可配置型表单,通过json对象的方式自动生成表单
- 具备更完善的功能,表单验证,动态删减表单,集成第三方的插件...
- 用法简单,扩展性强,可维护性强
- 能够用在更多的场景,比如弹框嵌套表单
准备工作
- 分析
element-plus表单能够在哪些方面做优化 - 完善我们封装表单的类型,支持ts
- 封装的表单要具备
element-plus原表单的所有功能 - 集成第三方的插件: markdown编辑器,富文本编辑器...
表单组件
包括各种element-plus组件、单独抽取上传文件组件和集成第三方插件,富文本编辑器。
还实现了弹窗里面嵌套表单。
form/src/index.vue
html
<template>
<el-form
ref="form"
v-if="model"
:validate-on-rule-change="false"
:model="model"
:rules="rules"
v-bind="$attrs"
>
<template v-for="(item, index) in options" :key="index">
<el-form-item
v-if="!item.children || !item.children!.length"
:prop="item.prop"
:label="item.label"
>
<component
v-if="item.type !== 'upload' && item.type !== 'editor'"
:placeholder="item.placeholder"
v-bind="item.attrs"
:is="`el-${item.type}`"
v-model="model[item.prop!]"
></component>
<el-upload
v-if="item.type === 'upload'"
v-bind="item.uploadAttrs"
:on-preview="onPreview"
:on-remove="onRemove"
:on-success="onSuccess"
:on-error="onError"
:on-progress="onProgress"
:on-change="onChange"
:before-upload="beforeUpload"
:before-remove="beforeRemove"
:http-request="httpRequest"
:on-exceed="onExceed"
>
<slot name="uploadArea"></slot>
<slot name="uploadTip"></slot>
</el-upload>
<div id="editor" v-if="item.type === 'editor'"></div>
</el-form-item>
<el-form-item
v-if="item.children && item.children.length"
:prop="item.prop"
:label="item.label"
>
<component
:placeholder="item.placeholder"
v-bind="item.attrs"
:is="`el-${item.type}`"
v-model="model[item.prop!]"
>
<component
v-for="(child, i) in item.children"
:key="i"
:is="`el-${child.type}`"
:label="child.label"
:value="child.value"
></component>
</component>
</el-form-item>
</template>
<el-form-item>
<slot name="action" :form="form" :model="model"></slot>
</el-form-item>
</el-form>
</template>
<script lang='ts' setup>
import { PropType, ref, onMounted, watch, nextTick } from 'vue'
import { FormInstance, FormOptions } from './types/types'
import cloneDeep from 'lodash/cloneDeep'
import E from "wangeditor"
let emits = defineEmits(['on-preview', 'on-remove', 'on-success', 'on-error', 'on-progress', 'on-change', 'before-upload', 'before-remove', 'on-exceed'])
let props = defineProps({
// 表单的配置项
options: {
type: Array as PropType<FormOptions[]>,
required: true
},
// 用户自定义上传方法
httpRequest: {
type: Function
}
})
let model = ref<any>(null)
let rules = ref<any>(null)
let form = ref<FormInstance | null>()
let edit = ref()
// 初始化表单
let initForm = () => {
if (props.options && props.options.length) {
let m: any = {}
let r: any = {}
props.options.map((item: FormOptions) => {
m[item.prop!] = item.value
r[item.prop!] = item.rules
if (item.type === 'editor') {
// 初始化富文本
nextTick(() => {
if (document.getElementById('editor')) {
const editor = new E('#editor')
editor.config.placeholder = item.placeholder!
editor.create()
// 初始化富文本的内容
editor.txt.html(item.value)
editor.config.onchange = (newHtml: string) => {
model.value[item.prop!] = newHtml
}
edit.value = editor
}
})
}
})
model.value = cloneDeep(m)
rules.value = cloneDeep(r)
}
}
// 重置表单
let resetFields = () => {
// 重置element-plus的表单
form.value!.resetFields()
// 重置富文本编辑器的内容
// 获取到富文本的配置项
if (props.options && props.options.length) {
let editorItem = props.options.find(item => item.type === 'editor')!
edit.value.txt.html(editorItem.value)
}
}
// 表单验证方法
let validate = () => {
return form.value!.validate
}
// 获取表单数据
let getFormData = () => {
return model.value
}
// 分发方法
defineExpose({
resetFields,
validate,
getFormData
})
onMounted(() => {
initForm()
})
// 监听父组件传递进来的options
watch(() => props.options, () => {
initForm()
}, { deep: true })
// 上传组件的所有方法
let onPreview = (file: File) => {
emits('on-preview', file)
}
let onRemove = (file: File, fileList: FileList) => {
emits('on-remove', { file, fileList })
}
let onSuccess = (response: any, file: File, fileList: FileList) => {
// 上传图片成功 给表单上传项赋值
let uploadItem = props.options.find(item => item.type === 'upload')!
model.value[uploadItem.prop!] = { response, file, fileList }
emits('on-success', { response, file, fileList })
}
let onError = (err: any, file: File, fileList: FileList) => {
emits('on-error', { err, file, fileList, })
}
let onProgress = (event: any, file: File, fileList: FileList) => {
emits('on-progress', { event, file, fileList })
}
let onChange = (file: File, fileList: FileList) => {
emits('on-change', { file, fileList })
}
let beforeUpload = (file: File) => {
emits('before-upload', file)
}
let beforeRemove = (file: File, fileList: FileList) => {
emits('before-remove', { file, fileList })
}
let onExceed = (files: File, fileList: FileList) => {
emits('on-exceed', { files, fileList })
}
</script>
<style lang='scss' scoped>
</style>form/src/types/rule.ts
typescript
export type RuleType =
| 'string'
| 'number'
| 'boolean'
| 'method'
| 'regexp'
| 'integer'
| 'float'
| 'array'
| 'object'
| 'enum'
| 'date'
| 'url'
| 'hex'
| 'email'
| 'pattern'
| 'any';
export interface ValidateOption {
// whether to suppress internal warning
suppressWarning?: boolean;
// when the first validation rule generates an error stop processed
first?: boolean;
// when the first validation rule of the specified field generates an error stop the field processed, 'true' means all fields.
firstFields?: boolean | string[];
messages?: Partial<ValidateMessages>;
/** The name of rules need to be trigger. Will validate all rules if leave empty */
keys?: string[];
error?: (rule: InternalRuleItem, message: string) => ValidateError;
}
export type SyncErrorType = Error | string;
export type SyncValidateResult = boolean | SyncErrorType | SyncErrorType[];
export type ValidateResult = void | Promise<void> | SyncValidateResult;
export interface RuleItem {
type?: RuleType; // default type is 'string'
required?: boolean;
pattern?: RegExp | string;
min?: number; // Range of type 'string' and 'array'
max?: number; // Range of type 'string' and 'array'
len?: number; // Length of type 'string' and 'array'
enum?: Array<string | number | boolean | null | undefined>; // possible values of type 'enum'
whitespace?: boolean;
trigger?: string | string[];
fields?: Record<string, Rule>; // ignore when without required
options?: ValidateOption;
defaultField?: Rule; // 'object' or 'array' containing validation rules
transform?: (value: Value) => Value;
message?: string | ((a?: string) => string);
asyncValidator?: (
rule: InternalRuleItem,
value: Value,
callback: (error?: string | Error) => void,
source: Values,
options: ValidateOption,
) => void | Promise<void>;
validator?: (
rule: InternalRuleItem,
value: Value,
callback: (error?: string | Error) => void,
source: Values,
options: ValidateOption,
) => SyncValidateResult | void;
}
export type Rule = RuleItem | RuleItem[];
export type Rules = Record<string, Rule>;
/**
* Rule for validating a value exists in an enumerable list.
*
* @param rule The validation rule.
* @param value The value of the field on the source object.
* @param source The source object being validated.
* @param errors An array of errors that this rule may add
* validation errors to.
* @param options The validation options.
* @param options.messages The validation messages.
* @param type Rule type
*/
export type ExecuteRule = (
rule: InternalRuleItem,
value: Value,
source: Values,
errors: string[],
options: ValidateOption,
type?: string,
) => void;
/**
* Performs validation for any type.
*
* @param rule The validation rule.
* @param value The value of the field on the source object.
* @param callback The callback function.
* @param source The source object being validated.
* @param options The validation options.
* @param options.messages The validation messages.
*/
export type ExecuteValidator = (
rule: InternalRuleItem,
value: Value,
callback: (error?: string[]) => void,
source: Values,
options: ValidateOption,
) => void;
// >>>>> Message
type ValidateMessage<T extends any[] = unknown[]> =
| string
| ((...args: T) => string);
type FullField = string | undefined;
type EnumString = string | undefined;
type Pattern = string | RegExp | undefined;
type Range = number | undefined;
type Type = string | undefined;
export interface ValidateMessages {
default?: ValidateMessage;
required?: ValidateMessage<[FullField]>;
enum?: ValidateMessage<[FullField, EnumString]>;
whitespace?: ValidateMessage<[FullField]>;
date?: {
format?: ValidateMessage;
parse?: ValidateMessage;
invalid?: ValidateMessage;
};
types?: {
string?: ValidateMessage<[FullField, Type]>;
method?: ValidateMessage<[FullField, Type]>;
array?: ValidateMessage<[FullField, Type]>;
object?: ValidateMessage<[FullField, Type]>;
number?: ValidateMessage<[FullField, Type]>;
date?: ValidateMessage<[FullField, Type]>;
boolean?: ValidateMessage<[FullField, Type]>;
integer?: ValidateMessage<[FullField, Type]>;
float?: ValidateMessage<[FullField, Type]>;
regexp?: ValidateMessage<[FullField, Type]>;
email?: ValidateMessage<[FullField, Type]>;
url?: ValidateMessage<[FullField, Type]>;
hex?: ValidateMessage<[FullField, Type]>;
};
string?: {
len?: ValidateMessage<[FullField, Range]>;
min?: ValidateMessage<[FullField, Range]>;
max?: ValidateMessage<[FullField, Range]>;
range?: ValidateMessage<[FullField, Range, Range]>;
};
number?: {
len?: ValidateMessage<[FullField, Range]>;
min?: ValidateMessage<[FullField, Range]>;
max?: ValidateMessage<[FullField, Range]>;
range?: ValidateMessage<[FullField, Range, Range]>;
};
array?: {
len?: ValidateMessage<[FullField, Range]>;
min?: ValidateMessage<[FullField, Range]>;
max?: ValidateMessage<[FullField, Range]>;
range?: ValidateMessage<[FullField, Range, Range]>;
};
pattern?: {
mismatch?: ValidateMessage<[FullField, Value, Pattern]>;
};
}
export interface InternalValidateMessages extends ValidateMessages {
clone: () => InternalValidateMessages;
}
// >>>>> Values
export type Value = any;
export type Values = Record<string, Value>;
// >>>>> Validate
export interface ValidateError {
message?: string;
fieldValue?: Value;
field?: string;
}
export type ValidateFieldsError = Record<string, ValidateError[]>;
export type ValidateCallback = (
errors: ValidateError[] | null,
fields: ValidateFieldsError | Values,
) => void;
export interface RuleValuePackage {
rule: InternalRuleItem;
value: Value;
source: Values;
field: string;
}
export interface InternalRuleItem extends Omit<RuleItem, 'validator'> {
field?: string;
fullField?: string;
fullFields?: string[];
validator?: RuleItem['validator'] | ExecuteValidator;
}form/src/types/types.ts
typescript
// 可配置的表单
import { CSSProperties } from 'vue'
import { RuleItem } from "./rule"
import { ValidateFieldsError } from 'async-validator'
interface Callback {
(isValid?: boolean, invalidFields?: ValidateFieldsError): void,
}
// 表单每一项的配置选项
export interface FormOptions {
// 表单项显示的元素
type: 'cascader' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'color-picker' |
'date-picker' | 'input' | 'input-number' | 'radio' | 'radio-group' | 'radio-button' | 'rate' |
'select' | 'option' | 'slider' | 'switch' | 'time-picker' | 'time-select' |
'transfer' | 'upload' | 'editor',
// 表单项的值
value?: any,
// 表单项label
label?: string,
// 表单项的标识
prop?: string,
// 表单项的验证规则
rules?: RuleItem[],
// 表单项的占位符
placeholder?: string,
// 表单元素特有的属性
attrs?: {
// css样式
style?: CSSProperties,
clearable?: boolean,
showPassword?: boolean,
disabled?: boolean,
},
// 表单项的子元素
children?: FormOptions[],
// 处理上传组件的属性和方法
uploadAttrs?: {
action: string,
headers?: object,
method?: 'post' | 'put' | 'patch',
multiple?: boolean,
data?: any,
name?: string,
withCredentials?: boolean,
showFileList?: boolean,
drag?: boolean,
accept?: string,
thumbnailMode?: boolean,
fileList?: any[],
listType?: 'text' | 'picture' | 'picture-card',
autoUpload?: boolean,
disabled?: boolean,
limit?: number,
}
}
export interface ValidateFieldCallback {
(message?: string, invalidFields?: ValidateFieldsError): void,
}
export interface FormInstance {
registerLabelWidth(width: number, oldWidth: number): void,
deregisterLabelWidth(width: number): void,
autoLabelWidth: string | undefined,
emit: (evt: string, ...args: any[]) => void,
labelSuffix: string,
inline?: boolean,
model?: Record<string, unknown>,
size?: string,
showMessage?: boolean,
labelPosition?: string,
labelWidth?: string,
rules?: Record<string, unknown>,
statusIcon?: boolean,
hideRequiredAsterisk?: boolean,
disabled?: boolean,
validate: (callback?: Callback) => Promise<boolean>,
resetFields: () => void,
clearValidate: (props?: string | string[]) => void,
validateField: (props: string | string[], cb: ValidateFieldCallback) => void,
}views/form/index.vue
html
<template>
<div>
<m-form
ref="form"
label-width="100px"
:options="options"
@on-change="handleChange"
@before-upload="handleBeforeUpload"
@on-preview="handlePreview"
@on-remove="handleRemove"
@before-remove="beforeRemove"
@on-success="handleSuccess"
@on-exceed="handleExceed"
>
<template #uploadArea>
<el-button size="small" type="primary">Click to upload</el-button>
</template>
<template #uploadTip>
<div style="color: #ccc;font-size: 12px;">jpg/png files with a size less than 500kb</div>
</template>
<template #action="scope">
<el-button type="primary" @click="submitForm(scope)">提交</el-button>
<el-button @click="resetForm">重置</el-button>
</template>
</m-form>
</div>
</template>
<script lang='ts' setup>
import { FormInstance, FormOptions } from '../../components/form/src/types/types';
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
interface Scope {
form: FormInstance,
model: any
}
let options: FormOptions[] = [
{
type: 'input',
value: '',
label: '用户名',
prop: 'username',
placeholder: '请输入用户名',
rules: [
{
required: true,
message: '用户名不能为空',
trigger: 'blur'
},
{
min: 2,
max: 6,
message: '用户名在2-6位之间',
trigger: 'blur'
}
],
attrs: {
clearable: true
}
},
{
type: 'input',
value: '',
label: '密码',
prop: 'password',
placeholder: '请输入密码',
rules: [
{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 15,
message: '密码在6-15位之间',
trigger: 'blur'
}
],
attrs: {
showPassword: true,
clearable: true
}
},
{
type: 'select',
value: '',
placeholder: '请选择职位',
prop: 'role',
label: '职位',
attrs: {
style: {
width: '100%'
},
},
rules: [
{
required: true,
message: '职位不能为空',
trigger: 'change'
}
],
children: [
{
type: 'option',
label: '经理',
value: '1'
},
{
type: 'option',
label: '主管',
value: '2'
},
{
type: 'option',
label: '员工',
value: '3'
}
]
},
{
type: 'checkbox-group',
value: [],
prop: 'like',
label: '爱好',
rules: [
{
required: true,
message: '爱好不能为空',
trigger: 'change'
}
],
children: [
{
type: 'checkbox',
label: '足球',
value: '1'
},
{
type: 'checkbox',
label: '篮球',
value: '2'
},
{
type: 'checkbox',
label: '排球',
value: '3'
}
]
},
{
type: 'radio-group',
value: '',
prop: 'gender',
label: '性别',
rules: [
{
required: true,
message: '性别不能为空',
trigger: 'change'
}
],
children: [
{
type: 'radio',
label: '男',
value: 'male'
},
{
type: 'radio',
label: '女',
value: 'female'
},
{
type: 'radio',
label: '保密',
value: 'not'
}
]
},
{
type: 'upload',
label: '上传',
prop: 'pic',
uploadAttrs: {
action: 'https://jsonplaceholder.typicode.com/posts/',
multiple: true,
limit: 3
},
rules: [
{
required: true,
message: '图片不能为空',
trigger: 'blur'
}
],
},
{
type: 'editor',
value: '123',
prop: 'desc',
label: '描述',
placeholder: '请输入描述',
rules: [
{
required: true,
message: '描述不能为空',
trigger: 'blur'
}
]
}
]
let form = ref()
let submitForm = (scope: Scope) => {
scope.form.validate((valid) => {
if (valid) {
console.log(scope.model)
ElMessage.success('提交成功')
} else {
ElMessage.error('表单填写有误,请检查')
}
})
}
// 重置表单
let resetForm = () => {
form.value.resetFields()
}
let handleRemove = (file: any, fileList: any) => {
console.log('handleRemove')
console.log(file, fileList)
}
let handlePreview = (file: any) => {
console.log('handlePreview')
console.log(file)
}
let beforeRemove = (val: any) => {
console.log('beforeRemove')
return ElMessageBox.confirm(`Cancel the transfert of ${val.file.name} ?`)
}
let handleExceed = (val: any) => {
console.log('handleExceed', val)
ElMessage.warning(
`The limit is 3, you selected ${val.files.length
} files this time, add up to ${val.files.length + val.fileList.length} totally`
)
}
let handleSuccess = (val: any) => {
console.log('success')
console.log(val)
}
let handleChange = (val: any) => {
console.log('change')
console.log(val)
}
let handleBeforeUpload = (val: any) => {
console.log('handleBeforeUpload')
console.log(val)
}
</script>
<style lang='scss' scoped>
</style>弹窗表单
modalForm/src/index.vue
html
<template>
<div :class="{ 'm-choose-icon-dialog-body-height': isScroll }">
<el-dialog v-model="dialogVisible" v-bind="$attrs">
<template #default>
<m-form
ref="form"
:options="options"
label-width="100px"
@on-change="onChange"
@before-upload="beforeUpload"
@on-preview="onPreview"
@on-remove="onRemove"
@before-remove="beforeRemove"
@on-success="onSuccess"
@on-exceed="onExceed"
>
<template #uploadArea>
<slot name="uploadArea"></slot>
</template>
<template #uploadTip>
<slot name="uploadTip"></slot>
</template>
</m-form>
</template>
<template #footer>
<slot name="footer" :form="form"></slot>
</template>
</el-dialog>
</div>
</template>
<script lang='ts' setup>
import { PropType, ref, watch } from 'vue'
import { FormOptions } from '../../form/src/types/types'
let props = defineProps({
// 是否只在可视区域内滚动
isScroll: {
type: Boolean,
default: false
},
visible: {
type: Boolean,
default: false
},
options: {
type: Array as PropType<FormOptions[]>,
required: true
},
onChange: {
type: Function
},
beforeUpload: {
type: Function
},
onPreview: {
type: Function
},
onRemove: {
type: Function
},
beforeRemove: {
type: Function
},
onSuccess: {
type: Function
},
onExceed: {
type: Function
},
})
let emits = defineEmits(['update:visible'])
// 表单实例
let form = ref()
// 弹出框的显示与隐藏
let dialogVisible = ref<boolean>(props.visible)
watch(() => props.visible, val => {
dialogVisible.value = val
})
watch(() => dialogVisible.value, val => {
emits('update:visible', val)
})
</script>
<style lang='scss' scoped>
</style>views/modalForm/index.vue
html
<template>
<div>
<el-button type="primary" @click="open">open</el-button>
<m-modal-form
isScroll
:options="options"
title="编辑用户"
width="50%"
v-model:visible="visible"
:on-change="handleChange"
:on-success="handleSuccess"
>
<template #footer="scope">
<el-button @click="cancel(scope.form)">取消</el-button>
<el-button type="primary" @click="confirm(scope.form)">确认</el-button>
</template>
<template #uploadArea>
<el-button size="small" type="primary">Click to upload</el-button>
</template>
<template #uploadTip>
<div style="color: #ccc;font-size: 12px;">jpg/png files with a size less than 500kb</div>
</template>
</m-modal-form>
</div>
</template>
<script lang='ts' setup>
import { ref } from 'vue'
import { FormOptions } from '../../components/form/src/types/types'
import { ElMessage } from 'element-plus'
let visible = ref<boolean>(false)
let open = () => {
visible.value = true
}
let options: FormOptions[] = [
{
type: 'input',
value: '',
label: '用户名',
prop: 'username',
placeholder: '请输入用户名',
rules: [
{
required: true,
message: '用户名不能为空',
trigger: 'blur'
},
{
min: 2,
max: 6,
message: '用户名在2-6位之间',
trigger: 'blur'
}
],
attrs: {
clearable: true
}
},
{
type: 'input',
value: '',
label: '密码',
prop: 'password',
placeholder: '请输入密码',
rules: [
{
required: true,
message: '密码不能为空',
trigger: 'blur'
},
{
min: 6,
max: 15,
message: '密码在6-15位之间',
trigger: 'blur'
}
],
attrs: {
showPassword: true,
clearable: true
}
},
{
type: 'select',
value: '',
placeholder: '请选择职位',
prop: 'role',
label: '职位',
attrs: {
style: {
width: '100%'
},
},
rules: [
{
required: true,
message: '职位不能为空',
trigger: 'change'
}
],
children: [
{
type: 'option',
label: '经理',
value: '1'
},
{
type: 'option',
label: '主管',
value: '2'
},
{
type: 'option',
label: '员工',
value: '3'
}
]
},
{
type: 'checkbox-group',
value: [],
prop: 'like',
label: '爱好',
rules: [
{
required: true,
message: '爱好不能为空',
trigger: 'change'
}
],
children: [
{
type: 'checkbox',
label: '足球',
value: '1'
},
{
type: 'checkbox',
label: '篮球',
value: '2'
},
{
type: 'checkbox',
label: '排球',
value: '3'
}
]
},
{
type: 'radio-group',
value: '',
prop: 'gender',
label: '性别',
rules: [
{
required: true,
message: '性别不能为空',
trigger: 'change'
}
],
children: [
{
type: 'radio',
label: '男',
value: 'male'
},
{
type: 'radio',
label: '女',
value: 'female'
},
{
type: 'radio',
label: '保密',
value: 'not'
}
]
},
{
type: 'upload',
label: '上传',
prop: 'pic',
uploadAttrs: {
action: 'https://jsonplaceholder.typicode.com/posts/',
multiple: true,
limit: 3
},
rules: [
{
required: true,
message: '图片不能为空',
trigger: 'blur'
}
],
},
{
type: 'editor',
value: '',
prop: 'desc',
label: '描述',
placeholder: '请输入描述',
rules: [
{
required: true,
message: '描述不能为空',
trigger: 'blur'
}
]
}
]
let confirm = (form: any) => {
let validate = form.validate()
validate((valid: boolean) => {
if (valid) {
console.log(form.getFormData)
ElMessage.success('验证成功')
} else {
ElMessage.error('表单填写有误,请检查')
}
})
}
let cancel = (form: any) => {
}
let handleSuccess = (val: any) => {
console.log('success')
console.log(val)
}
let handleChange = (val: any) => {
console.log('change')
console.log(val)
}
</script>
<style lang='scss' scoped>
</style>