项目中根据业务需求封装了一套动态表单组件自用代码
1. 支持4种控件类型(文本/日期/下拉/搜索等)
2. 动态表单配置与渲染分离
3. 搜索弹窗集成分页/筛选/单选功能
4. 数据双向绑定与验证机制
5. 响应式布局与搜索框数据与展示样式定制
控件类型对照表
| 类型 | *对应组件 | 特殊配置项 |
|---|---|---|
| input | a-input | defaultValue |
| select | a-select | value(选项数组) |
| search | Input+SearchModal | param_template响应模板 |
| date | a-date-picker | format |
具体实现
<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>