Vue3+TypeScript 企业级前端业务组件封装:Schema、字段权限与附件双向绑定
🌐文档地址:https://ruoyioffice.com
📦源码1·GitHub:https://github.com/yuqing2026/ruoyi-office
📦源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
📦源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬微信:17156169080(备注「RuoYi Office」)
真正难复用的不是输入框,而是“审批中的输入框”:同一字段在发起人、审批人、已办查看、抄送查看中可能分别是必填、可写、只读和隐藏;附件还要同时支持上传、预览、下载、删除与只读切换。本文不讲简单的 UI 包装,而是拆解一套可落地的 Vue3+TypeScript 企业级业务组件模型。
▲ 组件封装的核心不是把模板搬进公共目录,而是把 Schema、流程状态和字段权限合并成一个确定的渲染结果
引言:为什么“把代码复制成组件”仍然会失控?
业务团队通常从一个朴素方案开始:把表头、表单、附件表格和底部按钮抽成几个.vue文件。前十个页面看起来很顺利,到第五十个页面时却会出现四类问题:
| 表象 | 根因 | 最终后果 |
|---|---|---|
| 每个页面都传十几个布尔值 | 组件只抽了 DOM,没有抽状态模型 | Props 组合爆炸,边界不可推理 |
disabled被 watcher 反复覆盖 | 页面状态、字段权限、控件自身规则没有优先级 | 审批人该填的字段填不了 |
| 父组件回填一次,用户刚输的值消失 | 整包setValues覆盖了表单内部状态 | 低频随机丢数据,最难排查 |
| 附件删不掉或删错行 | 表格克隆行对象,代码却用对象引用比较 | 上传、编辑、回显后行为不一致 |
所以,企业级组件要解决的不是“少写几行模板”,而是三个更深的问题:
- 契约稳定:业务页只传领域数据,不感知组件内部实现;
- 策略可合并:页面状态、流程权限和字段自身规则有明确优先级;
- 数据不互相踩踏:外部回填、内部编辑、异步附件各走可预测的数据通道。
一、先分层:原子组件、平台组件与业务组件不能混在一起
1.1 三层职责
| 层次 | 典型内容 | 应该知道什么 | 不应该知道什么 |
|---|---|---|---|
| UI 原子层 | Input、Select、DatePicker、Table | 样式、交互、可访问性 | 单据、审批、权限 |
| 平台能力层 | Schema Form、Grid、Modal、File Preview | 通用数据驱动能力 | 某张业务表 |
| 企业业务层 | BasicForm、AttachmentList、审批轨迹 | 单据状态、字段权限、流程上下文 | 用印或应收的特有字段 |
BasicForm的定位是企业业务层的“页面骨架”。它统一表头、Tab、流程进度、审批记录、Schema 表单和底部动作,但不负责“印章怎么选”“应收金额怎么算”。后者仍留在业务页面。
这条边界非常重要:复用稳定机制,不复用偶然业务。
1.2 一个页面只保留三类代码
以用印申请为例,业务页主要保留:
SealApplyBill领域类型与接口调用;useFormSchema()描述字段、控件和校验;- 印章选择弹窗等模块特有交互。
表头、审批 Tab、撤回/取回、字段权限和附件表格都交给公共组件。
▲ 用印申请单:业务字段各不相同,但表头、状态、Tab、分区标题、附件和底部动作保持一致
二、TypeScript 契约:先把组件“允许什么”说清楚
2.1 Props 不等于参数越多越灵活
一个可维护的组件契约应围绕稳定概念组织,而不是把每个按钮都变成开关。
interfaceProps{headerData?:HeaderData;formData?:Record<string,any>;formSchema?:VbenFormSchema[];fieldPermission?:BusinessFieldPermission;processDefinitionKey?:string;processStatus?:number;viewType?:string;isApproval?:boolean;disabled?:boolean;columns?:number;}这里的参数可以分成四组:
| 参数组 | 代表字段 | 作用 |
|---|---|---|
| 单据上下文 | headerData | 单号、标题、申请人、组织、流程实例 |
| 表单描述 | formData、formSchema | 数据与结构分离 |
| 权限上下文 | fieldPermission、isApproval | 当前节点能看什么、改什么 |
| 页面状态 | viewType、processStatus、disabled | 新建、待办、已办、抄送等视图 |
相比“隐藏提交按钮、隐藏撤回按钮、隐藏删除按钮……”无限增长,状态上下文能让组件自己推导大部分 UI。少数业务差异再通过hideXxx兜底,不让例外反过来主导模型。
2.2 Emits 只表达业务意图
组件不应该直接调用某个业务模块的保存接口。它只发出稳定的意图:
constemit=defineEmits(['close','save','submit','revoke','reCreate','delete','withdraw','valuesChange',]);submit不是一个裸点击事件,它还会携带提交原因和发起人自选审批人:
emit('submit',{reason:submitReasonForm.value.reason,startUserSelectAssignees:submitAssignees.value,});这样做有两个收益:
- 组件掌握通用审批交互,业务页不重复造弹窗;
- 最终 API 调用仍由业务页负责,组件不会耦合具体模块。
2.3defineExpose是受控能力口,不是把内部全部暴露
父页面确实需要在提交前调用校验、获取表单值,或者审批后刷新轨迹。可显式暴露最小能力:
defineExpose({refreshAllData,asyncgetFormValues(){returnformApi?awaitformApi.getValues():{};},asyncvalidateForm(){returnformApi?awaitformApi.validate():{valid:true};},asyncsetFormValues(values:any,shouldValidate=false){returnformApi?awaitformApi.setValues(values,true,shouldValidate):undefined;},});不要暴露整个formApi。否则业务页面会绕过组件规则直接改 Schema、清校验、改禁用状态,公共组件很快只剩一层壳。
三、Schema 不是配置表,而是字段策略的中间表示
3.1 为什么要用 Schema?
传统模板把字段结构、控件、规则和布局散落在<template>中。Schema 把这些信息变成数据:
{fieldName:'cause',label:'用章事由',component:'Textarea',rules:'required',componentProps:{rows:3,placeholder:'请输入用章事由',},formItemClass:'col-span-4',}一旦字段变成数据,组件才能统一施加权限、只读、隐藏、必填和布局策略。
3.2 权限合并需要明确优先级
最终字段状态不是简单的disabled = props.disabled。它至少有五个来源:
- 抄送、已办等视图强制只读;
- 页面显式
disabled; - 流程节点配置的字段权限;
- 字段自身
componentProps.disabled; - 字段动态函数根据其他值计算出的禁用状态。
推荐优先级是:
字段自身强制禁用 > 流程节点 HIDDEN / READONLY / EDITABLE / REQUIRED > 页面全局只读 > 默认 Schema组件中的关键逻辑如下:
constresolvedSchema=computed<VbenFormSchema[]>(()=>{returnprops.formSchema.map((schema)=>{constfieldName=schema.fieldName;consthidden=isFieldHidden(fieldName);constrequired=isFieldRequired(fieldName);constownDisabled=schema.componentProps?.disabled===true;constfieldDisabled=hidden?effectiveDisabled.value:ownDisabled?true:isFieldEditable(fieldName)?false:isFieldReadonly(fieldName)?true:effectiveDisabled.value;return{...schema,hide:hidden||!!schema.hide,rules:required?'required':hidden?undefined:schema.rules,componentProps:{...schema.componentProps,disabled:fieldDisabled},};});});最容易被忽略的一点是:EDITABLE必须能覆盖页面的普通只读状态,否则审批节点配置了“允许审批人补充字段”,实际页面仍然不可编辑。
3.3 函数型componentProps不能被静态合并破坏
有些字段会根据其他字段动态返回组件属性:
componentProps:(values)=>({disabled:values.useType!==2,options:buildOptions(values.companyId),})这时不能把它当普通对象展开。正确做法是包装原函数,再合并最终权限:
constoriginal=schema.componentPropsasFunction;return{...schema,componentProps:(values:any,formApi:any)=>{constresolved=original(values,formApi);return{...resolved,disabled:resolved?.disabled===true?true:fieldDisabled,};},};这保证业务字段自身的动态约束仍然有效,同时保留流程层的权限控制。
四、为什么只保留一个 Schema Watcher?
4.1 多 watcher 的竞争问题
常见实现会分别监听:
disabled变化;fieldPermission变化;formSchema变化;processStatus变化。
每个 watcher 都调用updateSchema()。当审批详情和业务数据并行返回时,执行顺序取决于异步时机,最后一次写入可能把前面的权限覆盖掉。
更稳定的方式是先用一个computed合并所有输入,再用唯一 watcher 同步:
watch(resolvedSchema,(newSchema)=>{if(!formApi){if(newSchema.length>0)initForm();return;}if(newSchema.length>0){formApi.updateSchema(newSchema);}},{deep:true},);这实际上把“多条命令式更新”改成了“一个声明式结果”:
最终 Schema = f(原始 Schema, 页面状态, 字段权限, 审批上下文)只要输入相同,结果就相同,调试成本会显著下降。
4.2 规则变更和数据变更要分开
Schema watcher 只更新规则,数据 watcher 只更新值。不要在同一个 watcher 中既updateSchema又setValues,否则字段重建、校验触发和数据覆盖会互相影响。
五、局部 Patch:避免父组件回填覆盖用户输入
5.1 一个真实的丢值场景
用户先手动选择“调入公司”,随后业务页异步查到“调出公司名称”,把新的formData整包传回组件。若组件直接:
formApi.setValues(props.formData);父组件旧快照中的“调入公司”就会覆盖用户刚选的新值。
5.2 只同步真实变化且属于 Schema 的字段
组件先比较前后快照,再构建 patch:
constchangedKeys=getChangedFormDataKeys(newData,prevFormDataSnapshot.value,);prevFormDataSnapshot.value=cloneDeep(newData);constpatch:Record<string,any>={};for(constkeyofchangedKeys){if(formFieldNames.has(key)){patch[key]=newData[key];}}if(Object.keys(patch).length>0){awaitformApi.setValues(patch);}这里还有两个细节:
- 用
cloneDeep,因为附件扩展字段可能含Date或函数,structuredClone不一定能处理; - 过滤非 Schema 字段,避免附件数组、流程快照等扩展数据触发表单重置。
这是一条可以推广到所有复杂表单的原则:外部状态回填必须是 patch,而不是 replace。
六、附件组件:双向绑定最容易被低估的部分
6.1 附件不是 Upload 的简单包装
一个企业附件组件至少需要:
| 能力 | 关键问题 |
|---|---|
| 上传 | 数量、类型、大小、并发完成 |
| 回显 | 已保存附件与临时附件共存 |
| 编辑 | 备注、排序、删除 |
| 预览 | 图片、PDF、Office 文件和不支持格式降级 |
| 下载 | 私有文件先换取可访问 URL |
| 只读 | 动态隐藏上传、删除和可编辑备注 |
在同一套组件下,应收单也能获得与 OA 单据一致的附件交互。
▲ 财务应收单与用印申请字段完全不同,但复用了同一套页面骨架和附件组件
6.2v-model回流必须防止 watch 死循环
组件内部维护tableData,变化后 emit 新数组;父组件接收后又更新modelValue。没有方向标记就会来回触发:
letinternalUpdate=false;functionhandleUpdateValue(){internalUpdate=true;emit('update:modelValue',tableData.value.map((item)=>({...item})),);}watch(()=>props.modelValue,async(attachments)=>{if(internalUpdate){internalUpdate=false;return;}tableData.value=attachments.map((item)=>ensureRowKey({...item}));awaitreloadAttachmentGrid();},{deep:true},);注意 emit 时复制每一项,避免父子组件共享对象引用后发生“没 emit 但外部数据已被改”的隐式副作用。
6.3 表格行不能靠对象引用识别
表格组件可能克隆行对象,item === row并不可靠。附件组件使用稳定rowKey,并依次回退到id、fileUrl和组合字段:
functionisSameAttachment(item:Attachment,row:Attachment){if(item.rowKey&&row.rowKey){returnitem.rowKey===row.rowKey;}if(item.id!=null&&row.id!=null){returnitem.id===row.id;}if(item.fileUrl&&row.fileUrl){returnitem.fileUrl===row.fileUrl;}returnitem.fileName===row.fileName&&item.fileSize===row.fileSize&&String(item.uploadTime??'')===String(row.uploadTime??'');}这是“页面偶尔删错附件”与“长期稳定运行”的分水岭。
七、把审批上下文也收进组件,而不是每页复制
7.1 同一业务数据,不同视图语义
viewType不只是路由来源,它决定页面能力:
| 视图 | 数据可见性 | 字段编辑 | 底部动作 |
|---|---|---|---|
| 新建/我的 | 完整 | 按业务规则 | 保存、提交、删除 |
| 待办 | 完整 | 按节点字段权限 | 审批动作 |
| 已办 | 完整 | 强制只读 | 取回(后端判定可取回时) |
| 抄送 | 完整 | 强制只读 | 关闭 |
组件对已办和抄送做兜底只读,避免某个业务页漏传disabled后出现越权编辑。
7.2 审批信息与业务表单共享一个上下文
▲ 同一个 BasicForm 根据流程实例加载审批进度与记录,业务页无需重复实现轨迹查询和展示
当headerData.processInstanceId存在时,组件自动加载流程图、审批详情和任务列表;已办视图还会向后端查询是否可取回。后端是最终授权者,前端只负责展示结果,不能仅靠按钮隐藏决定安全。
八、组件 API 的设计检查表
8.1 Props
- 是否围绕稳定领域概念,而不是无限布尔开关?
- 是否提供安全默认值?
- 对象和数组默认值是否用工厂函数?
- 是否区分“全局只读”和“字段自身禁用”?
8.2 Emits
- 是否表达业务意图,而不是暴露内部 DOM 事件?
- Payload 是否有 TypeScript 类型?
- 是否避免组件直接调用具体业务 API?
8.3 Expose
- 是否只暴露校验、取值、刷新等必要能力?
- 是否避免把整个内部 API 暴露给父组件?
8.4 响应式同步
- 多输入是否先
computed合并,再单点同步? - 外部回填是否局部 patch?
- 双向绑定是否区分内部更新与外部更新?
- 列表项是否有跨克隆稳定的 key?
九、这种封装什么时候不适用?
不是所有页面都应该塞进BasicForm。
以下页面更适合独立实现:
- 大屏、驾驶舱、甘特图等强布局页面;
- Excel 式高密度编辑器;
- 画布、流程设计器、富文本编辑器;
- 交互主导而非单据主导的聊天、看板页面。
判断标准不是“能不能套进去”,而是“套进去后业务页是否只剩领域差异”。如果为了复用需要大量插槽、几十个布尔值和样式穿透,说明抽象边界错了。
十、技术亮点总结
| 设计要点 | 实现方式 | 业务价值 |
|---|---|---|
| 类型化契约 | Props / Emits / Expose 分工 | 调用方式可发现、可重构 |
| Schema 中间表示 | 字段结构数据化 | 权限和状态可统一变换 |
| 单一策略合并 | resolvedSchemacomputed | 消除多 watcher 竞争 |
| 权限优先级 | 自身规则 + 节点权限 + 页面状态 | 防止错误可写或不可写 |
| 局部数据同步 | changed keys + schema 过滤 | 不覆盖用户正在编辑的值 |
| 附件稳定身份 | rowKey + 多级回退 | 避免表格克隆导致删错 |
| 受控双向绑定 | internal update 标记 | 避免循环和隐式共享引用 |
| 审批上下文复用 | 轨迹、流程图、取回状态统一加载 | 大幅减少业务页重复代码 |
十一、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
推荐体验路径:
- 进入 OA → 印章管理 → 用印申请;
- 打开一张审批中的申请,观察统一表头和状态;
- 切换“单据信息 / 审批信息 / 流程图”;
- 对比待办、已办和我的申请中的字段与按钮;
- 再进入财务 → 应收应付 → 应收单;
- 对比两个业务域的表单分区、附件区和底部动作。
源码仓库:
- GitHub:https://github.com/yuqing2026/ruoyi-office
- GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
- Gitee:https://gitee.com/yqzy1688/ruoyi-office
常见问题(FAQ)
Vue3 企业级组件是不是封装得越大越好?
不是。组件应该收口稳定机制,而不是吞掉所有业务。表头、字段权限、附件和审批上下文稳定,适合复用;模块特有选择器、金额计算和领域校验应留在业务页。
Schema 表单会不会比手写模板更难调试?
简单表单确实没必要强行 Schema 化;当字段需要统一叠加权限、只读和动态校验时,Schema 能把最终规则收敛为一个可检查的数据结果,反而更容易定位问题。
为什么字段权限不能只在后端控制?
后端必须做最终权限校验,但前端仍要正确呈现隐藏、只读、可写和必填状态。两者职责不同:前端保证体验,后端保证安全。
Vue3 的 v-model 为什么会出现死循环?
父组件更新modelValue,子组件 watch 后再次 emit,若没有区分更新来源就会往返触发。可以使用内部更新标记,或采用单向数据流加显式提交。
如何判断一个组件是否值得沉淀为公共组件?
至少被三个业务场景复用,并且它们共享的是规则和状态模型,而不只是相似样式;同时新增一个业务时无需修改大量公共组件分支。
结语
企业级组件封装的最终目标,不是让页面文件变短,而是让状态变得可推理:任何一个字段为什么隐藏、为什么必填、为什么只读,都能从契约和优先级中得到唯一答案。
当类型契约、Schema 中间表示、权限合并、局部 patch 和稳定行身份建立起来后,OA、财务、人力、资产等不同业务才能真正共享一套可靠的前端底座。
💡想要体验 RuoYi Office 的强大功能?
🌐在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬技术咨询:添加微信17156169080,备注「RuoYi Office」
⭐如果觉得不错,请给个 Star 支持一下!