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)
📊 效果展示
- 动态表单:切换合同类型,字段实时变化
- 智能校验:数组项必填校验、格式校验
- 文件管理:预览、下载、在线编辑一体化
- 锚点导航:右侧悬浮目录,快速跳转
- 历史导入:一键复用旧合同数据
✅ 总结
本方案通过配置驱动的设计思想,将前端表单从"硬编码"中解放出来,实现了高度的灵活性和可扩展性。
核心亮点:
- 元数据驱动:后端配置决定前端形态
- 细粒度权限:字段级的显示/编辑/必填控制
- 复杂场景支持:数组校验、文件在线编辑、历史数据清洗
- 用户体验优化:锚点导航、防抖提交、弹框定位
这套架构不仅适用于合同管理,也可复用于 CRM、ERP 等各类 B 端系统的动态表单场景。
💡 提示:在生产环境中,建议增加字段配置的缓存机制、操作日志记录以及更完善的异常处理。
技术栈 :Vue3 | TypeScript | Vite | Ant Design Vue | Axios
适用场景:SaaS 平台、低代码系统、企业级后台管理系统
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏!代码已开源,欢迎交流~