:前端篇——从菜单路由到表单列表,完成业务页面)
7天学会SpringBootVue3企业级项目RuoyiOffice四前端篇——从菜单路由到表单列表完成业务页面文档地址https://ruoyioffice.com 文章底部获取源码和演示地址 17156169080获取产品咨询这是《7天学会SpringBootVue3企业级项目RuoyiOffice》系列第 4 篇。昨天后端把用车申请的接口做好了今天让用户真正看到它点开菜单进入列表新增一张单据保存、提交、撤回、删除。读完你能回答三个问题一个业务页面由哪几个文件组成、菜单与路由到底在哪里把它们连起来、页面上的按钮和字段应该跟着哪些状态变化。▲ 本篇核心视觉左边是用车申请单列表的线框六个编号标出菜单、搜索表单、工具栏、表格列、数据请求和跳转表单右边写明每个编号落在哪个文件、哪项配置。读完正文你应该能闭着眼睛把这张图补全。引言一个页面到底由什么组成很多人第一次接手 Vben 项目会盯着views目录问我要加一个页面到底该复制哪个文件这个问题的答案不在某个文件里而在一条链路里菜单记录决定入口组件路径决定加载哪个文件API 文件决定怎么说话data.ts决定长什么样index.vue决定怎么动。把这条链路看清楚复制谁都不会出错。决策点选择理由案例页面OA 用车申请单的列表页与表单页同时具备搜索、分页、权限按钮、字典、日期、弹窗选择和审批状态联动页面拆分list与info两个目录各自带data.ts列表和表单是两条独立的职责线字段定义不混在.vue里路由来源菜单记录里的组件路径而不是手写路由文件业务菜单由后台配置授权、隐藏、可见端都在同一处管理状态联动以流程状态为准页面只读不自己再造一套状态与第三篇的状态模型对齐避免前后端各说各话说明本文代码均节选自ruoyi-office-vben/apps/web-antd/src为便于阅读省略了部分导入与空行。菜单字段取自开发库静态数据导出文件具体行以你自己库中的system_menu为准。一、先看入口菜单记录怎么指向页面浏览器地址变化之后路由怎么知道要加载views/oa/car/carapply/list/index.vue答案是菜单表里的一行。用车申请有两条菜单记录一条在侧栏可见一条隐藏菜单项路由路径组件路径组件名是否显示在侧栏用车申请单管理列表car-apply-listoa/car/carapply/list/indexCarApplyList显示用车申请单详情表单car-apply-infooa/car/carapply/info/indexCarApplyInfo隐藏两条记录的父级相同都挂在「车辆管理」目录下。表单页之所以做成隐藏菜单是因为它不需要在侧栏出现却需要拥有自己的路由和授权。列表页里「新增」和点击单据编号都是通过router.push跳到这个隐藏路由。▲ 菜单管理「组件路径」一列就是页面文件相对于views目录的位置「权限标识」一列就是按钮上auth里写的那串字符。菜单记录还有一个容易被忽略的字段可见端。在新增菜单的弹窗里可以选择 PC、App 或两端右侧的「影响面」面板会实时提示这条菜单会不会出现在 PC 侧栏、流程中心发起、App 工作台等位置。▲ 新增菜单弹窗同一份菜单配置同时决定 PC 与移动端的入口右侧的「影响面」帮管理员在保存前看清后果。另外用车申请在流程模型里还登记了两个路径用于「在流程中心发起」和「在审批里嵌入详情」配置项值谁使用formCustomCreatePath/oa/car/car-apply-info流程中心发起时跳转到哪个路由formCustomViewPath/oa/car/carapply/info/index.vue审批详情里内嵌哪个组件这意味着info/index.vue同时要服务三种场景从列表新建或编辑、从流程中心发起、在审批页里作为只读详情。这也是为什么它的 props 里有isApproval、viewType和processInstance。第六篇会专门展开流程侧这里只需要记住路径变更要改菜单或流程模型不要改页面去适配错误的配置。二、API 层前端与后端之间唯一的一份契约页面不直接写请求地址所有接口集中在src/api/oa/car/carapply/index.ts。它做两件事用namespace声明数据结构用函数封装每个接口。exportnamespaceCarApplyBillApi{/** 用车申请单信息 */exportinterfaceCarApplyBill{id:number;// IDbillCode?:string;// 单据编号processInstanceId:string;// 流程实例编号processStatus:number;// 单据状态carId:number;// 车辆carNo:string;// 车牌号码goTime:Dayjs|string;// 出车时间returnTime:Dayjs|string;// 回车时间// ... 地点、事由、创建者、部门、公司等字段省略returnStatus?:number;// 还车状态attachments?:AttachmentApi.AttachmentSaveReq[];startUserSelectAssignees?:Recordstring,number[];}}/** 查询用车申请单分页 */exportfunctiongetCarApplyBillPage(params:PageParam){returnrequestClient.getPageResultCarApplyBillApi.CarApplyBill(/oa/car-apply-bill/page,{params},);}详情、保存、提交和删除分别对应第三篇里的/get、/save、/submit与/delete/** 查询用车申请单详情 */exportfunctiongetCarApplyBill(id:number){returnrequestClient.getCarApplyBillApi.CarApplyBill(/oa/car-apply-bill/get?id${id},);}/** 查询用车申请单详情BPM 审批嵌页 */exportfunctiongetCarApplyBillForBpm(id:number){returnrequestClient.getCarApplyBillApi.CarApplyBill(/oa/car-apply-bill/get-for-bpm?id${id},);}/** 保存用车申请单 */exportfunctionsaveCarApplyBill(data:CarApplyBillApi.CarApplyBill){returnrequestClient.post(/oa/car-apply-bill/save,data);}/** 提交用车申请单 */exportfunctionsubmitCarApplyBill(data:CarApplyBillApi.CarApplyBill){returnrequestClient.post(/oa/car-apply-bill/submit,data);}/** 删除用车申请单 */exportfunctiondeleteCarApplyBill(id:number){returnrequestClient.delete(/oa/car-apply-bill/delete?id${id});}有三处值得对着后端检查路径里没有/admin-api前缀由请求客户端统一拼接与第二篇讲的联调代理对得上。startUserSelectAssignees是提交时「发起人自选审批人」的载体只在提交接口里有意义。详情有两个接口/get给普通页面/get-for-bpm给审批嵌页info/index.vue根据isApproval选择其一。这里还有一个类型标注与实际数据不一致的地方goTime被标成Dayjs | string但页面里日期控件使用的是毫秒时间戳下文第五节实际在表单中流动的是number。它不会导致运行错误因为这些字段没有在 TypeScript 里被当成Dayjs去调用方法但新模块里应当直接写number让类型反映真实数据。三、列表页搜索、分页、权限按钮列表页由两个文件组成list/data.ts描述「长什么样」list/index.vue描述「怎么动」。▲ 用车申请单列表顶部是搜索表单中间是带复选框的分页表格右上角是新增、导出和批量删除三个带权限标识的按钮。3.1 data.ts字段只写一遍表格列定义里最有代表性的是三类写法路由链接列、字典标签列、时间格式化列。exportfunctionuseGridColumns():VxeTableGridOptionsCarApplyBillApi.CarApplyBill[columns]{return[{type:checkbox,width:40},createRouterLinkColumn({field:billCode,title:单据编号,path:/oa/car/car-apply-info,idField:id,queryParam:id,}),{field:processStatus,title:单据状态,minWidth:120,cellRender:{name:CellDict,props:{type:DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS},},},{field:goTime,title:出车时间,minWidth:140,formatter:formatDateTime,对照表如下搜索表单里的下拉选项来自同一套字典所以搜索和展示不会出现两套文案字段表格列写法搜索表单写法数据来源billCodecreateRouterLinkColumn点击跳到表单页并带上idInput后端单据号processStatusCellDict渲染彩色标签SelectgetDictOptions字典BPM_PROCESS_INSTANCE_STATUSreturnStatusCellDict渲染标签SelectgetDictOptions字典OA_CAR_RETURN_STATUSgoTime/returnTimeformatter: formatDateTime无毫秒时间戳createTimeformatter: formatDateTimeRangePicker毫秒时间戳3.2 index.vue把表格、表单和请求接起来useVbenVxeGrid一次返回表格组件和它的控制对象搜索表单、分页、数据请求全部在同一个配置里const[Grid,gridApi]useVbenVxeGrid({formOptions:{schema:useGridFormSchema(modalRef),wrapperClass:grid-cols-4,collapsed:true,},gridOptions:{columns:useGridColumns(),height:auto,pagerConfig:{enabled:true,},proxyConfig:{ajax:{query:async({page},formValues){returnawaitgetCarApplyBillPage({pageNo:page.currentPage,pageSize:page.pageSize,...formValues,companyId:userStore.userInfo?.companyId,creator:userStore.userInfo?.id,});},},},注意最后两行前端把当前用户的公司和用户编号一并传了出去。这只是为了让界面行为一致真正的约束在后端。第三篇已经证明后端分页会无视前端传来的creator强制改成当前登录用户。所以这里的写法不是安全措施只是减少一次无意义的“传空值”。页面外层用Page的auto-content-height让表格撑满剩余高度工具栏按钮放进TableActionPage auto-content-height Grid table-title用车申请单列表 template #toolbar-tools TableAction :actions[ { label: $t(ui.actionTitle.create), type: primary, icon: ACTION_ICON.ADD, auth: [oa:car-apply-bill:create], onClick: handleCreate, }, { label: $t(ui.actionTitle.deleteBatch), type: primary, danger: true, icon: ACTION_ICON.DELETE, disabled: isEmpty(checkedIds), auth: [oa:car-apply-bill:delete], onClick: handleDeleteBatch, }, ] / /templateTableAction里的auth会交给前端的访问码判断没有声明auth的按钮一律显示声明了的按钮在当前用户没有这个权限标识时不显示。这里要看清一个边界它只负责“看不见”不负责“调不通”。即使有人绕过界面直接请求接口拦截它的是后端的PreAuthorize这一点第五篇会专门讲。行内的删除按钮则用了ifShow和状态常量联动流程状态数值能否编辑能否删除未开始草稿-1能能审批中1否否按钮灰显审批通过2否否按钮灰显审批不通过3能能已取消4否能已撤回10能能这张表来自BpmProcessInstanceStatusEditValue未开始、不通过、已撤回与BpmProcessInstanceStatusDeleteValue在可编辑基础上再加已取消两个常量。页面不自己判断业务规则只引用同一个常量将来流程状态调整时只需要改一个地方。批量删除同样先在前端用这个常量筛一遍把不允许删除的单据编号提示出来而不是等后端报错。四、表单页保存、提交与状态联动点击「新增」会走到info/index.vue。它的外壳是BasicForm业务字段由useFormSchema提供下方插槽放附件。▲ 同一个info页面在审批通过后的样子顶部显示单据号与状态表单整体只读页签里并存审批信息与流程图。4.1 字段定义里的校验与联动先看出车时间字段。它同时用到了必填校验、时间戳格式和与回车时间的互相检查{fieldName:goTime,label:出车时间,rules:required,component:DatePicker,componentProps:{showTime:true,format:YYYY-MM-DD HH:mm:ss,valueFormat:x,placeholder:请选择出车时间,},dependencies:{triggerFields:[returnTime],trigger:(values,formApi){if(values.returnTimevalues.goTimevalues.returnTimevalues.goTime){message.error(出车时间不能晚于或等于回车时间);formApi?.setFieldValue(returnTime,undefined);}},},},这段配置里有三个要点rules: required来自表单校验体系提交前由validateForm统一触发。valueFormat: x表示控件的值是毫秒时间戳与后端LocalDateTime的默认序列化对齐。dependencies.trigger是字段联动对方字段变化时被调用这里用来拦住“回车早于出车”。前端校验的意义是让用户立刻知道错在哪不是业务规则的唯一出口。时间冲突这类涉及其他单据的规则第三篇讲过只能由后端判断。4.2 保存与提交保存和提交共用同一个函数区别只在于是否先校验、最后调用哪个接口asyncfunctionhandleSaveAndSubmit(isSubmit:boolean,submitOptions?:{startUserSelectAssignees?:Recordstring,number[]},){loading.valuetrue;if(!basicFormRef.value)return;// 提交前校验if(isSubmit){const{valid}awaitbasicFormRef.value.validateForm();if(!valid){loading.valuefalse;return;}}try{constformValues(awaitbasicFormRef.value.getFormValues())asCarApplyBillApi.CarApplyBill;constdata{...formData.value,...formValues,startUserSelectAssignees:submitOptions?.startUserSelectAssignees,};idawait(isSubmit?submitCarApplyBill(data):saveCarApplyBill(data));awaitloadData();// 成功提示与 finally 复位 loading 省略}catch(error){console.error(保存失败:,error);}}两个设计值得学一是保存草稿不校验、提交才校验符合用户“先存一半”的习惯二是成功后调用loadData()重新拉取详情页面展示的永远是后端回写后的数据包含单据号、流程实例编号、流程状态而不是前端自己拼出来的结果。4.3 页面状态怎么跟着业务状态走加载数据之后readonly由一个公共函数计算asyncfunctionloadData(){if(idundefined||idnull){formData.value{creator:userStore.userInfo?.id,companyId:userStore.userInfo?.companyId,deptId:userStore.userInfo?.deptId,processStatus:BpmProcessInstanceStatus.NOT_START,// 草稿状态attachments:[],};return;}constdataawait(props.isApproval||props.processInstance?getCarApplyBillForBpm(id):getCarApplyBill(id));formData.value{...data};readonly.valuecomputeBusinessFormReadonly(props.viewType,props.isApproval,formData.value.processStatusasnumber,);}它的判断顺序是已办、抄送视图一律只读审批态只读其余情况看流程状态是否在可编辑集合里。结合上一节的状态表可以得到下面的页面行为场景viewTypeisApprovalprocessStatus页面表现从列表新建无否-1新建时前端预置可编辑显示保存与提交提交后查看无否1 审批中只读可撤回被驳回后修改无否3 审批不通过可编辑可重新提交审批人在待办里查看todo是1 审批中只读底部隐藏发起人按钮已办、抄送查看done/copy否任意始终只读注意新建场景loadData在没有id时直接写入默认值其中processStatus取的是BpmProcessInstanceStatus.NOT_START也就是 -1这个草稿状态只存在于前端内存里直到第一次保存才会落库。五、弹窗选择车辆怎么选进表单车辆字段不是下拉框而是一个只读输入框点击后弹出车辆选择窗口。这个窗口是独立组件car-select-modal.vue内部同样使用useVbenVxeGrid外壳使用useVbenModal/** 模态框实例 */const[Modal,modalApi]useVbenModal({title:选择车辆,class:w-3/5 max-w-4xl,asynconConfirm(){returnhandleConfirm();},});/** 确认选择 */asyncfunctionhandleConfirm(){if(!formData.selectedCar){message.error(请选择车辆);returnfalse;}emit(select,formData.selectedCar);formData.selectedCarnull;awaitmodalApi.close();returntrue;}defineExpose({modalApi});// 暴露给父页面调用 open()调用链很清晰表单字段的onClick调用modalRef.value?.modalApi.open()打开弹窗用户单选或双击一行后弹窗通过select事件把整行车辆数据交还给表单页表单页的handleCarSelect只写入carNo与carId两个值并且只清除carNo这个字段的校验错误不触发其它字段校验。这里有一个细节弹窗按“公司编号”过滤车辆companyId由父页面传入这与列表页把companyId传给分页接口是同一思路目的是不让用户在选车时看到不属于自己公司的车。真正的租户隔离由后端保证这里只是体验层面的收窄。六、闭环一次从菜单到提交的完整操作把前面所有环节串起来一次完整的用户操作如下▲ 时序图路由负责把菜单记录变成组件列表页负责查询与跳转表单页负责校验、提交与重新加载后端 API 只返回数据与状态。步骤前端动作对应文件对应接口1点击侧栏「用车申请单管理」菜单记录 →list/index.vue无2表格自动查询第一页list/index.vue的proxyConfigGET /oa/car-apply-bill/page3点击「新增」跳到隐藏路由handleCreate→info/index.vue无4点击车辆框选择车辆car-select-modal.vueGET车辆分页5填写时间、地点、事由选择附件info/data.ts、AttachmentList无6点击提交先校验再请求handleSaveAndSubmit(true)POST /oa/car-apply-bill/submit7成功后重新加载页面变为只读loadData、computeBusinessFormReadonlyGET /oa/car-apply-bill/get8回到列表状态标签变成“审批中”onActivated触发gridApi.query()GET /oa/car-apply-bill/page最后一行用到了页签激活钩子onActivated会在用户切回列表页签时刷新表格所以不必每次手动点“刷新”。同样的页面骨架可以复用到还车单下图是还车申请单审批通过后的详情可以看到顶部的状态徽标、四个页签与只读表单结构和用车单完全一致▲ 还车申请单详情与用车申请单使用同一套BasicForm结构差别只在字段定义和关联单据这是“按规范拆分 list、info、data.ts”的收益。七、常见故障矩阵现象最可能的根因层如何确认正确的修法点击菜单后空白页或 404菜单的组件路径与文件不一致对照菜单管理里的组件路径与views目录改菜单记录不要为单个页面加路由别名流程中心发起打开了旧页面流程模型里的formCustomCreatePath过期在流程模型里查看自定义表单路径改流程模型或出数据库脚本按钮没有显示角色缺少对应权限标识角色管理里查看菜单权限对照auth数组给角色授权而不是把auth去掉按钮显示了但点击报无权限后端PreAuthorize与前端auth不一致对照后端 Controller 的权限字符串把两端权限标识统一日期控件显示NaN后端返回毫秒时间戳控件没配valueFormat: x看接口返回值是数字还是字符串按“时间戳模式”配置控件表单不能编辑processStatus不在可编辑集合内看详情返回的processStatus与viewType属于预期需要修改请先撤回列表数据看不到别人的单据后端强制按创建人过滤看第三篇 Service 里的分页逻辑属于预期要看全部单据需要另做管理视图字典标签显示原始数字字典类型名与常量不一致或字典未加载看字典管理里的类型名与DICT_TYPE常量补字典数据别在页面里硬编码文案八、本文发现的几处不足读源码时顺手记下了几处供你在新模块里避开API 类型把时间字段标成Dayjs | string与实际的毫秒时间戳不一致。列表页行操作里保留了被注释掉的“查看、编辑”按钮以及一个永远灰显的“占位删除”按钮属于历史遗留新页面不要照搬。列表请求把companyId、creator传给后端容易让读代码的人误以为这是安全控制实际上后端会强制覆盖。保存函数里loading在判断表单引用之前就被置为真若引用为空会提前返回而不复位新写的页面建议先判断、再置loading。失败时只有console.error用户是否看到提示取决于请求层需要在联调时确认。九、今天学完你应该能做到能画出“菜单记录 → 组件路径 →list/index.vue→api→ 后端”的链路并指出每一环由谁维护。能说出为什么字段定义放在data.ts而不是写在.vue里。能解释auth与PreAuthorize各自管什么为什么缺一不可。能看懂computeBusinessFormReadonly的判断顺序并预测一张单据在各个状态下的页面表现。能把valueFormat: x与后端LocalDateTime的默认序列化对应起来。常见问题FAQ新增一个业务页面应该复制哪个目录复制一个结构相近、规模适中的模块目录如用车申请的list、info加各自的data.ts再配套复制api下对应的文件。复制后先改 API 路径和类型再改字段定义最后才改交互顺序反了很容易漏改。为什么不直接在router目录里手写路由业务菜单由后台配置授权、隐藏、可见端和图标都在菜单表里管理。手写路由会让“菜单有、路由没有”或相反的情况重复出现。需要新增页面时优先新增菜单记录让组件路径指向页面文件。auth和ifShow有什么区别auth对应权限标识由角色授权决定ifShow对应业务条件由数据状态决定。比如删除按钮既要有oa:car-apply-bill:delete权限又要流程状态允许删除两者要同时满足。日期字段到底用时间戳还是字符串看后端字段类型。LocalDateTime默认序列化为毫秒时间戳前端用showTime: true加valueFormat: xLocalDate后端必须加JsonFormat前端用YYYY-MM-DD。两端要成对配置。表单页同时服务发起和审批会不会很乱靠isApproval、viewType和公共函数computeBusinessFormReadonly控制。页面本身只关心“当前是否只读”不关心自己被谁打开这是它能复用的前提。系列进度与下一篇预告篇目主题状态一架构篇——一个底座多端通达已发布二启动篇——从源码到前后端联调跑通开发环境已发布三后端篇——从业务建模到接口开发做出一个完整模块已发布四前端篇——从菜单路由到表单列表完成业务页面本篇五权限篇——把登录、角色、数据权限与租户隔离讲透下一篇六流程篇——接入审批让业务单据真正流转起来预告七上线篇——从测试验收到部署上线完成企业项目交付预告页面已经能用了但本文反复出现的“前端只负责看不见后端才负责挡得住”还没有讲透。下一篇进入权限篇从登录与令牌出发沿着请求链路看角色菜单、按钮权限、部门数据范围和租户隔离怎样一层一层把住门并解释为什么数据权限绝不能只靠前端。如果这篇对你有用点个「在看」或收藏。演示地址https://ruoyioffice.com/webGitHub 源码https://github.com/yuqing2026/ruoyi-officeGitee 源码https://gitee.com/yqzy1688/ruoyi-office微信17156169080获取产品咨询打开演示地址直接查看系统。