news 2026/10/2 7:44:31

Vue项目接入支付宝PC支付:扫码支付与跳转支付完整实现指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue项目接入支付宝PC支付:扫码支付与跳转支付完整实现指南

说实话,接到“PC端接入支付宝支付”这个需求时,我一开始是有点轻敌的。想的是:不就是调个接口、给个表单、用户扫码或者跳转过去付款吗?等真打开支付宝开放平台文档,面对alipay.trade.page.pay、alipay.trade.precreate这两个接口和一堆名词时,才发现事情没那么简单。尤其是 Vue 前端,既要把跳转支付的表单老老实实提交出去,又要在扫码支付场景里把后端返回的字符串变成二维码图片,还要处理轮询、回调、订单状态同步这一堆事。

这篇文章我就把在 Vue 项目里同时落地扫码支付和跳转支付这套完整流程捋一遍,包括前后端接口契约怎么定最省心、两种支付方式各自的实现思路、以及我在真实项目里踩过的坑。不管你是刚接手这类需求,还是已经在做但被各种边界问题卡住,这篇应该都能帮你省下不少排查时间。

1. 先分清两个接口:page.pay 和 precreate 各自解决什么问题

很多人在第一步就搞混了,以为 PC 端支付就一个接口搞定。实际上支付宝把 PC 端的支付能力拆成了两个不同场景的接口,前端落地的形态天然不同。

1.1 跳转支付背后是 alipay.trade.page.pay

这个接口叫“电脑网站支付”,它的特点就是:最终要把用户带到支付宝的收银台页面去完成付款。

后端调用这个接口后,返回的东西通常是一个表单 HTML 字符串(表单里包含所有请求参数和签名,action 指向支付宝网关),前端拿到后把表单渲染出来并自动 submit,浏览器就会跳转到支付宝页面。用户在那个页面里可以扫码付,也可以登录支付宝账号付,支付完成后支付宝再跳回你指定的 return_url。

它适合什么场景呢?我个人体会是:订单流程比较重、用户有明确的“下完单去付款”这个独立动作的场景。比如电商订单确认页、提交订单后的收银台页面。这时候跳转支付体验最顺畅,用户也能在支付宝页面看到详细的订单信息,信任感更强。

1.2 扫码支付背后是 alipay.trade.precreate

这个接口叫“统一收单线下交易预创建”,本质是:后端先向支付宝发起预下单,支付宝返回一个二维码字符串(qr_code),前端把这个字符串生成二维码图片展示在页面上,用户用支付宝 App 扫码完成付款。

注意,这个接口返回的不是支付链接,也不是表单,而是纯粹的字符串。你也无法通过它让用户“跳转”到支付宝页面,因为它的产品设计目标就是“线下面对面扫码”或“PC 网页展示二维码扫码”。

它适合的场景很典型:用户在你的页面/终端上看到了一个订单码,用手机扫码完成支付,支付完成后仍留在当前页面。像网吧充值、自助机点单、网银支付跳转会被用户抵触的场景、以及需要在收银台页面上保持“支付状态实时刷新”的场景,扫码支付明显更合适。

1.3 业务选型建议

我在实际项目里一般这么判断:

判断维度跳转支付(page.pay)扫码支付(precreate)
用户操作路径跳转到支付宝页面,付完再跳回来全程留在我的页面,手机扫码付
前端工作量需要处理表单自动提交和跳转需要生成二维码和轮询订单状态
支付状态感知依赖支付宝回跳和异步通知依赖轮询和异步通知
适合场景独立收银台、订单确认后支付页面内嵌收银台、终端机、充值页
弱网/弹窗屏蔽耐受相对较低(跳转可能被拦截)较高(页面还在)

这俩不是替代关系,是互补关系。如果产品只允许选一个,我通常更建议扫码支付,因为用户不离开你的页面,前端可控性更强,也不容易被浏览器弹窗拦截策略干扰。但如果你的产品有“订单提交后独立收银台”这个页面,那跳转支付的转化路径会更主流。

2. 前后端契约设计:接口返回什么,前端才能少改版

这步没想清楚,后面前端代码会被迫改好几次。我见过好几种“后端把活全干了”和“后端啥也不干”的极端设计,最后前端都很难受。这里说说我觉得最合理的一套接口约定。

2.1 两个后端接口,职责不能混

跳转支付场景,后端需要提供一个“创建支付单”接口,比如POST /api/pay/page,最终返回给前端的就是一个form 表单需要的参数集合,而不是所谓的“支付链接”,更不是直接返回跳转 URL 让前端 window.location 去跳。因为 page.pay 要求 POST 表单提交,如果你拿到的是一个 URL 想用 GET 跳,多数情况下是接不通的。

扫码支付场景,后端提供一个“预下单”接口,比如POST /api/pay/qrcode,返回的应该是一个qr_code 字符串,再加上这个二维码的过期时间、商户订单号等信息。

我推荐后端直接返回前端能直接消费的结构,不要做模棱两可的封装,例如跳转支付统一返回:

{ "code": 200, "data": { "orderId": "PAY20240101001", "payParams": { "app_id": "2021000123456789", "biz_content": "{"out_trade_no":"20240101001","total_amount":"100.00","subject":"测试商品"}", "charset": "utf-8", "method": "alipay.trade.page.pay", "sign_type": "RSA2", "timestamp": "2024-01-01 12:00:00", "version": "1.0", "sign": "xxxxx" } } }

支付参数让后端拼好,前端不要自己去构建这些参数。关键点在于:签名必须在后端做,密钥绝对不能暴露在前端代码里。前端拿到payParams后自己创建表单、填入参数、提交即可。

扫码支付接口返回:

{ "code": 200, "data": { "orderId": "PAY20240101001", "qrCode": "https://qr.alipay.com/xxxxx", "expireTime": 1800 } }

这样前端的逻辑就非常清晰:跳转支付,接受参数并触发提交;扫码支付,接受字符串并渲染二维码。

2.2 密钥、网关、签名这些基础概念速成

如果你第一次接触支付宝支付,有几个名词得先过一遍:

  • 应用网关与接口网关:接口网关是后端调支付宝 OpenAPI 时固定的请求地址,不在前端涉及范围内,前端不需要关心。
  • 应用私钥与支付宝公钥:商户用自己的应用私钥对请求参数签名,支付宝用支付宝公钥验签。反之,支付宝的通知和响应用支付宝私钥签名,商户用支付宝公钥验证。这俩都是后端管,前端完全不碰。
  • 签名机制:本质是把所有业务参数按字典序拼接,再用 RSA2 算法做签名,防止参数被篡改。前端如果擅自改 total_amount 这类参数,签名验不过,支付宝拒收。
  • 沙箱环境:支付宝官方提供的测试环境,网关地址是openapi-sandbox.alipaydev.com/gateway.do,有配套的沙箱应用 AppID、沙箱公私钥,以及沙箱买家账号。调通之前别碰正式环境。

说到这里得多提一句,网上那些“支付宝模拟器 1:1”“测试工具”什么的,我建议别用。且不说安全性,这类模拟器既不走官方验签流程,也模拟不了支付宝复杂的异步通知和状态机,测完爽了,上生产必出问题。老老实实用支付宝官方的沙箱环境,对接流程和正式环境完全一致,只是换个网关、换套密钥。

2.3 订单号与金额的约定必须提前锁定

这是前后端契约里最容易扯皮的部分。商户订单号out_trade_no最好是后端根据业务规则生成的唯一单号,前端不要自己拼。金额这里有个大坑:支付宝的 total_amount 单位是元,支持两位小数,但很多后端库喜欢用分存储,一旦转换不仔细就会出现 1000 传成 10.00 还是 100.00 的问题。我踩过一次:后端用分存储,转了字符串之后忘记除以 100,用户下单价 10 元的商品,跳过去付款详情里显示 1000 元,吓得我当场把支付关闭排查。这个事必须后端自己做类型转换和校验,前端只负责展示订单号和金额,不要参与任何计算。

3. 跳转支付的 Vue 实现:动态渲染表单并自动提交

跳转支付在前端的核心动作就一个:用后端返回的参数创建一个表单,然后自动提交。但就这么个动作,也有好几种写法,踩过的坑也不少。

3.1 最原生的做法:拼接表单并 submit

后端返回了payParams,我们就在 Vue 组件里动态创建一个<form>,把参数一个个塞成 hidden input,然后触发 submit。

function submitPayForm(payParams) { const form = document.createElement('form') form.style.display = 'none' form.method = 'POST' form.action = 'https://openapi.alipay.com/gateway.do' // 如果后端给的 payParams 里已经有 sign,直接循环塞进去就行 Object.keys(payParams).forEach((key) => { const input = document.createElement('input') input.type = 'hidden' input.name = key input.value = payParams[key] form.appendChild(input) }) document.body.appendChild(form) form.submit() document.body.removeChild(form) }

注意几个细节:

  • 网关地址不要硬编码。开发环境走沙箱网关,生产环境走正式网关,要支持从后端配置读取,否则联调完你很可能忘记切回正式环境。
  • 不建议直接用 window.location.href 跳转,因为支付宝网关只接受 POST 表单提交的请求,用 GET 方式访问大概率报错或验签失败。
  • form 要 append 到 body 再提交,否则某些浏览器会忽略没有挂载到文档流的表单提交。
  • 提交后不要立刻 remove,部分浏览器在 submit 过程中访问不到表单会导致失败。稳妥做法是setTimeout(() => form.remove(), 1000)或者干脆不清理(页面马上就跳走了)。

3.2 用 iframe 包裹,避免当前页面整个跳走

有的产品要求“支付时停留在原页面”,这时候可以把表单的target指向一个隐藏 iframe,让支付宝收银台在 iframe 里打开。

function submitPayFormInIframe(payParams) { const iframe = document.createElement('iframe') iframe.id = 'alipayIframe' iframe.name = 'alipayIframe' // 样式隐藏,但不能用 display:none,有些浏览器对 display:none 的 iframe 不做渲染 iframe.style.position = 'fixed' iframe.style.right = '0' iframe.style.bottom = '0' iframe.style.width = '760px' iframe.style.height = '600px' iframe.style.border = '0' document.body.appendChild(iframe) const form = document.createElement('form') form.style.display = 'none' form.method = 'POST' form.action = gatewayUrl form.target = 'alipayIframe' // ... 同样循环塞 payParams document.body.appendChild(form) form.submit() }

这种写法适合那种“订单信息保持在自己的系统里”“支付区域只是局部弹层”的页面。但这里要提醒:PC 扫码支付在 iframe 里打开支付宝收银台是没问题的,但千万不要试图让 alipay.trade.page.pay 的页面在你站内 iframe 里再套一层你们自己的登录态,支付宝页面有自己的安全和会话逻辑,你控制不了。

3.3 用户取消支付、关闭页面时怎么处理

跳转支付一个很容易被忽略的问题是:用户去了支付宝页面,可能付了一半就关掉了,也可能压根没付款就关了支付宝页面。这时支付宝不会产生异步通知,订单状态一直卡在“待支付”。所以跳转支付必须配合一个“主动查单”的兜底逻辑。

我在项目里的做法是:用户从支付宝 return_url 回到本站后,前端自动轮询一次订单状态;同时页面提供“我已完成支付”的按钮,点击后也去查一次单;如果还没支付,就提示用户稍后在订单列表里继续支付。注意,return_url的作用只是通知浏览器“用户回来了”,它并不可靠,这一点后面第 5 节会详细说。

4. 扫码支付的 Vue 实现:二维码生成与订单轮询

扫码支付前端的两个核心任务:把 qr_code 字符串变成能扫的二维码图片,在支付完成前持续查单。

4.1 二维码组件选型

Vue 生态里生成二维码的库不少,我实际用下来最顺手的是qrcode这个 npm 包。

npm install qrcode

它既支持浏览器环境,也支持 Node 环境,API 稳定,生成速度也快。另一个选择是qrcode.vue,它是个封装好的 Vue 组件,直接传值渲染,适合完全不想碰 Canvas 的场景。我比较倾向用qrcode自己写,因为可控性强,比如可以自定义尺寸、边距、错误修正级别。

<script setup> import { ref, onMounted } from 'vue' import QRCode from 'qrcode' const qrCodeUrl = ref('') function renderQrCode(text) { QRCode.toDataURL(text, { width: 220, margin: 1, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } }).then((url) => { qrCodeUrl.value = url }) } </script> <template> <div class="qr-box"> <img :src="qrCodeUrl" alt="支付宝扫码支付" /> </div> </template>

这里为什么要用toDataURL而不是直接在 Canvas 上画?因为很多场景下你需要把二维码图片展示在打印的小票上、或者嵌入到页面上传送到其他组件里,DataURL 更容易到处复用。如果只是临时展示,你也可以保留 Canvas 节点,效果一样。

4.2 二维码过期与刷新逻辑

二维码不是永远有效的,支付宝的 qr_code 默认有效期大概是 2 小时左右,但产品上通常不会让用户对着一个二维码等那么久。我建议在后端预下单时就设置timeout_express参数,比如30m,同时把过期时间返回给前端。

前端拿到过期时间后,开启一个倒计时;倒计时归零时,自动调后端“重新创建支付单”接口,获取新的 qr_code 并重新渲染。这样用户不用手动刷新页面就能获得一个全新的二维码。

const expireSeconds = ref(1800) const timer = setInterval(() => { expireSeconds.value-- if (expireSeconds.value <= 0) { clearInterval(timer) reloadQrCode() // 重新调预下单接口 } }, 1000)

有个细节:刷新二维码时,最好把商户订单号一并更新或者让它保持同一个订单号,取决于业务逻辑。如果同一个订单号重复调用 precreate,支付宝会直接返回上一次的 qr_code,那这个更新就没意义了。正常情况下应该让后端重新生成一个新的订单号,旧的订单号标记为已关闭,避免用户扫了旧码付了款,结果系统不知道往哪个订单上挂。

4.3 轮询订单状态的节奏与接口设计

扫码支付因为用户不离开页面,前端必须自己获取支付结果。轮询是最常见的方案。

const pollTimer = ref(null) const isPaying = ref(false) function startPolling(orderId) { isPaying.value = true pollTimer.value = setInterval(async () => { try { const res = await checkOrderStatus(orderId) if (res.data.status === 'PAID') { clearInterval(pollTimer.value) handlePaySuccess(res.data) } else if (res.data.status === 'CLOSED' || res.data.status === 'FAILED') { clearInterval(pollTimer.value) handlePayFail(res.data) } } catch (e) { // 单次查询失败不要立刻终止轮询,可能是网络抖动 console.error('poll error', e) } }, 3000) }

轮询频率我建议3 秒一次就好,太频繁没有意义,反而会给后端和支付宝带来不必要的压力。也不要完全依赖轮询,第 5 节会讲到,异步通知才是最终对账的依据,轮询只是让用户在前端页面尽快看到结果的一种手段。后端接口一般返回几个状态:WAITING_PAYMENT等待支付、PAID已支付、CLOSED已关闭/超时。前端只消费这些状态,不要自己判断。

轮询还有一个细节:用户离开页面时要清掉 pollTimer,不然组件卸载了定时器还在跑,会不停发请求。建议在onUnmounted里清理。

onUnmounted(() => { if (pollTimer.value) { clearInterval(pollTimer.value) } })

5. 支付结果确认:回调、return_url 和轮询三者的配合

这一节对经历过线上事故的同事来说应该感触最深:支付结果到底以什么为准?答案听过很多遍,但踩坑的时候还是容易忘。

5.1 谁说了算:异步通知 > 全链路轮询 > return_url

支付宝支付结果的确认有三个渠道:

  1. 异步通知(notify_url):支付宝服务器在用户支付成功后,主动向商户后端 notifica 地址发一个 POST 请求,携带支付结果和签名,签名验过后可以确认订单已支付。这是唯一能作为改单依据的渠道。
  2. return_url:用户支付完自动跳回商户页面时携带的参数,只用于展示给用户看“支付完成”,不能改订单状态。因为支付宝不一定保证 return_url 一定被访问(用户主动关掉支付结果页就不跳了),而且这个跳转是通过浏览器进行的,参数可以被伪造。
  3. 轮询/主动查单:前端轮询后端,后端调用支付宝查询接口去确认订单状态。这个可以作为用户前端体验的补充,但严格来说也要以支付宝查询接口返回为准,不能以自己数据库的状态为准。

在项目里明确的规则是:前端任何支付成功提示都只是“乐观提示”,订单最终入账必须靠后端异步通知。所以扫码支付场景里,前端轮询到 PAID 后,接下来弹窗提示用户支付成功,同时给后端发一个确认请求,后端可以再查一次支付宝订单状态做兜底确认,防止极端场景下前端拿到错误状态。

5.2 前端收到支付成功后的展示策略

我实操中的常见做法是:轮询到 PAID 或从 return_url 回到页面时,只弹一个“支付处理中”的过渡提示,再主动查一次后端接口,确认后端已经更新了订单状态后,再跳转订单详情页。如果后端还没更新(异步通知延迟),前端不要直接跳转成功页,不然会出现用户到了成功页,订单实际没入账的问题,客诉就是这么来的。

更稳妥的交互是:支付成功提示 + “我已完成支付”按钮 + “刷新订单状态”按钮。把主动权交给用户,同时也让系统有充分的处理时间。

5.3 后端处理异步通知时的几个关键校验点

虽然这是后端的事,但前端如果有一点概念,排查问题会快很多。后端收到通知后至少要校验这四样:

  • 验签:用支付宝公钥验证通知的签名,确保消息确实来自支付宝。
  • app_id 校验:通知里的 app_id 应该等于自己的应用 AppID。
  • 金额校验:通知里的 total_amount 要与订单表中的应付金额一致。
  • 商户订单号校验:out_trade_no 必须是自己系统里真实存在的订单号。

我见过一次事故,后端同事只做了验签,没校验金额,结果用户把订单金额改了(虽然改了签名会失败),但那个场景发生在支付之前,前端被改了属性后把订单金额写错了,通知回来按错误的金额入账了,查单查了半天。后来我把这四步校验写成了后端支付的改单前置检查,谁都不许省。

6. 这套方案在真实项目里的踩坑记录

最后这部分必须单独写,都是真金白银换来的经验。如果你按前文的步骤做,大部分问题碰不到,但这里如果再踩到,至少不用再去翻半天的日志了。

6.1 金额精度:元转分的正确姿势

支付宝用元,数据库经常用分。转换别用浮点数直接乘除,会有精度问题。正确做法是用字符串或 Decimal 处理:

# Python 后端示例,伪代码 def yuan_to_fen(amount): return int(round(Decimal(amount) * 100))

更关键的是:前端不要在 url 参数里用 unicode 传出金额。我见过一个同事把金额直接拼在 iframe 的 src 里,他用了中文逗号,支付宝直接报参数格式错误,问题定位了一下午。金额永远是数字字符串,别用科学计数法,别带货币符号。

6.2 时间格式:时区问题比想象中多

支付宝要求 timestamp 格式是yyyy-MM-dd HH:mm:ss,而且用的是东八区时间。如果后端服务器时区配的是 UTC,你拼出来时间戳直接是错的,签名会一直失败。解决思路是后端生成参数时显式指定时区:

TimeZone.setDefault(TimeZone.getTimeZone("GMT+8")); // 或者用带时区的 DateTimeFormatter

前端不用处理时间格式,但不能对时间做本地化偏移。有些同事喜欢在前端把时间 new Date() 后直接转字符串,结果跳转支付表单里的 timestamp 被改了个格式,验签直接挂。

6.3 重复通知的幂等处理

支付宝的异步通知会重试,频率是递增的:4m、10m、10m、1h、2h、6h、15h。如果后端收到通知后不做幂等处理,同一笔订单会被更新两次,可能造成重复入账(想象一下每次通知你都往余额里加钱)。这个虽然是后端责任,但前端如果做模拟支付测试,会更容易触发这种情况。我建议前端在测试环境模拟成功回调时,也顺便测一下接口的幂等性,这也算前后端联调的一部分。

6.4 表单提交后的加载状态

跳转支付如果是通过表单 submit 实现的,提交之后页面会进入“白屏等跳转”状态。用户如果网络慢,会以为没点成功,然后狂点按钮,结果同一个订单生成多个支付请求。前端解决办法是:提交一次后立刻把按钮置灰,文案改成“正在跳转支付宝收银台...”。同时用 sessionStorage 记录提交状态,刷新页面后还能恢复置灰,防止重复提交。

6.5 沙箱环境与模拟器

再提一次模拟器这个事。不少人会在本地用一个“支付宝模拟器”来假装支付成功,方便前端调试。我不建议这么干,原因上面说过:它绕过了支付宝的通知验签和状态机,你在模拟器上看到的成功和真实支付成功完全不是一回事。正确做法是:后端联调时用支付宝沙箱,前端用沙箱买家账号支付,支付宝也会真实给沙箱网关发异步通知,整个链路和线上完全一致。等真要切线上,只需要把后端网关、密钥换成正式环境就行,前端代码一行都不用改。

6.6 安全控件或电脑环境的影响

PC 端支付宝收银台有时会检测用户电脑上是否安装了安全控件,个别电脑环境会弹窗提示。这个前端没法控制,但产品上可以在引导页给个文案:“如无法弹出收银台,请检查浏览器是否拦截了弹窗”。从开发角度说,iframe 方式比整页跳转更少受弹窗拦截影响,如果你的用户群用的是顽固的企业浏览器,优先用 iframe 方案。

写在最后的小建议

如果你在看这篇文章的时候刚准备动手,我建议你把节奏放成三步:第一,先对照后端把两个接口的返回字段定死,别急着写页面;第二,用沙箱环境把跳转支付和扫码支付各跑通一遍,重点观察支付宝异步通知有没有成功打到后端;第三,再开始补前端轮询、二维码过期、按钮防重复这些体验层的东西。

我现在做这类需求,已经完全形成固定流程了:后端先出契约,前端用沙箱环境打通主链路,然后补状态机和异常分支。这套流程让我少踩了很多坑,希望这篇也能帮你少走点弯路。

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

一级代理商实战指南:从授权谈判到区域保护的运营全攻略

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

作者头像 李华
网站建设 2026/10/2 7:43:49

杰理AC63蓝牙名称稳定显示实战指南

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

作者头像 李华
网站建设 2026/10/2 7:43:15

基于协同过滤的新闻推荐系统:Python ItemCF实现与时效性优化

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

作者头像 李华
网站建设 2026/10/2 7:41:12

江森自控楼宇自控培训PPT拆解:BAS架构、DDC接线与METASYS调试实战

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

作者头像 李华
网站建设 2026/10/2 7:40:47

Claude Code源码级拆解:安装配置、避坑指南与团队规范落地

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

作者头像 李华
网站建设 2026/10/2 7:40:39

UC3842反激电源设计实战:60W12V5A参数计算、PSIM仿真与调试全流程

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

作者头像 李华