Vue3 动态表单:模块复用 + 动态增删行 + 数据回显

总体思路:

1.把表单拆成多个可复用模块组件

2.父页面通过 v-if 按需装配

3.模块内部支持动态增删行

4.提交时用 ... 合并成一个对象

5.回显时用 Object.assign / splice 保留引用

6.子组件用 computed(() => props.modelValue),避免数据不回显

代码示例

父组件示例代码

javascript 复制代码
<template>
  <div class="p-2">
    <el-card shadow="hover">
      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" :disabled="readonly">
        <!-- 户籍信息(公共模块) -->
        <el-divider content-position="left">户籍信息</el-divider>
        <el-row :gutter="20">
          <el-col :span="8">
            <HouseholdInfo v-model="form.household" prop-prefix="household" :disabled="readonly" />
          </el-col>
        </el-row>

        <!-- 教育信息(公共模块) -->
        <el-divider content-position="left">教育信息</el-divider>
        <el-row :gutter="20">
          <el-col :span="8">
            <EducationInfo v-model="form.education" prop-prefix="education" :disabled="readonly" />
          </el-col>
        </el-row>
        <!-- 家庭信息(公共模块,本页可控制是否需要) -->
        <template v-if="showFamily">
          <el-divider content-position="left">家庭信息</el-divider>
          <FamilyInfo v-model="form.family" prop-prefix="family" :disabled="readonly" />
        </template>
        <el-form-item v-if="!readonly" label=" ">
          <el-button type="primary" :loading="buttonLoading" @click="submitForm">提 交</el-button>
          <el-button type="warning" @click="loadMock">加载虚拟数据</el-button>
          <el-button @click="cancel">清 空</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup name="Person">
import HouseholdInfo from '@/components/FormBlocks/HouseholdInfo.vue';
import EducationInfo from '@/components/FormBlocks/EducationInfo.vue';
import FamilyInfo from '@/components/FormBlocks/FamilyInfo.vue';

const { proxy } = getCurrentInstance();

const formRef = ref();
const buttonLoading = ref(false);
/** 是否只读(详情页用) */
const readonly = ref(false);
/** 是否显示家庭信息模块(其他页面按需关闭) */
const showFamily = ref(true);

/** 本页表单结构:分模块,子组件通过 v-model 绑定各模块 */
const createEmptyForm = () => ({
  household: { householdType: undefined, address: undefined, moveInDate: undefined },
  education: { school: undefined, degree: undefined, major: undefined, enrollDate: undefined },
  family: { members: [] }
});

const form = reactive(createEmptyForm());

const rules = {
  // 户籍模块的校验规则放在子组件里,这里只写本页特有的(本页没有特有字段,所以为空)
};

/** 提交:把分模块对象合并成扁平结构(后端要一个对象) */
const buildPayload = () => ({
  ...form.household,
  ...form.education,
  members: form.family.members
});

const submitForm = () => {
  formRef.value?.validate(async (valid) => {
    if (!valid) return;
    buttonLoading.value = true;
    try {
      const payload = buildPayload();
      // TODO: 实际项目改为调用后端接口
      // if (payload.id) { await updatePerson(payload); } else { await addPerson(payload); }
      console.log('提交数据(扁平结构)', payload);
      proxy?.$modal.msgSuccess('操作成功,已打印到控制台');
    } finally {
      buttonLoading.value = false;
    }
  });
};

/** 回显:把后端扁平结构拆成各模块对象,逐个更新属性(保留引用,避免子组件持有的引用失效) */
const fillForm = (data) => {
  Object.assign(form.household, {
    householdType: data.householdType,
    address: data.address,
    moveInDate: data.moveInDate
  });
  Object.assign(form.education, {
    school: data.school,
    degree: data.degree,
    major: data.major,
    enrollDate: data.enrollDate
  });
  // members 是数组,保留数组引用,只替换元素
  form.family.members.splice(0, form.family.members.length, ...(data.members ?? []));
  formRef.value?.clearValidate();
};

/** 模拟后端返回的扁平结构 */
const loadMock = () => {
  const mockData = {
    id: Date.now(),
    householdType: '1',
    address: '北京市东城区',
    moveInDate: '2020-01-01',
    school: '清华大学',
    degree: '本科',
    major: '计算机科学',
    enrollDate: '2018-09-01',
    members: [
    
    ]
  };
  fillForm(mockData);
};

/** 清空表单 */
const cancel = () => {
  formRef.value?.resetFields();
  // 保留各子对象引用,只清空属性
  Object.assign(form.household, { householdType: undefined, address: undefined, moveInDate: undefined });
  Object.assign(form.education, { school: undefined, degree: undefined, major: undefined, enrollDate: undefined });
  form.family.members.splice(0, form.family.members.length);
  formRef.value?.clearValidate();
};

onMounted(() => {
  // 进入页面自动加载虚拟数据,演示回显效果
  loadMock();
});
</script>

HouseholdInfo 组件示例代码

javascript 复制代码
<template>
  <el-form-item label="户籍类型" :prop="`${propPrefix}.householdType`" :rules="rules.householdType">
    <el-select v-model="model.householdType" placeholder="请选择户籍类型" :disabled="disabled" clearable>
      <el-option label="城镇" value="1" />
      <el-option label="农村" value="2" />
    </el-select>
  </el-form-item>

  <el-form-item label="户口所在地" :prop="`${propPrefix}.address`" :rules="rules.address">
    <el-input v-model="model.address" placeholder="请输入户口所在地" :disabled="disabled" maxlength="100" />
  </el-form-item>

  <el-form-item label="迁入日期" :prop="`${propPrefix}.moveInDate`" :rules="rules.moveInDate">
    <el-date-picker
      v-model="model.moveInDate"
      type="date"
      value-format="YYYY-MM-DD"
      placeholder="请选择迁入日期"
      :disabled="disabled"
      class="!w-full"
    />
  </el-form-item>
</template>

<script setup name="HouseholdInfo">
const props = defineProps({
  /** 双向绑定的户籍信息对象 */
  modelValue: { type: Object, required: true },
  /** 是否禁用 */
  disabled: { type: Boolean, default: false },
  /** 校验路径前缀,嵌套对象时传 'household' */
  propPrefix: { type: String, default: 'household' }
});

defineEmits(['update:modelValue']);

/**
 * 关键:用 computed 保持对 props.modelValue 的响应式引用
 * 不能写 const model = props.modelValue (一次性解构,父组件替换引用后会指向旧对象)
 */
const model = computed(() => props.modelValue);

const rules = {
  householdType: [{ required: true, message: '请选择户籍类型', trigger: 'change' }],
  address: [{ required: true, message: '请输入户口所在地', trigger: 'blur' }],
  moveInDate: [{ required: false, message: '请选择迁入日期', trigger: 'change' }]
};

defineExpose({ rules });
</script>

EducationInfo组件示例代码:

javascript 复制代码
<template>
  <el-form-item label="毕业院校" :prop="`${propPrefix}.school`" :rules="rules.school">
    <el-input v-model="model.school" placeholder="请输入毕业院校" :disabled="disabled" maxlength="50" />
  </el-form-item>

  <el-form-item label="学历" :prop="`${propPrefix}.degree`" :rules="rules.degree">
    <el-select v-model="model.degree" placeholder="请选择学历" :disabled="disabled" clearable>
      <el-option label="博士" value="博士" />
      <el-option label="硕士" value="硕士" />
      <el-option label="本科" value="本科" />
      <el-option label="大专" value="大专" />
      <el-option label="其他" value="其他" />
    </el-select>
  </el-form-item>

  <el-form-item label="专业" :prop="`${propPrefix}.major`" :rules="rules.major">
    <el-input v-model="model.major" placeholder="请输入专业" :disabled="disabled" maxlength="50" />
  </el-form-item>

  <el-form-item label="入学日期" :prop="`${propPrefix}.enrollDate`" :rules="rules.enrollDate">
    <el-date-picker
      v-model="model.enrollDate"
      type="date"
      value-format="YYYY-MM-DD"
      placeholder="请选择入学日期"
      :disabled="disabled"
      class="!w-full"
    />
  </el-form-item>
</template>

<script setup name="EducationInfo">
const props = defineProps({
  modelValue: { type: Object, required: true },
  disabled: { type: Boolean, default: false },
  propPrefix: { type: String, default: 'education' }
});

defineEmits(['update:modelValue']);

/** 用 computed 保持对 props.modelValue 的响应式引用 */
const model = computed(() => props.modelValue);

const rules = {
  school: [{ required: true, message: '请输入毕业院校', trigger: 'blur' }],
  degree: [{ required: true, message: '请选择学历', trigger: 'change' }],
  major: [{ required: false, message: '请输入专业', trigger: 'blur' }],
  enrollDate: [{ required: false, message: '请选择入学日期', trigger: 'change' }]
};

defineExpose({ rules });
</script>

FamilyInfo 组件示例代码:

javascript 复制代码
<template>
  <div v-for="(member, index) in model.members" :key="index" class="family-row">
    <el-form-item
      label="姓名"
      :prop="`${propPrefix}.members.${index}.name`"
      :rules="{ required: true, message: '请输入成员姓名', trigger: 'blur' }"
    >
      <el-input v-model="member.name" placeholder="请输入姓名" :disabled="disabled" maxlength="20" />
    </el-form-item>

    <el-form-item
      label="关系"
      :prop="`${propPrefix}.members.${index}.relation`"
      :rules="{ required: true, message: '请选择关系', trigger: 'change' }"
    >
      <el-select v-model="member.relation" placeholder="请选择关系" :disabled="disabled" clearable>
        <el-option label="父亲" value="父亲" />
        <el-option label="母亲" value="母亲" />
        <el-option label="配偶" value="配偶" />
        <el-option label="子女" value="子女" />
        <el-option label="兄弟姐妹" value="兄弟姐妹" />
        <el-option label="其他" value="其他" />
      </el-select>
    </el-form-item>

    <el-form-item
      label="联系电话"
      :prop="`${propPrefix}.members.${index}.phone`"
      :rules="phoneRule"
    >
      <el-input v-model="member.phone" placeholder="请输入联系电话" :disabled="disabled" maxlength="11" />
    </el-form-item>

    <el-form-item v-if="!disabled" label=" ">
      <el-button type="danger" plain icon="Delete" @click="removeMember(index)">删除</el-button>
    </el-form-item>
  </div>

  <el-form-item v-if="!disabled" label=" ">
    <el-button type="primary" plain icon="Plus" @click="addMember">添加家庭成员</el-button>
  </el-form-item>
</template>

<script setup name="FamilyInfo">
const props = defineProps({
  modelValue: { type: Object, required: true },
  disabled: { type: Boolean, default: false },
  propPrefix: { type: String, default: 'family' }
});

defineEmits(['update:modelValue']);

/** 用 computed 保持对 props.modelValue 的响应式引用 */
const model = computed(() => props.modelValue);

const phoneRule = [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }];

/** 添加一行 */
const addMember = () => {
  props.modelValue.members.push({ name: undefined, relation: undefined, phone: undefined });
};

/** 删除一行 */
const removeMember = (index) => {
  props.modelValue.members.splice(index, 1);
};

defineExpose({ addMember, removeMember });
</script>

<style scoped>
.family-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0 12px;
  padding: 8px 0;
  border-bottom: 1px dashed var(--el-border-color);
}
</style>
相关推荐
Cxiaomu1 小时前
从 TRTC Demo 到独立音视频服务:React + TRTC Web SDK 服务化实践
前端·react.js·音视频
索西引擎1 小时前
【React】Redux 中间件机制:副作用处理与数据流增强的形式化分析
前端·react.js·中间件
寒草2 小时前
「寒草呈献」工作六年,是否仍有创造未来的勇气 ✨
前端·后端
HackTwoHub2 小时前
解锁 AI 红队全新玩法!Claude-Red 攻防 Skill 库,内置 SQLi、XXE、文件上传等 Web 专项 Skill,一键导入快速落地渗透实战
前端·人工智能·web安全·网络安全·自动化·系统安全
石小石Orz2 小时前
如何设计一个优秀的 Skills
前端·人工智能
程序员爱钓鱼3 小时前
Rust 切片 Slice 详解:安全访问连续数据
前端·后端·rust
alexander0685 小时前
CSS 类选择器组合
前端·css
寅时码10 小时前
React 之死·终章:一个 useRef,把闭包陷阱、依赖数组、漫天 rerender 全送走
前端·react.js·ai编程
不好听61310 小时前
HTML 事件监听机制:从 DOM Level 0 到 React 合成事件
前端·react.js·html