<!-- 表单组件 -->
|
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
<template>
|
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
<ElForm
|
ref="formRef"
|
:model="modelValue"
|
:label-position="labelPosition"
|
v-bind="{ ...$attrs }"
|
>
|
<ElRow class="flex flex-wrap" :gutter="gutter">
|
<ElCol
|
v-for="item in visibleFormItems"
|
:key="item.key"
|
:xs="getColSpan(item.span, 'xs')"
|
:sm="getColSpan(item.span, 'sm')"
|
:md="getColSpan(item.span, 'md')"
|
:lg="getColSpan(item.span, 'lg')"
|
:xl="getColSpan(item.span, 'xl')"
|
>
|
<ElFormItem
|
:prop="item.key"
|
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
>
|
<template #label v-if="item.label">
|
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
<span v-else>{{ item.label }}</span>
|
</template>
|
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
<component
|
:is="getComponent(item)"
|
:model-value="getFieldValue(item.key)"
|
@update:model-value="setFieldValue(item.key, $event)"
|
v-bind="getProps(item)"
|
>
|
<!-- 下拉选择 -->
|
<template v-if="item.type === 'select' && getProps(item)?.options">
|
<ElOption
|
v-for="option in getProps(item).options"
|
v-bind="option"
|
:key="option.value"
|
/>
|
</template>
|
|
<!-- 复选框组 -->
|
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
<ElCheckbox
|
v-for="option in getProps(item).options"
|
v-bind="option"
|
:key="option.value"
|
/>
|
</template>
|
|
<!-- 单选框组 -->
|
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
<ElRadio
|
v-for="option in getProps(item).options"
|
v-bind="option"
|
:key="option.value"
|
/>
|
</template>
|
|
<!-- 动态插槽支持 -->
|
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
<component :is="slotFn" />
|
</template>
|
</component>
|
</slot>
|
</ElFormItem>
|
</ElCol>
|
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
<div
|
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
:style="actionButtonsStyle"
|
>
|
<div class="flex gap-2 md:justify-center">
|
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
{{ t('table.form.reset') }}
|
</ElButton>
|
<ElButton
|
v-if="showSubmit"
|
type="primary"
|
class="submit-button"
|
@click="handleSubmit"
|
v-ripple
|
:disabled="disabledSubmit"
|
>
|
{{ t('table.form.submit') }}
|
</ElButton>
|
</div>
|
</div>
|
</ElCol>
|
</ElRow>
|
</ElForm>
|
</section>
|
</template>
|
|
<script setup>
|
import { useWindowSize } from '@vueuse/core'
|
import { useI18n } from 'vue-i18n'
|
import { toRaw } from 'vue'
|
import {
|
ElCascader,
|
ElCheckbox,
|
ElCheckboxGroup,
|
ElDatePicker,
|
ElInput,
|
ElInputTag,
|
ElInputNumber,
|
ElRadioGroup,
|
ElRate,
|
ElSelect,
|
ElSlider,
|
ElSwitch,
|
ElTimePicker,
|
ElTimeSelect,
|
ElTreeSelect
|
} from 'element-plus'
|
import { calculateResponsiveSpan } from '@/utils/form/responsive'
|
defineOptions({ name: 'ArtForm' })
|
const componentMap = {
|
input: ElInput,
|
// 输入框
|
inputtag: ElInputTag,
|
// 标签输入框
|
number: ElInputNumber,
|
// 数字输入框
|
select: ElSelect,
|
// 选择器
|
switch: ElSwitch,
|
// 开关
|
checkbox: ElCheckbox,
|
// 复选框
|
checkboxgroup: ElCheckboxGroup,
|
// 复选框组
|
radiogroup: ElRadioGroup,
|
// 单选框组
|
date: ElDatePicker,
|
// 日期选择器
|
daterange: ElDatePicker,
|
// 日期范围选择器
|
datetime: ElDatePicker,
|
// 日期时间选择器
|
datetimerange: ElDatePicker,
|
// 日期时间范围选择器
|
rate: ElRate,
|
// 评分
|
slider: ElSlider,
|
// 滑块
|
cascader: ElCascader,
|
// 级联选择器
|
timepicker: ElTimePicker,
|
// 时间选择器
|
timeselect: ElTimeSelect,
|
// 时间选择
|
treeselect: ElTreeSelect
|
// 树选择器
|
}
|
const { width } = useWindowSize()
|
const { t } = useI18n()
|
const isMobile = computed(() => width.value < 500)
|
const formInstance = useTemplateRef('formRef')
|
const props = defineProps({
|
items: { required: false, default: () => [] },
|
span: { required: false, default: 6 },
|
gutter: { required: false, default: 12 },
|
labelPosition: { required: false, default: 'right' },
|
labelWidth: { required: false, default: '70px' },
|
buttonLeftLimit: { required: false, default: 2 },
|
showReset: { required: false, default: true },
|
showSubmit: { required: false, default: true },
|
disabledSubmit: { required: false, default: false },
|
sanitizeOutput: { required: false, default: () => ({}) }
|
})
|
const emit = defineEmits(['reset', 'submit'])
|
const modelValue = defineModel({ default: {} })
|
const initialModelValue = ref({})
|
const cloneModelValue = (value) => {
|
if (!value) return {}
|
const deepClone = (source) => {
|
if (Array.isArray(source)) {
|
return source.map((item) => deepClone(item))
|
}
|
if (source && typeof source === 'object') {
|
const rawSource = toRaw(source)
|
return Object.keys(rawSource).reduce((accumulator, key) => {
|
accumulator[key] = deepClone(rawSource[key])
|
return accumulator
|
}, {})
|
}
|
return source
|
}
|
return deepClone(toRaw(value))
|
}
|
initialModelValue.value = cloneModelValue(modelValue.value)
|
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
|
const sanitizeOutputOptions = computed(() => ({
|
removeEmptyString: true,
|
removeEmptyArray: true,
|
removeEmptyObject: true,
|
removeEmptyRichText: true,
|
keepZero: true,
|
keepFalse: true,
|
...props.sanitizeOutput
|
}))
|
const PATH_NUMBER_RE = /^\d+$/
|
const parsePath = (path) => {
|
return path
|
.split('.')
|
.filter(Boolean)
|
.map((segment) => (PATH_NUMBER_RE.test(segment) ? Number(segment) : segment))
|
}
|
const getFieldValue = (path) => {
|
return parsePath(path).reduce((currentValue, segment) => {
|
if (currentValue == null) return void 0
|
return currentValue[segment]
|
}, modelValue.value)
|
}
|
const deleteFieldValue = (path) => {
|
const segments = parsePath(path)
|
if (!segments.length) return
|
const lastSegment = segments.pop()
|
const parent = segments.reduce((currentValue, segment) => {
|
if (currentValue == null) return void 0
|
return currentValue[segment]
|
}, modelValue.value)
|
if (parent != null && lastSegment !== void 0) {
|
delete parent[lastSegment]
|
}
|
}
|
const setFieldValue = (path, value) => {
|
const normalizedValue = value === '' ? void 0 : value
|
const segments = parsePath(path)
|
if (!segments.length) return
|
if (normalizedValue === void 0) {
|
deleteFieldValue(path)
|
return
|
}
|
let currentValue = modelValue.value
|
segments.forEach((segment, index) => {
|
const isLast = index === segments.length - 1
|
if (isLast) {
|
currentValue[segment] = normalizedValue
|
return
|
}
|
const nextSegment = segments[index + 1]
|
const nextContainer = typeof nextSegment === 'number' ? [] : {}
|
if (
|
currentValue[segment] === null ||
|
currentValue[segment] === void 0 ||
|
typeof currentValue[segment] !== 'object'
|
) {
|
currentValue[segment] = nextContainer
|
}
|
currentValue = currentValue[segment]
|
})
|
}
|
const isRichTextEmpty = (value) => {
|
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
return false
|
}
|
return (
|
value
|
.replace(/ /gi, '')
|
.replace(/<br\s*\/?>/gi, '')
|
.replace(/<[^>]*>/g, '')
|
.trim() === ''
|
)
|
}
|
const sanitizeOutputValue = (value) => {
|
const options = sanitizeOutputOptions.value
|
if (Array.isArray(value)) {
|
const sanitizedArray = value
|
.map((item) => sanitizeOutputValue(item))
|
.filter((item) => item !== void 0)
|
return sanitizedArray.length === 0 && options.removeEmptyArray ? void 0 : sanitizedArray
|
}
|
if (value && typeof value === 'object') {
|
const rawValue = toRaw(value)
|
const sanitizedObject = Object.entries(rawValue).reduce((accumulator, [key, item]) => {
|
const sanitizedItem = sanitizeOutputValue(item)
|
if (sanitizedItem !== void 0) {
|
accumulator[key] = sanitizedItem
|
}
|
return accumulator
|
}, {})
|
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
? void 0
|
: sanitizedObject
|
}
|
if (typeof value === 'string') {
|
if (options.removeEmptyString && value.trim() === '') {
|
return void 0
|
}
|
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
return void 0
|
}
|
return value
|
}
|
if (value === 0) {
|
return options.keepZero ? value : void 0
|
}
|
if (value === false) {
|
return options.keepFalse ? value : void 0
|
}
|
return value ?? void 0
|
}
|
const getSanitizedOutput = () => {
|
return sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}
|
}
|
const getProps = (item) => {
|
if (item.props) return item.props
|
const props2 = { ...item }
|
rootProps.forEach((key) => delete props2[key])
|
return props2
|
}
|
const getSlots = (item) => {
|
if (!item.slots) return {}
|
const validSlots = {}
|
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
if (slotFn) {
|
validSlots[key] = slotFn
|
}
|
})
|
return validSlots
|
}
|
const getComponent = (item) => {
|
if (item.render) {
|
return item.render
|
}
|
const { type } = item
|
return componentMap[type] || componentMap['input']
|
}
|
const getColSpan = (itemSpan, breakpoint) => {
|
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
}
|
const visibleFormItems = computed(() => {
|
return props.items.filter((item) => !item.hidden)
|
})
|
const actionButtonsStyle = computed(() => ({
|
'justify-content': isMobile.value
|
? 'flex-end'
|
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
? 'flex-start'
|
: 'flex-end'
|
}))
|
const handleReset = () => {
|
formInstance.value?.resetFields()
|
Object.keys(modelValue.value).forEach((key) => {
|
delete modelValue.value[key]
|
})
|
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value))
|
emit('reset')
|
}
|
const handleSubmit = () => {
|
emit('submit', getSanitizedOutput())
|
}
|
defineExpose({
|
ref: formInstance,
|
validate: (...args) => formInstance.value?.validate(...args),
|
reset: handleReset,
|
// 允许外部在不触发提交事件时主动获取清洗后的输出。
|
getOutput: getSanitizedOutput
|
})
|
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
</script>
|