JavaFX音乐播放器实战:MediaPlayer状态机与进度条联动

简介:这是一份基于JavaFX实现的简易音乐播放器项目源码,面向正在学习Java桌面开发、希望了解多媒体播放功能实现的初学者与进阶者。项目以Maven构建,包含完整的工程配置与可运行代码,能帮助读者理解JavaFX界面布局、事件响应与音频播放的基本流程。压缩包共35个文件,约30KB,其中8个java源文件承载核心业务逻辑,13个class为编译产物,2个fxml定义界面结构,另有properties、classpath、project等配置与构建文件,整体结构清晰,便于导入IDE后直接阅读与调试。目前已有1371人学习下载,说明该示例在入门实践中具有一定参考价值。读者可从中获取JavaFX窗口与控件组织方式、播放控制逻辑的编写思路,以及Maven项目在Eclipse环境下的配置范例,适合作为课程设计或自学练手的基础素材。

1. 从一份“能跑起来”的 JavaFX 音乐播放器说起

很多同学做课程设计或者练手项目时,都会卡在同一个地方:界面能画出来,按钮能点,但一到“播放声音”就翻车。要么是音频文件路径写错,要么是播放器对象生命周期没管好,点了暂停再播放直接报错。我最近拆了一个用 JavaFX 写的简易音乐播放器,整个项目没有引入任何第三方音频库,纯靠 JDK 自带的javafx.scene.media包就把播放、暂停、切歌、进度条拖动这些功能全做完了。它适合谁?适合刚学完 Java 基础、想找一个“麻雀虽小五脏俱全”的桌面项目来练手的人,也适合需要快速交一个多媒体类课程作业的开发者。项目结构不复杂,但里面关于 MediaPlayer 状态机、进度条双向绑定、播放列表管理的写法,都是实打实能复用到其他 JavaFX 项目里的。

2. 项目骨架与运行环境:从零把工程跑起来

2.1 技术选型与依赖说明

这个播放器的技术栈非常干净:JavaFX 负责 UI,javafx.scene.media.Media和MediaPlayer负责音频解码与播放,MediaView在这个项目里没用到,因为只做音频不做视频。JDK 版本建议用 11 或 17,因为从 JDK 11 开始 JavaFX 被移出了标准 JDK,需要单独引入模块。常见做法是用 Maven 或 Gradle 把javafx-controls和javafx-media两个模块加进来。如果你用的是 JDK 8,那 JavaFX 还内置在 JDK 里,直接 import 就能用,但我不建议这么做,因为后续想升级 JDK 会有一堆兼容问题。

项目目录结构大致是这样的:

SimpleMusicPlayer/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/example/player/ │ │ ├── Main.java │ │ ├── PlayerController.java │ │ └── PlaylistManager.java │ └── resources/ │ ├── fxml/ │ │ └── player.fxml │ ├── css/ │ │ └── style.css │ └── music/ │ ├── track1.mp3 │ └── track2.mp3 └── pom.xml

Main.java是入口,负责加载 FXML 和启动 Stage;PlayerController.java是控制器,处理所有按钮事件和进度条逻辑;PlaylistManager.java管播放列表的增删和当前索引。资源目录里放 FXML 布局文件、CSS 样式和音频文件。这里有个细节:音频文件放在resources/music下,打包成 jar 之后路径会变,所以代码里取路径不能用new File("src/main/resources/music/track1.mp3"),得用getClass().getResource("/music/track1.mp3").toExternalForm()。

2.2 Maven 配置与模块化参数

如果你用 Maven,pom.xml里至少要加这些:

<dependencies> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>17.0.2</version> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-media</artifactId> <version>17.0.2</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.openjfx</groupId> <artifactId>javafx-maven-plugin</artifactId> <version>0.0.8</version> <configuration> <mainClass>com.example.player.Main</mainClass> </configuration> </plugin> </plugins> </build>

javafx-media这个依赖必须显式加,很多人只加了javafx-controls,结果运行时报ClassNotFoundException: javafx.scene.media.MediaPlayer,这就是典型的漏依赖。版本号要和 JDK 匹配,JDK 17 配 JavaFX 17 比较稳。运行命令用mvn javafx:run,不要用mvn exec:java,因为后者不会自动加--module-path和--add-modules参数,会直接报模块找不到。

2.3 启动类与 FXML 加载

Main.java的写法很标准:

public class Main extends Application { @Override public void start(Stage primaryStage) throws Exception { // 从 resources/fxml 下加载布局文件 FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/player.fxml")); Parent root = loader.load(); // 设置窗口标题和场景 Scene scene = new Scene(root, 400, 300); scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm()); primaryStage.setTitle("简易音乐播放器"); primaryStage.setScene(scene); primaryStage.show(); } public static void main(String[] args) { launch(args); } }

FXMLLoader的load()方法会实例化 FXML 里fx:controller指定的控制器类,所以player.fxml根节点上要写fx:controller="com.example.player.PlayerController"。getResource()的路径以/开头表示从 classpath 根目录找,不要写成相对路径。scene.getStylesheets().add()加载 CSS 时也要用toExternalForm(),否则样式不生效。这一步跑通之后,你应该能看到一个空白窗口,说明 JavaFX 环境没问题了。

3. 核心播放逻辑:MediaPlayer 状态机与进度条联动

3.1 初始化 MediaPlayer 与播放列表

播放器的核心是MediaPlayer对象,但它不能直接 new,得先有Media。Media代表一个音频资源,MediaPlayer代表播放这个资源的状态机。项目里PlaylistManager维护了一个List<File>或者List<String>存所有曲目的路径,当前播放索引currentIndex从 0 开始。初始化代码大概长这样:

public class PlayerController implements Initializable { @FXML private Slider progressSlider; @FXML private Label timeLabel; @FXML private Button playBtn, pauseBtn, nextBtn, prevBtn; private MediaPlayer mediaPlayer; private List<String> playlist = new ArrayList<>(); private int currentIndex = 0; @Override public void initialize(URL location, ResourceBundle resources) { // 加载播放列表,这里写死两首示例 playlist.add(getClass().getResource("/music/track1.mp3").toExternalForm()); playlist.add(getClass().getResource("/music/track2.mp3").toExternalForm()); // 初始化第一首 loadTrack(currentIndex); } private void loadTrack(int index) { if (mediaPlayer != null) { mediaPlayer.stop(); mediaPlayer.dispose(); // 释放旧资源,否则切歌多了会内存泄漏 } Media media = new Media(playlist.get(index)); mediaPlayer = new MediaPlayer(media); // 绑定进度条和播放进度 mediaPlayer.currentTimeProperty().addListener((obs, oldVal, newVal) -> { if (!progressSlider.isValueChanging()) { progressSlider.setValue(newVal.toSeconds()); } timeLabel.setText(formatTime(newVal) + " / " + formatTime(media.getDuration())); }); // 播放结束后自动下一首 mediaPlayer.setOnEndOfMedia(() -> { currentIndex = (currentIndex + 1) % playlist.size(); loadTrack(currentIndex); mediaPlayer.play(); }); // 设置进度条最大值 mediaPlayer.setOnReady(() -> { progressSlider.setMax(media.getDuration().toSeconds()); }); } }

这里有几个关键点。第一,mediaPlayer.dispose()必须调用,否则每次切歌都会创建一个新的 MediaPlayer,旧的还占着音频资源,切个十几首之后就会报MediaException: Could not create player。第二,currentTimeProperty()监听里要判断progressSlider.isValueChanging(),不然用户拖动进度条的时候,播放进度会跟用户的手抢控制权,进度条会跳来跳去。第三,setOnEndOfMedia里先loadTrack再play,顺序不能反,因为loadTrack会重建 MediaPlayer,先 play 的话播的是旧对象。

3.2 播放、暂停与进度条拖动

按钮事件处理逻辑:

@FXML private void handlePlay() { if (mediaPlayer != null) { mediaPlayer.play(); } } @FXML private void handlePause() { if (mediaPlayer != null) { mediaPlayer.pause(); } } @FXML private void handleNext() { currentIndex = (currentIndex + 1) % playlist.size(); loadTrack(currentIndex); mediaPlayer.play(); } @FXML private void handlePrev() { currentIndex = (currentIndex - 1 + playlist.size()) % playlist.size(); loadTrack(currentIndex); mediaPlayer.play(); }

进度条拖动需要监听valueChangingProperty和mouseReleased:

progressSlider.valueChangingProperty().addListener((obs, wasChanging, isChanging) -> { if (!isChanging && mediaPlayer != null) { // 用户松手了,跳转到对应时间 mediaPlayer.seek(Duration.seconds(progressSlider.getValue())); } }); progressSlider.setOnMouseReleased(event -> { if (mediaPlayer != null) { mediaPlayer.seek(Duration.seconds(progressSlider.getValue())); } });

seek()方法接收一个Duration,单位是秒。注意progressSlider.getValue()返回的是 double,直接Duration.seconds()就行。这里有个坑:如果只监听valueChangingProperty,有些情况下鼠标松开事件不触发,所以补一个setOnMouseReleased更保险。另外seek之后播放状态不会自动恢复,如果之前是暂停的,seek 完还是暂停,这是符合预期的。

3.3 时间格式化与 UI 刷新

formatTime方法把Duration转成mm:ss:

private String formatTime(Duration duration) { int totalSeconds = (int) duration.toSeconds(); int minutes = totalSeconds / 60; int seconds = totalSeconds % 60; return String.format("%02d:%02d", minutes, seconds); }

timeLabel的更新放在currentTimeProperty监听里,每帧都会触发,所以不要在里面做耗时操作。如果觉得刷新太频繁,可以加一个判断,只有秒数变化时才更新 Label。media.getDuration()在setOnReady之前可能返回UNKNOWN,所以进度条最大值要等OnReady回调里再设,否则会报IllegalArgumentException。

4. 避坑与常见问题排查

4.1 音频文件路径找不到

现象:运行时报MediaException: MEDIA_UNAVAILABLE或者NullPointerException,定位到new Media(url)那一行。原因通常是用了new File("src/main/resources/music/track1.mp3").toURI().toString(),这个路径在 IDE 里能跑,但打包成 jar 之后src目录不存在了。解决方法是统一用getClass().getResource("/music/track1.mp3").toExternalForm(),资源文件放在src/main/resources/music下,打包时会自动进 classpath。

4.2 切歌多次后播放器无声

现象:连续点下一首十几次之后,播放器突然不出声了,控制台可能报MediaException: Could not create player。原因是旧的MediaPlayer没有释放,音频设备被占满。解决方法是每次loadTrack之前先调mediaPlayer.stop()再调mediaPlayer.dispose(),并且把mediaPlayer置为 null 再重新赋值。这个坑我踩过,当时以为是音频文件损坏,查了半天才发现是资源泄漏。

4.3 进度条拖动后播放位置不对

现象:拖动进度条到中间,松手后音乐跳到了开头或者直接停了。原因是只监听了valueChangingProperty,但 JavaFX 的 Slider 在某些鼠标操作下isChanging不会变回 false。解决方法是同时监听setOnMouseReleased,在松手时再 seek 一次。另外progressSlider.setMax()要在OnReady里设,设早了media.getDuration()还是UNKNOWN,最大值是 0,拖都拖不动。

4.4 打包成 jar 后 JavaFX 模块缺失

现象:java -jar player.jar报Error: JavaFX runtime components are missing。原因是 JavaFX 从 JDK 11 开始不再内置,普通 jar 不会自动带模块路径。解决方法是改用jlink或者jpackage打包,或者在pom.xml里配置maven-shade-plugin把 JavaFX 依赖打进去。最省事的做法是用javafx-maven-plugin的jlink目标,生成一个自带运行时的镜像。

4.5 播放列表为空时点下一首崩溃

现象:如果playlist是空的,currentIndex % playlist.size()会抛ArithmeticException: / by zero。原因是没有做空列表判断。解决方法是在handleNext和handlePrev开头加if (playlist.isEmpty()) return;,或者在初始化时至少保证有一首默认曲目。这个属于防御性编程,新手容易忽略。

5. 二次开发与功能扩展:从简易播放器到可用工具

5.1 增加音量控制和静音切换

原项目没有音量条,加一个很简单。在 FXML 里加一个Slider,范围 0 到 1,然后在控制器里绑定:

@FXML private Slider volumeSlider; // initialize 里加 volumeSlider.setValue(0.5); mediaPlayer.volumeProperty().bind(volumeSlider.valueProperty());

volumeProperty()是DoubleProperty,直接和 Slider 的valueProperty()绑定就行。静音按钮用mediaPlayer.setMute(true/false),记得用一个 boolean 变量记录当前状态,按钮文字在“静音”和“取消静音”之间切换。

5.2 支持拖拽文件到窗口播放

JavaFX 的拖拽事件挂在Scene上:

scene.setOnDragOver(event -> { if (event.getDragboard().hasFiles()) { event.acceptTransferModes(TransferMode.COPY); } event.consume(); }); scene.setOnDragDropped(event -> { Dragboard db = event.getDragboard(); if (db.hasFiles()) { for (File file : db.getFiles()) { if (file.getName().endsWith(".mp3") || file.getName().endsWith(".wav")) { playlist.add(file.toURI().toString()); } } event.setDropCompleted(true); } event.consume(); });

注意拖进来的文件用file.toURI().toString()转成 URL 字符串,不要用getResource,因为外部文件不在 classpath 里。加完之后播放列表就支持动态扩充了,比写死两首实用得多。

5.3 用 TableView 展示播放列表

如果曲目多了,需要一个可视化列表。用TableView<MusicTrack>,MusicTrack是一个简单的 POJO,包含title、artist、path三个属性。双击某一行时切换播放:

tableView.setRowFactory(tv -> { TableRow<MusicTrack> row = new TableRow<>(); row.setOnMouseClicked(event -> { if (event.getClickCount() == 2 && !row.isEmpty()) { currentIndex = row.getIndex(); loadTrack(currentIndex); mediaPlayer.play(); } }); return row; });

这里currentIndex要跟 TableView 的索引对齐,所以playlist的顺序和tableView的数据源顺序必须一致。常见做法是让PlaylistManager同时持有ObservableList<MusicTrack>,TableView 直接绑定这个列表,增删改查都走同一个数据源,避免两边不同步。

5.4 打包与分发注意事项

最后说一个我自己的习惯:每次改完播放逻辑,我都会强制走一遍“切歌十次 + 拖动进度条五次 + 暂停播放三次”的流程,确认没有内存泄漏和状态错乱。打包用jpackage生成 exe 或 dmg 时,记得把music目录一起打进去,或者改成从用户目录读取音频文件。如果音频文件是外部依赖,最好在启动时检查目录是否存在,不存在就弹一个提示框,而不是直接抛异常。希望这些改造思路能帮到你,拿到源码之后先跑通,再按自己的需求往上加功能,比从头写一个要快得多。

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

相关新闻

Harbor镜像推送避坑指南:认证、tag命名与TLS信任全解析

Harbor镜像推送避坑指南:认证、tag命名与TLS信任全解析

年初帮团队搭建镜像交付链路时&#xff0c;发现不少DevOps同事在push镜像到Harbor私有仓库时都会来问同一类问题&#xff1a;本地构建明明一切正常&#xff0c;为什么push过去提示未认证&#xff1b;tag按Docker Hub的习惯随手一写&#xff0c;结果仓库里根本找不到&#xff1b…

2026/10/21 5:20:07 阅读更多 →
knowledge-work-plugins:插件化知识工作流搭建与核心插件拆解

knowledge-work-plugins:插件化知识工作流搭建与核心插件拆解

1. 这个插件仓库到底解决了什么问题第一次看到knowledge-work-plugins这个标题&#xff0c;我脑子里蹦出来的第一个念头是&#xff1a;又是一个“大而全”的知识管理工具集合&#xff1f;但翻了一圈社区讨论和实际拆包之后&#xff0c;我发现它的定位比想象中要克制得多——它本…

2026/10/21 5:20:07 阅读更多 →
Docker Push 镜像到 Harbor 失败排查:从认证到配置的完整指南

Docker Push 镜像到 Harbor 失败排查:从认证到配置的完整指南

前一阵子帮同事排查一个推送镜像失败的场景&#xff0c;docker login都成功了&#xff0c;Harbor页面也登录得上&#xff0c;偏偏docker push就报unauthorized。折腾一圈&#xff0c;根因是那台机器上docker客户端缓存了旧的凭证&#xff0c;重新执行login时又因为终端开了代理…

2026/10/21 5:20:07 阅读更多 →

最新新闻

磁盘I/O监控实战:从iostat到故障排查的完整方法

磁盘I/O监控实战:从iostat到故障排查的完整方法

在公司做过几年性能排查&#xff0c;我养成一个习惯&#xff1a;新环境上线前&#xff0c;第一件事不是看CPU核数够不够&#xff0c;而是先摸一遍磁盘I/O的底细。很多故障表面上五花八门——接口超时、数据库连接打满、应用无响应——查到最后&#xff0c;有相当一部分根因都落…

2026/10/21 5:57:22 阅读更多 →
13种数字PID算法MATLAB/Simulink实现与工程落地指南

13种数字PID算法MATLAB/Simulink实现与工程落地指南

简介&#xff1a;本资源是一份面向自动化、控制工程及机器人方向初学者与进阶学习者的专业教学课件&#xff0c;系统讲解先进PID控制原理及其在MATLAB/Simulink环境下的建模仿真方法。内容覆盖模拟PID控制基础&#xff08;P/I/D各环节作用机制&#xff09;、连续系统PID仿真&am…

2026/10/21 5:57:22 阅读更多 →
知识工作插件化:从采集到检索的自动化知识管理实践

知识工作插件化:从采集到检索的自动化知识管理实践

1. 项目概述与整体规划1.1 为什么会有这个项目我做了七八年知识型工作&#xff0c;从最早写技术文档、做项目方案&#xff0c;到后来带团队做知识库建设&#xff0c;最大的感受是&#xff1a;大部分人的时间都浪费在“找东西”和“搬东西”上&#xff0c;而不是真正思考。明明写…

2026/10/21 5:57:22 阅读更多 →
Code::Blocks编译慢怎么破?从配置到代码的完整提速指南

Code::Blocks编译慢怎么破?从配置到代码的完整提速指南

说实话&#xff0c;用 Code::Blocks 写 C/C 的人&#xff0c;基本都会被“编译时间很长”这个问题卡过。尤其是项目文件一多&#xff0c;点一下编译&#xff0c;去接杯水回来发现还在跑&#xff0c;确实挺糟心。有人觉得是电脑配置太差&#xff0c;有人怀疑装了个假的 Code::Bl…

2026/10/21 5:57:22 阅读更多 →
GCC编译四阶段与GDB调试实战:从core dump定位程序崩溃

GCC编译四阶段与GDB调试实战:从core dump定位程序崩溃

1. 从"能跑"到"会查错"&#xff1a;为什么要系统过一遍gcc与gdb这两年我带过不少刚入门的朋友&#xff0c;发现一个很有意思的现象&#xff1a;很多人写代码用的是点一下就出结果的集成开发工具&#xff0c;编译、运行、调试全在一个按钮里完成了。这对于日…

2026/10/21 5:57:22 阅读更多 →
开源鸿蒙生态底座搭建指南:从内核适配到工程体系

开源鸿蒙生态底座搭建指南:从内核适配到工程体系

开源鸿蒙的生态底座&#xff0c;并不是某个团队一蹴而就的事情。我最早接触这一层技术栈&#xff0c;是在帮某厂商做系统适配评估的时候。当时对方拿着一份设备清单&#xff0c;问我能不能把他们的硬件平台快速接入OpenHarmony社区主线&#xff0c;我第一反应不是调代码&#x…

2026/10/21 5:56:21 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事&#xff1a;AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里&#xff0c;按宣传口径&#xff0c;本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”&#xff0c;而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起&#xff1a;用户到底在追求什么“封号重开”这四个字&#xff0c;放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流&#xff0c;背后是大量的时间和精力成本。正常情况下&#xff0c;没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”&#xff0c;而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时&#xff0c;就意识到&#xff1a;单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/21 4:27:17 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + 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/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/20 9:50:45 阅读更多 →