7天学会SpringBoot+Vue3企业级项目RuoyiOffice(四):前端篇——从菜单路由到表单列表,完成业务页面
🌐文档地址:https://ruoyioffice.com
👇 文章底部获取源码和演示地址 👇
💬 :17156169080(获取产品咨询)
这是《7天学会SpringBoot+Vue3企业级项目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-list | oa/car/carapply/list/index | CarApplyList | 显示 |
| 用车申请单详情(表单) | car-apply-info | oa/car/carapply/info/index | CarApplyInfo | 隐藏 |
两条记录的父级相同,都挂在「车辆管理」目录下。表单页之所以做成隐藏菜单,是因为它不需要在侧栏出现,却需要拥有自己的路由和授权。列表页里「新增」和点击单据编号,都是通过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?:Record<string,number[]>;}}/** 查询用车申请单分页 */exportfunctiongetCarApplyBillPage(params:PageParam){returnrequestClient.get<PageResult<CarApplyBillApi.CarApplyBill>>('/oa/car-apply-bill/page',{params},);}详情、保存、提交和删除分别对应第三篇里的/get、/save、/submit与/delete:
/** 查询用车申请单详情 */exportfunctiongetCarApplyBill(id:number){returnrequestClient.get<CarApplyBillApi.CarApplyBill>(`/oa/car-apply-bill/get?id=${id}`,);}/** 查询用车申请单详情(BPM 审批嵌页) */exportfunctiongetCarApplyBillForBpm(id:number){returnrequestClient.get<CarApplyBillApi.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():VxeTableGridOptions<CarApplyBillApi.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',对照表如下,搜索表单里的下拉选项来自同一套字典,所以搜索和展示不会出现两套文案:
| 字段 | 表格列写法 | 搜索表单写法 | 数据来源 |
|---|---|---|---|
billCode | createRouterLinkColumn,点击跳到表单页并带上id | Input | 后端单据号 |
processStatus | CellDict渲染彩色标签 | Select+getDictOptions | 字典BPM_PROCESS_INSTANCE_STATUS |
returnStatus | CellDict渲染标签 | Select+getDictOptions | 字典OA_CAR_RETURN_STATUS |
goTime/returnTime | formatter: 'formatDateTime' | 无 | 毫秒时间戳 |
createTime | formatter: 'formatDateTime' | RangePicker | 毫秒时间戳 |
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让表格撑满剩余高度,工具栏按钮放进TableAction:
<Page 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, }, ]" /> </template>TableAction里的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.returnTime&&values.goTime&&values.returnTime<=values.goTime){message.error('出车时间不能晚于或等于回车时间');formApi?.setFieldValue('returnTime',undefined);}},},},这段配置里有三个要点:
rules: 'required'来自表单校验体系,提交前由validateForm统一触发。valueFormat: 'x'表示控件的值是毫秒时间戳,与后端LocalDateTime的默认序列化对齐。dependencies.trigger是字段联动:对方字段变化时被调用,这里用来拦住“回车早于出车”。
前端校验的意义是让用户立刻知道错在哪,不是业务规则的唯一出口。时间冲突这类涉及其他单据的规则,第三篇讲过,只能由后端判断。
4.2 保存与提交
保存和提交共用同一个函数,区别只在于是否先校验、最后调用哪个接口:
asyncfunctionhandleSaveAndSubmit(isSubmit:boolean,submitOptions?:{startUserSelectAssignees?:Record<string,number[]>},){loading.value=true;if(!basicFormRef.value)return;// 提交前校验if(isSubmit){const{valid}=awaitbasicFormRef.value.validateForm();if(!valid){loading.value=false;return;}}try{constformValues=(awaitbasicFormRef.value.getFormValues())asCarApplyBillApi.CarApplyBill;constdata={...formData.value,...formValues,startUserSelectAssignees:submitOptions?.startUserSelectAssignees,};id=await(isSubmit?submitCarApplyBill(data):saveCarApplyBill(data));awaitloadData();// 成功提示与 finally 复位 loading 省略}catch(error){console.error('保存失败:',error);}}两个设计值得学:一是保存草稿不校验、提交才校验,符合用户“先存一半”的习惯;二是成功后调用loadData()重新拉取详情,页面展示的永远是后端回写后的数据(包含单据号、流程实例编号、流程状态),而不是前端自己拼出来的结果。
4.3 页面状态怎么跟着业务状态走
加载数据之后,readonly由一个公共函数计算:
asyncfunctionloadData(){if(id===undefined||id===null){formData.value={creator:userStore.userInfo?.id,companyId:userStore.userInfo?.companyId,deptId:userStore.userInfo?.deptId,processStatus:BpmProcessInstanceStatus.NOT_START,// 草稿状态attachments:[],};return;}constdata=await(props.isApproval||props.processInstance?getCarApplyBillForBpm(id):getCarApplyBill(id));formData.value={...data};readonly.value=computeBusinessFormReadonly(props.viewType,props.isApproval,formData.value.processStatusasnumber,);}它的判断顺序是:已办、抄送视图一律只读;审批态只读;其余情况看流程状态是否在可编辑集合里。结合上一节的状态表,可以得到下面的页面行为:
| 场景 | viewType | isApproval | processStatus | 页面表现 |
|---|---|---|---|---|
| 从列表新建 | 无 | 否 | -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.selectedCar=null;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的proxyConfig | GET /oa/car-apply-bill/page |
| 3 | 点击「新增」,跳到隐藏路由 | handleCreate→info/index.vue | 无 |
| 4 | 点击车辆框,选择车辆 | car-select-modal.vue | GET车辆分页 |
| 5 | 填写时间、地点、事由,选择附件 | info/data.ts、AttachmentList | 无 |
| 6 | 点击提交,先校验再请求 | handleSaveAndSubmit(true) | POST /oa/car-apply-bill/submit |
| 7 | 成功后重新加载,页面变为只读 | loadData、computeBusinessFormReadonly | GET /oa/car-apply-bill/get |
| 8 | 回到列表,状态标签变成“审批中” | 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: 'x';LocalDate后端必须加@JsonFormat,前端用YYYY-MM-DD。两端要成对配置。
表单页同时服务发起和审批,会不会很乱?
靠isApproval、viewType和公共函数computeBusinessFormReadonly控制。页面本身只关心“当前是否只读”,不关心自己被谁打开,这是它能复用的前提。
系列进度与下一篇预告
| 篇目 | 主题 | 状态 |
|---|---|---|
| (一) | 架构篇——一个底座,多端通达 | 已发布 |
| (二) | 启动篇——从源码到前后端联调,跑通开发环境 | 已发布 |
| (三) | 后端篇——从业务建模到接口开发,做出一个完整模块 | 已发布 |
| (四) | 前端篇——从菜单路由到表单列表,完成业务页面 | 本篇 |
| (五) | 权限篇——把登录、角色、数据权限与租户隔离讲透 | 下一篇 |
| (六) | 流程篇——接入审批,让业务单据真正流转起来 | 预告 |
| (七) | 上线篇——从测试验收到部署上线,完成企业项目交付 | 预告 |
页面已经能用了,但本文反复出现的“前端只负责看不见,后端才负责挡得住”还没有讲透。下一篇进入权限篇:从登录与令牌出发,沿着请求链路看角色菜单、按钮权限、部门数据范围和租户隔离怎样一层一层把住门,并解释为什么数据权限绝不能只靠前端。
如果这篇对你有用,点个「在看」或收藏。
🌐演示地址:https://ruoyioffice.com/web
📦GitHub 源码:https://github.com/yuqing2026/ruoyi-office
📦Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
💬微信:17156169080(获取产品咨询)
打开演示地址直接查看系统。