jsonSchema动态渲染表单

100 阅读3分钟

需求背景

  • web下发任务需要配置算法,不同算法需要配置的算法参数不同,算法很多

  • 不可能来一个算法写一个配置,这样做代码冗余,维护艰难

  • 希望重新设计一套方案,前端只用写一次,后面新增的算法都可以适配,就不需要再去做配置或者个性化开发

jsonSchema

项目中使用

  • java返回一个jsonSchema,web根据这个jsonSchema渲染表单,最后将用户填写的数据按照jsonSchema对应层级结构传给java

  • 社区有成熟的jsonSchema渲染表单的插件,但是没有采用,因为项目中使用的jsonSchema并不完全遵守标准的jsonSchema规范,有些自定义的类型(比如时间,type:'dateTime')

  • 找了个插件(插件在这)简单看了下,仿照写了个组件,满足项目使用

代码实现

  • schemaForm.vue
// schemaForm.vue
// schemaForm.vue
// schemaForm.vue
<template>
    <div class="container">
        <el-form
            ref="schemaForm"
            :model="model"
            :rules="rules"
            class="addForm"
            inline
            label-suffix=":"
            label-position="right"
            size="small"
        >
            <SchemaFormItems :model="model" :schema="schema" :options="options"></SchemaFormItems>
        </el-form>
    </div>
</template>

<script>
import SchemaFormItems from './schemaFormItems.vue'
export default {
    components: {
        SchemaFormItems,
    },

    data() {
        return {
            jsonSchema: {
                $schema: 'http://json-schema.org/draft-07/schema#',
                $id: 'http://json-schema.org/draft-07/schema#',
                title: 'EngineHelmetSchema',
                type: 'object',
                properties: {
                    sourceType: {
                        title: '视频数据源类型',
                        type: 'integer',
                        default: 0,
                        enum: [0, 1],
                        description: '数据源类型0或1',
                    },
                    isRepeat: {
                        title: '是否可重复',
                        type: 'boolean',
                        default: true,
                        description: '是否可重复',
                    },
                    algoTasks: {
                        type: 'array',
                        items: {
                            title: '算法任务列表',
                            type: 'object',
                            properties: {
                                algoTask: {
                                    title: '任务参数',
                                    type: 'integer',
                                    default: 9003,
                                    enum: [5002, 9003, 1004, 2005],
                                    description: '任务参数',
                                },
                                algoConfig: {
                                    description: '算法配置',
                                    type: 'object',
                                    default: {},
                                    properties: {
                                        isSaveCrop: {
                                            title: '是否指定保存副本',
                                            type: 'boolean',
                                            default: false,
                                            description: '是否指定保存副本',
                                        },
                                        isSavePanorama: {
                                            title: '是否保存全景图',
                                            type: 'boolean',
                                            default: true,
                                            description: '是否保存全景图',
                                        },
                                        pushUrl: {
                                            title: '三方推送地址',
                                            type: 'string',
                                            default: '',
                                            description: '三方推送地址',
                                            pattern: '^(https?|ftp)://([a-zA-Z0-9.-]+).([a-zA-Z]{2,6})([/w.-]*)*/?$',
                                        },
                                        startTime: {
                                            title: '开始时间',
                                            type: 'datetime',
                                            default: '2024-01-01 08:00:00',
                                            description: '开始时间',
                                            format: 'YYYY-MM-DD HH:MM:SS',
                                        },
                                        endTime: {
                                            title: '结束时间',
                                            type: 'datetime',
                                            default: '2025-01-01 08:00:00',
                                            description: '结束时间',
                                            format: 'YYYY-MM-DD HH:MM:SS',
                                        },
                                        minTargetWidth: {
                                            title: '最大目标宽度',
                                            type: 'integer',
                                            default: 10,
                                            description: '最大目标宽度',
                                            minimum: 5,
                                            maximum: 50,
                                        },
                                        threshold: {
                                            title: '算法阈值',
                                            type: 'number',
                                            default: 0.8,
                                            description: '算法阈值',
                                            minimum: 0,
                                            maximum: 1,
                                            exclusiveMinimum: true,
                                            exclusiveMaximum: true,
                                        },
                                    },
                                },
                                default: [],
                                description: '算法配置',
                                required: [
                                    'isSaveCrop',
                                    'pushUrl',
                                    'startTime',
                                    'endTime',
                                    'minTargetWidth',
                                    'threshold',
                                ],
                            },
                            required: ['algoTask', 'algoConfig'],
                            description: '算法任务列表',
                        },
                    },
                    required: ['sourceType', 'algoTasks'],
                },
            },
            model: {
                name: '',
                region: '',
                delivery: false,
                checkList: [],
                resource: '',
                desc: '',
                date: '',
            },
            schema: [
                { type: 'input', prop: 'name', formItem: { label: '活动名称' } },
                { type: 'select', prop: 'region', formItem: { label: '活动区域' } },
                { type: 'switch', prop: 'delivery', formItem: { label: '即时配送' } },
                { type: 'checkbox', prop: 'checkList', formItem: { label: '活动性质' } },
                { type: 'radio', prop: 'resource', formItem: { label: '特殊资源' } },
                { type: 'input', prop: 'desc', formItem: { label: '活动形式' }, attrs: { type: 'textarea' } },
                { type: 'date', prop: 'date', formItem: { label: '活动时间' } },
            ],
            options: {
                region: [
                    { label: '区域一', value: 'shanghai' },
                    { label: '区域二', value: 'beijing' },
                ],
                checkList: [
                    { label: '美食/餐厅线上活动', value: '美食/餐厅线上活动' },
                    { label: '地推活动', value: '地推活动' },
                ],
                resource: [
                    { label: '线上品牌商赞助', value: '线上品牌商赞助' },
                    { label: '线下场地免费', value: '线下场地免费' },
                ],
            },
            rules: {
                name: [
                    { required: true, message: '请输入活动名称', trigger: 'blur' },
                    { min: 3, max: 5, message: '长度在 3 到 5 个字符', trigger: 'blur' },
                ],
                region: [{ required: true, message: '请选择活动区域', trigger: 'change' }],
                checkList: [{ type: 'array', required: true, message: '请至少选择一个活动性质', trigger: 'change' }],
                resource: [{ required: true, message: '请选择活动资源', trigger: 'change' }],
                desc: [{ required: true, message: '请填写活动形式', trigger: 'blur' }],
                date: [{ type: 'date', required: true, message: '请选择日期', trigger: 'change' }],
            },
        }
    },
    mounted() {
        this.initSchema()
    },

    methods: {
        getSaveData() {
            const handleData = this.getStructureData(this.jsonSchema, {})
            console.log(handleData)
        },

        /*** form ***/

        initSchema() {
            const schemaData = this.getSchemaData(this.jsonSchema, [])
            const modelData = this.getModelData(schemaData, {})
            const requiredData = this.getRequired(this.jsonSchema, [])
            const renderData = schemaData.filter((v) => requiredData.includes(v.prop))
            const options = this.getOptionsData(renderData, {})
            const ruleData = this.getRuleData(renderData, {})

            this.schema = renderData
            this.model = modelData
            this.options = options
            this.rules = ruleData
        },
        getSchemaData(jsonSchema, schema) {
            if (!jsonSchema.properties) return []
            for (const key in jsonSchema.properties) {
                const item = jsonSchema.properties[key]

                const type = item?.type ?? ''
                if (!type) continue

                if (!['array', 'object'].includes(item.type)) {
                    const obj = this.getSchemaItemByType(item.type, key, item)
                    schema.push(obj)
                } else if (item.type === 'array' && item.items) {
                    this.getSchemaData(item.items, schema)
                } else if (item.type === 'object') {
                    this.getSchemaData(item, schema)
                }
            }
            return schema
        },
        getSchemaItemByType(type, key, item) {
            const baseSchema = {
                type: 'input',
                prop: key,
                formItem: { label: item.title, default: item.default, description: item.description },
            }
            switch (type) {
                case 'boolean':
                    baseSchema.type = 'switch'
                    break
                case 'integer':
                    if (item.enum) {
                        baseSchema.type = 'select'
                        baseSchema.formItem = {
                            ...baseSchema.formItem,
                            enum: item.enum,
                        }
                    } else {
                        baseSchema.type = 'number-int'
                        baseSchema.formItem = {
                            ...baseSchema.formItem,
                            minimum: item?.minimum ?? undefined,
                            maximum: item?.maximum ?? undefined,
                            exclusiveMinimum: item?.exclusiveMinimum ?? false,
                            exclusiveMaximum: item?.exclusiveMaximum ?? false,
                        }
                    }
                    break
                case 'number':
                    if (item.enum) {
                        baseSchema.type = 'select'
                        baseSchema.formItem = {
                            ...baseSchema.formItem,
                            enum: item.enum,
                        }
                    } else {
                        baseSchema.type = 'number-float'
                        baseSchema.formItem = {
                            ...baseSchema.formItem,
                            precision: 4,
                            minimum: item?.minimum ?? undefined,
                            maximum: item?.maximum ?? undefined,
                            exclusiveMinimum: item?.exclusiveMinimum ?? false,
                            exclusiveMaximum: item?.exclusiveMaximum ?? false,
                        }
                    }
                    break
                case 'string':
                    baseSchema.formItem = {
                        ...baseSchema.formItem,
                        pattern: item?.pattern ?? '',
                    }
                    break
                case 'datetime':
                    baseSchema.type = 'datetime'
                    baseSchema.formItem = {
                        ...baseSchema.formItem,
                        format: item?.format ?? 'YYYY-MM-DD HH:MM:SS',
                    }
                    break
            }
            return baseSchema
        },
        getModelData(schemaData, model) {
            if (!schemaData.length) return {}
            schemaData.forEach((item) => {
                const { type, prop, formItem } = item
                if (type === 'input') {
                    model[prop] = formItem.default ?? ''
                } else if (type === 'select') {
                    model[prop] = formItem.default ?? ''
                } else if (type === 'radio') {
                    model[prop] = formItem.default ?? ''
                } else if (type === 'checkbox') {
                    model[prop] = formItem.default ?? []
                } else if (type === 'switch') {
                    model[prop] = formItem.default ?? false
                } else if (type === 'datetime') {
                    model[prop] = formItem.default ?? ''
                } else if (type === 'number-int') {
                    model[prop] = formItem.default ?? ''
                } else if (type === 'number-float') {
                    model[prop] = formItem.default ?? ''
                }
            })
            return model
        },
        getRequired(jsonSchema, req) {
            for (const key in jsonSchema) {
                if (typeof jsonSchema[key] != 'object') continue

                if (key == 'required') {
                    req.push(...jsonSchema[key])
                } else {
                    this.getRequired(jsonSchema[key], req)
                }
            }
            return req
        },
        getOptionsData(renderData, option) {
            renderData.forEach((v) => {
                if (v.type == 'select') {
                    option[v.prop] = v.formItem.enum.map((val) => {
                        return {
                            label: val,
                            value: val,
                        }
                    })
                }
            })
            return option
        },
        getRuleData(renderData, ruleData) {
            renderData.forEach((v) => {
                const baseRule = { required: true, message: this.$t('length.required'), trigger: 'blur' }
                if (v.type == 'input') {
                    delete baseRule.message
                    baseRule.pattern = v.formItem.pattern
                    baseRule.validator = (rule, value, callback) => {
                        if (!value) callback(new Error(this.$t('length.required')))

                        if (!rule.pattern) callback()

                        const reg = new RegExp(rule.pattern)
                        if (!reg.test(value)) callback(new Error('正则验证不通过'))

                        callback()
                    }
                }
                ruleData[v.prop] = [baseRule]
            })
            return ruleData
        },
        getStructureData(jsonSchema, structure) {
            if (!jsonSchema.properties) return {}
            for (const key in jsonSchema.properties) {
                const item = jsonSchema.properties[key]

                const type = item?.type ?? ''
                if (!type) continue

                if (!['array', 'object'].includes(item.type)) {
                    structure[key] = this.model[key]
                } else if (item.type == 'array') {
                    const curArr = []
                    curArr.push(this.getStructureData(item.items, {}))
                    structure[key] = curArr
                } else if (item.type == 'object') {
                    const curObj = {}
                    this.getStructureData(item, curObj)
                    structure[key] = curObj
                }
            }
            return structure
        },
    },
}
</script>

<style lang="scss" scoped>
.container {
    border: 1px solid #f00;
    ::v-deep .el-input-number--small {
        line-height: 2.8;
    }
}
</style>

  • schemaFormItems.vue
// SchemaFormItems.vue
// SchemaFormItems.vue
// SchemaFormItems.vue
<template>
    <div class="schema-form-items">
        <section v-for="(item, index) of schema" :key="index">
            <!-- text -->
            <el-form-item v-if="typeIs(item, 'input')" :label="setLabel(item)" :prop="setProp(item)">
                <el-input
                    :key="setProp(item)"
                    type="text"
                    v-model.trim="model[item.prop]"
                    placeholder="请输入"
                ></el-input>
            </el-form-item>
            <!-- select -->
            <el-form-item v-if="typeIs(item, 'select')" :label="setLabel(item)" :prop="setProp(item)">
                <el-select v-model="model[item.prop]" placeholder="请选择">
                    <el-option
                        v-for="optionItem in options[item.prop]"
                        :key="optionItem.value"
                        :label="optionItem.label"
                        :value="optionItem.value"
                    >
                    </el-option>
                </el-select>
            </el-form-item>
            <!-- switch -->
            <el-form-item v-if="typeIs(item, 'switch')" :label="setLabel(item)" :prop="setProp(item)">
                <el-col :span="4">
                    <el-switch v-model="model[item.prop]" active-color="#13ce66" inactive-color="#626262"> </el-switch>
                </el-col>
            </el-form-item>
            <!-- checkbox -->
            <el-form-item v-if="typeIs(item, 'checkbox')" :label="setLabel(item)" :prop="setProp(item)">
                <el-checkbox-group v-model="model[item.prop]">
                    <el-checkbox
                        v-for="(checkItem, index) of options[item.prop]"
                        :key="index"
                        :label="checkItem.label"
                    ></el-checkbox>
                </el-checkbox-group>
            </el-form-item>
            <!-- radio -->
            <el-form-item v-if="typeIs(item, 'radio')" :label="setLabel(item)" :prop="setProp(item)">
                <el-radio-group v-model="model[item.prop]">
                    <el-radio v-for="(radioItem, index) of options[item.prop]" :key="index" :label="radioItem.value"
                        >{{ radioItem.label }}
                    </el-radio>
                </el-radio-group>
            </el-form-item>
            <!-- datetime -->
            <el-form-item v-if="typeIs(item, 'datetime')" :label="setLabel(item)" :prop="setProp(item)">
                <el-date-picker
                    type="datetime"
                    placeholder="选择日期"
                    v-model="model[item.prop]"
                    style="width: 100%"
                    value-format="yyyy-MM-dd HH:mm:ss"
                ></el-date-picker>
            </el-form-item>
            <!-- number-int -->
            <el-form-item v-if="typeIs(item, 'number-int')" :label="setLabel(item)" :prop="setProp(item)">
                <el-row>
                    <el-col :span="6">
                        <el-input-number
                            style="width: 100%"
                            v-model="model[item.prop]"
                            :step="1"
                            step-strictly
                            :min="setMin(item)"
                            :max="setMax(item)"
                        >
                        </el-input-number>
                    </el-col>
                </el-row>
            </el-form-item>
            <!-- number-float -->
            <el-form-item v-if="typeIs(item, 'number-float')" :label="setLabel(item)" :prop="setProp(item)">
                <el-row>
                    <el-col :span="6">
                        <el-input-number
                            style="width: 100%"
                            v-model="model[item.prop]"
                            :min="setMin(item)"
                            :max="setMax(item)"
                            :precision="setPrecision(item)"
                        >
                        </el-input-number>
                    </el-col>
                </el-row>
            </el-form-item>
        </section>
    </div>
</template>

<script>
export default {
    props: {
        model: {
            type: Object,
            default: () => {},
        },
        schema: {
            type: Array,
            default: () => [],
        },
        options: {
            type: Object,
            default: () => {},
        },
    },
    data() {
        return {}
    },
    computed: {},
    mounted() {},
    methods: {
        typeIs(val, type) {
            return val.type === type
        },
        setLabel(val) {
            return val.formItem.label
        },
        setProp(val) {
            return val.prop
        },
        setMin(val) {
            const { minimum, exclusiveMinimum, precision } = val.formItem

            if ([undefined, null].includes(minimum)) return -Infinity

            if (!exclusiveMinimum) return minimum

            const minVal = minimum + this.getValByPrecision(precision)

            return minVal
        },
        setMax(val) {
            const { maximum, exclusiveMaximum, precision } = val.formItem

            if ([undefined, null].includes(maximum)) return Infinity

            if (!exclusiveMaximum) return maximum

            const maxVal = maximum - this.getValByPrecision(precision)

            return maxVal
        },
        getValByPrecision(precision) {
            return precision > 0 ? Number('0.' + '1'.padStart(precision, 0)) : 0
        },
        setPrecision(val) {
            return val.formItem?.precision ?? 4
        },
    },
}
</script>

<style lang="scss" scoped></style>

  • 使用的时候只需要传一个jsonSchema就好

  • 也没撒封装,主要就是initSchema里面处理数据,转换成需要的格式数据

  • 最后提交表单时,java需要按之前的层级结构给他,真是想捶死他,争论了一番,没争赢,只好再写一个getStructureData

总结

  • 写这个东西花了点时间,主要是没找到可以满足项目使用的插件

  • 自己写了一下这个东西,感觉也不难,可能很多东西没有想到吧