后台管理系统里最不起眼、也最容易翻车的模块往往就是商品管理这一块。前面几个小节我们把商品列表、搜索、分页、删除都磨完了剩下的就是最后两个动作新增/编辑商品以及提交成功后跳回商品列表页面。别看只有两个动作真正动手写的时候你会发现表单回显、接口区分、防重复提交、跳转后列表要不要刷新每一步都藏着能让你调半天的小坑。我们用的是 React 技术栈配 antd 表单和 react-router这套组合在国内后台管理系统里几乎是标配所以这篇讲的思路你直接抄过去基本能跑。这篇适合已经写过一点 React、正在做或者准备做后台管理系统商品模块的朋友新手能看懂老手可以对着自己的代码查漏补缺。接下来我按设计思路、表单细节、提交实现、跳转同步、问题排查这条线把商品管理的收尾部分一次性讲透。1. 商品管理收尾新增/编辑与跳转的整体设计思路商品模块做到最后这一步其实是在做一个闭环——前面的列表负责看现在要负责写。写完之后还得回到看的状态并且看到自己刚写的成果。这个闭环看起来简单但如果一开始思路没理清后面会在跳转、刷新、数据同步这几个地方反复打补丁。所以动手之前先把整体设计想清楚比闷头写代码重要得多。1.1 为什么把新增和编辑塞进同一个组件我第一次做后台的时候是老老实实写了两套页面一个 AddProduct一个 EditProduct。结果写完之后发现两个页面的表单结构一模一样字段、校验规则、布局几乎没有任何区别唯一的差别是编辑页多了一步回显接口数据和提交时调的是修改接口。后来维护的时候更痛苦产品经理说加个字段我得改两个地方改漏一个就出 bug。所以现在我的做法非常固定新增和编辑共用同一个页面组件靠路由上有没有id参数来区分模式。这是后台管理系统里最经典的一种模式叫同构表单。它的核心逻辑就一句话const { id } useParams(); const isEdit Boolean(id);isEdit为true就是编辑模式为false就是新增模式。这一个布尔值后面会贯穿整个组件标题文案、按钮文字、是否发起回显请求、提交时调哪个接口、提交成功后跳转时带什么提示全都靠它来决定。这样做的好处很直接——只有一处维护成本字段加减只改一次逻辑分支集中在一个地方不容易漏。注意区分模式的判断依据要统一。有的团队用路由id有的用 query 参数有的用 props 传mode。选一种就别混着用否则后面某个入口传漏了参数编辑页会被当成新增页直接把老数据覆盖掉这是生产事故级别的坑。那为什么不用一个mode的 state 手动切换呢因为路由参数是可分享、可刷新、可后退的。用户刷新编辑页/product/edit/123这个 URL 还在页面重新进来依然知道自己在编辑哪条数据。如果是靠内存里的 state一刷新就全丢了页面会退化成新增模式体验非常差。1.2 路由参数与模式判断id 存在与否决定一切路由这块我用 react-router v6 的写法配置大概是这样的Route path/product/list element{ProductList /} / Route path/product/add element{ProductEdit /} / Route path/product/edit/:id element{ProductEdit /} /你看新增和编辑指向的是同一个组件ProductEdit区别只是编辑路由多了:id这一段。这么配的好处是新增入口和编辑入口天然分开菜单高亮、面包屑都好处理。列表页里点编辑就navigate(\/product/edit/${record.id})点新增就navigate(/product/add)。拿参数的时候用useParams注意它是返回字符串的const params useParams(); const productId params.id; // 编辑模式下是字符串新增模式下是 undefined这里有个小细节很多人忽略id从 URL 里取出来永远是字符串。如果你的后端是用数字 ID 的别忘了在调接口的时候转一下或者干脆跟后端约定好接口同时接受字符串省得类型对不上。我就遇到过后端用严格比较number前端传了字符串123结果查不到数据的尴尬情况排查了小半天。还有一个判断时机的问题isEdit的判断要放在组件渲染的早期因为它决定了要不要发回显请求。合理的做法是放到useEffect里依赖productIduseEffect(() { if (productId) { fetchDetail(productId); } }, [productId]);依赖数组只放productId不要放整个表单实例或者随渲染变化的对象否则会造成无限请求。这个在下文讲回显的时候会再展开。1.3 提交后的跳转策略为什么用 navigate 而不是 window.location提交成功后要跳回列表页这个动作有两种写法一种是window.location.href /product/list一种是navigate(/product/list)。我强烈建议用后者也就是 react-router 提供的编程式导航。原因在于window.location.href会触发整页刷新也就是浏览器重新加载整个 SPA。这一步的代价是所有内存状态清零、所有接口重新请求、页面白屏一瞬间、用户能看到明显的闪一下。而后台管理系统本身就是 SPA整页刷新反而丢掉了 SPA 的优势。用navigate是路由级跳转页面不刷新状态还在体验顺滑。更重要的是跳转的时候我们通常还想给用户一个反馈比如保存成功。这时候可以借助路由 state 把消息带过去navigate(/product/list, { state: { refresh: true, message: 保存成功 }, });列表页useLocation拿到这个 state就能决定要不要重新拉一次列表顺便弹个提示。用window.location的话这些都做不到只能靠全局变量或者 storage很别扭。提示navigate是 v6 的用法v5 里对应的是history.push。如果你手上项目还是老版本别照抄 API思路是一样的写法换了而已。设计层面还有一个决策点提交成功后到底跳不跳。有些团队的编辑页保存后不跳转就停留在原地提示保存成功方便用户继续改。我的经验是新增商品保存后一定要跳回列表因为用户需要确认我加的东西进去了没有编辑商品保存后可以给两个选择既可以停留也可以跳转看你们的业务习惯。这篇按标题要求统一按提交后跳回列表来处理后面章节会给出具体实现。2. 表单核心细节回显、校验与状态管理表单是整个提交环节的地基。地基没打好后面接口调通了也会出现改了这个字段那个丢了校验拦不住脏数据这类问题。这一章专门讲表单的三个核心难点编辑态的数据回显、校验规则的设计以及表单状态的管理方式。2.1 受控与非受控antd Form 的取值方式怎么选antd 的 Form 组件提供了两套用法一套是受控每个字段自己维护 statevalueonChange一套是非受控交给 Form 实例统一管理通过form.setFieldsValue和form.getFieldsValue读写。后台管理系统的表单字段通常十几个起步用受控写法会写一堆useState维护成本极高所以我基本都直接用Form 实例的非受控模式。先拿实例const [form] Form.useForm();取值就用form.getFieldsValue()可以传true拿到包括未触发校验字段在内的完整值const values form.getFieldsValue(true);赋值就用form.setFieldsValue({ ... })。这里有个非常关键的细节也是我踩过的坑setFieldsValue是合并不是覆盖。也就是说你只传{ name: abc }其他字段不会被清空。这在回显的时候是好事但在重置表单的时候就是灾难——你以为传了个空对象就清空了其实啥也没动。清空要用form.resetFields()。表单提交的时候antd 提供了onFinish回调它只会在所有校验通过之后才触发参数就是当前所有字段的值Form form{form} onFinish{handleSubmit} ... /Form所以真正的提交逻辑写在handleSubmit里就行不用自己判断校验有没有过antd 帮你把住了这道门。如果校验失败会走onFinishFailed一般用来把页面滚动到第一个出错的字段const handleFailed (errorInfo) { form.scrollToField(errorInfo.errorFields[0].name); };注意非受控模式下表单值的唯一来源是 Form 实例不要再去外面用 useState 存一份。两份状态一旦不同步就会出现界面上是新值提交出去是旧值这种灵异事件。要么全交给 Form要么全自己管切忌混用。2.2 编辑态回显把接口数据精准灌进表单回显是编辑模式的核心动作。流程就是拿到productId请求详情接口拿到数据setFieldsValue灌进表单。听起来简单但有几个坑必须提前避。第一个坑是字段名对齐。接口返回的字段名和表单字段名经常不一致比如接口是product_name下划线表单是productName驼峰。这时候你不是直接setFieldsValue(res.data)而是要做一次映射。我一般会写一个转换函数const mapDetailToForm (data) ({ productName: data.product_name, price: data.price, stock: data.stock, categoryId: data.category_id, cover: data.cover, // 图片字段 });这样接口怎么变我只改这一处映射表单内部字段名保持稳定逻辑清晰。第二个坑是图片、富文本这类非纯值字段的格式。比如封面图表单里 antd Upload 期望的是fileList数组格式每项有uid、name、url而接口返回的往往就是一个 URL 字符串。回显的时候要手动转cover: data.cover ? [{ uid: -1, name: cover.png, status: done, url: data.cover }] : [],这个转换不做编辑页的图片就是空的用户以为图片没了一保存还真就丢了。这种坑非常隐蔽因为表单校验不会报错。第三个坑是下拉框选项的异步回显。分类是一个下拉框选项要从接口拉。如果详情回了categoryId: 5但分类选项还没请求回来setFieldsValue之后下拉框会显示成 value 数字比如直接显示5而不是分类名。解决办法是等选项数据到位再回显useEffect(() { const init async () { await fetchCategories(); // 先拿选项 if (productId) { const detail await fetchDetail(productId); form.setFieldsValue(mapDetailToForm(detail)); } }; init(); }, [productId]);顺序很重要先选项、后回显。反过来的话就会出现闪一下数字再变成名称的观感问题虽然最终能对上但体验不好。2.3 校验规则价格、库存、SKU 的约束怎么设计后台表单的校验不能只是必填还得管住业务逻辑。我挑几个典型字段说说规则怎么配。价格字段常见的规则是必填、数字、大于等于 0、最多两位小数。antd 的rules可以这样写{ name: price, label: 售价, rules: [ { required: true, message: 请输入售价 }, { type: number, min: 0, message: 售价不能为负数 }, { validator: (_, value) { if (value undefined || value null) return Promise.resolve(); if (!/^\d(\.\d{1,2})?$/.test(String(value))) { return Promise.reject(new Error(最多保留两位小数)); } return Promise.resolve(); }, }, ], }这里用validator而不是正则写在pattern里是因为type: number和字符串正则容易打架。用自定义validator最灵活逻辑想怎么写就怎么写。库存字段除了非负整数还得考虑上限。有些业务不允许库存超过 99999那就加个max。SKU 编码通常要求唯一这就涉及异步校验——需要调接口去查这个编码有没有被占用。antd 支持返回 Promise 的 validator天然适配{ name: sku, label: SKU 编码, rules: [ { required: true, message: 请输入 SKU }, { validator: async (_, value) { if (!value) return; const res await checkSkuUnique(value, productId); if (!res.unique) { throw new Error(该 SKU 已存在); } }, }, ], }checkSkuUnique里记得把当前商品 ID 也带上因为编辑自己时查到的是自己占用自己应该算通过。后端判断时排除当前 ID 即可。提示异步校验会频繁触发每次输入变化都可能触发一定要做防抖否则用户还没输完就发一堆请求。我一般给这类校验加 300~500ms 的延迟或者改成失焦时才校验。antd 的validateTrigger可以指定触发时机设成onBlur就很省心。2.4 富文本与多图的联动处理别让表单变脏商品详情常见是富文本编辑器商品图是多图上传。这两类字段和普通文本字段不一样值不是简单字符串处理不好会让表单数据变脏。富文本的值通常是一段 HTML 字符串。提交前最好做一次清洗比如去掉开头结尾的空标签、把相对路径的图片补成绝对路径。因为用户在编辑器里粘贴的内容可能带一堆乱七八糟的样式直接存库以后前台渲染会很难看。多图上传的字段值是一个数组每个元素是一个文件对象。提交前要把它转成后端要的 URL 数组const images (values.images || []) .map((file) file.url || file.response?.url) .filter(Boolean);.filter(Boolean)这一步很关键能过滤掉上传失败或还没传完的空槽位避免提交一堆undefined给后端。还有个容易被忽略的点图片上传和表单提交是两个独立的过程。用户点保存的时候如果图片还在上传中status: uploading你直接提交就会丢图。稳妥的做法是在提交前检查上传状态const uploading (values.images || []).some((f) f.status uploading); if (uploading) { message.warning(图片还在上传中请稍候); return; }这几行代码帮我省了无数次图片丢了的售后问题。后台管理系统的体验往往就是这些细节堆出来的。3. 提交与修改接口的实操实现表单搞定了接下来就是把它变成真正的数据操作。这一章讲请求层怎么封装、新增和修改怎么区分、防重复提交怎么做以及提交成功后如何干净利落地收尾。3.1 请求层封装统一错误处理的必要性后台管理系统里如果你每个页面都写一遍axios.post然后每个地方都手写try/catch弹错误提示代码会变得又长又重复。我的习惯是在项目里封一层请求模块统一处理 baseURL、超时、token 注入、错误提示。这样做的好处是业务代码里只管拿数据不用到处写错误处理。一个精简版封装大概是这样import axios from axios; import { message } from antd; const request axios.create({ baseURL: /api, timeout: 15000, }); request.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { message.error(res.message || 请求失败); return Promise.reject(new Error(res.message || Error)); } return res.data; }, (error) { message.error(error.message || 网络异常请稍后重试); return Promise.reject(error); } ); export default request;这里我做了一个约定业务成功统一用code 0表示具体数字看你们后端。拦截器里判断非 0 就直接弹错误并 reject业务层拿到的永远是干净的数据。这样写业务的时候const detail await request.get(/product/${id});不用判断 code直接用非常清爽。注意错误提示最好在拦截器里做一次别在业务层再弹一次否则一个错误会弹两个 toast。我见过有人两处都写了message.error用户看到两条相同的提示一脸懵。3.2 新增与修改一个函数覆盖两种模式前面说了isEdit决定调哪个接口。我喜欢把它收敛到一个提交函数里逻辑集中改动只改一处const handleSubmit async (values) { setSubmitting(true); try { const payload buildPayload(values); if (isEdit) { await request.put(/product/${productId}, payload); } else { await request.post(/product, payload); } message.success(保存成功); navigate(/product/list, { state: { refresh: true } }); } catch (e) { // 错误提示已在拦截器统一处理这里按需补充 } finally { setSubmitting(false); } };buildPayload负责把表单值整理成后端要的结构图片转 URL 数组、富文本清洗、字段名映射、去掉不需要的字段。把它单独拆出来好处是方便写单元测试也方便排查提交上去的数据长什么样这类问题。我调试接口的时候经常先在buildPayload里console.log一下最终 payload很多字段对不上的问题一眼就能看出来。关于方法是PUT还是PATCH其实取决于后端的约定。PUT通常表示全量更新PATCH是部分更新。后台商品编辑一般是把整份表单数据都提交上去所以用PUT居多。这个不用纠结跟后端对齐就行前端改一个词的事。3.3 防重复提交loading 状态与按钮禁用这是最容易被忽略、但线上最常出问题的点。用户点了保存接口稍微慢一点他等不及又点了一下结果同一条商品被创建了两条——尤其新增场景这个 bug 非常常见。解决办法就一个提交进行中把按钮禁掉。实现上我用一个submitting状态控制按钮const [submitting, setSubmitting] useState(false); Button typeprimary htmlTypesubmit loading{submitting} disabled{submitting} {isEdit ? 保存修改 : 立即创建} /ButtonhtmlTypesubmit让按钮直接触发表单的onFinish同时loading和disabled双保险loading给视觉反馈disabled从行为上阻止第二次点击。但这里还有个细节光靠按钮禁用不够。如果用户用回车键提交或者提交逻辑被别的地方触发按钮的禁用拦不住。所以我还会在handleSubmit开头加一道锁const handleSubmit async (values) { if (submitting) return; setSubmitting(true); ... };提示setState是异步的高频点击下submitting可能还没更新就进来第二次。要彻底稳可以用useRef存一个同步的标志位。99% 的场景setState就够了但对并发极其敏感的操作用 ref 更保险。3.4 提交成功的收尾提示、跳转、状态重置提交成功后有三件事要做顺序和方式都有讲究。第一件是给反馈。message.success(保存成功)是最基本的。反馈要即时不能等跳转之后再弹那样用户会觉得我点了半天没反应。所以提示放在跳转之前。第二件是跳转。前面讲了用navigate并且通过 state 把refresh标记传给列表页。跳转的同时如果这个编辑页是被缓存过的比如用了 keep-alive 类似的方案记得把组件状态重置避免下次进来看到上一篇的数据。第三件是数据同步。这一件其实发生在列表页就是收到refresh之后重新拉数据。这一块内容比较多放到下一章细讲。这里我说一个真实踩过的坑。有一次我把message.success放在了navigate之后结果开发环境里跳转很快提示一闪就没了用户根本来不及看。后来改到跳转前虽然只早了那么几十毫秒但观感就完全不一样了。反馈一定要先于跳转记住这个就行。4. 跳回商品列表页面与数据同步跳回列表这件事表面上是navigate一行代码但真正的难点在于跳回去之后列表得是最新的。这一章把跳转的几种姿势、列表刷新的触发时机、以及多标签缓存下的数据一致性一起讲清楚。4.1 navigate 传参state 比 query 更适合传刷新指令跳列表页的时候我们可以带两类参数queryURL 上看得见的比如/product/list?refresh1和 stateURL 上看不见的挂在 history 上。它们有什么区别用哪个更合适query 的好处是刷新页面后还在坏处是会污染 URL用户看到?refresh1会觉得莫名其妙而且一眼看去不知道干嘛的。state 的好处是干净、不影响 URL 观感坏处是刷新页面后 state 会丢——但这恰好符合我们的需求刷新意味着重新加载本来就会重新请求不需要额外的刷新指令了。所以我的选择是statenavigate(/product/list, { state: { refresh: true, from: product-edit }, });列表页接收const location useLocation(); useEffect(() { if (location.state?.refresh) { fetchList(); // 重新拉数据 // 用完清掉避免返回时重复触发 navigate(location.pathname, { replace: true, state: {} }); } }, [location.state]);这里有个关键操作用完 state 之后要把它清掉用replace: true重置。为什么因为如果不清用户从列表点了详情再返回location.state还是那个{ refresh: true }会又触发一次刷新没必要。用replace重置成空 state就干净了。4.2 列表刷新的三种触发时机与取舍列表什么时候该刷新其实有三种情况得分开处理。第一种是从编辑/新增页跳回来。这种情况必须刷新因为数据变了。这就是上面 state 传refresh的用武之地。第二种是在列表页内部操作比如删除了一条、下架了一条。这时候没必要整页刷新最优雅的做法是局部更新——从当前 list 数组里把那条删掉或者改它的状态const handleDelete async (id) { await request.delete(/product/${id}); setList((prev) prev.filter((item) item.id ! id)); message.success(删除成功); };局部更新的好处是不闪屏、不重置分页、体验好尤其当前页码不是第一页的时候整页刷新会跳回第一页用户还得重新翻很难受。第三种是保留分页和筛选条件。用户在第 3 页、筛选了某个分类编辑完某条商品回来理想情况是还停在第 3 页、筛选条件还在只是数据更新了。如果无脑fetchList()重置了筛选用户会骂人。所以我的做法是把分页和筛选参数外置到 URL query 或者状态管理里刷新的时候带上这些参数一起请求。为了兼顾带回分页和数据新鲜两个需求跳转时可以顺手把列表的参数也带上navigate(/product/list, { state: { refresh: true }, });列表页本身维护page、pageSize、filters刷新的时候用当前的这些值去请求用户的上下文就保住了。注意如果你用了状态管理Redux、zustand 等把列表的查询参数放进去比放在组件 state 更稳。因为组件一旦卸载state 就没了跳回来就丢上下文。查询参数放全局卸载重挂也能恢复。4.3 数据一致性缓存、多标签页与脏读的处理这一节讲点进阶但不难的内容。后台管理系统里列表数据可能被缓存比如用了 React Query、SWR或者自己手写的缓存。缓存能减少请求、加快渲染但也会带来数据不新鲜的问题。假设你用了 React Query从编辑页跳回来切回列表页时它可能直接从缓存拿旧数据渲染你看到的是修改前的状态。解决办法是提交成功后让相关缓存失效const queryClient useQueryClient(); // 提交成功后 await queryClient.invalidateQueries({ queryKey: [product-list] });缓存失效会触发后台重新拉取用户看到的就是最新数据。这套机制比手动传refresh更优雅如果你项目里已经在用数据请求库优先用它自带的失效机制。再说多标签页。用户可能在两个标签页都开着商品列表。在 A 标签改了商品B 标签还显示旧数据。这种场景在后台管理系统里其实挺常见。彻底解决要靠跨标签页通信比如监听 storage 变化、BroadcastChannel但对大部分项目来说保证当前标签页数据新鲜就够了多标签一致性可以暂时不做除非业务明确要求。我自己的取舍是优先保证提交后立刻在本标签看到变化这是核心体验多标签一致性属于加分项等有空再补。别为了一个边缘场景搞一堆复杂机制反而把主流程搞乱了。5. 常见问题与排查技巧实录前面讲的是怎么写对这一章讲写错了怎么查。商品提交和跳转这块出问题往往不好定位因为牵扯表单、接口、路由好几层。我把这些年踩过的坑整理出来你照着对号入座能省不少时间。5.1 编辑页数据回显失败或显示旧值这是最高频的问题。表现是进编辑页字段是空的或者显示的是上一个商品的名称。排查按这个顺序走。先确认请求真的发出去了而且带对了 id。打开 Network 面板看详情接口有没有被调用URL 里的 id 对不对。如果没发请求八成是useEffect的依赖或者条件判断写错了比如if (productId)里productId是undefined。再看回显的时机。前面讲过如果下拉框选项是异步的回显必须在选项到位之后。如果接口数据回来了但setFieldsValue在选项之前执行了下拉框就对不上。如果显示的是上一个商品的值那是状态没重置导致的。组件可能被复用了同一个组件渲染不同 id这时候要在productId变化时先form.resetFields()再回显useEffect(() { form.resetFields(); if (productId) { fetchDetail(productId).then((d) form.setFieldsValue(mapDetailToForm(d))); } }, [productId]);先清空再填充就不会串数据了。5.2 提交成功了但列表没变化这个问题的根源几乎都在刷新没触发。检查三处。第一跳转的时候 state 传了没navigate(/product/list, { state: { refresh: true } })。有没有漏掉 state。第二列表页有没有正确地读 state 并触发刷新。location.state?.refresh拿到的是不是true。第三如果用了缓存缓存失效了没。用了 React Query 之类的别忘了invalidateQueries。还有一种情况是数据其实更新了但列表展示的是别的字段你肉眼没看出来。这种情况多在 Network 里对比一下请求返回和页面渲染的字段确认是数据没更新还是展示没对齐。5.3 重复提交导致的脏数据表现是列表里出现了两条一模一样的商品或者库存被扣了两次。前面讲过防重复提交这里补充几个容易漏的入口。一是回车提交。表单里按回车会触发onFinish如果你只在按钮上做了禁用回车绕过了按钮的禁用。所以handleSubmit开头的if (submitting) return一定要有。二是双击保存按钮。loading有延迟极快双击可能第二次进来时submitting还没变。用useRef做同步锁最稳。三是接口超时后的重试。有时候不是用户点的是网络层自动重试导致的。这种情况要在后端接口做幂等比如用唯一请求 ID前端拦不住。跟后端约定好幂等机制是最根本的解法。5.4 常见问题速查表把上面这些整理成一张表遇到问题直接对照查效率更高。现象可能原因排查/解决编辑页字段空白详情请求没发或回显时机不对看 Network确认依赖和选项加载顺序显示上一条商品数据组件复用未重置useEffect里先resetFields再回显图片编辑后丢失回显格式不对或提交未过滤回显转 fileList提交转 URL 数组提交成功列表不变refresh 标记没传或缓存未失效检查 state必要时 invalidateQueries出现重复商品重复提交或接口未幂等加 submitting 锁后端做幂等下拉框显示数字选项未加载完就回显先拉选项再回显提交后卡片仍在转圈成功分支未关闭 loadingfinally里重置 submitting页码被重置到第一页无脑重新请求列表把分页参数外置刷新时带上5.5 几条我踩坑后才悟出来的经验最后分享几条没法从文档里学到、只能自己撞了才懂的经验。提交逻辑和表单校验分开写。别在handleSubmit里再写一遍if (!values.name) return那是 Form 的活。校验交给rules提交只管业务。混在一起后规则的唯一来源就分裂了改一处漏一处。payload 打印一次胜过猜十次。调接口对不上字段时第一件事就是把最终 payloadconsole.log出来对着接口文档一个一个比。我见过太多人对着代码空想半小时没想明白打印一下就秒解决。跳转前先落数据跳转后再刷界面。提交成功 → 提示 → 跳转 → 列表刷新这个顺序别乱。先跳转再提示提示会被路由切换打断先刷新再跳转用户在编辑页看到一个变了样子的列表也很怪。给每个接口调用留一个 catch 兜底。哪怕拦截器已经统一处理了错误提示业务层最好还是有个finally保证 loading 能关掉。否则一旦请求抛异常按钮就永远卡在 loading 状态用户以为界面死了。这个坑我踩过不止一次想起来都肉疼。商品管理到这儿就算收尾了。回头看看从列表到表单、从表单到接口、从接口到跳转其实每一环都不复杂难的是把它们的衔接处处理干净——回显别串数据、提交别重复、跳转别丢上下文、刷新别丢分页。这几件事做扎实了整块商品管理的体验就立住了。真正做过后台的人都明白功能能跑只是及格衔接处不别扭才算合格。后续如果还要扩展可以考虑给商品加个草稿箱、批量操作或者把提交改成乐观更新这些都是在现在这个骨架上顺手就能加的。