news 2026/10/7 9:26:57

泛微E9 workflowService开发实战:流程增删改查与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
泛微E9 workflowService开发实战:流程增删改查与避坑指南

简介:泛微E9 workflowServeice流程开发Demo是一份面向企业开发者的流程管理二次开发示例,围绕流程模板的增删改查展示如何通过RESTful API对接E9平台,适合正在集成泛微OA或需要快速上手流程服务接口的Java工程师。资源包共41个文件,压缩包约24.75MB,其中包含11个Java源码与12个编译后的class文件、8个jar依赖库、5个XML配置以及RSA密钥、说明文档等,既可直接部署调试,也能帮助理解流程服务的调用逻辑与鉴权机制。示例项目以workflow-restful-demo为主体,结合readme说明和外部API文档压缩包,完整覆盖从流程创建、修改到删除查询的典型操作场景,并涉及fastjson、httpclient等常用库的实际运用。目前该资源已有1963人学习,对于希望在泛微E9上构建自动化流程集成、提升审批流转效率的开发者,是一份具备参考价值的实战Demo。

1. 泛微 E9 workflowService 能做什么:从一个集成需求说起

在泛微E9的二次开发里,workflowService 是绕不开的一套WebService接口,有些同事搜索时把服务名拼成 workflowServeice,实际服务端发布的服务名通常是 workflowService。一个ERP要直接往OA里推采购申请,MES要自动获知某张审批单走到哪个节点,或者用户填错单后想让外部系统帮你把单子撤回来,这些需求最终都会归结为对流程实例的增、删、改、查。打开wsdl一看,方法名一大片,没有方向感的人很容易翻车。这篇笔记就把 workflowService 流程开发demo从头拆到尾,先讲清楚接口背后的流程模型,再给出创建、更新、查询、撤回的最小代码,最后把那些让人血压升高的坑一次性列出来。适合刚接手OA集成的二开工程师,也适合正在评估接口集成方案的技术负责人。

2. workflowService 的接口模型与最小调用环境:先看懂 requestid、workflowid 和字段标识

2.1 三个核心ID:workflowid、requestid、nodeid 分别决定了什么

调用workflowService之前,先花五分钟把流程模型对齐,否则后面写代码会一直拿错参数。泛微E9里的一个审批流,存在三套关键ID:workflowid是流程模板的ID,它决定了一个流程走哪些节点、用哪个表单;requestid是流程实例ID,也就是某一次具体审批单的身份证号,后续的改、查、撤、删几乎都以它为唯一入口;nodeid是当前节点ID,它不只是在页面展示“走到第几步”,更决定了哪些字段此刻可写、哪些字段被锁定。

还有字段概念也要分清。表单字段有物理字段名和显示标签两层,比如页面上写着“申请人姓名”,底层字段名可能是dg0lb1。workflowService拼请求参数时用的是物理字段名。如果不先把这个映射关系摸清楚,创建流程大概率会拿到一张字段全空的审批单。这也是为什么我每次接手新流程,都先去表单设计器里把字段标识列表导出一份,存到项目文档里备查。

数据库里这些数据散在 workflow_requestbase(流程头)、workflow_requestlog(流程日志)、formtable_main_xxx(业务表单表)等几张表里。有人图省事直接拿SQL去增删改查,把requestid一update就完事,短期看不出问题,等流程跟踪、消息待办、报表统计全部对不上时,代价非常大。我的建议是:SQL只能用来排查问题,对流程实例的操作一律走接口。

2.2 用 curl 跑通第一个 SOAP 查询:确认服务活着

先别急着写Java,用curl把接口探一遍,能省掉后面一大半的联调焦虑。泛微E9的workflowService通常以SOAP方式发布,地址一般形如http://OA服务器端口/services/workflowService?wsdl,具体路径以你们服务端实际发布为准。

curl -X POST "http://oa-server:8080/services/workflowService" \ -H "Content-Type: text/xml; charset=utf-8" \ -H "SOAPAction: \"getRequestInfo\"" \ -d '<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wor="http://webservice.ws.ecology9.com"> <soapenv:Body> <wor:getRequestInfo> <loginId>admin</loginId> <password>你的密码</password> <requestid>407</requestid> <ignoreuser>false</ignoreuser> </wor:getRequestInfo> </soapenv:Body> </soapenv:Envelope>'

这段命令里,SOAPAction对应你要调用的方法名 getRequestInfo,Body里的入参按wsdl定义填写。ignoreuser 传 false 表示按登录账号的权限返回数据,传 true 则绕过当前用户权限直接读流程信息,但前提是账号本身有管理员授权。如果接口通了,返回的XML里能看到 requestname、createdate、nodeid 等字段;如果报 NoSuchMethod 或方法不存在,去wsdl里搜一下,有的小版本把查询方法命名为 getRequestInfoById 或 getWorkflowRequestInfo,命名差异在E9各个补丁版本里很常见。

用curl先跑通的意义在于:先把网络、认证、地址三个变量排除掉,之后写Java客户端时如果调用失败,你就可以确定问题出在Java代码,而不是OA那边根本没开发这个服务。我通常把这段curl保存成一个shell脚本,后续每接一条新接口都先这样验一次。

2.3 Java 侧最小客户端:一个通用的 callWorkflowService 方法

生产环境不会用curl,还是要落到Java里。很多老项目用AXIS生成客户端桩,也有用CXF的,但如果你只是需要一个能快速跑通的demo,我建议先封装一个通用的SOAP调用方法,不绑定任何第三方库。

import java.io.*; import java.net.HttpURLConnection; import java.net.URL; public class WorkflowSoapClient { public static String call(String endpoint, String soapAction, String soapXml) { try { URL url = new URL(endpoint); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "text/xml; charset=utf-8"); conn.setRequestProperty("SOAPAction", "\"" + soapAction + "\""); conn.setDoOutput(true); conn.getOutputStream().write(soapXml.getBytes("UTF-8")); BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), "UTF-8")); StringBuilder result = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { result.append(line); } return result.toString(); } catch (Exception e) { e.printStackTrace(); return null; } } }

这段代码的核心就一件事:把方法名和SOAP请求体发到 workflowService 地址,拿回响应字符串。参数 endpoint 是wsdl里看到的服务地址,soapAction 是方法名,soapXml 是按SOAP格式拼好的请求体。注意请求头一定要带text/xml; charset=utf-8,否则中文会出现乱码。调用失败时它会打印堆栈但不抛出异常,demo阶段可以先保证看清返回内容,正式项目里建议改成抛出可识别的业务异常。

有了这个通用client,下面的增删改查都围绕 call 方法展开。你可以把 endpoint 统一放到配置文件里,不同环境切换OA地址时只改一处,后面维护会轻松不少。

3. 用 workflowService 做流程“增”和“改”:createRequest 与 updateRequest 的实战参数

3.1 创建流程:createRequest 的参数拆解与 requestid 的获取

创建流程是集成场景里最常用的能力。泛微发布采购单、推送请假单、自动生成报销审批,走的都是同一个思路:确定 workflowid,拼好表单字段,调 createRequest。

public static String createRequest(String endpoint, String loginId, String password, String workflowid, String startUserId, Map<String, String> fields, String createTime) throws Exception { StringBuilder fieldXml = new StringBuilder(); fieldXml.append("<Data>"); for (Map.Entry<String, String> entry : fields.entrySet()) { fieldXml.append("<Field name=\"").append(entry.getKey()) .append("\"><Value>").append(entry.getValue()) .append("</Value></Field>"); } fieldXml.append("</Data>"); String soapXml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" " + "xmlns:wor=\"http://webservice.ws.ecology9.com\"><soapenv:Body>" + "<wor:createRequest>" + "<loginId>" + loginId + "</loginId>" + "<password>" + password + "</password>" + "<workflowid>" + workflowid + "</workflowid>" + "<startUserId>" + startUserId + "</startUserId>" + "<requestDataXml>" + fieldXml + "</requestDataXml>" + "<createTime>" + createTime + "</createTime>" + "</wor:createRequest></soapenv:Body></soapenv:Envelope>"; String resp = WorkflowSoapClient.call(endpoint, "createRequest", soapXml); // 返回的resp里包含新增流程的requestid节点,用DOM解析取出即可 return resp; }

这段代码把字段 Map 转成标准的字段XML,拼进 createRequest 的 requestDataXml 参数。字段值收口在 Map 里,不再散落到业务代码各处。返回的 resp 里会带一个 requestid,这是本次创建出来的流程实例唯一标识,很多项目在这里翻车:流程创建成功了,但没把 requestid 保存下来,后续查状态、改字段全部抓瞎,只能去待办列表里人工翻。

参数说明:workflowid 是流程模板ID,到“流程引擎-流程设计器”里看URL或属性就能找到;startUserId 是OA里的登录账号,必须是个真实存在的系统用户,否则流程发起后找不到申请人;createTime 不传时一般取当前时间,如果业务上要补齐历史单据,再把时间戳按格式传进去。requestDataXml 里的字段名必须是物理字段名,不要用页面显示名,这一步最容易出错。

创建成功后,我一般会立刻把 requestid 写回业务系统的关联表。下次业务系统再来查询或撤回时,不需要再走一次模糊查找,直接拿着 requestid 去调接口,性能好也不容易错。这就是“泛微获取流程id”这条经验里最核心的一环。

3.2 修改流程:状态限制、字段权限与 updateRequest

public static boolean updateRequest(String endpoint, String loginId, String password, String requestid, Map<String, String> fields) { StringBuilder values = new StringBuilder(); for (Map.Entry<String, String> entry : fields.entrySet()) { values.append("<Field name=\"").append(entry.getKey()) .append("\"><Value>").append(entry.getValue()) .append("</Value></Field>"); } String soapXml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" " + "xmlns:wor=\"http://webservice.ws.ecology9.com\"><soapenv:Body>" + "<wor:updateRequest>" + "<loginId>" + loginId + "</loginId>" + "<password>" + password + "</password>" + "<requestid>" + requestid + "</requestid>" + "<requestValues>" + values + "</requestValues>" + "</wor:updateRequest></soapenv:Body></soapenv:Envelope>"; String resp = WorkflowSoapClient.call(endpoint, "updateRequest", soapXml); return resp != null && resp.contains("flag=\"1\""); }

修改流程跟创建流程的最大区别是:它受节点状态影响。流程还没提交时,字段随便改;一旦进入审批节点,当前节点如果没给字段开放修改权限,updateRequest 即使返回成功,页面上的值也可能没变。所以我一般在调用 updateRequest 之前,先用 getRequestInfo 看当前状态,再决定这个更新要不要走。有些版本接口叫 doUpdateRequest,参数顺序可能不同,以 wsdl 为准。

还有一个容易忽略的点:updateRequest 改的是流程实例的表单值,不是流程日志。想修改“审批记录”或“办理意见”,那是另一个接口族,不要在表单更新里硬塞。改字段前最好确认当前环节对字段是否有写权限,这个用流程设计器里的节点权限配置判断。外部系统要做“审批中允许修改”的需求,通常还需要流程设计器配合,把对应节点字段设成可编辑。

3.3 表单默认值、隐藏字段与加密字段在后端接口的处理

创建流程时带上表单默认值,是很多集成的隐性需求。比如请假单的开始日期默认当天、加班单的申请人默认当前登录人,这些默认值不一定非得靠前端页面触发,外部系统在拼 requestDataXml 时直接把值算好写进去,流程进入后就是最终值,比依赖表单公式稳定。

“根据筛选框隐藏字段”也是E9上常见的交互需求。页面上常常通过一个下拉框决定某些字段显示还是隐藏,如果外部系统把所有字段一股脑传进 workflowService,被隐藏字段的值也会写入业务表,审批人打开详情时看到“不该出现的数据”,观感很糟。我习惯把隐藏逻辑前移到接口层:外部系统在下拉框选某个值时,就不拼对应字段到 requestDataXml 里,或者传空值,让OA端保持字段隐藏。前端再做一层同样的显隐控制,双保险。

字段加密设置则要格外小心。E9表单字段如果开了加密存储,workflowService 返回的字段值很可能是密文,尤其是手机号、身份证这类敏感字段。你把这个密文存到自己的库里,后续数据校验和报表统计都很被动。常见做法是:让管理员确认接口调用账号是否有查看明文的权限,或者调用泛微的解密服务先解一遍再落库。不要自己去研究密文格式,那是黑匣子,猜不出来的。

4. 流程“查”和“删”的接口组合:状态查询、表单明细与安全撤单

4.1 查流程状态:getRequestInfo 的返回结构与解析要点

public static String queryRequestInfo(String endpoint, String loginId, String password, String requestid) { String soapXml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" " + "xmlns:wor=\"http://webservice.ws.ecology9.com\"><soapenv:Body>" + "<wor:getRequestInfo>" + "<loginId>" + loginId + "</loginId>" + "<password>" + password + "</password>" + "<requestid>" + requestid + "</requestid>" + "<ignoreuser>true</ignoreuser>" + "</wor:getRequestInfo></soapenv:Body></soapenv:Envelope>"; return WorkflowSoapClient.call(endpoint, "getRequestInfo", soapXml); }

返回的XML里能看到 requestid、workflowid、requestname、creator、createdate、nodeid 这些字段。解析时不要硬用 String.indexOf 去截,建议直接用 DocumentBuilder 把XML转成DOM,按节点名取值。nodeid 就是当前审批节点,配合流程设计器里维护的节点ID,就能判断这条流程是刚到部门经理,还是已经走到归档。集成系统做“流程进度”页面,核心就是循环调用这个接口。

注意 ignoreuser 的取值。外部系统集成时,调用账号往往不是流程发起人,如果 ignoreuser 传 false,可能连这条流程都查不到。我一般会传 true,让接口以账号的权限为基准,只要账号有管理员角色就能查。权限管控严一点的公司,可以给集成账号配置“流程查看”的角色,再决定是否开启这个开关,不要为了图省事随便放权。

4.2 查表单明细:getRequestData 与加密字段的两种返回

getRequestData 是另一个高频接口,它返回的是这张审批单的具体表单数据,比如报销金额、出差城市、合同编号,而不是流程头信息。实际项目里,业务系统经常用这个接口把OA表单内容同步回自己的数据库,做数据对账。

public static String getRequestData(String endpoint, String loginId, String password, String requestid) { String soapXml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" " + "xmlns:wor=\"http://webservice.ws.ecology9.com\"><soapenv:Body>" + "<wor:getRequestData>" + "<loginId>" + loginId + "</loginId>" + "<password>" + password + "</password>" + "<requestid>" + requestid + "</requestid>" + "</wor:getRequestData></soapenv:Body></soapenv:Envelope>"; return WorkflowSoapClient.call(endpoint, "getRequestData", soapXml); }

返回的字段XML结构跟创建时类似,每个字段一个标签,属性带字段名和值。加密字段在这里有两种可能:一种是系统按调用账号权限直接返回明文,另一种是返回密文串。判断标准是看字段值是否具备可读性,比如手机号如果是11位数字,那就是明文;如果是一长串字母数字混合,多半是密文。经验是普通集成账号拿到密文的概率比较大,能配合管理员配置“返回明文”时尽量配掉,或者用解密接口处理。

getRequestInfo 和 getRequestData 的区别,很多新手会记反:前者查流程走到哪个环节,后者查表单里的业务数据长什么样。区分清楚之后,接口文档就不难翻了。另外注意,如果表单里嵌了明细表,返回XML里会出现一整套子节点,不要按主表平铺的方式去解析。

4.3 删和撤的区别:cancelRequest 与 deleteRequest 的适用场景

最后说“删除”。流程引擎里的删除是个敏感动作,我的原则是能撤不删,逻辑删除优先。申请人发现单子填错了,应该走 cancelRequest 撤单接口,流程终止,数据保留,审批记录还在,后续能追溯。外部ERP想作废一笔订单对应的审批流程,也可以调这个接口,业务上完全说得通。

public static String cancelRequest(String endpoint, String loginId, String password, String requestid) { String soapXml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" " + "xmlns:wor=\"http://webservice.ws.ecology9.com\"><soapenv:Body>" + "<wor:cancelRequest>" + "<loginId>" + loginId + "</loginId>" + "<password>" + password + "</password>" + "<requestid>" + requestid + "</requestid>" + "</wor:cancelRequest></soapenv:Body></soapenv:Envelope>"; return WorkflowSoapClient.call(endpoint, "cancelRequest", soapXml); }

deleteRequest 则是管理员用来物理删除流程实例的接口,删除后流程记录、日志和关联表单数据都会受影响。有些公司还会要求这类操作在集成平台上单独授权,调用前必须写日志,记录谁删的、为什么删、原 requestid 是多少。我这里强烈不建议外部系统默认调 deleteRequest,因为一旦物理删除,后续审计、报表、对账全部断层,想找后悔药都找不到。先想清楚“撤单”和“删单”在业务上的区别,再决定接口怎么接。

撤回成功后,流程状态会变成已撤销,业务系统要做状态判重,避免重复调用同一张单子。可以在业务表里记录撤回状态,接口每次调用前先查一遍,防止同一条流程被外部定时任务反复触发。

5. workflowService 避坑记录:5 个让集成翻车的典型问题

没有踩过坑的集成项目是不完整的。这里我把 workflowService 开发中最常遇到的5个问题整理出来,每条都按“现象、原因、解决”顺序写,能直接当排查手册用。

5.1 创建流程报“无权限”,但页面操作完全正常

现象:管理员账号在OA页面上能正常发起流程,但调用 workflowService 的 createRequest 时,返回提示没有权限。 原因:泛微E9里接口调用的权限和页面操作权限是两套体系。页面有权限不代表WebService接口对这个账号放行,系统管理里一般还有独立的WebService接口授权。 解决:到系统管理-接口安全/WebService配置里找到 workflowService,把当前账号加入授权列表,再重新调用。改完不用重启服务,立刻生效。如果菜单位置找不到,就在后台搜索“WebService”或“接口密钥”,每个版本菜单命名略有不同,但方向一致。

5.2 流程创建成功,表单业务字段却是空的

现象:createRequest 返回了 requestid,OA里也能看到这条待办,但打开审批详情,表单上大量字段空白。 原因:requestDataXml 里用的是表单显示名称,而接口要求物理字段名;另一种情况是字段属于明细表,按主表字段平铺传值当然存不进去。 解决:先在表单设计器里找到字段标识,把显示标签映射为物理字段名。创建成功后写条测试数据,去 formtable_main_xxx 表里查一遍,确认哪些字段真正写入,哪些字段还是空。明细表字段要用明细数据结构单独拼,不要指望它能自动拆行识别。

5.3 更新字段总不生效,接口也没有报错

现象:updateRequest 调用后返回成功标识,但审批页面打开,字段值并没有变化。 原因:流程已经进入审批节点,该节点把目标字段设成只读,接口写入被流程引擎拦截,但返回值仍是成功。这个行为很容易让人误判为调用成功。 解决:更新前先调 getRequestInfo,看看当前节点ID和流程状态;如果流程在途且必须改,要去流程设计器把对应节点字段改为可编辑。外部系统展示“修改成功”前,最好再查一次值,确认生效了再给用户反馈,不要只信接口返回标志位。

5.4 加密字段取出来是一串密文,没法直接用

现象:getRequestData 返回的手机号、身份证号是一串看起来像乱码的字符,存进业务库后完全没法做条件查询。 原因:表单字段开启了加密存储和加密显示,接口按当前调用账号的权限决定是否返回明文。这不是bug,是权限策略。 解决:让管理员在后台给集成账号开放查看明文权限,或调用解密组件做一遍解密。不要自己写固定算法去猜,泛微的加密机制在不同版本上有差异,猜错就会被带到黑匣子里。上线前用一个已知字段值测一次,确认拿到的是明文再放心接。

5.5 SOAP 调用中文乱码与特殊字符报错

现象:接口偶尔返回数据正常,偶尔汉字变成乱码;字段值里带了 &、< 等符号时,请求直接失败。 原因:SOAP请求没有统一指定UTF-8编码,或者XML里的特殊字符没有转义。 解决:所有请求头保持text/xml; charset=utf-8,拼XML时对& < > " '做转义。这是最基础的坑,但每次项目都有人踩,提前写进工具类就能避免。字段值里出现英文单引号也会破坏XML结构,建议在工具类里统一封装一个字段值编码方法。

排查顺序建议是:先看服务地址通不通,再看账号授权有没有,最后再怀疑XML拼写。很多问题表面是接口报错,实际是前面的公共环节出错。

6. 把 workflowService 封装成可复用工具类:一个值得投入的工程习惯

6.1 按“增删改查”收口的客户端接口

demo跑通后,不要急着接到业务代码里,我建议先把接口收口成一个工具类。四个核心方法固定下来:

方法入参返回
createworkflowid、字段Map、发起人账号requestid
updaterequestid、字段Map是否成功
queryrequestid流程状态与表单明细
cancelrequestid、撤回原因是否成功

四个方法内部统一走同一个SOAP调用入口,统一做异常处理、日志记录、字段转义。业务系统里不要再出现裸调 workflowService 的代码,后续换接口版本、调整服务地址,都只动这个类。

6.2 封装后的验证顺序与长期维护

封装好之后,先用一张测试流程把四个方法按顺序走一遍:创建流程,拿到 requestid,查状态,改一个字段,再查确认值变了,最后撤单。这个过程能覆盖90%的集成问题。我自己的习惯是把这个验证脚本保留在项目里,每次OA升级或者换了补丁版本,就在测试环境重跑一遍,看哪个接口行为变了。这比出了问题再翻日志舒服得多。

长期维护时,字段映射变化是最频繁的。建议把每个流程的物理字段名与业务字段名单独维护成映射表,不要散落在业务代码里。以前我图省事,哪里用到就在哪里写死,后来表单一升级,全项目都在改字段名,改到怀疑人生。现在我会专门建一张映射配置,增删改查统一从配置取值,流程变更时只改配置,代码不用动。

这套做法不复杂,但很值得做。希望帮到你。

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

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

如何用 chdman 把 ISO 转成 CHD:ROM 库缩减 40%-60% 的完整教程

如何用 chdman 把 ISO 转成 CHD&#xff1a;ROM 库缩减 40%-60% 的完整教程 【免费下载链接】romm A beautiful, powerful, self-hosted ROM manager and player. 项目地址: https://gitcode.com/GitHub_Trending/rom/romm 自托管 ROM 库里塞满了光盘镜像&#xff0c;4T…

作者头像 李华
网站建设 2026/10/7 9:20:08

数字地与模拟地EMC设计:回流路径真相与PCB实战整改

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

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

Allegro 17.4位号重排与OrCAD反标实战:从PCB到原理图同步

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

作者头像 李华