news 2026/9/8 3:34:34

Android来电提醒小助手:基于电话状态监听与局域网推送

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android来电提醒小助手:基于电话状态监听与局域网推送

手机在包里调成静音,电脑在旁边开着通知栏,电话进来时什么都没有发生;等拿起手机看到未接来电,已经错过了重要信息。为了解决这种场景,可以做一个“快点接电话啦”来电提醒小助手:手机检测到响铃事件后,立即通过 HTTP 请求把事件推送到同一局域网里的电脑,电脑端播放提示音提醒你接起电话。这个项目规模不大,却把 Android 电话状态监听、运行时权限、异步网络调用、服务端接口和局域网联调串成了一条完整链路,适合在 Android 模拟器或一台真机上跑通,也能加深对事件推送场景的理解。

下面会从需求拆解开始,先说明这条链路为什么成立,再给出两端的环境准备、项目结构、核心代码、联调步骤和排错清单。整体以最小可运行版本为主,最后补充生产化和扩展建议。

1. 先拆解“快点接电话啦”的完整提醒链路

1.1 适用场景和普通提醒方案的区别

这个项目解决的典型问题是:手机不在身边、手机静音、会议中手机调成免打扰,但电脑或者平板就在眼前。通用方案是戴智能手环,但手环也有漏振或没电的情况。另一种方案是让手机在来电时通过本地网络通知电脑,这种方式不依赖第三方云服务,数据只在内网传输,延迟低,也方便自己控制逻辑。

和普通闹钟提醒相比,区别很明显:

  • 普通闹钟是定时触发,来电提醒是状态触发。
  • 普通闹钟不需要系统状态权限,来电提醒需要获得电话状态监听能力。
  • 普通闹钟只在本机播放声音,这个项目可以跨设备投递提醒事件。

所以它不只是一个“响铃程序”,而是一个典型的端到端事件系统:端侧负责采集状态,服务端负责消费状态并执行提醒动作。

1.2 端到端数据流

一条完整的来电提醒链路可以拆成这样:

  1. Android 手机系统检测到电话状态变化。
  2. 应用通过TelephonyManager注册监听器,收到CALL_STATE_RINGING状态。
  3. 应用把“当前有来电”这个事件组织成 JSON,通过 HTTP POST 发送给局域网服务端。
  4. 电脑上的服务端接收事件后,打印日志并调用系统播放能力播放提示音。
  5. 用户听到“快点接电话啦”的提示音,回身拿起手机。

关键点在于:Android 端只负责“上报事实”,服务端负责“执行动作”。这种解耦方式让后续扩展变得容易。以后可以不播放声音,而是改成发短信提醒、推送到钉钉群、控制智能家居灯闪烁,服务端接口保持不变,只需要修改消费逻辑。

1.3 为什么选用局域网 HTTP 推送而不是云推送

选择局域网 HTTP 推送是刻意为之,有几个原因:

  • 不依赖公网服务器,数据不经过第三方,适合在家庭或办公内网使用。
  • 实现成本低,Android 端一个HttpURLConnection就能完成发送。
  • 排错直观,服务端打印日志、curl 模拟请求、浏览器访问端口,都能快速定位链路。

代价是只能在同一局域网下工作,手机和电脑必须能互相访问。如果手机使用 4G/5G 网络而电脑在家,链路就断了。但作为教学项目和家庭内网工具,这个限制可以接受。要扩大到公网环境,后续可以把 HTTP 推送替换为 WebSocket、MQTT 或者云厂商的消息通道。

2. 环境准备和项目结构要对齐,否则后面联调会出错

2.1 两端环境要求

工具/依赖建议
AndroidAndroid Studio使用较新的稳定版本
Android编译语言Kotlin 1.8 以上
AndroidminSdk21 以上
AndroidtargetSdk33 或 34,注意新版本权限差异
服务端Python3.9 以上
服务端FastAPI0.100 以上
服务端Uvicorn0.20 以上
服务端系统播放能力Windows 用 winsound;其他系统替换播放命令

如果原始环境没有明确版本,落地前要先确认本机已安装对应工具,避免把时间浪费在版本冲突上。Android 工程创建时选择空 Activity 模板即可,Kotlin 版本跟随 Android Studio 默认设置。

2.2 项目目录结构

整个项目不复杂,建议按两端分目录管理:

call-reminder/ ├── android/ # Android Studio 工程 │ └── app/src/main/ │ ├── AndroidManifest.xml │ └── java/com/example/callreminder/MainActivity.kt └── server/ ├── server.py # FastAPI 服务端 ├── requirements.txt └── call_reminder.wav # 提醒音频文件

Android 端的包名可以根据自己的工程调整,下面代码中的com.example.callreminder只作为示例。服务端的音频文件不是必须用固定名称,只要server.py里引用路径保持一致即可。

2.3 联调前的网络条件

  • 手机和电脑连接同一个路由器,或者手机热点开给电脑。
  • 电脑能通过局域网 IP 被手机访问,不只是本机的127.0.0.1
  • 如果用 Android 模拟器,模拟器访问宿主机必须使用特殊地址10.0.2.2,这是模拟器内置的宿主机回环地址,不能写127.0.0.1

这一步很多人会忽略,实际是最常见的失败原因之一。后面联调章节会专门说明。

3. Android 端:权限、监听、推送三个部分要一起实现

3.1 权限申请与 Android 13+ 的差异

Android 端要监听电话状态,至少需要READ_PHONE_STATE权限。这个权限属于危险权限,Android 6(API 23)之后必须在运行时动态申请,不能只写在 Manifest 里。

Manifest 配置如下:

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />

INTERNET权限用于发送 HTTP 请求。READ_PHONE_STATE用于读取电话状态。

在 Android 13 和 Android 14 环境中,READ_PHONE_STATE仍然可以正常申请,但需要注意:电话状态和通话记录是两类权限。本示例只判断“是不是响铃”,不需要读取完整通话记录,因此不需要申请READ_CALL_LOG。如果后续要获取完整来电号码,则要单独申请READ_CALL_LOG,并且在隐私政策里明确说明用途。本教程出于安全考虑,只上报事件类型、脱敏号码或未知占位符,不依赖完整号码完成提醒。

权限申请代码:

private val PERMISSION_REQUEST_CODE = 1001 private fun checkAndRequestPermission() { if (ContextCompat.checkSelfPermission( this, Manifest.permission.READ_PHONE_STATE ) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.READ_PHONE_STATE), PERMISSION_REQUEST_CODE ) } }

在权限回调中再注册监听器:

override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<out String>, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == PERMISSION_REQUEST_CODE && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED ) { registerPhoneListener() } else { Toast.makeText(this, "没有电话权限,无法监听来电", Toast.LENGTH_SHORT).show() } }

不要在权限未授予时调用TelephonyManager.listen(),否则回调可能不触发,甚至抛出安全异常。

3.2 用 PhoneStateListener 监听来电状态

Android 中监听电话状态的核心类是TelephonyManager,配合PhoneStateListener注册回调。当电话状态变成CALL_STATE_RINGING时,说明有电话正在响铃。

这里有一个版本背景:Android 12(API 31)之后官方更推荐使用TelephonyCallback,但PhoneStateListener作为旧接口在兼容场景下仍可运行。新项目可以关注TelephonyCallback的迁移方案,本示例为了保持代码简单,使用PhoneStateListener进行演示。

private val phoneListener = object : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { if (state != TelephonyManager.CALL_STATE_RINGING) return // phoneNumber 在没有 READ_CALL_LOG 权限时可能为 null val safePhone = phoneNumber?.takeLast(4)?.let { "****$it" } ?: "unknown" sendEvent("RINGING", safePhone) } } private fun registerPhoneListener() { telephonyManager = getSystemService(TelephonyManager::class.java) telephonyManager.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE) }

这段代码做了两件关键事情:

  1. 只对RINGING状态感兴趣,避免在空闲和通话中状态重复触发。
  2. 对电话号码做脱敏处理,只保留后四位,并且用****前缀占位,服务端日志不会记录完整号码。

一个容易踩的坑是:在 Android 9 之后,如果应用没有READ_CALL_LOG权限,phoneNumber参数可能直接是null。很多新手以为是监听器没生效,其实不是没生效,是系统出于隐私保护不给你号码。因此不要把整个提醒逻辑建立在获取号码上。

3.3 用 HttpURLConnection 发送事件,不能在主线程执行

收到响铃状态后,需要向服务端发送 HTTP POST 请求。Android 不允许在主线程执行网络请求,否则会抛出NetworkOnMainThreadException。所以发送逻辑要放到子线程中。

private fun sendEvent(event: String, phone: String) { if (serverUrl.isBlank()) return Thread { try { val body = JSONObject() .put("event", event) .put("phone", phone) .put("time", System.currentTimeMillis() / 1000) .toString() val conn = URL(serverUrl).openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.connectTimeout = 3000 conn.readTimeout = 3000 conn.setRequestProperty("Content-Type", "application/json") conn.doOutput = true conn.outputStream.write(body.toByteArray()) Log.i("CallReminder", "send result code=${conn.responseCode}") conn.disconnect() } catch (e: Exception) { Log.e("CallReminder", "send failed: ${e.message}") } }.start() }

把连接超时和读取超时都设置为 3 秒,是为了防止服务端不可达时线程长时间阻塞。日志里记录responseCode,联调时可以直接用 Logcat 确认请求是否到达服务端。

3.4 UI 入口和服务地址配置

为了能灵活切换电脑的 IP,不要硬编码服务端地址。布局文件中放一个EditTextButton,用户输入类似http://192.168.1.100:8000的地址后点击保存,应用会拼上/incoming-call接口路径并保存到SharedPreferences

class MainActivity : AppCompatActivity() { private lateinit var telephonyManager: TelephonyManager private var serverUrl = "" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val etServer = findViewById<EditText>(R.id.etServer) val btnSave = findViewById<Button>(R.id.btnSave) val saved = getSharedPreferences("reminder", MODE_PRIVATE) etServer.setText(saved.getString("serverUrl", "")) btnSave.setOnClickListener { val raw = etServer.text.toString().trim() if (raw.isBlank()) { Toast.makeText(this, "请先填写服务端地址", Toast.LENGTH_SHORT).show() return@setOnClickListener } serverUrl = raw.trimEnd('/') + "/incoming-call" saved.edit().putString("serverUrl", serverUrl).apply() registerPhoneListener() Toast.makeText(this, "已保存并开始监听", Toast.LENGTH_SHORT).show() } } // 权限申请、PhoneStateListener、sendEvent 代码见上文 }

布局文件的关键是EditTextidetServerButtonidbtnSave,和 Kotlin 代码保持一致。

这里的监听器注册时机比较重要。建议在用户点击保存地址并且权限已授予后再注册,避免地址还没配置好就开始上报无用事件。

4. 服务端:接收事件后播报“快点接电话啦”

4.1 为什么选 FastAPI

服务端只需要暴露一个 POST 接口,FastAPI 用 Pydantic 定义请求体后,JSON 解析、参数校验、错误响应这些工作都不需要手动写。对于这种小型工具,代码量比 Flask 更精简。

如果你的环境不方便安装 FastAPI,改用 Flask 也可以,但下面的代码以 FastAPI 为例。

4.2 接口定义和请求数据格式

服务端接收的事件结构如下:

{ "event": "RINGING", "phone": "****1234", "time": 1700000000 }

字段说明:

字段类型含义
eventstring事件类型,目前固定为 RINGING
phonestring脱敏后的号码或 unknown
timenumber事件发生的 Unix 时间戳

服务端代码:

# server.py from fastapi import FastAPI from pydantic import BaseModel import time try: import winsound except ImportError: winsound = None app = FastAPI() class CallEvent(BaseModel): event: str phone: str = "" time: int = 0 def play_reminder(): if winsound is None: print("非 Windows 环境:请替换为系统播放命令") return try: winsound.PlaySound( "call_reminder.wav", winsound.SND_FILENAME | winsound.SND_ASYNC, ) except Exception as e: print("播放失败:", e) @app.post("/incoming-call") def incoming_call(evt: CallEvent): current = time.strftime("%H:%M:%S") print(f"[{current}] event={evt.event} phone={evt.phone or 'unknown'}") if evt.event == "RINGING": play_reminder() return {"code": 0, "message": "ok"}

winsound.PlaySound只在 Windows 上可用,所以用ImportError捕获导入失败的情况。在 Linux 或 macOS 上,可以先打印日志,再根据系统替换为aplayafplaypyttsx3文本转语音工具。

4.3 安装依赖并启动服务

先创建requirements.txt

fastapi uvicorn

然后安装并启动:

pip install -r requirements.txt uvicorn server:app --host 0.0.0.0 --port 8000

这里必须写--host 0.0.0.0。如果只写127.0.0.1或直接执行uvicorn server:app,默认将只监听本机回环地址,真机通过局域网 IP 访问会失败。

4.4 用 curl 先验证服务端

Android 端还没联调之前,先用 curl 模拟一个事件,确认服务端能正常接收和播放声音:

curl -X POST http://127.0.0.1:8000/incoming-call \ -H "Content-Type: application/json" \ -d '{"event":"RINGING","phone":"****1234","time":1700000000}'

预期返回:

{"code":0,"message":"ok"}

同时服务端控制台会打印一行日志,并且如果音频文件路径正确,电脑会播放提醒音。

5. 模拟器与真机联调:从 gsm 命令到防火墙检查

5.1 用 Android 模拟器模拟来电

Android 模拟器提供了模拟电话的调试命令。打开终端执行:

adb emu gsm call 13800000000

这会让模拟器进入响铃状态,相当于真的有一个电话进来。取消来电使用:

adb emu gsm cancel 13800000000

在 Android Studio 的 Extended Controls 中也可以找到类似入口,但命令行方式更适合写进自动化脚本。

模拟器联调时,服务端地址要填:

http://10.0.2.2:8000

不能填http://127.0.0.1:800010.0.2.2是 Android 模拟器专门用于访问宿主机回环地址的固定地址,这是模拟器网络模型决定的。只改地址这一处,其他逻辑不变。

5.2 真机联调时的网络和防火墙检查

真机场景下,手机和电脑要连在同一个局域网。

先查出电脑的局域网 IP:

  • Windows 执行ipconfig,找到“IPv4 地址”。
  • macOS 执行ifconfig,找到en0下的inet
  • Linux 执行ip addr,找到对应网卡的 IP。

然后在 App 的输入框中填写:

http://<电脑的局域网IP>:8000

例如http://192.168.1.100:8000

如果真机请求失败,极可能是 Windows 防火墙拦截了 8000 端口。可以先用管理员权限的命令行放行端口:

netsh advfirewall firewall add rule name="CallReminder" dir=in action=allow protocol=TCP localport=8000

这只是一个测试用的规则。生产环境不要随意开放端口,应该按最小权限原则配置防火墙。

5.3 预期日志和闭环验证

一次完整联调成功后,应该看到三个结果:

  1. 服务端控制台打印类似日志:
[10:23:45] event=RINGING phone=****1234
  1. Android 的 Logcat 中能看到:
CallReminder: send result code=200
  1. 电脑端播放提醒音,或者至少打印了播放相关日志。

如果其中一环缺失,就进入下一章的排查步骤。

6. 来电不提醒时,按这条链路逐层排查

6.1 排查清单速查表

现象可能原因检查方式处理建议
App 保存后没有任何日志没有授予电话权限或监听未注册检查系统设置中的权限状态;查看 Logcat在权限回调中注册监听,不要提前 register
模拟器请求超时错误使用 127.0.0.1查看 serverUrl 配置模拟器使用 10.0.2.2
真机请求失败uvicorn 只监听 127.0.0.1 或防火墙拦截执行 netstat 查看端口监听地址;手机浏览器访问服务端地址uvicorn 加 0.0.0.0;放行防火墙端口
phone 始终是 unknown未申请 READ_CALL_LOG,系统不返回完整号码查看服务端打印的 phone 字段不要依赖号码;确需号码再单独处理 READ_CALL_LOG
锁屏后不提醒应用进程被系统回收或 Doze 限制查看进程是否存活改为前台服务并常驻通知

6.2 关于权限和号码的常见误解

很多人在联调时发现phone字段一直是unknown,第一反应是代码写错了。实际上这是 Android 隐私保护策略的一部分。没有READ_CALL_LOG权限时,系统不会把完整来电号码传给普通应用。

这个项目的核心目标是在电脑端提醒“快点接电话”,不依赖完整号码也能工作。把“是否响铃”这个事实成功推送出去,链路就算通了。如果想在日志里看到准确号码,需要单独申请通话记录权限,并且要确保应用在 Play Store 或应用分发场景下符合隐私政策,这一步不能绕过系统权限机制。

6.3 网络和服务端相关排查

服务端能接收 curl 请求,不代表手机一定能访问。要按顺序检查:

  1. 手机和电脑是否在同一网段,可以先互相 ping 一下。
  2. 服务端是否监听在0.0.0.0:8000,用netstat -ano | findstr 8000ss -ltnp | grep 8000查看。
  3. 电脑系统防火墙是否放行 8000 端口。
  4. 如果服务端跑在虚拟机和 Docker 中,还要确认端口映射是否配置正确。

还有一个非常隐蔽的问题:如果电脑有多个网卡,例如同时开着 Wi-Fi 和 VMware 虚拟网卡,uvicorn --host 0.0.0.0会监听所有网卡,这没问题,但手机必须连接和电脑同一 Wi-Fi 的那个网段,不能走虚拟网卡网段。

6.4 Android 生命周期和主线程问题

PhoneStateListener的回调默认运行在主线程。如果直接在回调里写HttpURLConnection,Android 会直接抛NetworkOnMainThreadException。发送事件必须放到子线程、协程或其他异步机制中。

进程被杀问题更隐蔽。当前示例在 Activity 中注册监听器,一旦用户划掉任务卡片,进程可能被系统回收,电话监听自然失效。要让这个功能持续可用,生产版本必须使用前台服务和常驻通知。教学版本中,只要保持 App 在前台或在最近任务中不划掉,就能正常演示。

7. 本地跑通之后,生产化还差哪些工作

7.1 学习版和生产版的差距

当前版本是一个最小演示,离真正每天使用还有差距:

  • 通过 Activity 注册监听,进程存活不稳定。
  • 使用明文 HTTP,局域网内其他设备可以伪造事件。
  • 没有重试机制,一次网络抖动就丢事件。
  • 播放声音时如果多个事件同时到达,会出现声音叠加。
  • 日志没有落盘,也没有保留策略。

如果只是学习 Android 电话状态监听和 HTTP 推送,这些都不是问题。但如果想用在家里或小办公室,建议至少补上常驻服务和基础鉴权。

7.2 安全和隐私建议

  • 不要把完整手机号推送到服务端,建议只传事件类型和时间戳。
  • 如果确实需要号码,必须脱敏后再传,日志中禁止打印完整号码。
  • 服务端接口增加简单 token 校验,例如 Header 中带X-Token,防止局域网内其他设备恶意触发播报。
  • 局域网内不推荐长期开放无鉴权端口,尤其不要绑定到公网网卡。
  • 日志设置保留时间,避免无限增长。

7.3 三个可扩展方向

方向一:接入群机器人提醒

把服务端逻辑从“播放声音”改成调用钉钉、企业微信或飞书机器人 Webhook,手机静音时也能在电脑或工作群里收到提醒。服务端接口不变,只改提醒动作的发送目标。

方向二:改为前台服务

PhoneStateListener移入前台服务,配合常驻通知,能明显提高后台存活率。这是所有 Android 后台监听类项目迟早要做的改造。

方向三:改用 WebSocket 或 MQTT

局域网内规模变大后,HTTP 短连接可能不够灵活。可以升级为 WebSocket 长连接或 MQTT 消息订阅,让电脑端实时收到事件,也能支持多个提醒端同时在线。

7.4 联调前的检查清单

每次换环境、换电脑、换模拟器后,建议按下面清单快速检查:

  • 手机和电脑是否在同一网段,能否互相 ping 通。
  • 服务端是否以0.0.0.0绑定端口。
  • 服务端是否用 curl 自测通过,返回code等于0
  • 电脑防火墙是否放行目标端口。
  • 模拟器场景是否使用10.0.2.2作为服务端地址。
  • App 是否已授予READ_PHONE_STATE权限。
  • Logcat 中是否出现send result code=200
  • 音频文件路径是否正确,电脑系统是否开启声音。

回到最开始的问题,判断一个提醒方案是否好用,不是看它用了多新的框架,而是看在真正漏接电话的那一刻,端到端链路是否稳定、是否能在几秒内触达。把“快点接电话啦”这个最小项目跑通之后,你会对电话权限、后台进程、网络投递和服务端接口之间的配合有非常具体的认识。

下一步可以从本地局域网推送扩展到群机器人或手机推送通道,也可以把客户端改成前台服务增强稳定性。对新手来说,最有价值的练习是先完整记录一次从模拟来电到电脑播报的过程,再尝试在手机锁屏、断网、权限被拒等异常情况下观察日志,这样才算真正吃透这条链路。

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

face-aip.js自定义检测模型替换实战:从SSD到YOLO

简介&#xff1a;face-api.js 的官方预训练检测模型合集&#xff0c;适合需要在浏览器或 Node.js 中实现人脸检测、特征点定位、表情识别、年龄性别估计等功能的 JavaScript 开发者。压缩包内共 63 个文件&#xff0c;总大小约 346.5MB&#xff0c;包含 face_landmark_68、face…

作者头像 李华
网站建设 2026/9/8 3:33:36

服务器从搭建到运维:高可用、迁移、散热与并发的实践指南

本周的“壹周新知04”话题很杂&#xff1a;服务器泡汤、游戏转世、热浪烧钱、奶茶店打咖啡战。单独看像是社会新闻&#xff0c;但从技术读者视角拆一下&#xff0c;四个话题都能落到同一个基础设施对象上——服务器。服务器宕机、游戏服务器迁移、机房散热成本、连锁门店订单系…

作者头像 李华
网站建设 2026/9/8 3:32:14

从LQR到NMPC:车辆极限工况横向控制的建模与Matlab仿真

做轨迹跟踪控制研究有一段时间了&#xff0c;说实话&#xff0c;最开始我用的一直是LQR和带线性预测模型的MPC。这两种方法在小曲率、中等车速工况下表现都不错&#xff0c;可一旦进入高速双移线或者紧急避障这类极限工况&#xff0c;控制效果就变得不太可靠。后来我把车辆动力…

作者头像 李华
网站建设 2026/9/8 3:27:51

用Cocos Creator 3.8开发3D合成大西瓜:物理碰撞与合并实战

如果你也刷到过那段时间满屏的“合成大西瓜”&#xff0c;大概会和我有同样的感受&#xff1a;玩法一眼到底&#xff0c;但真让自己动手写一个&#xff0c;反而不知道从哪里开始。尤其是把它改成 3D 版以后&#xff0c;事情变得更有意思——水果不再只是在 2D 平面里“啪嗒”一…

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

嵌入式内建函数实战:AC5/AC6差异与Keil MDK应用指南

内嵌式开发里折腾久了&#xff0c;你会发现一个很有意思的现象&#xff1a;同样是调用一个函数&#xff0c;有些函数编译后真的会生成一条跳转指令&#xff0c;老老实实跳过去执行&#xff1b;而另一些函数&#xff0c;编译器根本不生成调用&#xff0c;直接就在原地给你展开成…

作者头像 李华
网站建设 2026/9/8 3:26:40

AI生成3D模型技术解析:从文本描述到实战应用

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

作者头像 李华