Skip to content

添加新组件

自定义组件可以被集成到设计器中,用于扩展表单渲染能力与设计态配置能力。本文会用项目内置的 SignaturePad(手写签名) 作为参考样例,讲清楚“新增一个组件”在项目里要补齐哪些环节,并给出一个从 0 到 1 的完整扩展示例。

渲染器文档中可以查看内置表单组件及其所有可配置参数。

自定义表单组件可查阅自定义表单组件的开发规范与标准要求。

在源码中可以参考现有组件的拖拽规则实现(如 src/config/rule/*)。

注意

设计器已支持通过 addDragRule 全局注册拖拽规则,实现一次配置多处使用。

新增组件要改哪些地方?

在设计器里新增一个“可拖拽 + 可配置 + 可渲染”的组件,通常涉及 4 层:

  • 表单渲染组件(Runtime Component):真正参与 v-model、触发事件的 Vue 组件(PC/移动端通常各一份)
  • 渲染器注册(form-create 注册):把组件注册进渲染器,使 rule.type 能渲染出对应组件
  • 拖拽规则(DragRule):决定左侧面板展示、拖入后生成的 rule、右侧属性面板、事件、多语言等
  • 设计器画布注册(designerForm 注册):设计态画布也是一个表单渲染环境,需要能渲染你的组件

参考样例:SignaturePad 的接入链路

SignaturePad 是一个典型“表单组件 + 只读预览特殊展示”的例子。相关文件一般可以在以下位置找到:

  • 组件实现(PC)src/components/SignaturePad.vue
  • 组件实现(Mobile)src/components/mobile/SignaturePad.vue
  • 拖拽规则src/config/rule/signaturePad.js
  • 渲染器注册(PC/移动)src/form/elm.jssrc/form/mobile.js
  • 设计器入口注册src/index.js

1. 命名建议(避免“找不到组件”)

你会看到 SignaturePad 存在“大小写不一致”的情况:

  • DragRule name / rule.typesignaturePad
  • Vue 组件 name / 注册 key:SignaturePad

为了避免踩坑,新增组件时建议直接做到:

  • rule.type 与注册 key 完全一致(最稳)
  • 如果历史原因必须兼容两套命名,可使用双注册兜底:
js
formCreate.component('myType', MyComp);
formCreate.component('MyComp', MyComp);

2. 组件实现要点(建议遵守)

以 SignaturePad 的实现风格为参照,推荐你的组件至少具备:

  • v-model 规范modelValue + update:modelValue事件 (Vue2中是value + input事件)
  • 事件透出:必要时同时触发 change(便于统一监听)
  • 禁用态:支持 disabled
  • 注入对象:如 formCreateInject(用于多语言、options、api 等)

3. 阅读模式适配(按需)

像签名、上传、富文本这类组件,在“阅读模式/预览模式”下往往需要特殊渲染(例如只展示图片)。SignaturePad 就在预览逻辑里对 type === 'signaturePad' 做了分支处理:

  • PC:一般在 src/utils/preview.js
  • Mobile:一般在 src/form/mobile.jsdefaultPreview
  • 或者在组件中使用自定义渲染方式

扩展示例:新增一个 UserSelect 用户选择组件

下面给出一个可直接照抄的示例:新增一个 UserSelect 组件,可选择用户并输出 string(用户 id)。你可以把它替换为你自己的业务组件。

1. 新增组件文件

1.1 PC 组件(ElementPlus)

新建 src/components/UserSelect.vue

vue
<template>
    <div class="_fc-user-select">
        <!-- 阅读模式下:自定义回显 -->
        <template v-if="formCreateInject?.preview">
            <span class="_fc-read-view">{{ previewText }}</span>
        </template>

        <!-- 编辑模式下:正常表单交互 -->
        <template v-else>
            <el-select
                v-bind="$attrs"
                :modelValue="modelValue"
                :disabled="disabled"
                :placeholder="placeholder"
                @update:modelValue="onChange"
                style="width: 100%;"
            >
                <el-option v-for="u in options" :key="u.value" :label="u.label" :value="u.value" />
            </el-select>
        </template>
    </div>
</template>

<script>
import { defineComponent } from 'vue';

export default defineComponent({
    name: 'UserSelect',
    emits: ['update:modelValue', 'change'],
    props: {
        modelValue: [String, Number],
        disabled: Boolean,
        placeholder: String,
        // 注入 formCreate 上下文(用于判断阅读模式 preview)
        formCreateInject: Object,
        // 简化示例:直接通过 props 传 options;真实业务也可以通过 formCreateInject / request 动态拉取
        options: {
            type: Array,
            default: () => [],
        },
    },
    computed: {
        previewText() {
            const val = this.modelValue;
            const hit = (this.options || []).find(o => o?.value === val);
            return hit?.label ?? (val == null || val === '' ? '' : String(val));
        },
    },
    methods: {
        onChange(val) {
            this.$emit('update:modelValue', val);
            this.$emit('change', val);
        },
    },
});
</script>

1.2 Mobile 组件(Vant,可选)

如果你的项目支持移动端渲染,建议同步提供一个移动端版本。新建 src/components/mobile/UserSelect.vue(示例用 van-field + van-picker 简化):

vue
<template>
    <div class="_fc-m-user-select">
        <!-- 阅读模式下:自定义回显 -->
        <template v-if="formCreateInject?.preview">
            <span class="_fc-read-view">{{ label }}</span>
        </template>

        <!-- 编辑模式下:正常表单交互 -->
        <template v-else>
            <van-field
                :modelValue="label"
                :disabled="disabled"
                :placeholder="placeholder"
                readonly
                is-link
                @click="open"
            />
            <van-popup v-model:show="show" position="bottom">
                <van-picker
                    :columns="options"
                    @confirm="confirm"
                    @cancel="show = false"
                />
            </van-popup>
        </template>
    </div>
</template>

<script>
import { defineComponent } from 'vue';

export default defineComponent({
    name: 'UserSelect',
    emits: ['update:modelValue', 'change'],
    props: {
        modelValue: [String, Number],
        disabled: Boolean,
        placeholder: String,
        // 注入 formCreate 上下文(用于判断阅读模式 preview)
        formCreateInject: Object,
        // Vant Picker columns: [{ text, value }]
        options: { type: Array, default: () => [] },
    },
    data() {
        return { show: false };
    },
    computed: {
        label() {
            const hit = (this.options || []).find(o => o.value === this.modelValue);
            return hit ? hit.text : '';
        },
    },
    methods: {
        open() {
            if (!this.disabled) this.show = true;
        },
        confirm(item) {
            const val = item?.value;
            this.$emit('update:modelValue', val);
            this.$emit('change', val);
            this.show = false;
        },
    },
});
</script>

2. 注册组件(渲染器 + 设计器画布)

PC/移动渲染器要能渲染,设计器画布也要能渲染。

2.1 PC 渲染器注册

src/form/elm.js 中引入并注册:

js
import UserSelect from '../components/UserSelect.vue';

formCreate.component('UserSelect', UserSelect);

2.2 Mobile 渲染器注册(如果支持)

src/form/mobile.js 中引入并注册:

js
import UserSelect from '../components/mobile/UserSelect.vue';

formCreateMobile.component('UserSelect', UserSelect);

2.3 设计器入口注册

src/index.js 中引入并注册:

js
import UserSelect from './components/UserSelect.vue';

addComponent('UserSelect', UserSelect);

如果你的项目把“设计器画布注册”和“渲染器注册”拆开了,请确保两边都注册到了,否则会出现“能拖拽但画布不显示”的问题。

3. 定义拖拽规则(右侧属性面板)

新建 src/config/rule/userSelect.js

js
import uniqueId from '@form-create/utils/lib/unique';
import { localeProps } from '../../utils';

const label = '用户选择';
const name = 'UserSelect';

export default {
    menu: 'main',
    icon: 'icon-select',
    label,
    name,
    input: true,
    mask: true,
    validate: ['string', 'number'],
    event: ['change'],
    rule({ t }) {
        return {
            type: name,
            field: uniqueId(),
            // 关键:开启自定义阅读模式(组件内部通过 formCreateInject.preview 回显)
            readMode: 'custom',
            title: t('com.userSelect.name') || label,
            info: '',
            $required: false,
            props: {
                placeholder: t('com.userSelect.props.placeholder') || '请选择用户',
                // 示例:默认静态 options;你也可以只提供空数组,运行时由业务层注入
                options: [
                    { label: '张三', value: '1' },
                    { label: '李四', value: '2' },
                ],
            },
        };
    },
    props(_, { t }) {
        return localeProps(t, name + '.props', [
            { type: 'switch', field: 'disabled' },
            { type: 'input', field: 'placeholder' },
            // 这里给出一种常见方式:用 TableOptions/TreeOptions 等配置器管理 options
            // 不同项目的 options 配置器名称可能不同,请按现有组件规则对齐
        ]);
    },
};

重要提示

readMode: 'custom' 只对后续新拖入设计器的规则生效。对于已经保存的规则不会自动补上该配置;若要对已有规则启用自定义阅读模式,需要手动编辑规则或重新拖拽组件。

4. 导入拖拽规则(让左侧面板出现)

src/config/index.js 中:

js
import userSelect from './rule/userSelect';

// 放入 ruleList 合适的位置
ruleList.push(userSelect);

5. 补齐多语言(建议)

src/locale/zh-cn.jscom 节点中新增:

js
userSelect: {
    name: '用户选择',
    props: {
        disabled: '禁用',
        placeholder: '占位文字',
    },
},

并建议同步维护 en.jsjp.js(避免切换语言后配置项缺失)。

6. 阅读模式显示(示例)

阅读模式的开启与自定义回显方式,参考 src/preview.md

  • 开启阅读模式:在表单 options 中设置 options.preview = true
  • 自定义回显:组件内部通过 formCreateInject.preview 分支渲染回显内容(前提是 DragRule 已设置 readMode: 'custom'

UserSelect 来说:

  • 编辑模式:显示下拉框 / Picker
  • 阅读模式:显示当前值对应的用户名称(找不到则回退显示原始值)

布局组件开发参考

布局组件(如栅格布局 Row、Col)主要用于页面布局,不收集表单数据。参考实现:

  • Row 组件src/config/rule/row.js
  • Col 组件src/config/rule/col.js

布局组件特点

  1. 不收集数据:通常设置 input: false 或不设置 input 属性
  2. 支持嵌套子组件:通过 children 属性管理子规则

Row 组件示例

js
import { localeProps } from '../../utils';

const label = '栅格布局';
const name = 'fcRow';

export default {
    menu: 'layout',           // 左侧面板分类
    icon: 'icon-row',
    label,
    name,
    mask: false,             // 布局组件通常不需要遮罩
    children: 'col',          // 指定子组件类型
    childrenLen: 2,          // 默认子组件数量

    rule() {
        return {
            type: name,
            props: {},
            children: [],    // 布局组件必须有 children
        };
    },

    props(_, { t }) {
        return localeProps(t, name + '.props', [
            {
                type: 'inputNumber',
                field: 'gutter',      // 栅格间距
                props: { min: 0 },
            },
            {
                type: 'switch',
                field: 'type',
                props: { activeValue: 'flex', inactiveValue: 'default' },
            },
            {
                type: 'select',
                field: 'justify',     // flex 布局下的水平排列方式
                options: [
                    { label: 'start', value: 'start' },
                    { label: 'end', value: 'end' },
                    { label: 'center', value: 'center' },
                    { label: 'space-around', value: 'space-around' },
                    { label: 'space-between', value: 'space-between' },
                ],
            },
            {
                type: 'select',
                field: 'align',       // flex 布局下的垂直对齐方式
                options: [
                    { label: 'top', value: 'top' },
                    { label: 'middle', value: 'middle' },
                    { label: 'bottom', value: 'bottom' },
                ],
            },
        ]);
    },
};

Col 组件示例

js
import { localeProps } from '../../utils';

const name = 'col';

// 响应式断点配置
const devices = {
    xs: '<768px',
    sm: '≥768px',
    md: '≥992px',
    lg: '≥1200px',
    xl: '≥1920px',
};

export default {
    name,
    label: '格子',
    drag: true,
    dragBtn: false,
    inside: true,        // 只能拖入到特定父组件内
    mask: false,

    rule() {
        return {
            type: name,
            props: { span: 12 },  // 默认占 12 列(24 栅格系统)
            children: [],
        };
    },

    props(_, { t }) {
        return localeProps(t, name + '.props', [
            // 基础配置
            { 
                type: 'slider', 
                field: 'span', 
                value: 12, 
                props: { min: 0, max: 24 } 
            },
            { 
                type: 'slider', 
                field: 'offset',   // 栅格左侧的间隔格数
                props: { min: 0, max: 24 } 
            },
            { 
                type: 'slider', 
                field: 'push',     // 栅格向右移动格数
                props: { min: 0, max: 24 } 
            },
            { 
                type: 'slider', 
                field: 'pull',     // 栅格向左移动格数
                props: { min: 0, max: 24 } 
            },

            // 响应式配置(使用 ConfigItem + elTabs 组织)
            {
                type: 'ConfigItem',
                props: {
                    label: t('props.reactive'),
                    warning: t('com.col.info'),
                },
                children: [
                    {
                        type: 'elTabs',
                        style: { width: '100%' },
                        slot: 'append',
                        children: Object.keys(devices).map(k => {
                            return {
                                type: 'elTabPane',
                                props: { label: devices[k] },
                                style: 'padding:0 10px;',
                                children: [
                                    {
                                        type: 'slider',
                                        field: k + '>span',    // 使用 > 分隔符表示嵌套属性
                                        title: t('com.col.props.span'),
                                        value: 12,
                                        props: { min: 0, max: 24 },
                                    },
                                    {
                                        type: 'slider',
                                        field: k + '>offset',
                                        title: t('com.col.props.offset'),
                                        props: { min: 0, max: 24 },
                                    },
                                    // ... 其他响应式属性
                                ],
                            };
                        }),
                    },
                ],
            },
        ]);
    },
};

布局组件开发要点

  1. inside: true:限制组件只能拖入到特定父组件内(如 Col 只能拖入 Row)
  2. children 属性:指定允许的子组件类型
  3. 响应式配置:使用 field: 'xs>span' 这种格式表示嵌套属性,配合 ConfigItem + elTabs 组织复杂配置

子表单组件开发参考

子表单组件用于收集嵌套的表单数据,支持对象(object)和数组(array)两种数据结构。参考实现:

  • 分组子表单(subForm)src/config/rule/subForm.js - 收集对象数据
  • 表格表单(tableForm)src/config/rule/tableForm.js - 收集数组数据
  • 表格表单列(tableFormColumn)src/config/rule/tableFormColumn.js - 表格表单的列配置

子表单组件特点

  1. 数据收集:设置 input: truesubForm: 'object'subForm: 'array'
  2. 数据转换:通过 loadRuleparseRule 方法处理设计态和运行态的数据格式转换
  3. 嵌套规则:子表单内部可以包含其他表单组件

分组子表单(subForm)示例

js
import { localeProps } from '../../utils';
import uniqueId from '@form-create/utils/lib/unique';

const label = '分组';
const name = 'subForm';

export default {
    menu: 'subform',
    icon: 'icon-group',
    label,
    name,
    input: true,              // 收集数据
    inside: false,
    drag: true,
    dragBtn: true,
    mask: false,
    subForm: 'object',        // 收集对象类型数据
    event: ['change'],

    // 加载规则:将运行态数据转换为设计态格式
    loadRule(rule) {
        // 运行态:rule.props.rule 存储子规则数组
        // 设计态:rule.children 存储子规则数组
        rule.children = rule.props.rule || [];
        rule.type = 'FcRow';  // 设计态使用 FcRow 渲染
        delete rule.props.rule;
    },

    // 解析规则:将设计态数据转换为运行态格式
    parseRule(rule) {
        // 设计态 → 运行态
        rule.props.rule = rule.children;
        rule.type = 'subForm';
        delete rule.children;
    },

    rule({ t }) {
        return {
            type: 'fcRow',      // 设计态类型
            field: uniqueId(),
            title: t('com.subForm.name'),
            info: '',
            $required: false,
            props: {},
            children: [],       // 子规则数组
        };
    },

    props(_, { t }) {
        return localeProps(t, name + '.props', [
            {
                type: 'switch',
                field: 'disabled',
            },
        ]);
    },
};

表格表单(tableForm)示例

js
import unique from '@form-create/utils/lib/unique';
import { localeProps } from '../../utils';

const label = '表格表单';
const name = 'tableForm';

export default {
    menu: 'subform',
    icon: 'icon-table-form',
    label,
    name,
    input: true,
    mask: false,
    subForm: 'array',         // 收集数组类型数据
    languageKey: ['add', 'operation', 'dataEmpty'],  // 需要多语言支持的 key
    event: ['change', 'add', 'delete'],
    children: 'tableFormColumn',  // 子组件类型(列配置)

    // 加载规则:运行态 → 设计态
    loadRule(rule) {
        if (!rule.props) rule.props = {};
        const columns = rule.props.columns || [];
        // 将 columns 配置转换为 tableFormColumn 子规则
        rule.children = columns.map(column => {
            return {
                type: 'tableFormColumn',
                _fc_drag_tag: 'tableFormColumn',
                props: {
                    label: column.label,
                    align: column.align,
                    required: column.required || false,
                    width: column.style.width || '',
                    color: column.style.color || '',
                },
                children: column.rule || [],  // 每列的表单规则
            };
        });
        delete rule.props.columns;
    },

    // 解析规则:设计态 → 运行态
    parseRule(rule) {
        const children = rule.children || [];
        // 将 tableFormColumn 子规则转换为 columns 配置
        rule.props.columns = children.map(column => {
            return {
                label: column.props.label,
                required: column.props.required,
                align: column.props.align,
                style: {
                    width: column.props.width,
                    color: column.props.color,
                },
                rule: column.children || [],  // 每列的表单规则
            };
        });
        rule.children = [];
    },

    rule({ t }) {
        return {
            type: name,
            field: unique(),
            title: t('com.tableForm.name'),
            info: '',
            props: {},
            children: [],
        };
    },

    props(_, { t }) {
        return localeProps(t, name + '.props', [
            {
                type: 'switch',
                field: 'disabled',
            },
            {
                type: 'switch',
                field: 'addable',        // 是否可添加行
                value: true,
            },
            {
                type: 'switch',
                field: 'deletable',      // 是否可删除行
                value: true,
            },
            {
                type: 'switch',
                field: 'filterEmptyColumn',  // 是否过滤空列
                value: true,
            },
            {
                type: 'inputNumber',
                field: 'min',            // 最小行数
                props: { min: 0 },
            },
            {
                type: 'inputNumber',
                field: 'max',            // 最大行数
                props: { min: 0 },
            },
        ]);
    },
};

表格表单列(tableFormColumn)示例

js
import { localeProps, localeOptions } from '../../utils';

const name = 'tableFormColumn';

export default {
    icon: 'icon-cell',
    name,
    aide: true,              // 辅助组件(不在左侧面板显示)
    drag: true,
    dragBtn: false,
    mask: false,
    style: false,            // 不显示样式配置
    advanced: false,         // 不显示高级配置
    variable: false,        // 不显示变量配置

    rule({ t }) {
        return {
            type: name,
            props: {
                label: t('com.tableFormColumn.label'),
                width: 'auto',
            },
            children: [],    // 该列包含的表单规则
        };
    },

    props(_, { t }) {
        return localeProps(t, name + '.props', [
            {
                type: 'input',
                field: 'label',         // 列标题
            },
            {
                type: 'select',
                field: 'align',         // 对齐方式
                options: localeOptions(t, [
                    { label: 'left', value: 'left' },
                    { label: 'center', value: 'center' },
                    { label: 'right', value: 'right' },
                ]),
            },
            {
                type: 'switch',
                field: 'required',      // 是否必填
            },
            {
                type: 'input',
                field: 'width',         // 列宽
            },
            {
                type: 'ColorInput',
                field: 'color',         // 列颜色
            },
        ]);
    },
};

子表单组件开发要点

  1. subForm 属性

    • 'object':收集对象数据,如 { name: 'xxx', age: 18 }
    • 'array':收集数组数据,如 [{ name: 'xxx' }, { name: 'yyy' }]
  2. loadRule 方法

    • 在加载规则时调用(运行态 → 设计态)
    • 将运行态的数据格式转换为设计态可编辑的格式
    • 例如:将 props.columns 转换为 children 数组
  3. parseRule 方法

    • 在保存规则时调用(设计态 → 运行态)
    • 将设计态的格式转换为运行态的数据格式
    • 例如:将 children 数组转换为 props.columns
  4. 嵌套字段路径

    • 子表单内的组件字段路径会自动拼接父字段
    • 例如:父字段 user,子字段 name,最终路径为 user.name
  5. children 属性

    • 指定子组件类型(如 children: 'tableFormColumn'
  6. 辅助组件标记

    • 设置 aide: true 表示该组件不在左侧面板显示
    • 只能通过父组件的 children 属性关联使用

常见问题排查

1. 能拖拽但画布不显示

  • 设计器画布未注册组件(缺 designerForm.component 或入口 addComponent
  • rule.type 与注册 key 不一致

2. 运行态可渲染但设计态不行(或反过来)

  • 只注册了其中一侧(运行态 formCreate / 设计态 designerForm)
  • PC/移动端注册不一致导致跨端预览异常

3. 右侧配置改了没效果

  • 组件未使用对应的 props
  • 拖拽规则 props()field 没映射到你真正读取的字段(通常是 rule.props.xxx