动态表单生成渲染组件封装(含弹窗)

122 阅读7分钟

项目中根据业务需求封装了一套动态表单组件自用代码

1. 支持4种控件类型(文本/日期/下拉/搜索等)

2. 动态表单配置与渲染分离

3. 搜索弹窗集成分页/筛选/单选功能

4. 数据双向绑定与验证机制

5. 响应式布局与搜索框数据与展示样式定制

控件类型对照表

类型*对应组件特殊配置项
inputa-inputdefaultValue
selecta-selectvalue(选项数组)
searchInput+SearchModalparam_template响应模板
datea-date-pickerformat

具体实现

<template>
  <div class="form-container">
    <div class="form-title">请填写表格</div>
    <a-form>
      <div class="custom-table">
        <div v-for="(item, index) in card" :key="index" class="table-row">
          <div class="table-cell">
            <span class="label">{{ item.name }}</span>
            <div class="input-wrapper" v-if="item.type === 'search'">
              <a-input
                v-model:value="form[item.field_id]"
                :placeholder="`请选择${item.name}`"
                readonly
              />
              <div class="input-actions">
                <SearchOutlined class="search-icon" @click="openSearch(item)" />
                <CloseOutlined
                  v-if="form[item.field_id]"
                  class="clear-icon"
                  @click="clearSearch(item)"
                />
              </div>
            </div>
            <component
              v-else
              :is="getComponentType(item.type)"
              v-model:value="form[item.field || item.field_id]"
              :placeholder="`请输入${item.name}`"
              class="underline-input"
              v-bind="getComponentProps(item)"
            >
              <template v-if="item.type === 'select'">
                <a-select-option
                  v-for="option in item.value"
                  :key="option.code"
                  :value="option.code"
                >
                  {{ option.name }}
                </a-select-option>
              </template>
            </component>
          </div>
        </div>
      </div>
      <div class="form-actions">
        <a-button type="primary" @click="handleSubmit">提交</a-button>
        <a-button style="margin-left: 10px" @click="handleCancel">取消</a-button>
      </div>
 
    </a-form>

    <!-- 搜索弹窗 -->
    <SearchModal
      v-if="currentSearchItem"
      v-model:visible="searchVisible"
      :searchFields="currentSearchItem.value.param_template"
      :responseData="currentSearchItem.value.response_template"
      @selectItem="handleSearchSelect"
      @cancel="handleSearchCancel"
    />
  </div>
</template>

<script setup lang="jsx">
import { reactive, ref, onMounted } from 'vue'
import { computed } from 'vue'
import dayjs from 'dayjs'
import { Input, Select, DatePicker, Button, Modal } from 'ant-design-vue'
import { SearchOutlined, CloseOutlined } from '@ant-design/icons-vue'
import SearchModal from './SearchModal.vue'

const props = defineProps({
  formData: {
    type: Object,
    required: true
  }
})
// const form = reactive({from: "北京",
//   to: "哈尔滨",
//   fromTime: dayjs("2025-03-04"),
//   toTime: dayjs("2025-03-04"),
//   isShow: "0",
//   data: "您发送的信息为从北京出发到哈尔滨的行程"})
// const form =reactive(props.formData);

const card = ref([
  {
    name: '电话',
    field: 'tel',
    field_type: 'string',
    type: 'input',
    // value: "13198781234"
    value: '',
    defaultValue: '13198781234' // 默认值代表含有历史填写信息,如果含有历史填写信息,则当前取历史填写信息 ,否则为空
  },
  // {
  //   name: "文件",
  //   field: "file",
  //   field_type: "file",
  //   type: "file",
  //   value: ""
  // },
  {
    name: '人员岗位',
    field_id: 'postinfo',
    field_type: 'string',
    type: 'select', 
    defaultValue: '',
    value: [
      {
        name: '人员岗位1',
        code: '1'
      },
      {
        name: '人员岗位2',
        code: '2'
      },
      {
        name: '人员岗位3',
        code: '3'
      }
    ]
  },
  {
    name: '项目信息',
    field_id: 'projectName',
    field_type: 'string',
    type: 'search',
    showValue: 'projectName',// 如果是搜索带弹窗的,需要指定弹窗后显示的值 和下面param_value保持一致!!!
    value: {
      url: 'http://xxx',
      param_template: [
        // {
        //   param_name: "人员岗位",
        //   param_value: "postinfo",
        //   isRequire: "1"
        // },
        {
          param_name: '项目编码',
          param_value: 'projectCode',// 这里params_value的值需要和下面response_template的属性值保持一致!! 下方表格数据和上方搜索框一致
          isRequire: '0', // 条件查询是否为必填项
          width: '30%' // 配置下方表格展示的宽度 相加为100%
        },
        {
          param_name: '项目名称',
          param_value: 'projectName',
          isRequire: '0',
          width: '70%'
        }
      ],
      response_template: [
        { seq: '1', projectName: '移动端系统应用开发管理项目', projectCode: 'Y001' },
        { seq: '2', projectName: '数据库应用开发管理项目', projectCode: 'Y002' },
        { seq: '3', projectName: '管理购置应用开发管理项目', projectCode: 'Y003' },
        { seq: '4', projectName: '系统集成中间件项目', projectCode: 'Y004' },
        { seq: '5', projectName: '云平台应用开发管理平台扩容项目', projectCode: 'Y005' },
        { seq: '6', projectName: '网络安全项目', projectCode: 'Y006' },
        { seq: '7', projectName: '大数据项目', projectCode: 'Y007' },
        { seq: '8', projectName: 'AI智能项目', projectCode: 'Y008' },
        { seq: '9', projectName: '运维服务项目', projectCode: 'Y009' },
        { seq: '10', projectName: '技术咨询建设1期项目', projectCode: 'Y010' }
      ]
    }
  },

  {
    name: '城市',
    field_id: 'city',
    field_type: 'string',
    type: 'select',
    defaultValue: '',
    value: [
      {
        name: '哈尔滨',
        code: '4'
      },
      {
        name: '北京',
        code: '5'
      }
    ]
  }
])
const form = reactive({})

// 初始化表单数据
onMounted(() => {
  card.value.forEach((item) => {
    const fieldName = item.field || item.field_id
    // 对所有类型的组件,优先使用 defaultValue
    form[fieldName] = item.defaultValue || item.value || ''

    if (item.type === 'select' && !item.defaultValue) {
      form[fieldName] = item.value[0].code
    }
    if (item.type === 'search') {
      form[fieldName] = ''
    }
  })
})

// 动态获取组件类型
const getComponentType = (type) => {
  switch (type) {
    case 'input':
      return Input
    case 'select':
      return Select
    case 'search':
      return Modal
    case 'date':
      return DatePicker
    // case 'file':
    //   return Upload;
    default:
      return Input
  }
}

// 获取组件属性
const getComponentProps = (item) => {
  const props = {
    placeholder: `请输入${item.name}`,
    class: `underline-${item.type}`
  }

  if (item.type === 'select') {
    props.allowClear = true
    props.style = { width: '100%' }
  }

  if (item.type === 'input') {
    props.defaultValue = item.defaultValue
  }

  return props
}

const isEditing = ref(false)
const handleReset = () => {
  Object.keys(form).forEach((key) => {
    form[key] = key === 'isCore' ? '0' : ''
  })
}
const handleSubmit = () => {
  const formData = {}
  card.value.forEach((item) => {
    const fieldName = item.field || item.field_id
    formData[fieldName] = form[fieldName]
  })
  console.log('提交的表单数据:', formData)
  console.log('原始表单数据:', form)
}

// 取消
const handleCancel = () => {
  card.value.forEach((item) => {
    const fieldName = item.field || item.field_id
    // 重置时也使用 defaultValue
    form[fieldName] = item.defaultValue || item.value || ''
  })
}

// 搜索相关
const searchVisible = ref(false)
const currentSearchItem = ref(null)

// 打开搜索弹窗
const openSearch = (item) => {
  currentSearchItem.value = item
  searchVisible.value = true
}

// 清除搜索值
const clearSearch = (item) => {
  form[item.field_id] = ''
}

// 处理搜索选择
const handleSearchSelect = (record) => {
  if (currentSearchItem.value) {
    Object.keys(record).forEach((key) => {
      if (key === currentSearchItem.value.showValue) {
        form[currentSearchItem.value.field_id] = record[currentSearchItem.value.showValue]
      }
    })
    currentSearchItem.value = null
  }
}

// 处理搜索取消
const handleSearchCancel = () => {
  currentSearchItem.value = null
}
</script>

<style scoped>
.form-title {
  margin-bottom: 15px;
  font-size: 16px;
  color: rgb(89, 89, 89);
  font-family: 微软雅黑;
  font-size: 16px;
  font-weight: 400;
  line-height: 22px;
  letter-spacing: 0px;
  text-align: left;
}
.form-container {
  width: 800px;
  margin: 20px auto;
  font-family: 'Microsoft YaHei', sans-serif;
}

.table-cell {
  flex: 1;
  display: flex;
  align-items: center;
  width: 100%;
  height: 44px;
  border-bottom: 1px solid #cecece;
}

.table-cell:last-child {
  border-bottom: none;
}

/* 标签样式 */
.label {
  width: 120px;
  height: 100%;
  min-width: 120px;
  max-width: 120px;
  display: flex;
  align-items: center;
  color: rgb(89, 89, 89);
  font-family: 微软雅黑;
  font-size: 14px;
  font-weight: 400;
  line-height: 44px;
  border-right: 1px solid #cecece;
  padding: 0 12px;
  background-color: #fafafa;
}

.input-wrapper {
  position: relative;
  flex: 1;
  display: flex;
  align-items: center;
  /* padding: 0 0px; */
}

/* 选择器样式优化 */
:deep(.ant-select) {
  width: 100%;
}

:deep(.ant-select-selector) {
  border-radius: 2px solid #d9d9d9;
  box-shadow: none !important;
  border: 1px solid #d9d9d9 !important;
  height: 32px !important;
  text-align: center !important;
  margin: 10px;
}

:deep(.ant-select-selection-item) {
  line-height: 30px !important;
  color: rgba(0, 0, 0, 0.85) !important;
  text-align: center !important;
  display: flex !important;
  align-items: center !important;
  justify-content: center !important;
}

:deep(.ant-select-selection-placeholder) {
  text-align: center !important;
  display: flex !important;
  align-items: center !important;
  justify-content: center !important;
}

/* 输入框样式 */
:deep(.ant-input, .ant-select-selector) {
  text-align: center;
  margin: 10px;
}

.custom-table {
  border: 1px solid #cecece;
  border-radius: 4px;
  overflow: hidden;
}

.table-row {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  border-bottom: 1px solid #cecece;
  background: rgb(255, 255, 255);
  position: relative;
}

.table-row:last-child {
  border-bottom: none;
}

.table-row:nth-child(odd) {
  background-color: #f9f9f9;
}

.table-row:nth-child(even) {
  background-color: #ffffff;
}

.divider {
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 1px;
  background-color: #cecece;
}

.input-actions {
  position: absolute;
  right: -45px;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  gap: 8px;
}

.search-icon,
.clear-icon {
  cursor: pointer;
  color: #999;
  font-size: 16px;
  transition: color 0.3s;
}

.search-icon:hover,
.clear-icon:hover {
  color: #666;
}
.form-actions {
  margin: 20px;
}
</style>

带弹窗

<template>
  <a-modal
    :visible="visible"
    title="搜索信息"
    @cancel="handleCancel"
    @ok="handleOk"
    width="800px"
    okText="确定"
    cancelText="取消"
  >
    <!-- 搜索表单 -->
    <a-form :model="searchForm" layout="inline">
      <a-row :gutter="16">
        <a-col :span="12" v-for="(field, index) in searchFields" :key="field.param_value">
          <a-form-item 
            :label="field.param_name"
            :required="field.isRequire === '1'"
          >
            <a-input
              v-model="searchForm[field.param_value]"
              :placeholder="'请输入' + field.param_name"
            />
          </a-form-item>
          <!-- 每两个字段后换行 -->
          <a-col v-if="(index + 1) % 2 === 0" :span="24" style="height: 8px" />
        </a-col>
        <a-col :span="24" style="text-align: right; margin-top: 16px;">
          <a-button type="primary" @click="handleSearch">查询</a-button>
          <a-button style="margin-left: 8px" @click="resetSearch">重置</a-button>
        </a-col>
      </a-row>
    </a-form>

    <!-- 数据表格 -->
    <a-table
      :columns="columns"
      :data-source="tableData"
      :pagination="pagination"
      :row-selection="{
        type: 'radio',
        selectedRowKeys: selectedRowKeys,
        onChange: onSelectChange,
        preserveSelectedRowKeys: false
      }"
      @change="handleTableChange"
      :rowKey="record => record.seq"
    />
  </a-modal>
</template>

<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { message } from 'ant-design-vue';

const props = defineProps({
  visible: Boolean,
  searchFields: {
    type: Array,
    default: () => []
  },
  responseData: {
    type: Array,
    default: () => []
  }
});

const emit = defineEmits(['update:visible', 'selectItem', 'cancel']);

// 搜索表单数据
const searchForm = reactive({});

// 表格列定义
const columns = computed(() => {
  // 序号列
  // const baseColumns = [
  //   { 
  //     title: '序号', 
  //     width: 60,
  //     align: 'center',
  //     customRender: ({ index }) => index + 1 
  //   },
  //   // 固定显示名称和编码列
  //   {
  //     title: '名称',
  //     dataIndex: 'name',
  //     ellipsis: true,
  //     align: 'center'
  //   },
  //   {
  //     title: '编码',
  //     dataIndex: 'code',
  //     ellipsis: true,
  //     align: 'center'
  //   }
  // ];

  const dynamicColumns = props.searchFields.map(field => ({
    title: field.param_name,
    dataIndex: field.param_value,
    ellipsis: true,
    align: 'center',
    width:field.width||""
  }));
  return dynamicColumns;
});

// 表格数据和分页
const tableData = ref([]);
  // 监听 responseData 变化,更新表格数据
 
const pagination = reactive({
  current: 1,
  pageSize: 5,
  total: 0,
  showTotal: total => `共 ${total} 条`,
  showSizeChanger: false
});


// 监听 responseData 变化,更新表格数据
watch(() => props.responseData, (newVal) => {
  // 直接更新表格数据
  tableData.value = newVal || [];
  pagination.total = newVal?.length || 0;
}, { immediate: true });

// 选择相关
const selectedRowKeys = ref([]);
const selectedRecord = ref(null);

// 处理表格选择
const onSelectChange = (selectedKey, selectedRows) => {

  selectedRowKeys.value = selectedKey;
  selectedRecord.value = selectedRows[0] || null;
};

// 处理查询
const handleSearch = () => {
  // 重置选择状态
  selectedRowKeys.value = [];
  selectedRecord.value = null;
  pagination.current = 1;
  
  // 验证必填项
  const requiredFields = props.searchFields.filter(field => field.isRequire === '1');
  const missingFields = requiredFields.filter(field => !searchForm[field.param_value]);
  
  if (missingFields.length > 0) {
    message.error(`请填写必填项:${missingFields.map(f => f.param_name).join(', ')}`);
    return;
  }

  // 调用模拟接口
  loadData(searchForm);
};

// 重置搜索
const resetSearch = () => {
  // 清空搜索表单
  Object.keys(searchForm).forEach(key => {
    searchForm[key] = '';
  });
  // 重置分页和选择状态
  pagination.current = 1;
  selectedRowKeys.value = [];
  selectedRecord.value = null;
  
  // 重新加载数据
  loadData();
};

// 处理表格分页变化
const handleTableChange = (pag) => {
  pagination.current = pag.current;
  loadData(searchForm);
};

// 组件挂载时加载初始数据
onMounted(() => {
  loadData();
});

// 确定选择
const handleOk = () => {
  if (!selectedRecord.value) {
    message.warning('请选择一条记录');
    return;
  }
  emit('selectItem', selectedRecord.value);
  emit('update:visible', false);
  // 清空选择
  selectedRowKeys.value = [];
  selectedRecord.value = null;
};

// 取消选择
const handleCancel = () => {
  selectedRecord.value = null;
  selectedRowKeys.value = [];
  emit('cancel');
  emit('update:visible', false);
  // 重置搜索表单
  resetSearch();
};

// 模拟API调用
const fetchData = (params = {}) => {
  const { pageSize = 5, current = 1, ...searchParams } = params;
  
  // 过滤数据
  let filteredData = [...tableData.value];
  if (Object.keys(searchParams).length > 0) {
   
    filteredData = tableData.value.filter(item => {
      return Object.entries(searchParams).every(([key, value]) => {
        if (!value) return true;
        // 根据搜索字段名匹配对应的数据字段
        let itemValue = '';

        if (key === 'projectCode') {
          itemValue = item.code;
        } else if (key === 'projectName') {
          itemValue = item.name;
        }
        return itemValue?.toString().toLowerCase().includes(value.toString().toLowerCase());
      });
    });
  }
  
  // 计算分页
  const total = filteredData.length;
  const start = (current - 1) * pageSize;
  const end = start + pageSize;
  const data = filteredData.slice(start, end);
  
  return {
    data,
    total,
    current,
    pageSize
  };
};

// 加载数据
const loadData = (params = {}) => {
  const result = fetchData({
    pageSize: pagination.pageSize,
    current: pagination.current,
    ...params
  });
  
  tableData.value = result.data;
  pagination.total = result.total;
};
</script>

<style scoped>
.ant-form {
  margin-bottom: 16px;
}

.ant-table {
  margin-top: 16px;
}

:deep(.ant-table-wrapper) {
  height: 300px;
  background: #e5e5e5;
}
:deep(.ant-table-row-selected){
    background: #e5e5e5;
}
:deep(.ant-table-body) {
  max-height: 250px !important;
  overflow-y: auto !important;
}

:deep(.ant-form-item) {
  margin-bottom: 16px;
  width: 100%;
}

:deep(.ant-form-item-label) {
  min-width: 80px;
}

/* 调整表格行高 */
:deep(.ant-table-thead > tr > th),
:deep(.ant-table-tbody > tr > td) {
  padding: 8px 16px;
  height: 40px;
}

/* 调整模态框内容区域高度 */
:deep(.ant-modal-body) {
  max-height: 600px;
  overflow-y: auto;
}
</style>