从 30 个重复页面到 1 个配置驱动列表页:前端架构演进实录
本文属于「码动四季·开源同行」秋季征稿 —— 技术经验体系化沉淀赛道。
记录开源项目 AtomOps 前端从"每个模块一个 Vue 页面"到"一份配置生成所有页面"的架构演进。
痛点:CRUD 页面的重复地狱
运维平台有大量 CRUD 模块:主机管理、脚本管理、作业管理、定时任务、告警规则、账号管理、参数中心……每个模块都需要:
- 列表表格 + 分页 + 搜索
- 新增对话框 + 表单校验
- 编辑对话框 + 数据回填
- 删除确认
- 特殊字段处理(密码框、下拉选、开关、文本域)
最初的做法是每个模块写一个.vue文件。写到第 5 个的时候发现:80% 的代码是复制粘贴。
<!-- Hosts.vue --> <el-table :data="list"> <el-table-column prop="hostname" label="主机名" /> <el-table-column prop="ip" label="IP" /> ... </el-table> <el-dialog> <el-form> <el-form-item label="主机名"><el-input v-model="form.hostname" /></el-form-item> ... </el-form> </el-dialog> <!-- Scripts.vue —— 几乎一模一样 --> <el-table :data="list"> <el-table-column prop="name" label="名称" /> <el-table-column prop="script_type" label="类型" /> ... </el-table>问题清单:
- 新增模块要复制 200+ 行模板代码
- 修改表格样式要改 N 个文件
- 交互逻辑(搜索、分页、CRUD)每个文件各写一遍
- Bug 修了一个文件,其他文件还在
演进:配置驱动的 ListPage
核心思想
用一个 JSON 配置描述页面结构,用一个通用组件渲染所有页面。
// menu.js —— 一份配置就是一个页面{path:'/coc/scripts',title:'脚本管理',page:'list',// 使用 ListPage 组件endpoint:'/api/coc/scripts',// API 地址columns:[// 列定义{prop:'name',label:'名称',type:'text',required:true},{prop:'script_type',label:'类型',type:'select',options:[['Shell','shell'],['Python','python']]},{prop:'content',label:'内容',type:'textarea'},],actions:[// 行操作按钮{label:'执行',exec:'script'}]}路由生成
配置驱动路由,不再手写routes:
// router/index.jsconstmoduleRoutes=allMenus.map((m)=>({path:m.path,component:m.page==='list'?()=>import('../views/ListPage.vue')// 所有 list 页面复用:legacyViews[m.component],props:m.page==='list'?{item:m}:{}// 配置作为 props 传入}))ListPage.vue —— 一个组件统治所有
<template> <!-- 搜索栏 --> <el-input v-model="search" :placeholder="item.searchPlaceholder" @keyup.enter="load" /> <!-- 表格 --> <el-table :data="list"> <el-table-column v-for="c in columns" :prop="c.prop" :label="c.label"> <!-- 根据字段类型渲染不同组件 --> <el-tag v-if="c.type === 'select'">{{ optionLabel(c, row[c.prop]) }}</el-tag> <el-switch v-else-if="c.type === 'switch'" :model-value="row[c.prop]" disabled /> <span v-else>{{ row[c.prop] }}</span> </el-table-column> <!-- 操作列 --> <el-table-column label="操作"> <el-button v-for="a in actions" @click="handleAction(a, row)">{{ a.label }}</el-button> <el-button @click="open(row)">编辑</el-button> <el-button @click="del(row)">删除</el-button> </el-table-column> </el-table> <!-- 新增/编辑对话框 --> <el-dialog v-model="dialog"> <el-form> <el-form-item v-for="f in fields" :label="f.label"> <el-input v-if="f.type === 'text'" v-model="form[f.prop]" /> <el-select v-if="f.type === 'select'" v-model="form[f.prop]"> <el-option v-for="o in f.options" :label="o[0]" :value="o[1]" /> </el-select> <el-input v-if="f.type === 'textarea'" type="textarea" v-model="form[f.prop]" /> <el-switch v-if="f.type === 'switch'" v-model="form[f.prop]" /> </el-form-item> </el-form> </el-dialog> </template>字段类型扩展
新增字段类型只需在 ListPage 加一个v-if,不需要改任何配置文件:
<!-- 密码字段:编辑时留空不修改 --> <el-input v-if="f.type === 'password'" type="password" :placeholder="f.placeholder || '留空则不修改'" /> <!-- 作业步骤编辑器 --> <div v-if="f.type === 'steps'"> <div v-for="(step, i) in jobSteps"> <el-input v-model="step.name" placeholder="步骤名称" /> <el-select v-model="step.script_id" @focus="loadScripts"> <el-option v-for="s in scriptOptions" :label="s.name" :value="s.id" /> </el-select> </div> </div> <!-- 定时任务目标选择:动态加载脚本/作业 --> <el-select v-if="f.type === 'target_select'" @focus="loadTargetOptions"> <el-option v-for="o in targetOptions" :label="o.label" :value="o.id" /> </el-select> <!-- 主机多选 --> <el-select v-if="f.type === 'host_multi_select'" multiple filterable @focus="loadAllHosts"> <el-option v-for="h in allHosts" :label="`${h.hostname} (${h.ip})`" :value="h.id" /> </el-select>效果对比
代码量
| 指标 | 演进前 | 演进后 |
|---|---|---|
| 每个新模块代码 | ~200 行 Vue | ~15 行配置 |
| 总页面文件 | 15 个 .vue | 1 个 ListPage.vue |
| 样式修改 | 改 15 个文件 | 改 1 个文件 |
| 新增模块耗时 | 2 小时 | 10 分钟 |
新增模块示例
添加"账号管理"页面,只需在menu.js加一段配置:
{path:'/coc/accounts',title:'账号管理',page:'list',endpoint:'/api/coc/accounts',columns:[{prop:'name',label:'名称',type:'text',required:true},{prop:'resource_type',label:'资源类型',type:'select',options:[['服务器','server'],['数据库','db'],['网络','network']]},{prop:'username',label:'用户名',type:'text',required:true},{prop:'password_encrypted',label:'密码',type:'password',sensitive:true},]}零行 Vue 代码,零行 API 代码——后端用build_crud_router同样配置驱动生成 CRUD 接口。前后端各加一段配置,新模块上线。
设计原则
1. 配置只描述"是什么",不描述"怎么做"
// ✅ 好的配置:声明式{prop:'name',label:'名称',type:'text',required:true}// ❌ 坏的配置:命令式{prop:'name',render:(row)=>`<input value="${row.name}" />`}2. 通用组件处理 80%,插槽处理 20%
大部分字段用内置类型(text/select/switch/textarea)。少数特殊需求用具名插槽扩展:
<el-table-column> <template #default="{ row }" v-if="c.type === 'steps'"> <el-tag v-for="(s, i) in row.steps">{{ i+1 }}.{{ s.name }}</el-tag> </template> </el-table-column>3. 配置和组件分离
menu.js只管配置,ListPage.vue只管渲染。新增字段类型不改配置格式,只改组件。
踩坑记录
坑1:v-if 链条太长
一开始所有字段类型用v-if/v-else-if链条判断,10+ 个类型后模板可读性很差。
解决:把复杂类型(steps、target_select)拆成独立组件,在 ListPage 中按类型分发。
坑2:配置和组件耦合
最初在配置里写onChange回调函数,导致配置文件引用了组件内部状态。
解决:配置只描述字段属性,交互逻辑全部在 ListPage 内部根据type判断。
坑3:编辑时 JSON 字段处理
target_hosts字段存的是 JSON 字符串"[]",但前端el-select multiple需要数组。
解决:在open(row)时解析,在save()时序列化:
functionopen(row){if(f.type==='host_multi_select'){form[f.prop]=JSON.parse(row[f.prop]||'[]')// string → array}}functionsave(){if(f.type==='host_multi_select'){data[f.prop]=JSON.stringify(form[f.prop]||[])// array → string}}总结
配置驱动不是新概念,但在运维场景特别有效——因为 CRUD 模块多且结构相似。
核心收益:
- 开发效率:新增模块从 2 小时降到 10 分钟
- 一致性:所有页面交互统一,用户学习成本低
- 可维护性:样式/逻辑修改集中在一个文件
- 可扩展性:新字段类型只需加一个
v-if分支
适用场景:CRUD 为主的内部工具、管理后台、运维平台。不适用于高度定制化的面向 C 端的页面。
项目仓库:https://gitcode.com/cpyaxjq/AtomOps
本文为 AtomGit「码动四季·开源同行」秋季征稿投稿。如果觉得有帮助,欢迎到仓库点个 Star ⭐