前端页面写不好,往往不是Vue不会,而是设计文档没读懂
我第一次独立写前端页面是三年前。那时候刚学完 Vue2,看完文档觉得自己行了,领导让我做个"用户管理"列表页,我打开 IDE 就开始写el-table。
写了两小时,页面能跑,数据也能显示。我兴冲冲地发给前端同事看,他看了三十秒,问了我五个问题:
- 这个页面在"只读角色"下是不是应该隐藏操作列?
- 手机号字段后端返回的是带星号的,你这里为什么又 format 了一遍?
- 删除之后是刷新当前页还是回到第一页?
- 批量选择跨页保持吗?
- 这个"状态"字段的字典值,你写死在前端还是从接口拿?
我一个都答不上来。不是因为我 Vue 写得烂,而是我根本没看过设计文档——那个项目压根就没有完整的前端页面设计文档,只有一份后端的接口清单。
那次返工了三轮。后来我慢慢明白一件事:后端转前端,最大的坎不是语法,是你不知道一个页面该有多少"看不见的约定"。这些约定全在设计文档里,而后端出身的人习惯性地跳过文档直接看接口。
这篇就讲讲我现在怎么读前端页面设计文档,以及飞算JavaAI的/前端开发是怎么把这些文档变成可运行页面的。
一、为什么后端转前端特别容易跳过设计文档
后端写代码有个天然优势:接口就是契约。给我一个 Swagger 地址,我能把整个 service 层写出来,因为入参出参类型都定死了,剩下的只是逻辑。
前端不是这样。前端的输入除了接口,还有一大堆"没写在接口里"的东西:
- 这个字段在表格里要不要显示,宽度多少,超长怎么截断;
- 这个按钮什么条件下禁用;
- 这个页面从菜单进来和从详情页跳转进来,行为是不是一样;
- 列表筛选条件要不要持久化到 URL;
- 权限点到底是控制到按钮,还是控制到列。
这些东西接口文档里一个字都没有,全在前端页面设计文档里。后端转前端的人不知道有这份文档存在,或者知道但觉得"先写出来再说",于是就开始返工循环。
我现在的习惯是:接口文档决定我能不能调通,设计文档决定我能不能一次做对。两者的权重,设计文档还更高一些。
二、飞算JavaAI的前端开发,是被上游文档卡住的
这一点我第一次用的时候还挺意外。飞算JavaAI(https://www.feisuanyz.com)的/前端开发指令,入口是智能会话 → 添加指令 → 选/前端开发,但它不是你输了指令它就开写。
它依赖/前后端设计产出的一整套文档:工作区上下文、技术栈决策、数据库设计、接口设计、技术需求覆盖、后端设计规范基线。如果这些文件缺失,它会直接提示你回到/前后端设计去补齐,而不是硬着头皮生成一堆对不上的代码。
我记得有个纯后端项目,我直接跑/前端开发,它给我回了缺失提示,让我先把前端相关的设计文档补上。当时我嫌麻烦,后来发现这个阻断是对的——没有页面设计文档就生成页面,产出的东西跟随机拼凑没区别。
完整链路是这样的:
/需求分析:产出需求文档 + 业务设计文档,落在项目 docs 目录;/前后端设计:产出数据库设计、接口设计、前端页面设计、技术栈决策等;/前端开发:严格依据前端页面设计文档生成代码,实现高保真 UI 与交互逻辑;npm install→npm run dev,跑起来看效果。
我特别认可第 2 步和第 3 步的分离。很多团队(包括我以前)是把"设计"和"写代码"混在一起的,边写边想,最后页面是出来了,但没人说得清它为什么长这样。拆开之后,设计文档成了可评审的交付物,我可以在评审阶段就砍掉不合理的地方,而不是等代码写完再改。
不过要说实话:生成的页面我只当作"第一版骨架"。它能把结构、字段、接口调用搭对,但细节上我每次都要改。这个后面单独说。
三、前端页面设计文档里,真正要看的五类信息
我把前端页面设计文档的信息分成五类。每次拿到文档,我按这个顺序过一遍,缺任何一类我都会退回要求补齐,而不是自己脑补。
| 类别 | 具体要确认什么 | 缺失时的后果 |
|---|---|---|
| 页面结构 | 页面分区(筛选区/工具栏/表格/分页)、组件层级、路由路径与参数 | 布局全靠猜,返工率最高 |
| 字段映射 | 每个展示项对应哪个接口字段、格式化规则、字典来源、空值兜底 | 字段名对不上,或格式二次加工出错 |
| 接口调用 | 调用哪个接口、何时触发(挂载/点击/翻页)、并发与串行关系、失败重试 | 请求打多次,或数据覆盖错乱 |
| 状态管理 | 哪些状态放组件内、哪些进全局 store、哪些同步到 URL | 刷新丢状态,返回列表筛选条件没了 |
| 权限控制 | 权限点编码、控制粒度(页面/按钮/列)、无权限时的表现(隐藏 or 禁用) | 越权可见,或者直接白屏 |
1. 页面结构
看这一块的时候,我重点看路由参数。比如一个编辑页/user/edit/:id,我就知道:必须处理 id 缺失的情况、必须支持从列表带 id 跳入、必须支持浏览器刷新。这些如果文档里没写路由,我八成会写成"从 store 里取当前选中行",然后一刷新页面就白屏——这个坑我踩过两次。
2. 字段映射
这是最琐碎但最容易出事的一类。我关心三点:
字段名是不是驼峰。后端 Java 是驼峰,但有些老接口返回下划线,前端转不转?必须在文档里定死,否则调试时满屏undefined。
格式化在哪一侧做。时间戳转日期,后端转还是前端转?我现在的约定是:后端返回原始值 + 前端统一格式化,但金额和小数位数由后端定(避免精度问题)。这个不写清楚,就会出现前端 format 了一遍已脱敏/已格式化的数据,显示成***或者2024-01-01 00:00:00的二次加工错误——就是开头同事问我的那个问题。
字典从哪来。状态、类型这类字段,字典是前端写死还是从/dict接口拉?我的原则是业务字典必须走接口,只有纯展示用的枚举(比如性别)才写死。写死的字典一旦后端改了枚举值,前端就显示空白。
3. 接口调用
除了"调哪个接口",我还会看触发时机和依赖关系。最典型的是级联下拉:省市区三级联动,是父级变了立刻清子级,还是保留旧值?这个文档里不写,前端就会做出"选了新的省,市还显示上一个省的数据"这种 bug。
4. 状态管理
我的判据很简单:需要跨页面共享或刷新后保留的,进 URL 或 store;只在当前组件内部流转的,放 local state。列表页的筛选条件我一律同步到 URL query,因为用户会复制链接发给同事,这个是管理后台的高频操作。
5. 权限控制
这块是安全事故高发区。我要求文档必须写清权限点编码和无权限时的表现。隐藏和禁用是两种完全不同的语义:隐藏意味着"不知道有这功能",禁用意味着"知道但不能用"。多数管理后台用隐藏,但涉及"申请开通"这类引导性操作时要用禁用。
我读文档的顺序,和一个反面案例
拿到一份前端页面设计文档,我现在的阅读顺序是固定的:权限 → 路由 → 字段 → 接口 → 状态。
为什么权限排第一?因为权限决定页面骨架。如果某个角色看不到工具栏,那你的组件树里可能压根不该渲染那一块,而不是渲染出来再隐藏。先读权限,能让你在搭结构的时候就少一层嵌套。路由排第二,因为它决定组件的入参和生命周期。字段和接口是填充物,放中间。状态最后看,因为它是在前四者都确定之后才浮现出来的东西。
反面案例说一个我自己的。去年做一个"工单详情"页,设计文档里写了"详情页支持从列表点击跳入",我照做了。但文档里还有一句"支持从邮件通知链接直接打开",我没细看,用的是从列表路由传参的方式拿工单 ID。结果运营同学从邮件点进来,页面一片空白,因为 URL 里没有那个参数,而我没做从 query 读取的兜底。
这个 bug 修起来只要五分钟——加一句route.query.id ?? route.params.id。但它在生产环境躺了三天,因为运营以为"工单系统是坏的",直接绕过去用微信找研发手工处理。事后复盘,问题不在我 Vue 写得不好,在于我跳过了文档里那一行不起眼的话。
从那以后我给自己定了个规矩:设计文档里每一句带"支持"两个字的话,都必须能在代码里找到对应实现。找不到的,要么补实现,要么回去问清楚为什么没做。
四、从设计文档到 Vue 组件:一个完整映射
光讲原则没意思,我拿一个"告警规则列表页"的真实文档片段演示一遍映射过程。
设计文档里的描述(简化后):
页面路径
/alert/rule,权限点alert:rule:list。顶部为筛选区(规则名称模糊查询、状态下拉、启用开关);工具栏含"新建规则"(权限点alert:rule:add)与"批量删除"(权限点alert:rule:batchDel,需选中至少一行才可用);主体为表格,列包括规则名称、告警级别、状态、最近触发时间、创建人、操作;分页为服务端分页,默认 20 条;筛选条件同步至 URL query;状态列用字典alert_rule_status;无alert:rule:edit权限时操作列的编辑按钮隐藏。
我把这段拆成组件结构,是这么落的:
views/alert/rule/ ├── index.vue // 页面容器:拼装筛选区+工具栏+表格+分页 ├── components/ │ ├── RuleFilter.vue // 筛选区:受控组件,v-model 双向绑定 │ └── RuleTable.vue // 表格:纯展示,通过 props 收数据、emit 抛事件 ├── composables/ │ └── useRuleList.ts // 接口调用 + 分页 + URL 同步 └── types.ts // 与后端 VO 对齐的 TS 类型关键的映射规则是:文档里的"区"对应组件,文档里的"状态"对应 composable。筛选区、表格是组件;分页页码、筛选条件、选中行是状态,统一进 composable,不散在组件里。
下面是useRuleList.ts的核心逻辑,注意 URL 同步那一段:
// composables/useRuleList.tsimport{ref,watch}from'vue'import{useRoute,useRouter}from'vue-router'import{fetchRulePage,typeRuleQuery,typeRuleVO}from'@/api/alert/rule'exportfunctionuseRuleList(){constroute=useRoute()constrouter=useRouter()// 筛选条件初始值从 URL query 恢复,保证刷新和分享链接都有效constquery=ref<RuleQuery>({name:(route.query.nameasstring)??'',status:(route.query.statusasstring)??'',enabled:route.query.enabled==='true',pageNum:Number(route.query.pageNum??1),pageSize:Number(route.query.pageSize??20),})constrows=ref<RuleVO[]>([])consttotal=ref(0)constloading=ref(false)asyncfunctionload(){loading.value=truetry{constres=awaitfetchRulePage(query.value)// 后端统一 Result<T> 包装,业务码非 0 时由拦截器抛出rows.value=res.data.items total.value=res.data.total}finally{loading.value=false}}// 筛选条件变化 → 同步回 URL,并重置到第一页watch(()=>({...query.value}),(val)=>{router.replace({query:{...val,pageNum:String(val.pageNum)}})load()},{deep:true})return{query,rows,total,loading,load}}这里有两个点值得说。一是筛选条件变化必须重置 pageNum,否则你在第 5 页改了筛选条件,请求的还是第 5 页,很可能返回空列表,用户以为"查不到数据"。这个 bug 我见过太多次了。二是router.replace而不是push,避免用户点返回键时在筛选历史里绕不出来。
表格组件里,权限控制我用统一的指令处理,不要在模板里写v-if="hasPerm('xxx')"这种散落判断:
<!-- components/RuleTable.vue --> <template> <el-table :data="rows" v-loading="loading" @selection-change="onSelect"> <el-table-column type="selection" width="48" /> <el-table-column prop="ruleName" label="规则名称" min-width="180" show-overflow-tooltip /> <el-table-column prop="level" label="告警级别" width="100"> <template #default="{ row }"> <!-- 级别用字典渲染,字典来自全局字典 store --> <el-tag :type="levelTagType(row.level)">{{ dictLabel('alert_level', row.level) }}</el-tag> </template> </el-table-column> <el-table-column prop="status" label="状态" width="90"> <template #default="{ row }"> {{ dictLabel('alert_rule_status', row.status) }} </template> </el-table-column> <el-table-column prop="lastTriggerTime" label="最近触发" width="170"> <template #default="{ row }"> <!-- 后端返回毫秒时间戳,前端统一格式化;空值必须有兜底 --> {{ row.lastTriggerTime ? formatTime(row.lastTriggerTime) : '—' }} </template> </el-table-column> <el-table-column label="操作" width="140" fixed="right"> <template #default="{ row }"> <!-- 权限点控制到按钮级,无权限时隐藏而非禁用 --> <el-button v-perm="'alert:rule:edit'" link type="primary" @click="emit('edit', row)"> 编辑 </el-button> <el-button v-perm="'alert:rule:del'" link type="danger" @click="emit('delete', row)"> 删除 </el-button> </template> </el-table-column> </el-table> </template>v-perm是个自定义指令,逻辑很简单——拿全局权限列表比对,不在列表里就直接remove()掉 DOM 节点。这样做的好处是权限判断只有一处实现,评审时也好检查:
// directives/perm.tsimporttype{Directive}from'vue'import{useUserStore}from'@/store/user'exportconstperm:Directive<HTMLElement,string>={mounted(el,binding){constcodes=useUserStore().permCodesif(!codes.includes(binding.value)){el.remove()// 直接移除,不留占位}},}接口层我建议单独抽一个api/目录,并且类型直接由 OpenAPI 生成,不要手写。手写 TS 类型等于把接口契约抄了一遍,抄错的概率不低:
// api/alert/rule.ts —— 该文件由 openapi-generator 产出,不要手改importrequestfrom'@/utils/request'importtype{Result,PageResult,RuleVO,RuleQuery}from'./types'exportfunctionfetchRulePage(params:RuleQuery){returnrequest.get<Result<PageResult<RuleVO>>>('/api/v1/alert/rules',{params})}exportfunctiondeleteRules(ids:number[],idempotencyKey:string){returnrequest.post<Result<null>>('/api/v1/alert/rules/batch-delete',{ids},{headers:{'Idempotency-Key':idempotencyKey}})}注意批量删除带了Idempotency-Key——这就是设计文档里"接口调用"那一类要写清的东西。如果文档没标,前端根本不知道要传这个头,后端也没强制校验,最后就是重复提交产生脏数据。
五、飞算JavaAI生成的页面,我每次都要改这几处
/前端开发生成的页面能跑,结构也对,但我每次都会改:
字典来源。它倾向于把状态、类型的中文映射直接写死在前端的 map 里。这在管理后台是隐患,我一律改成走全局字典接口。
权限粒度。它通常会做页面级和按钮级,但列级权限基本没有。涉及敏感字段(成本、手机号)的列,我手动加v-perm。
空值和超长兜底。生成的表格列经常没有show-overflow-tooltip,长文本会把行高撑开;空值显示成空白而不是—。这些都是小改,但很影响观感。
URL 同步。这一块它基本不管,筛选条件不会进 URL。我都会按上面的useRuleList模式重做一遍。
分页重置。前面说的"改筛选不重置页码",生成的代码里普遍存在。
改完这五处,页面才算能进提测。
六、一句话总结
Vue 语法三天能学完,"一个页面该有多少约定"要踩一年坑才补得齐;先读文档,再写代码,是后端转前端最省时间的一条路。
我的设计文档检查清单(拿到文档先过一遍,缺一项就退回):
- 页面路由路径和参数写了吗?刷新场景考虑了吗?
- 每个展示字段对应的接口字段名、格式化规则、空值兜底标了吗?
- 字典是走接口还是写死,写清楚了吗?
- 接口的触发时机、依赖关系、失败语义标了吗?
- 哪些状态进 store、哪些进 URL,分清楚了吗?
- 权限点编码给了吗?控制到按钮还是列?无权限是隐藏还是禁用?
- 分页默认值和"改条件是否重置页码"定了吗?
这七条能在文档阶段对齐,页面基本一次成型。对齐不了,就等着在联调阶段一条条吵回来。