尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
服务管理前端开发实战:从列表页到表单联调的后台系统基本功
今天是我在这个后台项目里的第二个开发日。Day01把登录、脚手架、布局骨架都铺好了Day02的重点落到基础数据开发上具体任务是服务管理前端。这名字听起来不如订单、报表那么有存在感但它恰恰是整个业务链路的源头之一。我花了一个工作日把服务列表、新增编辑、停用启用、筛选分页全流程跑通后最大的体会是这种“普通页面”才是大多数企业后台项目的真实日常也是最值得打磨基本功的地方。这篇记录适合两类人看。一类是刚跟着项目练前端开发的新手想看看一个列表页从路由到联调是怎么一步步搭起来的另一类是在做内部系统但经常被细节坑到的开发者比如查询条件重置、弹窗校验失效、接口字段拿不到这类问题。下面按我今天的操作顺序把思路和代码都放出来尽量说人话少讲大道理。1. 为什么基础数据的第一站选了服务管理1.1 服务在业务链路里的位置在业务系统里“服务”指的不是后端接口而是企业对外提供的有价服务项目。比如咨询服务、运维服务、实施服务、培训服务都是可以被报价、下单、合同引用的标准项目。服务管理前端要做的就是维护这些服务项目的基本信息编码、名称、分类、计价方式、单价、状态这些。如果把基础数据模块里的东西摆在一起通常会看到这么几类子模块业务作用下游依赖服务管理维护服务项目主数据报价单、订单、合同、发票归集客户管理维护客户档案线索、合同、回款组织架构维护部门和人员审批流、数据权限数据字典维护通用枚举值全局下拉选项、状态映射服务管理排在第一站不是随机选的是因为它的字段结构最典型有编码、名称、分类、价格、状态、备注几乎涵盖了后续所有基础数据页面的通用要素。做完这一个页面后面再做客户管理、字典管理流程上能省掉一大半重复摸索的时间。另外要提醒一句服务编码、服务名称这类字段一旦被下游单据引用就不能随意改了。编码往往是单据回填的依据名称会直接影响报价单展示所以前端在做编辑操作时要对类似字段做禁用保护。这个点今天做表单时也会再遇到。1.2 从服务数据结构反推页面边界拿到需求先不要写代码先把字段和字段的“出场场景”列清楚。我是这样拆的字段含义页面用途serviceNo服务编码查询条件、表格列、表单必填serviceName服务名称查询条件、表格列、表单必填categoryId服务分类ID查询条件下拉、表格列字典转换、表单必填pricingType计价方式表格列、表单必填price标准单价表格列、表单必填保留两位小数status状态查询条件、表格列Tag、表单开关remark备注表单选填列表不展示createTime创建时间表格列只读展示这个反推过程几乎是后台页面的标准动作。前端最常见返工的原因就是字段遗漏尤其是状态和创建时间很多人一开始只按接口文档里的必填字段做页面结果列表做出来发现少了一列状态查询区少了一个状态筛选又得回头补。2. 路由和菜单接入让空白页面先出现在系统里2.1 路由表与菜单的映射关系Day01已经把布局组件搭好了左侧菜单、顶栏、标签页、内容区。今天要做的是新增一个“基础数据-服务管理”页面让菜单点进去能渲染出对应组件。我们项目用的是静态路由加菜单权重的折中方案后端下发菜单编码控制入口是否显示前端路由表负责真正的组件渲染。路由配置大概长这样// router/routes.js 部分示例 const routes [ { path: /base, name: BaseData, component: () import(/layout/Index.vue), redirect: /base/service, meta: { title: 基础数据, icon: Menu }, children: [ { path: service, name: BaseService, component: () import(/views/base/service/index.vue), meta: { title: 服务管理, keepAlive: true } } ] } ]这段配置里有几个值得注意的地方redirect 写到了/base/service不然点击“基础数据”一级菜单时会是一个空白目录页体验很怪。子路由的 path 用的是相对路径service会自动拼成/base/service。如果这里写成/service会变成一级路径和布局结构对不上。meta.title 是菜单显示的文字meta.icon 是菜单图标名布局组件里会基于它去渲染。2.2 菜单点击到内容区渲染的小细节布局组件里有一个 router-view 作为挂载点子路由的组件会渲染到那里。Vite 项目里用动态 import 就自动实现了路由懒加载不需要额外配置。真正容易踩坑的是图标渲染。后端或者菜单配置里返回的 icon 是字符串比如Menu如果直接在el-iconcomponent :ismeta.icon //el-icon里引用会提示找不到组件因为字符串不会自动映射到注册过的图标组件。解决办法是提前把图标集合注入全局import * as Icons from element-plus/icons-vue for (const [name, component] of Object.entries(Icons)) { app.component(name, component) }这样 meta.icon 里的字符串就能对应到真实组件了。另一个细节是 keepAlive。服务管理列表页我开启了缓存这样从编辑弹窗关闭后切走再回来查询条件和页码都能保留。但开启缓存后组件激活时的生命周期就变了取数逻辑写在 onMounted 里可能不会再次触发。所以列表请求我放在了 onActivated 里这个点后面还会再提。3. 列表页三件套查询条件、分页联动和状态列3.1 查询区设计要克制查询条件不是越多越好。服务管理这个页面业务上通常只需要三个筛选维度服务名称、服务分类、状态。名称用输入框分类和状态下拉用选择器。查询区的模板长这样el-form :inlinetrue :modelsearchForm submit.prevent el-form-item label服务名称 el-input v-modelsearchForm.serviceName placeholder请输入服务名称 clearable / /el-form-item el-form-item label服务分类 el-select v-modelsearchForm.categoryId placeholder全部 clearable el-option v-foritem in categoryOptions :keyitem.id :labelitem.name :valueitem.id / /el-select /el-form-item el-form-item label状态 el-select v-modelsearchForm.status placeholder全部 clearable el-option label启用 :value1 / el-option label停用 :value0 / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form这里有个容易被忽略的习惯下拉框的默认值必须写成空字符串而不是 undefined否则后面做参数清理时会出现一堆多余的键。另外给输入框加 clearable 之后用户清空内容不会自动触发查询需要配合查询按钮或者监听 clear 事件不然会以为筛选失灵了。3.2 分页参数与筛选条件必须联动列表请求的核心逻辑我是这样组织的script setup import { ref, onActivated } from vue import { getServiceList, updateServiceStatus } from /api/service import { ElMessage, ElMessageBox } from element-plus const loading ref(false) const list ref([]) const total ref(0) const page ref(1) const pageSize ref(10) const searchForm ref({ serviceName: , categoryId: , status: }) async function fetchList() { loading.value true try { const res await getServiceList({ ...cleanParams(searchForm.value), page: page.value, pageSize: pageSize.value }) list.value res.data.list || [] total.value res.data.total || 0 } finally { loading.value false } } function handleSearch() { page.value 1 fetchList() } function handleReset() { searchForm.value { serviceName: , categoryId: , status: } page.value 1 fetchList() } function handlePageChange(currentPage) { page.value currentPage fetchList() } onActivated(fetchList) /script为什么查询和重置都要先把页码重置为 1因为用户可能正停留在第 5 页此时改变筛选条件后符合条件的数据可能只有 2 页如果不重置页码第 5 页就会请求到空数据页面白茫茫一片用户会以为是系统挂了。里面用到的 cleanParams 是一个很实用的小函数作用是去掉空字符串、null 和 undefinedfunction cleanParams(params) { const result {} Object.keys(params).forEach((key) { const value params[key] if (value ! value ! null value ! undefined) { result[key] value } }) return result }有些后端会把空字符串也当查询条件传进去结果是名称等于空什么都查不出来。前端在请求前清理一次能省下大量联调时的扯皮。3.3 状态映射、字典转换和操作列表格列里最常用的是三块状态列、字典列、操作列。状态列用 el-tag 区分颜色el-table-column label状态 width90 aligncenter template #default{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 启用 : 停用 }} /el-tag /template /el-table-column服务分类列不能直接显示 categoryId要映射成名称。分类下拉数据从字典接口拿到后顺手构建成 Map模板里用categoryMap.get(row.categoryId)就能取到名称。如果分类数据量很大就不要一次性拉全量改成懒加载或者接口回显名字更好这里先不展开。操作列一般包括编辑、停用/启用、删除。停用和启用是同一个按钮根据状态切换文案el-table-column label操作 width180 template #default{ row } el-button link typeprimary clickopenEdit(row)编辑/el-button el-button link typeprimary clickhandleToggleStatus(row) {{ row.status 1 ? 停用 : 启用 }} /el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column停用和删除这种危险操作必须二次确认。我用的是 ElMessageBox.confirm文案写清楚后果比如“停用后新增报价单时该服务将不可选择”。按钮点击后加 loading 状态防止用户重复点击导致连续调接口。4. 新增与编辑表单校验、提交、回显这些不起眼的细节4.1 为什么这里用弹窗而不是跳转页面服务管理的表单字段不算多没有价格配置多行子表也没有分步骤引导所以用 Dialog 弹窗最轻量。关闭弹窗后直接刷新列表不需要页面跳转带来的参数传递和缓存同步。如果字段超过 15 个或者编辑时存在复杂的联动、子项增删比如服务价格按区间配置那弹窗就不合适了建议升级为 Drawer 抽屉或独立路由页面。判断标准很简单弹窗高度超过一屏视觉上就很难受尤其是小屏幕笔记本上底部按钮都看不到。另外弹窗表单和列表页共用一个组件文件就可以不需要单独拆目录省掉父子组件传值那一堆事。4.2 校验规则按字段逐条过表单结构长这样el-dialog v-modeldialogVisible :titleform.id ? 编辑服务 : 新增服务 width560px el-form refformRef :modelform :rulesrules label-width90px el-form-item label服务编码 propserviceNo el-input v-modelform.serviceNo :disabledisEdit placeholder如 FW-2024-001 / /el-form-item el-form-item label服务名称 propserviceName el-input v-modelform.serviceName maxlength50 show-word-limit / /el-form-item el-form-item label服务分类 propcategoryId el-select v-modelform.categoryId placeholder请选择分类 stylewidth: 100% el-option v-foritem in categoryOptions :keyitem.id :labelitem.name :valueitem.id / /el-select /el-form-item el-form-item label计价方式 proppricingType el-radio-group v-modelform.pricingType el-radio :value1按次/el-radio el-radio :value2按时/el-radio el-radio :value3按周期/el-radio /el-radio-group /el-form-item el-form-item label标准单价 propprice el-input-number v-modelform.price :min0 :precision2 stylewidth: 100% / /el-form-item el-form-item label状态 propstatus el-switch v-modelform.status :active-value1 :inactive-value0 / /el-form-item el-form-item label备注 propremark el-input v-modelform.remark typetextarea :rows2 maxlength200 show-word-limit / /el-form-item /el-form template #footer el-button clickdialogVisible false取消/el-button el-button typeprimary :loadingsubmitting clickhandleSubmit保存/el-button /template /el-dialog校验规则必须和业务字段一一对应const rules { serviceNo: [ { required: true, message: 请输入服务编码, trigger: blur }, { pattern: /^[A-Z0-9-]{4,20}$/, message: 编码需为4-20位大写字母、数字或横杠, trigger: blur } ], serviceName: [ { required: true, message: 请输入服务名称, trigger: blur }, { min: 2, max: 50, message: 长度在2到50个字符之间, trigger: blur } ], categoryId: [{ required: true, message: 请选择服务分类, trigger: change }], pricingType: [{ required: true, message: 请选择计价方式, trigger: change }], price: [{ required: true, message: 请输入标准单价, trigger: blur }] }这里的 trigger 是有讲究的输入框类组件用 blur点击类组件比如 select、radio、switch 用 change。如果全部写成 blurselect 校验会有延迟用户可能已经填完却还提示错误。最容易翻车的点是 el-form-item 上的 prop 必须和 form 字段名完全一致。比如 form.categoryId 在 el-form-item 写成了 category校验规则就永远不会触发。这个错误新手经常犯而且看不出来因为没有控制台报错只是点保存没反应。4.3 回显、字段对齐与列表刷新编辑回显的写法要注意不能直接把行对象丢给表单function openEdit(row) { formRef.value?.resetFields() form.value { ...row, price: Number(row.price) } dialogVisible.value true }为什么不能直接form.value row因为这样写会直接在表格行对象上改数据用户编辑过程中哪怕只是点了一下取消表格那一行的展示字段也可能已经被改掉视觉上数据被污染了。浅拷贝一份表单怎么改都不会影响列表原始数据。price 转 Number 也很关键接口返回的可能是字符串100.00而 el-input-number 要求 number 类型不转的话组件显示会有问题。提交时我做了字段白名单处理async function handleSubmit() { const valid await formRef.value.validate().catch(() false) if (!valid) return submitting.value true try { const payload { id: form.value.id, serviceNo: form.value.serviceNo, serviceName: form.value.serviceName, categoryId: form.value.categoryId, pricingType: form.value.pricingType, price: form.value.price, status: form.value.status, remark: form.value.remark } if (form.value.id) { await updateService(payload) ElMessage.success(保存成功) } else { await addService(payload) ElMessage.success(新增成功) } dialogVisible.value false fetchList() } finally { submitting.value false } }直接展开 form.value 再提交虽然快但 form 里可能混着回显用的 categoryName、createTime 等展示字段后端接口对接容易出问题。用白名单方式构建 payload代码多几行但字段完全可控。编辑态下服务编码是禁用的。因为编码被下游单据引用后改动历史单据会失去关联这是数据主数据的红线。5. 联调阶段最常翻车的四个问题5.1 响应结构不统一导致取数逻辑到处分叉联调第一天就会遇到各接口返回结构不一致的问题。有的返回list/total有的直接返回数组有的外层还有个 success 字段。今天我在项目里和同学统一了约定所有接口返回{ code, msg, data }分页的 data 固定为{ list, total }。请求封装里把 code 判断写死// request.js 简化 import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default service拦截器返回的是整个 res 而不是 res.data这样页面请求代码里取 list 时是res.data.list结构统一。如果后端的某个接口不守规矩前端不要在每个页面里加兼容分支直接找后端改接口让他把结构对齐。前端兼容逻辑越少后期迁移和维护越轻松。5.2 创建时间格式化和时区问题服务列表里的创建时间接口返回的可能是一个带 T 的字符串比如2025-06-18T10:30:00.000Z。直接把这种字符串丢到表格里视觉体验很差。我习惯统一封装一个格式化函数import dayjs from dayjs function formatTime(value, pattern YYYY-MM-DD HH:mm) { if (!value) return - const time dayjs(value) if (!time.isValid()) return - return time.format(pattern) }这里先判断空值再转最后判断 isValid避免传了一个非法串进去展示出 Invalid Date。对于内部基础数据系统后端返回的通常已经是北京时间的字符串直接格式化即可。如果返回的是 ISO 带时区格式需要确认服务端存储的时区再决定要不要手动加时区偏移不要在模板里裸用。5.3 空数据和 undefined 的防御表格数据里经常出现某条记录的某个字段没返回前端一渲染就白屏或者报错。今天我在 fetchList 里加了保险const data res.data || { list: [], total: 0 } list.value data.list || [] total.value data.total || 0展示字段的兜底我建议显示-而不是空字符串让用户一眼看出“这个位置本来有值但没有返回”。空字符串和 null 在表格里都表现为空白用户会以为是页面渲染问题。如果某个字段在接口里真的没有要么让后端补字段要么前端给默认值。不要在前端大量使用可选链一层层判断写起来累维护也累。简单兜底就好。5.4 按钮级权限怎么接入服务管理的操作按钮不是所有人都有权限点的。今天的快速方案是登录后拿到 buttonCodes 存在用户状态里页面用工具函数判断import { useUserStore } from /store/user function hasPermission(code) { return useUserStore().buttonCodes.includes(code) }模板里这样用el-button v-ifhasPermission(service:add) typeprimary clickopenAdd 新增服务 /el-button初版这样写直观但项目页面多了以后会散落大量权限判断。后续可以封装成 v-permission 指令或者权限按钮组件今天的服务管理前端先把功能跑通权限收敛留到全部基础数据模块做完再统一收口避免重复改造。6. 封装通用表格组件的教训今天这次尝试我后悔了6.1 第一版通用表格是怎么写废的上午做列表页的时候我本来想顺手封装一个通用表格组件用 columns 配置驱动列渲染想着后续基础数据其他页面都能复用。于是写了个 common-table.vueprops 接收 columns、data、loading再加上字典转换和操作列插槽。写到服务管理页面时就开始别扭了。状态列需要 tag 色我给列配置加了个 type 字段和 tagMap分类列需要走字典又加了 dictCode操作列三个按钮还要各自绑定事件最后只能开一个名为 action 的具名插槽把按钮模板全塞回父组件里。结果就是配置项越来越多看配置根本不知道最终渲染成什么样调试一个列格式化还要先在组件源码里翻半天。到下午我直接把这版通用表格删了用回显式的 el-table-column。服务管理页面并没有超出八列直接写模板也就是几下的事通用化的收益完全覆盖不了理解成本。6.2 我现在判断是否封装的取舍标准经过今天这次推翻重来我给自己定了一套标准场景是否值得封装通用表格页面多列结构重复只有纯文本和状态两种列类型值得页面少每个页面都有字典、插槽、自定义样式不值得团队需要长期维护通用组件并补文档慎重只是一个人写两三个列表页别封装比起强行封装表格组件我更推荐抽一个列表逻辑的 composable比如 useServiceList把 loading、list、total、page、pageSize、fetchList、resetPage 集中管理。模板里保持显式表格结构数据和事件从 composable 返回。这样既减少了重复的请求逻辑又不会让模板变成半配置化的“黑盒”。这次改动后服务管理页面的代码清爽很多。如果后续基础数据模块里出现了五六个结构高度一致的列表页再考虑提取通用组件也不迟而且那时的提取会有真实数据支撑不会为了抽象而抽象。到今天结束服务管理前端给我的经验就一句话大多数后台页面没有高深技术难在把每个普通细节做对。晚上我还会习惯性把今天的代码再过一遍重点看三个地方查询条件是否会被空字符串干扰、弹窗提交后列表是否自动刷新、按钮权限接口传参是否正确。Day03我准备做服务分类和字典下拉预计会把这两个地方复用起来。如果你也在做类似的Day02任务建议先不要急着封装任何组件把原生的表格、表单跑顺再多想一步“这个字段从哪来、到哪里去”比任何框架技巧都管用。
RELATED

相关推荐

零基础入门NLP:手写中文分词工具实战指南

零基础入门NLP:手写中文分词工具实战指南

从标题写作角度看,不少人对中文分词的第一反应是“这有什么好写的,不就是拿词典切一刀吗”。但等你真把“零基础入门NLP之搭建中文分词工具”这个项目做完,你会发现事情没那么简单:切法对不对、歧义怎么消、新词怎么发现、性能怎么…

📅 2026/10/14 5:20:06
C++桥接模式详解:运行时多态、类型擦除、模板与Pimpl变体选型指南

C++桥接模式详解:运行时多态、类型擦除、模板与Pimpl变体选型指南

先聊一个很多C开发者都绕不过去的问题:一个系统里,抽象层和实现层互相纠缠,改一处接口就要动一串代码,最后变成谁也动不了的混凝土块。桥接模式的核心思想是“抽象与实现分离”,让这两边各自独立演化。但C这门语言很特…

📅 2026/10/14 5:20:06
Android ViewFlipper从入门到实战:多视图切换与轮播动画详解

Android ViewFlipper从入门到实战:多视图切换与轮播动画详解

很多Android初学者在学到“多页面切换”的时候,都会遇到一个绕不开的控件——ViewFlipper。我当时学它的时候,手里有一本老掉牙的教程,章节号正好就是2.5.6,标题也是“ViewFlipper(翻转视图)的基本使用”。那时候全网都在讲ViewPa…

📅 2026/10/14 5:20:06
MORE NEWS

更多资讯

📰

AMLL Vue 绑定包演进实录:从 0.4 到 0.5 的架构重构、歌词组模型与关键修复解读

前端UI组件 【免费下载链接】applemusic-like-lyrics An Apple Music style lyric player component, with React & Vue support. 一个类 Apple Music 歌词显示组件,同时提供 React 和 Vue 绑定。 项目地址: https://gitcode.com/gh_mirrors/ap/appl…

📰

无铜点击葡聚糖:DBCO-Dextran DBCO-葡聚糖DBCO-Dex

DBCO-Dextran(DBCO - 葡聚糖),属于葡聚糖骨架改性的可点击高分子多糖,在葡聚糖葡萄糖单元的羟基上,通过连接臂共价引入 DBCO(二苯并环辛炔)应变炔基团。一、基本参数中文名 DBCO-葡聚糖 / 二…

📰

开源AI助手WorkBuddy企业化改造:私有化部署与协作升级

说起这事,得从我们团队被合规部门“约谈”那天讲起。当时大家正在用开源的 WorkBuddy 当个人 AI 助理,查资料、写摘要、整理会议纪要不亦乐乎,直到合规同事甩过来一张截图:你们的提问内容、内部文档摘要,全都通过公网模…

📰

易语言恒云雨驱动控制模块:用户态与内核态通信实战指南

简介:易语言恒云雨驱动控制模块是一份面向易语言开发者的系统级驱动交互源码资源,适合希望深入硬件驱动管理与控制、提升底层编程能力的开发者学习与二次开发。模块围绕驱动全生命周期提供完整功能,涵盖获取通讯、加载与启动驱动、停止与删除…

📰

WebSphere MQ入门拆解:队列管理器、通道与系统管理实战

简介:IBM WebSphere MQ入门教程.pdf 是一份面向初学者的完整入门手册,适合对消息中间件缺乏系统认知的开发、运维及架构人员。全书共217页,从中间件的定义与优点讲起,逐步过渡到三种通信技术对比、生产者-消费者异步通信模型&…

📰

从零搭建多角色博弈模拟系统:多智能体强化学习实战与调优

1. 从“A Game of Thrones(32)”这个标题说起第一次看到“A Game of Thrones(32)”这个标题,很多人会下意识以为这是某部作品的第三十二章,或者某个系列视频的第三十二集。但如果你真的把它当成一个编号去理解,那就完全跑偏了。这个标题的核心…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬