news 2026/9/8 2:34:10

uniapp集成融云IM实现聊天与音视频通话完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
uniapp集成融云IM实现聊天与音视频通话完整指南

简介:一份面向uniapp开发者的融云IM集成资源,完整覆盖单聊、群聊及单/多人音视频通话场景,适合需要快速在跨端应用中接入即时通讯与呼叫能力的中高级前端或移动端开发者。配套文档包含后端token获取与maven环境搭建说明,并有可直接运行的demo工程,具体涉及消息监听、消息撤回回执、分页拉取聊天记录、会话未读总数与单会话未读数、免打扰时段设置、输入状态消息以及多路音视频通话等接口实现。资源包共651个文件,约79.12MB,以png图片素材、h头文件、js脚本、plist配置及aar/静态库等组件构成,同时包含apk/ipa可安装包与vue/nvue页面文件,便于对照源码理解原生依赖与前端交互逻辑。已有2636人学习下载,对希望快速跑通融云IM全流程并了解Android/iOS底层库集成的开发者具有较高参考价值。 上个月刚把一个基于uniapp的社交类项目跑通,聊天、群聊、音视频通话都集成了融云IM,后端用Java搭的token服务,Maven管理依赖,还整理了一份带demo的完整文档。这阵子陆续有人问我“融云在uniapp里怎么接”“token怎么动态获取”“多人通话怎么实现”,问的人多了,索性把整个对接过程、踩坑点、核心代码全都梳理成一篇,给后面要做IM/音视频方向的朋友做个参考。

这篇文章适合三类人:一是uniapp前端开发者,想给App或小程序塞进IM能力;二是后端同学,需要搭建融云token服务和签名机制,配合Maven环境;三是想快速跑通一个“单聊+群聊+音视频”demo的团队或个人。整个项目用到的核心链路是:客户端uniapp拿到后端动态签发的token → 调用融云connect建立长连接 → 收发单聊/群聊消息 → 通过融云RTC组件发起或加入音视频通话。

1. 项目整体思路与方案选型

1.1 为什么选融云IM而不是自己写聊天

很多人第一反应是“IM不就是WebSocket发消息吗,自己写不就行了”。真有这个想法的一般是还没踩过坑。聊天系统的难点从来不在“发一条消息”,而在消息可靠性、多端同步、离线推送、群成员管理、未读计数、历史消息拉取、消息已读回执,还有音视频通话的信令协商、网络穿透、弱网切换。这些全自己做,一个5人团队至少得投入半年,做出来还不一定稳定。

融云这类IM云服务把最难的通信底层全包了,客户端SDK负责长连接和数据同步,服务端SDK负责token、用户体系、消息路由。我把精力放在业务层,比如好友关系、社群运营、消息内容的业务处理上,比从零造轮子划算得多。凡是App里直接内嵌聊天界面,第一优先级都是接这种成熟的IM PaaS。

1.2 整体技术架构:前端、服务端和融云云的三角关系

一个标准的融云接入项目,技术链路是这样的:

  • 客户端(uniapp):引入融云SDK,首次启动时向后端服务器请求token,拿到token后调用SDK的connect接口建立长连接。
  • 服务端(Java + Maven):负责调用融云服务端API生成token,持有融云AppKey和AppSecret,绝不能把这两个密钥放到客户端代码里。
  • 融云云端:负责维护长连接通道、消息存储、群组信息、音视频信令转发。

这里有个特别容易理解错的地方:融云IM不是让客户端直接拿AppKey和AppSecret去连,客户端只需要一个token,这个token由你的后端向融云服务端申请,然后下发给客户端。token跟具体用户绑定,一个用户在同一时间只会有一个有效token,重复获取会让之前的token失效。这个机制保证了消息通道的安全,也意味着token必须是后端签发的,不能由客户端自己拼接。

1.3 为什么Maven环境是这个项目的地基

项目的后端服务采用Java技术栈,那么Maven就是集成融云Java SDK最顺手的方式。Maven把融云SDK的依赖声明写进pom.xml,它会自动拉取融云相关的jar包,以及这些jar包依赖的其他第三方库。没有Maven的话,你得手动下载融云jar包、融云依赖的httpclient等,还要自己处理包冲突,版本稍微不对,运行期就是各种NoSuchMethodError或者ClassNotFoundException。

我在这套项目里用的Spring Boot 2.7 + Maven 3.8组合,融云Java SDK通过中央仓库坐标引入,一条依赖就搞定所有jar包传递。这也是我把“Maven环境”单独写进项目标题的原因——后端项目如果Maven环境没搭好,token接口根本跑不起来。

2. 后端Token服务搭建:Maven环境与核心代码

2.1 本地Maven环境怎么配置最省事

Maven本身的安装不复杂,下载二进制包解压,配置环境变量,关键是国内网络环境下要改镜像源,否则下载依赖能等到怀疑人生。我用的阿里云公共仓库镜像,直接在~/.m2/settings.xml里配置:

<mirrors> <mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror> </mirrors>

IDEA里的Maven配置也建议统一指向本地settings.xml,这样命令行和IDEA里用的是同一份配置,避免两套环境拉下来的依赖不一致。这一步看着基础,但凡是“我IDEA里能跑、命令行就报错”的诡异情况,十有八九就是IDEA用了内置Maven但命令行用的是另外一套。

2.2 pom.xml引入融云服务端SDK

在Spring Boot的pom.xml里加入融云SDK依赖:

<dependency> <groupId>cn.rongcloud.sdk</groupId> <artifactId>sdk-core</artifactId> <version>1.2.4</version> </dependency>

需要注意的是,融云服务端SDK一直在迭代,不同版本的包名和类名会有差异。老版本用的是io.rong.RongCloud,新版本部分模块包结构调整过,引入前先看融云官方文档的“服务端SDK下载”页面确认版本号。项目中的示例代码以cn.rongcloud.sdk为例,如果你用的是别的版本,第一步就是先把包名和引入路径对齐,不然后面全是红叉。

2.3 生成Token的后端接口实现

整个后端服务其实就一个核心接口:根据userId和用户信息返回token。代码逻辑很直接:

@RestController @RequestMapping("/api/im") public class ImTokenController { @Value("${rongcloud.app-key}") private String appKey; @Value("${rongcloud.app-secret}") private String appSecret; @PostMapping("/token") public Map<String, Object> getToken(@RequestBody TokenRequest request) { // 初始化融云服务端SDK RongCloud rongCloud = RongCloud.getInstance(appKey, appSecret); // 组装用户信息,userId在融云体系里是用户的唯一标识 User user = new User(); user.setId(request.getUserId()); user.setName(request.getUserName()); user.setPortrait(request.getUserPortrait()); // 调用服务端接口获取token TokenResult result = rongCloud.user().getToken(user.getId(), user.getName(), user.getPortrait()); Map<String, Object> resp = new HashMap<>(); if (result.getCode() == 200) { resp.put("code", 200); resp.put("token", result.getToken()); } else { resp.put("code", result.getCode()); resp.put("msg", result.getErrorMessage()); } return resp; } }

这个接口必须在你的业务服务器上部署,而且要用HTTPS。调用融云服务端API时,服务端会校验AppKey和AppSecret的签名,这两项配置放在application.yml或环境变量里,别写死在代码里:

rongcloud: app-key: 你的AppKey app-secret: 你的AppSecret

2.4 为什么token不能在前端生成

这个问题几乎每次都会被问到。融云的连接机制是:客户端SDK持有一个token,拿token去融云服务器换取连接授权。如果前端自己能调用接口拿token,那等同于前端持有AppSecret,任何用户都能通过反编译或抓包拿到你的密钥,就能冒充任意用户发消息。所以token签发必须在你自己的后端完成,AppKey可以暴露给客户端,AppSecret只能存在于服务端。

token的有效期也需要注意。融云官方说明token长期有效,但如果你调用了“刷新用户信息”接口,旧的token会立即失效。因此我在后端做了一层缓存:用户第一次请求token时生成并缓存,后续请求只查缓存不重新调融云接口,除非主动刷新用户信息。这样既避开了token被反复签发导致互相顶掉的问题,也减少了服务端API调用量。

3. uniapp客户端集成:从初始化到单聊群聊

3.1 manifest.json配置要点

uniapp端的嵌工作,最先要搞定的是manifest.json。这块不做好,后面运行到微信开发者工具或真机上就是各种白屏、报错、权限不生效。

App平台需要勾选“IM模块”,并填入融云的AppKey。具体路径是manifest.json的App模块配置里,找到“融云IM”或“Cloud”相关配置项。如果用的是HBuilder X,直接在可视化界面勾选模块并填AppKey即可。

iOS和Android都要配置摄像头、麦克风权限描述,用于音视频通话。iOS的权限描述如果不填,真机调用摄像头时会直接崩溃。同时iOS上架App Store强制要求隐私政策弹窗,如果用户不同意隐私政策,需要退出App。我一般在App.vue的onLaunch里做这个逻辑:

onLaunch() { // 假设这里调用了一个检查用户隐私同意状态的方法 if (!getPrivacyAgreeStatus()) { // 弹窗提示用户阅读隐私政策 uni.showModal({ title: '提示', content: '需要您同意隐私政策后才能继续使用', confirmText: '同意', cancelText: '不同意', success(res) { if (res.confirm) { setPrivacyAgreeStatus(true); } else { // 用户不同意,退出App plus.runtime.quit(); } } }); } }

微信小程序平台则要注意,融云的SDK在微信小程序里有专门的小程序版本,跟App版是两套。如果你同时要发布到App和小程序,得在代码里做平台判断,加载不同的SDK入口。

3.2 SDK引入与初始化连接

uniapp项目引入融云SDK,我用的方式是npm包@rongcloud/cloud-core配合官方提供的uniapp插件市场里的IM插件。安装完成之后,核心的连接流程如下:

// 封装的IM连接模块 im.js import { RongIMClient } from '@rongcloud/cloud-core'; // 初始化SDK,AppKey从manifest配置里读取或全局常量获取 RongIMClient.init('你的AppKey'); function connectIM() { return new Promise((resolve, reject) => { // 先从自己的后端获取token uni.request({ url: 'https://你的后端域名/api/im/token', method: 'POST', data: { userId: currentUserId, userName: currentUserName, userPortrait: currentUserAvatar }, success(res) { const token = res.data.token; // 用token建立融云连接 RongIMClient.connect(token, { onSuccess(userId) { console.log('连接成功', userId); resolve(userId); }, onTokenIncorrect() { console.log('token无效,需要重新获取'); reject(new Error('token无效')); }, onError(error) { console.log('连接失败', error); reject(error); } }); } }); }); }

连接成功之后,SDK会自动管理长连接的重连、心跳、消息同步,这些都不用自己处理。App前后台切换时,SDK也会自动处理连接状态恢复。

3.3 单聊与群聊消息收发

发消息和收消息是聊天最基础的能力。融云SDK的消息对象用ConversationType区分单聊和群聊,PRIVATE是单聊,GROUP是群聊。我封装了一套统一的发送方法:

import { RongIMClient, MessageType } from '@rongcloud/cloud-core'; // 发送文本消息 function sendTextMessage(conversationType, targetId, contentText) { const conversation = { conversationType: conversationType, // ConversationType.PRIVATE 或 GROUP targetId: targetId, // 对方用户id或群组id content: { messageType: MessageType.TEXT, content: contentText } }; RongIMClient.getInstance() .sendMessage(conversation, { onSuccess(message) { console.log('发送成功', message.messageUId); }, onError(error) { console.log('发送失败', error); } }); }

接收消息则通过消息监听器处理:

RongIMClient.getInstance().setOnReceiveMessageListener({ onReceived(message) { // message.conversationType 判断是单聊还是群聊 // message.senderUserId 发送人id // message.content 消息内容 // 这里把消息push进vuex或页面响应式数据里 handleNewMessage(message); } });

这里有一个实际项目中很容易踩的坑:群里有人说话时,所有群成员都会收到这条消息的广播,但发送者自己也会收到。你需要判断message.senderUserId !== currentUserId再去更新UI,否则自己发的消息会被追加两次。我的做法是在handler里统一做去重判断,以messageUId为唯一标识。

另外,消息落库的问题也提前说一下。融云SDK虽然能拉取历史消息,但客户端本地存储能力有限,尤其是小程序平台,建议把聊天记录的业务数据同步到你自己的服务端,用消息messageUId去重,避免前端重复渲染。

3.4 会话列表与未读消息

聊天App不可能只有聊天页,会话列表页也得有。融云SDK提供了会话列表和未读数的查询接口,直接遍历会话列表时,可以把每个用户的头像、名称、最后一条消息展示出来。这里要提醒的是,首次接入时,并没有历史会话数据,所以会话列表通常是空的,需要先发起至少一条消息会话才会出现。测试时不要怀疑SDK坏了,先用两个账号互发消息,会话列表就出来了。

未读数获取的方式是RongIMClient.getInstance().getUnreadCount(),这个接口支持按会话类型和会话id查询,也可以查所有会话的未读总数。我做红点点亮逻辑时,直接监听onReceived消息事件,来一条加一条,点击进入聊天页清空对应会话的未读数。这样比轮询未读数接口省不少资源。

4. 单人和多人音视频通话的实现细节

4.1 音视频模块的引入与初始化

融云IM和融云音视频RTC是两套SDK,IM解决消息问题,RTC解决音视频传输问题。uniapp端引入RTC能力时,我用的方式是安装融云官方提供的“融云音视频通话”插件,本质上是native层的封装,底层是iOS和Android的融云RTC SDK。

引入之后,音视频模块需要和IM连接绑定,调用初始化代码启动RTC服务:

import { RongRTC } from '@/uni_modules/rongcloud-rtc'; function initRTC(userId) { RongRTC.init({ appKey: '你的AppKey', userId: userId, token: currentToken // 与IM连接使用的同一个token }); }

这里的token直接复用IM的长连接token,所以要保证是先connectIM成功后再initRTC。如果顺序反了,可能出现RTC初始化时找不到IM连接状态的问题。

4.2 单人音视频通话发起与接听

单人通话的发起逻辑,我用了一个很直观的思路:先通过IM通道通知对方“我要打音视频过来了”,RTC模块同时去创建通话房间。这样对端能实时收到一个自定义消息(CUSTOM类型),在消息拦截器里识别到之后弹出接听界面。

发起端核心逻辑:

function callSingle(userId, mediaType) { // mediaType: 'audio' 或 'video' // 1. 构造一个自定义通话信令消息,通过融云IM发送给对方 // 2. 创建RTC通话实例 RongRTC.startCall({ targetId: userId, mediaType: mediaType, // 处理对方拒绝、对方无响应等回调 onHangUp: (reason) => { console.log('通话结束', reason); } }); }

接听端则比较简单,对方发送的自定义消息到达时会触发onReceived监听,在消息内容里判断是“通话邀请”,然后根据mediaType拉起本地接听页面,用户点击接听后调用RongRTC.acceptCall()

这里要补充一个独家经验:app进程在后台或锁屏状态下,自定义消息的到达可能不及时。如果做正式项目,建议在onReceived里判断App是否处于前台,如果不在前台,走融云的推送通道(Android厂商推送/iOS APNs)把“来电”推出去,等用户点击推送回到前台后,再通过IM消息里的conversationType和targetId拉起通话界面。这个细节不做,真机测试时很容易出现“你给对方打电话,对方手机没反应”的问题。

4.3 多人音视频通话:创建房间与邀请加入

多人通话跟单人通话的区别在于,它有一个“房间”的概念。融云RTC的多人通话叫RongRTCRoom,可以指定一个roomId,然后邀请一个或多个用户加入。我的做法是:发起人创建roomId,把roomId通过IM发送一条CUSTOM消息给参与人,参与人收到消息后加入这个roomId。

核心代码:

// 创建并加入一个多人通话房间(发起人) async function startMultiCall(roomId, userIds, mediaType) { const room = await RongRTC.createRoom({ roomId: roomId, mediaType: mediaType }); // 向每个被邀请人发送IM消息,带上roomId userIds.forEach(uid => { sendCustomMessage(uid, 'invite_call', { roomId: roomId }); }); // 加入房间后,开始推流(把自己摄像头/麦克风数据传到房间) room.publish(); } // 被邀请人收到邀请后加入房间 async function joinMultiCall(roomId) { const room = await RongRTC.joinRoom({ roomId: roomId }); room.publish(); }

publish操作是把自己本地的音视频流推送到房间。多人通话最需要注意的是,要管理好“谁在说话”“谁开启了摄像头”的UI状态。融云SDK会有用户加入、离开、发布流的回调,我在页面上用一个Map存用户的流状态,回调里增删或标记变更,否则多人画面会越飘越乱。

另外一个常见问题是多人通话时的噪声和回声。建议在进房间前统一设置音频自动增益和降噪参数,融云RTC默认有降噪能力,但不同手机原声效果差别挺大,真机测试时如果对方听到回声,先把音量调到中等再观察,通常不是SDK问题,而是测试环境靠近扬声器导致的物理回声。

4.4 音视频真机调试前的权限检查清单

音视频功能在模拟器上是没法完整验证的,必须真机。每次换新手机调试前,我都会过一遍权限清单:

  • Android 6.0以上需要动态申请权限,融云SDK会自动弹出权限申请,但前提是manifest里已经声明了CAMERA、RECORD_AUDIO等权限。
  • iOS需要在manifest的App权限配置里写清NSCameraUsageDescription、NSMicrophoneUsageDescription,描述文案越详细越好,审核时也会看。
  • 微信小程序里无法直接走原生RTC,融云的音视频小程序端方案是独立的,和App方案不同。产品需求如果同时覆盖小程序和App,音视频部分要提前做好技术选型评估。

5. 常见问题与排查技巧实录

5.1 token相关坑位:连接失败、token失效、403

token是整个项目里最容易出问题的地方,我把遇到的典型问题列成了一张速查表:

现象可能原因排查方式
onTokenIncorrect回调服务端AppSecret不对,或客户端传的token与用户不匹配用后端日志确认token签发时用的AppKey和AppSecret,再和融云开发者后台的核对
连接成功后又立刻掉线同一userId被其他端重复获取token,导致后获取的token顶掉前面的检查后端是否每次请求都重新生成token,必要时加缓存
获取token接口报403后端调用融云服务端API时的签名错误,多因时间戳不统一或AppSecret复制带了空格检查服务器时间是否准确,密钥配置是否多了空格
消息收发正常但音视频初始化失败RTC初始化的token与IM未同一会话确认initRTC时传的token是否与connect时的最新token一致

其中token被顶掉的问题尤其隐蔽。我之前测试时发现用户A在手机上正常收发消息,但登录后台管理系统时把用户A的信息拉取了一遍,后端代码直接调用了融云的refreshUser接口,结果手机端立刻断线。融云服务端有个规则:用户信息一旦刷新,旧token立即失效。所以遇到“好好的突然断了”的情况,优先检查后端有没有无意间调用了刷新用户信息的接口。

另外有一种报错是token exchange failed相关的,出现在第三方登录或推送服务对接的时候,跟融云本身无关。这种情况一般是你的推送服务或认证服务那边token过期,和IM token不是一回事,排查时认准融云回调里的onTokenIncorrect才是IMtoken真正失效的信号。

5.2 uniapp运行到微信开发者工具没反应

这个问题困扰了我一晚上,最后发现是manifest里没有重新获取微信小程序AppID配置,或者微信开发者工具的“服务端口”没开。UNI小程序跟普通原生小程序不同,它需要微信开发者工具开启“设置-安全设置-服务端口”选项,HBuilder X才能推送过去。另外还有一点:微信开发者工具里要选择“不使用缓存”,否则改的代码推送过去还是旧包。

5.3 软键盘遮挡聊天输入框

聊天页在微信小程序里打开时,手机自带软键盘会把输入框挡住,这几乎是uniapp+聊天场景标配问题。我采用的方案是监听页面onKeyboardHeightChange,拿到键盘高度后把输入框bottom值动态垫高:

onKeyboardHeightChange(e) { this.keyboardHeight = e.height; // 聊天容器高度跟着调整 this.scrollIntoViewBottom(); }

注意这个API在App端和微信小程序端的行为有区别,App端要设置adjustPosition: true,小程序端则依赖keyboard-height-change事件或input组件的adjust-position属性。两者不能一套逻辑通吃,建议封装成平台判断函数。

5.4 自定义分享好友与全局分享方法冲突

项目里有“分享给好友”的功能,需要调用uniapp的uni.share或微信小程序的onShareAppMessage。但融云或第三方插件有时候会全局覆盖onShareAppMessage方法,导致你的分享代码不生效。我的教训是:不要直接在小程序页面里重写onShareAppMessage,而是先调用uni.removeStorageSync判断当前是否是从自定义按钮触发的分享,再通过uni.showShareMenu控制菜单显隐。同时在页面onLoad里优先拿到options.scene,如果是单聊分享进来的,自动打开对应聊天页。这个需求看着简单,但要是没先检查全局方法是否被覆盖,调试一天都找不到问题。

5.5 上架与隐私合规的一些提醒

项目的demo已经能跑通,如果要走应用市场,还有几个硬性要求必须提前做:一是iOS必须设置隐私政策弹窗,用户不同意就直接退出App;二是Android的targetSdkVersion如果较高,必须配合动态权限申请流程。uniapp的manifest里以“隐私弹窗”或“隐私政策”为关键词进行配置,并在App.vue里处理用户拒绝逻辑。这一点在上架审核时是必查项,不要等审核被拒了再改。

写在最后

这个uniapp+融云IM的项目从搭建到跑通大概花了三周,真正写业务代码的时间其实不多,大部分时间都花在理清“token由谁签、何时失效、客户端怎么拿”这三个问题上。demo我已经整理出来,包含后端token接口、uniapp前端聊天和音视频全套代码,后端maven环境配置也写在文档里,照着配一遍就能跑起来。

最后再分享一个实用小技巧:你可以在服务端给融云token配置一个过期时间策略,比如每7天强制刷新一次用户信息,这样客户端token自动失效后,App的onTokenIncorrect回调里会自动重新请求token并重连。我在线上就是这么做的,能保证异常情况下自动恢复,不用用户手动杀进程重进。这个机制加上融云SDK自带的重连逻辑,双保险之下,对话稳定性会高很多,也不容易丢失离线消息。

本文还有配套的精品资源,点击获取

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

ARM嵌入式串口调试实战:从Linux minicom配置到问题排查

简介&#xff1a;串口调试是嵌入式开发中最基础也最关键的环节&#xff0c;而Linux环境下&#xff0c;minicom作为一款轻量级命令行串口工具&#xff0c;凭借稳定性和灵活性成为工程师的首选。串口通信的本质是双方按约定格式交换数据&#xff0c;因此波特率、数据位、流控等参…

作者头像 李华
网站建设 2026/9/8 2:30:58

AI绘画+Python后处理:从提示词到批量生成Q版大头像

从今天开始记录一个很有意思的玩法&#xff1a;用 AI 生成工具“摸”一张高质量的大头照。这里说的“大头”&#xff0c;不是随手一截的放大图&#xff0c;而是类似大头贴、Q 版头像、二次元大头、半身人像特写这类视觉冲击力很强的头像图。我会把 Day 1 的完整流程拆开写清楚&…

作者头像 李华
网站建设 2026/9/8 2:30:31

球探报告数据解读:从高分到五大联赛的评估框架

如果只看传播标题&#xff0c;李宝库和赵松源这两个名字&#xff0c;最近已经被很多球迷记住了。原因很直接&#xff1a;一份球探数据报告把两位年轻球员放进了高分档&#xff0c;“数据炸裂”“大心脏球员”“未来有希望登陆五大联赛”几个词叠在一起&#xff0c;确实容易让人…

作者头像 李华
网站建设 2026/9/8 2:30:12

RAG检索增强生成实战:原理、Embedding与Rerank调优指南

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

作者头像 李华
网站建设 2026/9/8 2:29:18

DataEase飞书多维表格插件开发指南:从API接入到数据同步实践

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

作者头像 李华