Vue3 + TypeScript 实战:打造企业级动态合同表单系统(附完整源码)

Vue3 + TypeScript 实战:打造企业级动态合同表单系统(附完整源码)

在 SaaS 化的合同管理系统中,如何让用户自定义每个业务模块的字段、验证规则和显示逻辑?本文将带你深入剖析一个基于 Vue3 + TypeScript + Ant Design Vue 的动态表单架构,实现从"配置驱动"到"流程审批"的全链路解决方案。


🎯 项目背景

传统的硬编码表单在面对复杂多变的业务需求时显得力不从心。我们的合同管理系统需要支持:

  • 动态字段配置:不同合同类型展示不同字段
  • 灵活验证规则:必填、长度、自定义校验 dynamically 生成
  • 模块化布局:基本信息、相对方、履约节点等模块可插拔
  • 文件在线编辑:集成第三方 Office 在线编辑能力
  • 历史数据导入:智能回填可编辑字段

为此,我们设计了一套「配置驱动」的表单引擎。


🏗️ 核心架构设计

1. 整体页面结构

vue 复制代码
<template>
  <div class="contract-container">
    <!-- 顶部操作栏 -->
    <page-header>
      <template #extra>
        <a-button @click="handleImportHistory">历史合同导入</a-button>
        <a-button @click="router.back()">返回</a-button>
      </template>
    </page-header>

    <div class="contract-content">
      <!-- 右侧锚点导航 -->
      <div class="right-anchor">
        <a-anchor :getContainer="getContainer">
          <a-anchor-link 
            v-for="group in data.fieldGroupList" 
            :key="group.id" 
            :href="`#${group.id}`"
            :title="group.name"
          />
        </a-anchor>
      </div>

      <!-- 动态表单区域 -->
      <a-form ref="ruleForm" :model="data.form" :rules="data.rules">
        <div v-for="group in data.fieldGroupList" :key="group.id" :id="group.id">
          <!-- 核心:动态渲染组件 -->
          <BaseMessage 
            :group="group" 
            :fieldList="getVisibleFields(group)" 
            :form="data.form" 
          />
        </div>
      </a-form>
    </div>

    <!-- 底部操作栏 -->
    <div class="handle-bottom">
      <a-button type="primary" @click="debounceSave">提交</a-button>
      <a-button @click="handleCache">暂存</a-button>
    </div>
    
    <!-- 各类弹窗组件 -->
    <ImportHistoryContractModal @select="handleHistoryContractSelect" />
    <ProcessDetailModal />
  </div>
</template>

2. 动态字段配置引擎

后端返回的 fieldGroupList 决定了表单的形态。我们通过递归处理生成验证规则:

typescript 复制代码
/**
 * 根据合同类别获取字段配置并生成验证规则
 */
const findContractFieldConfigListTree = async (categoryId: number | undefined) => {
  const [err, res] = await to(FindContractFieldConfigListTree(categoryId))
  
  if (!err && res) {
    // 1. 过滤显示的分组和字段
    data.fieldGroupList = res
      .filter(group => group.id !== 5) // 排除特定模块
      .map(group => ({
        ...group,
        children: (group.children || []).filter(item => item?.whetherShow)
      }))
    
    // 2. 动态生成验证规则
    data.rules = {}
    data.fieldGroupList?.forEach(group => {
      if ([2, 4, 8].includes(group.id)) {
        // 特殊模块(相对方、履约节点、关联合同)使用自定义校验器
        generateCustomRules(group)
      } else {
        // 普通字段使用通用规则生成
        getFieldRules(group.children || [])
      }
    })
    
    // 3. 切换类别后清空旧校验状态
    await nextTick()
    ruleForm.value?.clearValidate()
  }
}

/**
 * 递归添加普通表单规则
 */
const getFieldRules = (fieldList: any[]) => {
  fieldList?.forEach(field => {
    if (field.whetherShow && field.whetherEdit && field.whetherMust) {
      // 针对不同控件类型设置不同的触发方式
      const changeTriggerFieldIds = [6, 24, 25, 52] // 下拉、上传等
      const trigger = changeTriggerFieldIds.includes(field.id) 
        ? ['change', 'blur'] 
        : 'blur'

      data.rules[field.key] = [{
        required: true,
        message: '请填写必填项',
        trigger
      }]
    }
    // 递归处理嵌套字段
    if (field.children?.length) {
      getFieldRules(field.children)
    }
  })
}

3. 复杂模块的自定义校验

对于"相对方"、"履约节点"这类数组型数据,我们需要校验数组内每一项的必填字段:

typescript 复制代码
/**
 * 创建针对数组模块的自定义校验器
 * @param groupId 模块ID (2=相对方, 4=履约节点, 8=关联合同)
 * @param fieldKey 字段Key
 */
const createCustomValidator = (groupId: number, fieldKey: string) => {
  return async (_rule: Rule, value: string) => {
    let arrayData: any[] = []
    
    // 获取对应的数组数据
    if (groupId === 2) arrayData = data.form.counterpartList || []
    else if (groupId === 8) arrayData = data.form.relatedContract || []
    else if (groupId === 4) arrayData = data.form.fulfillmentNodes || []

    if (arrayData.length === 0) {
      return Promise.reject('请至少添加一条数据')
    }

    // 遍历检查每一行
    for (let i = 0; i < arrayData.length; i++) {
      const row = arrayData[i]
      const fieldItem = row.find((item: any) => item?.key === fieldKey)
      
      const fieldValue = fieldItem?.[fieldKey] ?? fieldItem?.value
      
      if (!fieldValue) {
        return Promise.reject(`第${i + 1}条数据未填写`)
      }
    }
    
    return Promise.resolve()
  }
}

📂 文件上传与在线编辑

1. 封装通用上传组件

upload-file-field.vue 实现了文件预览、下载、删除及"使用范本"功能:

vue 复制代码
<template>
  <div class="upload-box">
    <div class="upload-header">
      <span>{{ fieldLabel }}</span>
      <!-- 合同范本按钮 -->
      <template v-if="isContractMainText && hasTemplates">
        <a-button type="link" @click="handleUseTemplate">使用合同范本</a-button>
      </template>
      <!-- 上传按钮 -->
      <a-upload :customRequest="customRequest">
        <a-button type="link">上传文件</a-button>
      </a-upload>
    </div>
    
    <div class="upload-body">
      <div v-for="(file, index) in fileList" :key="file.id" class="file-item">
        <span @click="preview(file)">{{ file.name }}</span>
        
        <!-- 在线编辑入口 -->
        <template v-if="isOnlineEditSupported(file)">
          <span @click="handleOnlineEdit(file)">在线编辑</span>
        </template>
        
        <span @click="downloadFile(file)">下载</span>
        <span @click="deleteContractList(index)">删除</span>
      </div>
    </div>
  </div>
</template>

2. 集成第三方在线编辑

通过生成签名参数,安全地打开第三方 Office 编辑页面,并轮询获取编辑后的文件 ID:

typescript 复制代码
const handleOnlineEdit = (file: any) => {
  const userId = store.state.userInfo.userId
  const cacheKey = `online_edit_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
  const editMode = 2 // 强制留痕模式
  const signSalt = 'CloudTopContractSavePageOfficeOnlineEditFile'
  
  // 生成双重 MD5 签名
  const signStr = `${userId}${cacheKey}${file.id}${editMode}${signSalt}`
  const sign = doubleMd5(signStr)
  
  const onlineUrl = 'https://e.wfec.edu.cn/ContractPageOffice'
  const url = `${onlineUrl}/gateway?cacheKey=${cacheKey}&fileId=${file.id}&editMode=${editMode}&sign=${sign}`
  
  // 通知父组件开始轮询结果
  emitter.emit('startOnlineEditPolling', { cacheKey, fileId: file.id })
  
  window.open(url)
}

🔄 历史合同导入策略

导入不是简单的全量覆盖,而是仅回填"可编辑"字段

typescript 复制代码
const handleHistoryContractSelect = async (record: any) => {
  // 1. 先加载目标类别的字段配置
  await findContractFieldConfigListTree(record.categoryId)
  await nextTick() // 等待 DOM 更新
  
  // 2. 获取合同详情
  const [err, res] = await to(FindContractDetailsInfo(record.id))
  
  // 3. 数据清洗与格式化
  const patch = buildFormPatchFromDetail(res)
  
  // 4. 核心:只回填允许编辑的字段
  const editableKeys = getEditableFieldKeys()
  Object.keys(patch).forEach(key => {
    if (editableKeys.has(key) && patch[key] !== undefined) {
      data.form[key] = patch[key]
    }
  })
  
  // 5. 清除校验状态
  ruleForm.value?.clearValidate()
}

/**
 * 递归收集所有可编辑字段的 Key
 */
const getEditableFieldKeys = (): Set<string> => {
  const keys = new Set<string>()
  const collect = (fields: any[]) => {
    fields.forEach(f => {
      if (f.whetherEdit && f.key) keys.add(f.key)
      if (f.children?.length) collect(f.children)
    })
  }
  data.fieldGroupList?.forEach(g => collect(g.children || []))
  return keys
}

💡 关键难点与解决方案

1. 弹框定位问题

问题 :表单在滚动容器内,Select/Dropdown 弹框位置错乱。
解决 :统一使用 getPopupContainer 指向滚动容器。

typescript 复制代码
const getPopupContainer = (triggerNode: HTMLElement) => {
  const contractContent = document.querySelector('.contract-content')
  return contractContent || triggerNode.parentElement || document.body
}

2. 字段联动显隐

问题 :选择"否签订合同"时,隐藏相关字段但保留备注。
解决 :在 BaseMessage 组件中通过 getVisibleFields 动态过滤。

typescript 复制代码
const getVisibleFields = (group: any): any[] => {
  if (group.id === 9 && data.form.whetherSignContract === 0) {
    // 仅保留是否签订和备注
    return group.children.filter(f => 
      f.key === 'whetherSignContract' || f.key === 'contractSignRemark'
    )
  }
  return group.children
}

3. 防抖提交

防止用户重复点击提交按钮:

typescript 复制代码
import { debounce } from '@/utils/index'

const handleSave = async () => {
  await ruleForm.value.validate()
  await submitOperate(false)
}

// 500ms 防抖
const debounceSave = debounce(handleSave, 500)

📊 效果展示

  • 动态表单:切换合同类型,字段实时变化
  • 智能校验:数组项必填校验、格式校验
  • 文件管理:预览、下载、在线编辑一体化
  • 锚点导航:右侧悬浮目录,快速跳转
  • 历史导入:一键复用旧合同数据

✅ 总结

本方案通过配置驱动的设计思想,将前端表单从"硬编码"中解放出来,实现了高度的灵活性和可扩展性。

核心亮点

  1. 元数据驱动:后端配置决定前端形态
  2. 细粒度权限:字段级的显示/编辑/必填控制
  3. 复杂场景支持:数组校验、文件在线编辑、历史数据清洗
  4. 用户体验优化:锚点导航、防抖提交、弹框定位

这套架构不仅适用于合同管理,也可复用于 CRM、ERP 等各类 B 端系统的动态表单场景。

💡 提示:在生产环境中,建议增加字段配置的缓存机制、操作日志记录以及更完善的异常处理。


技术栈 :Vue3 | TypeScript | Vite | Ant Design Vue | Axios
适用场景:SaaS 平台、低代码系统、企业级后台管理系统

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏!代码已开源,欢迎交流~