news 2026/9/29 7:26:54

React 后台商品新增编辑提交:表单复用、防重复提交与列表刷新

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React 后台商品新增编辑提交:表单复用、防重复提交与列表刷新

做过后台管理系统的人大概都有过这样的时刻:商品表单填了七八个字段,图片传完,点下"保存",页面卡在那儿转圈,你不知道它到底提交成功没有;再点一次,列表里多了一条重复数据。等你好不容易跳回列表页,发现刚才那条新商品根本没出现——因为列表用的是上一次的缓存数据。这一整套"提交、修改、跳回列表"的链路,看起来只是几个函数的调用,实际上是整个商品管理模块里最容易出问题的一段。这篇就围绕 React 后台管理系统里商品新增与编辑的提交环节,把表单复用、数据组装、防重复提交、路由回退和列表刷新这几件事一次讲透。适合已经能跑通增删改查、但在细节上反复踩坑的前端同学,也适合正在接手一个半成品后台项目、需要快速摸清代码结构的人。

1. 一套表单吃两个场景:新增与编辑的模式判定

先说结论:新增和编辑不要写两个表单组件。字段完全一样,校验规则完全一样,布局完全一样,唯一的差别是初始值从哪来、提交时打哪个接口。写两套的后果是,产品经理三个月后说"商品名称要加个 60 字长度限制",你改了一处,忘了另一处,线上就出现了新增能过、编辑不能过的诡异现象。

1.1 从路由参数里读出"我是谁"

最常见的做法是用路由参数区分。React Router v6 里,列表页的"编辑"按钮跳转到/product/edit/:id,而"新增"跳转到/product/create,两者渲染同一个组件:

// routes.tsx <Route path="/product/list" element={<ProductList />} /> <Route path="/product/create" element={<ProductForm />} /> <Route path="/product/edit/:id" element={<ProductForm />} />

组件内部用useParams拿 id:

const { id } = useParams<{ id: string }>(); const isEdit = Boolean(id);

这里有个很容易被忽略的细节:useParams拿到的永远是字符串。如果后端接口要求 id 是数字类型,直接透传过去,某些后端框架会在路由匹配或数据库查询时报类型错误,或者更糟——隐式转换后查到了错误的记录。我的习惯是在组件入口就转一次,并且做一次防御性判断:

const productId = id ? Number(id) : undefined; const isEdit = Number.isFinite(productId);

为什么用Number.isFinite而不是!Number.isNaN?因为用户手改 URL 是常事,/product/edit/abc这种地址完全可能出现。NaN会被Number.isFinite挡掉,此时可以渲染一个"参数错误"的占位,或者直接重定向回列表页,而不是让一个带着NaN的请求打到后端。

1.2 initialValues 只在挂载时生效这件事,坑了不止一次

编辑模式要回填数据,于是很自然地想到给<Form initialValues={detail}>。问题在于,antd 的initialValues只在 Form 首次挂载时读取一次。你的detail是通过接口异步拿到的,初始渲染时它是undefined,等请求回来setDetail(data)触发重渲染,Form 内部的字段值不会跟着变。现象就是:页面打开是空的,但控制台里 data 明明有值。

两种解法,各有适用场景。

第一种是等数据回来再渲染表单:

{loading ? <Skeleton active /> : ( <Form form={form} initialValues={detail} onFinish={handleSubmit}> {/* ... */} </Form> )}

这种写法的好处是干净,Form 挂载时数据已经就绪,initialValues一次到位。代价是页面会有一个骨架屏阶段。

第二种是不管数据到没到,表单先渲染,用setFieldsValue回填:

useEffect(() => { if (!isEdit || !detail) return; form.setFieldsValue({ name: detail.name, price: detail.price, categoryId: detail.categoryId, stock: detail.stock, }); }, [detail, isEdit, form]);

我个人的偏好是第二种,但前提是必须处理好"用户已经开始输入、数据才回来"的竞态问题,这部分留到第 5 节细说。

还有一个更省事的方案:给 Form 加key。

<Form key={isEdit ? productId : 'create'} ... />

key 变了,React 会把旧组件卸载、新组件挂载,initialValues自然重新生效。但要注意,卸载会丢掉所有未保存的输入,如果用户在编辑页改了东西之后触发了 key 变化,输入就没了。所以 key 更适合"新增/编辑切换"这种确定会重置的场景,不适合回填。

1.3 共用表单要付出的代价

共用不是白拿的,有两个地方必须显式分支。

一是文案。页面标题、提交按钮文字、成功提示都要跟着模式走:

const pageTitle = isEdit ? '编辑商品' : '新增商品'; const submitText = isEdit ? '保存修改' : '立即创建';

硬编码成"提交"当然也能跑,但用户看到"编辑商品"下面的按钮写着"立即创建",会怀疑自己是不是点进了新增页。

二是提交接口。我习惯在 API 层把两个接口分开,组件里只做一次分支判断,而不是把参数拼起来丢给一个"万能接口":

const request = isEdit ? () => updateProduct(productId!, payload) : () => createProduct(payload);

为什么强调分开?因为新增和编辑的返回结构经常不一样。新增接口往往返回整条新记录(带自增 id),编辑接口可能只返回{ success: true }。如果两个接口合并调用,你得在组件里写if ('id' in res)这种判断,耦合度反而更高。

2. 从表单值到接口字段:提交前的数据组装与校验

表单拿到的数据,和接口要的数据,几乎从来都不是一个东西。中间那层转换如果偷懒,问题会在某个不经意的时刻集中爆发。

2.1 字段映射不是简单的透传

举几个后台系统里几乎必然会遇到的例子。

价格字段。前端用InputNumber让用户输入元,后端存的可能是分。这里要明确一个方向:我倾向于"前端负责换算,后端只存整数分",理由是浮点数在数据库里做聚合统计容易出精度问题。

price: Math.round(values.price * 100),

Math.round不能省。19.9 * 100在 JavaScript 里等于1989.9999999999998,直接取整会变成 1989,少一分钱。用户不会为了这一分钱来投诉,但财务报表对不上的时候,够你查一下午。

分类字段。表单可能用TreeSelect让用户选到三级分类,值是叶子节点的 id,但后端要同时存一级、二级、三级。这时候组件里就得有个映射表,或者干脆在提交前查一次分类树。

多选字段。Select mode="multiple"的值是数组,后端可能要逗号分隔的字符串。values.tags.join(',')看着简单,但如果 tag 内容里本身带逗号,就会解析错。这种时候宁可存 JSON 字符串,也别用逗号拼。

空值处理。这是最容易出线上事故的一类。表单里用户清空了一个输入框,值是'';用户没动过的字段,值是undefined。这两个东西传给后端,行为可能完全不同:''可能被当成"用户要把这个字段清空",而undefined在 axios 里会被直接忽略、不参与序列化,后端收到的是"字段未传"。

我的处理方式是在组装函数里统一一次:

const payload = { name: values.name?.trim(), price: Math.round(values.price * 100), categoryId: values.categoryId ?? null, description: values.description?.trim() || '', tags: values.tags?.join(',') ?? '', };

注意description用的是|| ''而categoryId用的是?? null。区别在于:描述字段用户清空就是清空,传空字符串合理;分类字段如果允许为空,后端可能定义了null才是"无分类",空字符串会触发枚举校验失败。这两个符号看着像,语义差得远。

trim()也别漏。用户从 Excel 复制粘贴过来的商品名,前后经常会带空格或者不可见的\u200b,存进数据库之后,列表页搜索的时候死活搜不到,因为搜索关键词是精确匹配。

2.2 自定义校验规则写在哪一层

基础校验交给 Form 的rules,这个没什么争议。但有几类校验,放到rules里还是放到提交前,需要想清楚。

必须放在 rules 里的:长度限制、必填、数字范围、正则格式。这些是字段级的,用户输了就能立刻看到错误,体验最好。

适合放在提交前的:跨字段校验、需要请求接口的校验。

跨字段的典型例子是价格区间:

// 放在 onFinish 里,或者用 Form 的 dependencies 做联动 if (values.minPrice != null && values.maxPrice != null && values.minPrice > values.maxPrice) { form.setFields([{ name: 'maxPrice', errors: ['最高价不能低于最低价'] }]); return; }

用form.setFields手动塞错误,比弹一个message.error好得多,因为错误直接标在了具体字段下面,用户一眼就知道改哪。

需要请求接口的校验,比如"商品编码不能重复"。这里有个取舍:实时校验(配合validateTrigger="onBlur")体验好,但编辑模式下必须排除自己——用户打开编辑页什么都没改,失焦后提示"编码已存在",那就很尴尬了。所以请求里一定要带上当前的 id:

const checkCodeUnique = async (_: unknown, value: string) => { if (!value) return Promise.resolve(); const { exists } = await api.checkProductCode({ code: value, excludeId: productId }); return exists ? Promise.reject(new Error('该商品编码已被占用')) : Promise.resolve(); };

另外要提醒一句:前端校验永远只是体验优化,后端的唯一索引和校验一个都不能少。前端能绕过,Flutter 说的"客户端不可信",在这里同样成立。

2.3 校验失败后让用户知道错在哪

表单字段少的时候,用户自己能看到红字。字段一多,比如一个商品表单有 20 多个字段、分了四个折叠面板,用户点提交之后什么都没发生——因为错误藏在第二个面板里。

antd 的 Form 提供了scrollToField,配合onFinishFailed就能自动滚动:

const onFinishFailed = ({ errorFields }: { errorFields: { name: (string | number)[] }[] }) => { if (errorFields.length) { form.scrollToField(errorFields[0].name, { behavior: 'smooth', block: 'center' }); } message.error(`还有 ${errorFields.length} 处内容需要检查`); };

message.error里带上错误数量,是很有用的一招。用户看到"还有 3 处内容需要检查",就知道不是按钮坏了,而是自己没填完。

3. 提交按钮按下去之后发生的事:状态、幂等与错误处理

从点击到跳转,中间这段时间的处理质量,直接决定了这个模块会不会被测试同学反复打回。

3.1 防重复提交的三种做法与取舍

做法一:loading 状态 + disabled。最基础也最必要。

const [submitting, setSubmitting] = useState(false); <Button type="primary" htmlType="submit" loading={submitting} disabled={submitting}> {submitText} </Button>

注意loading和disabled要一起用。只加loading的话,按钮虽然转圈,但在某些浏览器和某些 antd 版本下依然可以点击(尤其是按钮内部是自定义内容的时候)。

做法二:请求层面拦截。用 axios 的拦截器给相同请求做去重,或者用AbortController取消上一个未完成的请求。这个更适合列表查询这类高频请求,表单提交用它是杀鸡用牛刀,但如果你们的商品表单支持"草稿自动保存",那就必须做。

做法三:后端幂等。这是最终的兜底。前端再怎么防,也防不住网络抖动导致的超时重试、用户狂点、或者用户在网络恢复的瞬间又点了一次。最稳妥的做法是前端生成一个requestId(可以用crypto.randomUUID()),提交时一起带给后端,后端用它做幂等键,同一个 requestId 只处理一次。

我见过太多项目只做了做法一,然后在大促期间出现了重复订单。做法三多写十行代码,能省掉后面一堆脏数据清理工作。

3.2 把错误分成三类分别处理

不要用一个catch把所有错误都变成message.error('操作失败')。用户看到这个提示,除了再点一次,没有任何别的动作可做。

我习惯把错误分成三类:

错误类型典型表现处理方式
网络层错误超时、断网、请求被取消提示"网络异常,请检查网络后重试",保留表单内容,不自动重试
业务校验错误后端返回 400 + 具体字段信息把错误映射回对应字段,用setFields标红
鉴权/权限错误401、403清空登录态,跳登录页;403 则提示无权限并返回列表

业务校验错误的映射尤其值得做。后端如果返回{ field: 'price', message: '价格不能低于成本价' },前端直接:

catch (err) { const res = err?.response?.data; if (res?.field) { form.setFields([{ name: res.field, errors: [res.message] }]); form.scrollToField(res.field); } else { message.error(res?.message ?? '提交失败,请稍后重试'); } }

这套逻辑的前提是跟后端约定好错误响应结构。如果后端返回的是纯字符串或者嵌套三层的data.error.detail[0].msg,那前端就只能写一堆兼容代码。这件事在项目启动阶段就要定下来。

3.3 图片先传后提交带来的半成品数据

商品表单里一般都有主图和详情图上传。常见的实现是"选中图片立刻上传到对象存储,拿到 URL 后存进表单字段,提交时只传 URL 列表"。

这个方案本身没问题,但会产生一批孤儿文件:用户传了三张图,改了主意,直接关掉页面,那三张图就永远留在了存储桶里,没有任何记录指向它们。

处理方式有两种。一是定时清理任务,每天扫一遍存储,找出创建超过 24 小时但没有被任何商品引用的文件删掉。二是"延迟上传",提交时把 File 对象和表单数据一起用FormData发出去,后端先存商品再存图,失败了整体回滚。方案二更干净,但会把上传压力集中到提交那一下,大图多的时候用户等待时间会明显变长。

我的选择是:主图走延迟上传(就一张,体积可控),详情图走先传后提交 + 定时清理。这是权衡之后的折中,没有绝对正确的答案。

4. 跳回列表页:路由回退与列表刷新之间的取舍

提交成功之后跳回列表,看起来就一行navigate的事,实际上是这个模块里最容易留下"数据不一致"观感的地方。

4.1 navigate(-1) 和 navigate('/product/list') 的真实差异

navigate(-1)是浏览器历史回退,等价于用户点了后退按钮。navigate('/product/list')是替换当前历史栈位置(如果配合replace: true)。

选哪个,取决于用户是怎么进入表单页的。如果只能从列表页点"新增"进来,那-1没问题。但如果用户可能从别的入口进来——比如从商品详情页点"编辑",或者直接粘贴 URL 打开——-1会把他退回详情页,而不是列表页,这个行为跟"提交成功了"的语义就不匹配了。

我的建议是用明确路径,同时带上replace:

navigate('/product/list', { replace: true, state: { refresh: true, tip: '商品已保存' } });

replace: true的作用是把这个表单页从历史栈里替换掉。用户从列表点进新增、提交、回到列表,此时按后退,会回到列表页的上一页(比如首页),而不是又回到那个已经提交过的表单页。少一次"我明明保存了怎么又看到表单"的困惑。

4.2 返回后列表数据是旧的,三种解法

跳回列表,列表组件重新挂载,useEffect里的查询会重新执行,数据自然是最新的——如果你的列表页是这么写的,那恭喜,没这个问题。但现实里很多项目做了缓存:列表数据存在全局状态或者 react-query 的缓存里,回到列表页直接命中缓存,看到的还是旧数据。

三种解法,从轻到重:

解法一:依赖组件挂载重新请求。最简单,代价是每次回列表都要转一圈 loading,用户如果在列表和详情之间来回切换,会很难受。

解法二:用 state 传一个"脏标记"。

// 列表页 const location = useLocation(); useEffect(() => { if (location.state?.refresh) { fetchList(); window.history.replaceState({}, ''); } }, [location.state]);

注意那个window.history.replaceState,它的作用是把 state 清掉。不清的话,用户刷新页面,location.state可能还在(取决于路由器的实现),会触发一次多余的请求。

解法三:查询缓存失效。用 react-query 或者 SWR 的话,直接在提交成功后调queryClient.invalidateQueries(['product-list']),缓存失效,列表页自己会重新拉数据。这是最优雅的方案,但前提是项目已经引入了这类库。

4.3 把分页和筛选条件搬到 URL 上

这个坑比刷新更隐蔽:用户在列表第 5 页,筛选了"上架中"的分类,点进某个商品编辑,保存后跳回列表——结果回到了第 1 页,筛选条件也没了。用户得重新点一遍,然后骂一句"这破系统"。

根因是筛选条件存在了组件的useState里,组件一卸载就没了。

解法是把分页和筛选条件同步到 URL 的 query 上:

const [searchParams, setSearchParams] = useSearchParams(); const page = Number(searchParams.get('page') ?? 1); const keyword = searchParams.get('keyword') ?? ''; const status = searchParams.get('status') ?? 'all'; const handleSearch = (next: Partial<FilterState>) => { setSearchParams({ page: '1', keyword, status, ...next }); };

这样做还有个额外好处:用户可以把带筛选条件的列表页链接直接发给同事,对方打开看到的是一模一样的视图,而不是一个空列表。这个体验提升是免费的。

跳回列表的时候,如果希望保留原来的页码,就不能简单地navigate('/product/list'),得把之前的 query 带上。做法是在跳去表单页的时候,把当前的location.search存进 state,回来时拼上:

// 去表单页 navigate(`/product/edit/${id}`, { state: { from: location.pathname + location.search } }); // 提交成功后回来 const backTo = location.state?.from ?? '/product/list'; navigate(backTo, { replace: true, state: { refresh: true } });

这里要注意state里存的只能是可序列化的数据,别往里塞函数或者 DOM 节点。

5. 编辑回填的竞态:异步数据遇上用户的手速

这是最容易被忽视、但一旦发生就很难复现的一类问题。因为它的触发条件是"手速",测试同学不一定测得出来,但你会在用户反馈里反复看到。

5.1 请求未返回就提交

编辑页打开,骨架屏显示中,用户在等待的这两秒里疯狂点"保存修改"按钮。如果按钮的 disabled 只绑定了submitting,那第一次点击时表单是空的,提交上去的就是一堆空字段,把商品名称直接改成了空。

解法是加一个"数据就绪"的状态:

const [detailReady, setDetailReady] = useState(!isEdit); useEffect(() => { if (!isEdit) return; let alive = true; fetchDetail(productId) .then(data => { if (!alive) return; form.setFieldsValue(data); setDetailReady(true); }) .catch(() => setDetailReady(false)); return () => { alive = false; }; }, [isEdit, productId]); const canSubmit = detailReady && !submitting;

注意那个alive标志位。React 18 的 StrictMode 在开发环境会让 effect 执行两次,如果没有清理逻辑,你会看到两个请求同时飞出去,其中一次的结果可能覆盖另一次。生产环境不会有这个问题,但开发时看到的诡异现象会让人怀疑人生。

5.2 覆盖用户输入的两次事故

事故一:用户打开编辑页,数据回来了,用户开始改商品描述,写了 200 多字。这时候某个操作(比如切换了商品分类,触发了重新拉取数据的逻辑)导致detail变化,useEffect再次执行setFieldsValue,用户写了一半的描述被原始值覆盖了。

解决办法是用一个 ref 记录"用户是否已经动过表单":

const dirtyRef = useRef(false); useEffect(() => { if (!detail || dirtyRef.current) return; form.setFieldsValue(detail); }, [detail]); <Form onValuesChange={() => { dirtyRef.current = true; }} />

onValuesChange在程序化调用setFieldsValue时也会触发,这是个大坑。所以初始化回填的那一次必须绕开它——要么回填时先设一个标志位、回填完再打开标志,要么就用form.setFieldsValue之外的单向赋值。我实际用的是前者:

const fillingRef = useRef(false); useEffect(() => { if (!detail) return; fillingRef.current = true; form.setFieldsValue(detail); fillingRef.current = false; }, [detail]); const handleValuesChange = () => { if (fillingRef.current) return; dirtyRef.current = true; };

事故二:用户改了半天,直接点浏览器后退。所有改动没了。这个不是代码 bug,是交互缺失。加一个离开确认就够了:

useEffect(() => { const handler = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler); }, []);

beforeunload只能覆盖浏览器级别的关闭和刷新。React Router 内部的路由跳转不走这个事件,需要在路由层面配合useBlocker(v6.4+ 才有)。如果项目版本低,退而求其次的做法是在表单内部的所有"返回列表"按钮上做拦截,至少覆盖住主要路径。

5.3 用 AbortController 收尾

如果用户在编辑页点了保存,提交请求还在飞,他直接点了左上角的"返回"或者切换到了另一个商品——这时候请求回来,回调里还在调message.success,你会在没有任何上下文的地方看到一个飘出来的成功提示,或者更糟,跳转到一个已经不存在的页面。

处理方式是在组件卸载时取消未完成的请求:

useEffect(() => { const controller = new AbortController(); return () => controller.abort(); }, []);

配合 axios 的话,把signal传进去:

await api.updateProduct(productId, payload, { signal: controller.signal });

取消后的错误会是一个CanceledError,需要在 catch 里识别并静默忽略,否则会弹出一个莫名其妙的错误提示。

6. 上线前值得再过一遍的排查清单

这一段是我在几个后台系统上线前会实际跑一遍的检查项,按"出问题概率 × 排查成本"排序。

现象常见根因排查方向
编辑页打开是空的,控制台有数据initialValues早于异步数据换setFieldsValue或加骨架屏
保存成功但列表没更新列表用了缓存 / 无刷新标记检查 state 传参或缓存失效逻辑
偶发重复数据无幂等键,用户重复点击前端 requestId + 后端唯一约束
价格差一分钱浮点运算未取整Math.round(precision)
提交时提示"字段不能为空"但页面有值传了 undefined 或前后有空格组装函数统一trim和默认值
返回后回到第一页分页参数存在组件 state挪到 URL query
提示飘出来了但页面已跳走组件卸载后仍调用静态 message用App.useApp()的 message 实例
编辑时提示编码重复唯一性校验没排除自身请求带excludeId

最后一条关于 message 的,值得单独说一句。antd 的静态方法message.success()在 React 18 的并发渲染下,可能拿到的是旧的主题上下文,甚至在某些挂载时机下会出现提示不显示的情况。正确姿势是在组件里用const { message } = App.useApp();,这样拿到的是跟随当前配置的实例。这个改动很小,但在深色主题或者自定义主题的项目里,能避免"提示是白色背景但页面是深色"这种奇怪的视觉效果。

再补一个我个人踩过的坑:onFinish里不要写async之后忘记try/finally。

const onFinish = async (values) => { setSubmitting(true); try { await submit(values); navigate('/product/list', { replace: true, state: { refresh: true } }); } catch (e) { handleError(e); } finally { setSubmitting(false); } };

为什么要有finally?因为成功路径里已经navigate走了,组件卸载,setSubmitting(false)触发的更新会被 React 忽略,不会有警告;但失败路径如果忘了这一步,按钮就会永远停在 loading 状态,用户只能刷新页面。这个 bug 我在两个项目里都见过,而且都不是自己写的代码,是接手时发现的。

还有一个容易被忽略的点是:navigate之后立刻调用message.success。因为跳转和提示几乎是同时发生的,视觉上提示会跟着页面一起切过去,看起来是正常的。但如果提示组件挂载在表单页的子树里,跳转后它就卸载了,提示会闪一下消失。所以提示信息最好是放在全局布局层,或者通过state传给列表页,由列表页来弹。

我个人在实际项目里的做法是,把提交成功后要展示的提示文案塞进 navigate 的 state,列表页挂载时读一次、弹一次、然后清掉。这么做的好处是提示的归属清晰,不会出现"提示还在飞但页面已经换了"的割裂感。至于新增和编辑共用一个表单这件事,写第二遍的时候你会觉得省事,写到第三遍、需要处理回填竞态和脏数据拦截的时候,你会重新思考这个决定的代价——但结论还是共用好,只是那些边界条件,得在一开始就想清楚,别等到测试同学拿着 bug 单来问你"为什么我改了一半的数据自己变回去了"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 7:25:31

S/4HANA BP主数据与CVI模型深度解析:从配置到故障排查全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 7:25:12

基于PyTorch的原型网络:小样本学习分类实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 7:25:11

车规芯片烧录代工选型指南:资质、设备、数据保护与追溯

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华