news 2026/9/20 14:29:04

MATLAB多输入多输出DNN全连接神经网络预测系统及GUI实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MATLAB多输入多输出DNN全连接神经网络预测系统及GUI实战

简介:基于MATLAB的DNN全连接神经网络多输入多输出项目实例,适合具备一定编程基础、熟悉MATLAB和深度学习基础的技术爱好者及研发人员。项目系统讲述多维输入输出场景下的网络构建,涵盖环境准备、数据预处理、模型训练、防止过拟合、参数调整、模型评估与可视化等全流程,并利用App Designer搭建图形界面,便于完成数据加载、训练和结果查看。资源包为1个docx文档,大小65KB,文档虽小但信息密度高,包含完整实现代码、代码详解、算法流程图,以及系统架构设计、实时数据流处理、GPU/TPU加速推理和API服务集成等生产部署要点,还配有丰富图表帮助理解。已有74人次学习浏览,适用于智能交通、医学影像、金融数据预测等多输入多输出场景。读者可据此快速搭建一套可用的DNN多输入多输出原型,并借鉴其中的调参与部署经验,延伸到自身研究或工程项目中。 最近接了个小项目,要求用MATLAB做一个多输入多输出的DNN全连接神经网络预测系统。需求本身不复杂:输入若干个工业过程参数,模型要同时输出两个甚至更多的目标值,而且还得交付一个能点点按钮就完成“加载数据—训练—预测”的图形界面。我一开始想偷懒直接跑Python脚本,结果对方明确要求整套东西都要在MATLAB环境里闭环,包括GUI也得用MATLAB画。没办法,硬着头皮把DNN、数据预处理和App Designer串了一条龙,做完之后发现这套组合拳其实挺顺手,也踩了不少文档里不会写的坑。这里就把完整的项目实例、代码和GUI设计思路都梳理出来。

这个话题适合谁看?如果你正在做多目标回归预测相关的课程设计、毕业设计,或者工作中需要用MATLAB给业务方交付一个能演示的预测工具,这篇文章应该能帮你省不少事。我会把网络结构怎么定、训练参数怎么调、GUI怎么组织回调函数、以及最容易翻车的维度匹配和归一化问题都讲清楚。

1. 项目需求与整体方案选型

接到一个项目,第一反应不是急着写代码,而是把问题描述翻译成网络能理解的数学形式。这一步想清楚了,后面整个工程都不会歪。

1.1 多输入多输出问题的本质与建模思路

所谓的多输入多输出(MIMO)回归,说白了就是输入是一个样本的多个特征值,输出是对应的多个目标值。举个例子:我手里的数据是设备运行状态记录,每一行代表一个采样时刻,包含4个特征(转速、扭矩、冷却液温度、喷油量),我们需要预测同一时刻的2个输出目标(燃油消耗率、排气温升)。这就是典型的4输入2输出回归问题。

神经网络在这里做的事情,就是学习一个从4维向量到2维向量的映射函数。DNN全连接网络处理这种任务非常自然:输入层的神经元个数等于特征数(4),输出层的神经元个数等于目标数(2),中间再堆几层隐藏层让网络有足够的表达能力去拟合非线性关系。

这里要说清楚一点:MATLAB的trainNetworklayerGraph在默认配置下,一个网络只能有一个输入层和一个输出层。所谓多输出,不是网络结构上多出来一个分支,而是输出层的节点数大于1。如果你将来遇到那种真正意义上有多个独立输入分支的复杂模型,那得用dlnetwork配合forward手动定义前向传播,复杂度会高很多。对于大部分工程预测需求,单输入层多节点、单输出层多节点这种经典结构已经够用。

1.2 为什么选MATLAB而不直接用Python

这个问题在项目评审时被问过很多次。我的真实看法是:如果纯做算法原型,Python的PyTorch和TensorFlow确实更灵活,社区资源也更多。但MATLAB有它不可替代的场景。

我整理过一个简单对比,贴在项目文档里了:

对比维度MATLAB方案Python方案
数据读取与预处理内置函数多,表格和数组转换方便需要pandas、numpy配合
深度学习工具箱接口统一,层定义直观框架多,依赖管理繁琐
GUI开发App Designer可视化布局非常快tkinter难用,PyQt学习成本高
系统联调能与Simulink、控制系统工具箱无缝配合需要额外写通信接口
部署交付可打包独立App,或生成C/C++代码打包体积大,环境问题多

另外,MATLAB深度学习工具箱里的trainNetwork提供了非常简洁的高层接口,网络结构和训练选项一目了然。做工程交付的时候,这种“能看见全貌”的感觉很重要,毕竟后面维护代码的人很可能不是算法工程师。

1.3 数据准备与归一化的必要性

神经网络训练对数据尺度极其敏感。假设输入特征是转速(数值范围几百到几千)和喷油量(数值范围几到几十),如果直接扔进网络,梯度下降会偏向尺度大的特征,训练过程容易震荡,收敛也慢。

我用的方法是经典的z-score标准化:对每个特征列求均值和标准差,然后变换为均值为0、方差为1的分布。输出目标也需要归一化,这也是很多人容易忽略的细节。输出不归一化会导致损失值掉不下去,因为尺度太大时MSE的梯度数值不稳定。

需要注意一个原则:归一化参数(均值和标准差)必须在训练集上计算,然后同样应用到验证集和测试集上。绝对不能把全部数据一起归一化再做切分,否则会引入未来信息,造成检验结果虚高。这个坑我在第一版代码里就踩过,后面做结果分析时发现验证误差小到离谱,排查半天才找到原因。

2. 核心网络结构与训练实现

定好方案后,下一步就是把网络结构和训练逻辑落地。这一节我直接把能跑通的代码结构拆开来讲,包括如何定义层、怎么选参数、怎么观察训练状态。

2.1 DNN逻辑架构与层参数选择

对于4输入2输出的回归任务,我设计的网络结构是:输入层接收4个特征,经过两个隐藏层(每层16个神经元),最后输出2个目标值。

直接上代码,这是核心部分:

% 定义网络层 layers = [ featureInputLayer(4, 'Name', 'input', 'Normalization', 'none') fullyConnectedLayer(16, 'Name', 'fc1') reluLayer('Name', 'relu1') fullyConnectedLayer(16, 'Name', 'fc2') reluLayer('Name', 'relu2') fullyConnectedLayer(2, 'Name', 'output') regressionLayer('Name', 'regression') ]; % 转换为图结构并查看 lgraph = layerGraph(layers); analyzeNetwork(lgraph);

隐藏层的神经元数量为什么选16?这个没有绝对标准。我做过一组小实验,分别对比了8、16、32个神经元的效果,结论是对于这个数据量(800个样本),16个神经元已经能把训练误差压到比较低的水平,再增加神经元数量训练时间变长,但验证集误差提升不明显。如果你处理的是更高维的输入,可以按“输入维度×2到×4”的经验范围去尝试。

激活函数选ReLU而不是sigmoid或tanh,原因在于ReLU能有效缓解梯度消失问题,计算速度也快。输出层不接激活函数,保持线性输出,因为回归任务需要网络能输出任意范围内的实数。

2.2 超参数与训练选项解析

网络定义好了,训练选项同样关键。我用的是传统的trainNetwork,因为封装度高,不需要手动写训练循环,适合快速验证和交付。

options = trainingOptions('adam', ... 'MaxEpochs', 500, ... 'MiniBatchSize', 32, ... 'InitialLearnRate', 0.01, ... 'L2Regularization', 0.001, ... 'ValidationData', {XValNorm, YValNorm}, ... 'ValidationFrequency', 20, ... 'Plots', 'training-progress', ... 'Verbose', true);

逐项解释一下关键参数:

adam优化器是当前实践里的默认选择,它结合了动量和自适应学习率的优点,对学习率的敏感性比SGD低不少。MiniBatchSize设为32,这是一个平衡点:太小(比如8)会导致梯度估计噪声大,太大(比如256)会占内存且收敛慢。数据量只有几百到几千时,32是稳妥的选择。

InitialLearnRate设为0.01,这个值是在我反复试验后确认的。学习率太大(0.1以上)损失曲线会像过山车一样乱跳,太小(0.0001以下)收敛慢到让人怀疑程序Bug了。如果你发现训练不收敛,优先调低学习率,而不是去调网络结构。

L2Regularization是权重衰减项,设为0.001可以抑制过拟合。工程实践中,这种小数据量任务非常容易过拟合,加了正则项之后,验证误差和训练误差的差距明显缩小。

训练过程中我习惯开着'Plots', 'training-progress',这是MATLAB自带的可视化窗口,能实时看到损失值和验证指标的变化。第一版代码训练到300轮左右时,训练损失已经降到了很低,但验证损失开始反弹,这就是过拟合信号。随后我加了L2正则并把隐藏层神经元从32降到16,问题得到缓解。

2.3 训练观测与模型评估

训练完成后,我习惯用两个图来评估模型:误差散点图和误差分布直方图。误差散点图以真实值为横轴、预测值为纵轴,理想情况下所有点都在45度对角线上。误差分布直方图则能直观看到预测误差集中在哪个范围。

我这里的输出有两个维度,所以评估时分别看每个维度的表现。更严格的做法是计算均方根误差(RMSE)和决定系数(R²),这两个指标能定量描述模型好坏。实测下来,两个输出维度的R²都能达到0.95以上,对于工程预测场景已经足够用了。

3. GUI设计与交互流程实现

模型训练好只是第一步,交付给用户使用的永远是一个可视化工具。MATLAB的App Designer是我推荐的方式,比老旧的GUIDE好太多,控件布局是拖拽式的,回调函数结构清晰,还支持代码视图和设计视图实时切换。这个项目里我用App Designer搭了一个非常实用的界面。

3.1 界面布局与控件功能规划

整个界面我划分为三个功能区:左侧是数据操作区,中间是训练状态区,右侧是预测结果区。

数据操作区放了两个按钮:“加载数据”和“开始训练”。“加载数据”会弹出文件选择对话框,支持读取Excel或CSV格式的数据文件。“开始训练”则锁定输入维度并执行训练过程。

训练状态区放了一个坐标区,用于显示训练过程中的损失曲线。这是让用户理解模型收敛情况的直观手段,放在界面上比放在命令行窗口里友好得多。

预测结果区有一组用于输入特征的编辑框,一个“预测”按钮,以及一个用于显示输出结果的标签控件。用户输入新的特征数据后点击预测,就能看到模型给出的多目标预测结果。

我还在最底端加了一个文本框,用来输出日志信息,比如“数据加载成功,共500条记录”“训练完成,验证RMSE=0.12”等。这个设计看起来不起眼,但在现场演示时非常加分,因为用户能清楚地看到程序每一步在做什么。

3.2 回调函数与数据流设计

App Designer的核心是回调函数。每个按钮都有对应的回调函数,用户在界面上操作后,系统自动触发这些函数。

% “加载数据”按钮回调 function LoadDataButtonPushed(app, event) [file, path] = uigetfile({'*.xlsx;*.csv', '数据文件'}); if isequal(file, 0) return; end fullPath = fullfile(path, file); dataTable = readtable(fullPath); app.Data = table2array(dataTable); app.LogTextArea.Value = sprintf('数据加载完成,共%d条记录', height(dataTable)); end % “开始训练”按钮回调 function TrainButtonPushed(app, event) % 从表格数据中拆分特征和输出 X = app.Data(:, 1:end-2); Y = app.Data(:, end-1:end); % 调用训练函数,传入坐标区句柄用于画图 [net, info] = trainDNNModel(X, Y, app.LossAxes); app.Net = net; app.LogTextArea.Value = sprintf('训练完成,迭代次数%d', length(info.TrainingLoss)); end % “预测”按钮回调 function PredictButtonPushed(app, event) % 读取界面上输入的特征值 x1 = str2double(app.MeanEditField.Value); x2 = str2double(app.TorqueEditField.Value); x3 = str2double(app.TempEditField.Value); x4 = str2double(app.FuelEditField.Value); sample = [x1, x2, x3, x4]; if isempty(app.Net) app.LogTextArea.Value = '请先完成模型训练'; return; end % 预测并反归一化 yPredRaw = predict(app.Net, sample); yPred = yPredRaw .* app.OutStd + app.OutMean; app.FuelResultLabel.Text = sprintf('%.2f', yPred(1)); app.TempResultLabel.Text = sprintf('%.2f', yPred(2)); end

这里有一个设计经验:不要把训练代码直接塞进按钮回调里,那样会让单文件代码变得冗长难维护。我通常把训练逻辑封装成一个独立的函数文件trainDNNModel.m,回调里只需要一行代码调用。这样UI代码和算法代码分离,调试起来飞快。

回调里访问和修改控件值,统一用app.控件名.属性的语法结构。比如app.LogTextArea.Value = 'xxx'就是修改日志文本框的内容,app.Net则是自定义属性,用于在多个回调之间共享模型数据。

3.3 防止界面卡死与进度反馈

这是一个非常实际的问题。我在第一版GUI里测试时发现,点击“开始训练”之后整个界面立即陷入假死状态,鼠标转圈,什么也点不了。原因是训练过程占据了主线程,UI事件队列得不到处理。

解决办法有两种。第一种最简单:训练规模不大(几百轮、小网络)就忍一下,训练结束时界面自然会恢复。但如果是大网络、大训练集,这种办法用户根本受不了。第二种方法是使用parfeval进行并行计算,把训练任务丢到后台线程执行,同时保留主线程处理UI事件。

function TrainButtonPushed(app, event) X = app.Data(:, 1:end-2); Y = app.Data(:, end-1:end); % 后台执行训练 f = parfeval(@trainDNNModel, 2, X, Y); app.TrainingFuture = f; % 添加完成回调 afterEach(f, @(net, info) trainingFinished(app, net, info)); end function trainingFinished(app, net, info) app.Net = net; app.TrainingFuture = []; app.LogTextArea.Value = sprintf('训练完成,迭代次数%d', length(info.TrainingLoss)); end

parfeval需要注意一个问题:后台线程里的训练过程不能直接更新UI控件,需要借助afterEach回到主线程再做界面更新。这种方式虽然多写了点代码,但体验提升非常明显:训练过程中用户仍然可以操作界面查看数据,不会觉得程序崩溃了。

4. 完整代码与关键函数详解

这一节我把整个项目的核心代码都过一遍。我会按照数据准备、模型训练、结果预测三个模块来讲,每段代码都有注释和解释,你只要把数据文件格式对齐,就能直接套用。

4.1 数据准备与归一化模块

为了让代码能够直接复现,我这里用模拟数据来演示。实际项目里你只需要把生成数据部分替换成你自己的数据读取逻辑即可。

function [XTrain, YTrain, XVal, YVal, normParams] = prepareData() % 生成模拟数据:4个输入特征,2个输出目标 rng(42); n = 1000; X1 = rand(n, 1) * 3000 + 800; % 转速 X2 = rand(n, 1) * 400 + 50; % 扭矩 X3 = rand(n, 1) * 30 + 70; % 冷却液温度 X4 = rand(n, 1) * 60 + 10; % 喷油量 % 构造非线性关系,加入噪声模拟真实场景 Y1 = 0.02 * X1 + 0.015 * X2 - 0.1 * X3 + 0.3 * X4 + randn(n, 1) * 5; Y2 = 0.01 * X1 + 0.008 * X2 + 0.2 * X3 - 0.15 * X4 + randn(n, 1) * 3; X = [X1, X2, X3, X4]; Y = [Y1, Y2]; % 按8:2切分训练集和验证集 idx = randperm(n); nTrain = round(0.8 * n); trainIdx = idx(1:nTrain); valIdx = idx(nTrain+1:end); XTrainRaw = X(trainIdx, :); YTrainRaw = Y(trainIdx, :); XValRaw = X(valIdx, :); YValRaw = Y(valIdx, :); % 仅在训练集上计算归一化参数 XMean = mean(XTrainRaw); XStd = std(XTrainRaw); YMean = mean(YTrainRaw); YStd = std(YTrainRaw); % 应用归一化 XTrain = (XTrainRaw - XMean) ./ XStd; YTrain = (YTrainRaw - YMean) ./ YStd; XVal = (XValRaw - XMean) ./ XStd; YVal = (YValRaw - YMean) ./ YStd; normParams = struct('XMean', XMean, 'XStd', XStd, ... 'YMean', YMean, 'YStd', YStd); end

有两个细节值得单独说。第一,randperm随机索引切分数据,保证训练集和验证集互不重叠,而且这种切分方式能避免数据按时间排序时带来的分布偏移问题。第二,计算归一化参数时只用XTrainRawYTrainRaw,这个我在1.3节强调了,是最容易犯的错误之一。

4.2 网络定义与训练封装

function [net, info] = trainDNNModel(XTrain, YTrain, XVal, YVal, lossAxes) % 网络结构:输入维度由XTrain的列数动态决定 inputDim = size(XTrain, 2); outputDim = size(YTrain, 2); layers = [ featureInputLayer(inputDim, 'Name', 'input') fullyConnectedLayer(32, 'Name', 'fc1') reluLayer('Name', 'relu1') fullyConnectedLayer(32, 'Name', 'fc2') reluLayer('Name', 'relu2') fullyConnectedLayer(16, 'Name', 'fc3') reluLayer('Name', 'relu3') fullyConnectedLayer(outputDim, 'Name', 'output') regressionLayer('Name', 'regression') ]; options = trainingOptions('adam', ... 'MaxEpochs', 300, ... 'MiniBatchSize', 32, ... 'InitialLearnRate', 0.005, ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', 30, ... 'Verbose', false, ... 'Plots', 'training-progress'); [net, info] = trainNetwork(XTrain, YTrain, layers, options); end

这个版本的网络比之前多了几层,从4到32到16最后到2,形成一个逐渐压缩再输出的结构。为什么要这样设计?输入维度小的时候,第一层可以直接扩展到较大维度从而增加特征组合的可能性;输出层之前逐渐压缩回目标维度,让网络在较深层做非线性变换时不会信息过载。

trainNetwork从R2018b开始就支持多输出回归了,本质上就是输出层节点数大于1。新版MATLAB(R2023b以后)更推荐用trainnet配合dlnetwork,训练代码会更灵活,但封装性差一些。如果你是做工程交付,我个人建议还是先用trainNetwork,稳定而且好读。

4.3 模型预测与反归一化

训练完成后,预测阶段需要对输入做同样的归一化,然后对网络输出做反归一化才能得到真实量纲的预测值。

function yPred = predictWithNet(net, sample, normParams) % 输入sample是1x4行向量,原始量纲 xNorm = (sample - normParams.XMean) ./ normParams.XStd; yNorm = predict(net, xNorm); yPred = yNorm .* normParams.YStd + normParams.YMean; end

这段代码在GUI预测按钮里被反复调用。需要特别提醒的是,predict函数的输入默认是列向量优先的表格或数值数组。如果你传入一个行向量,有时候会触发维度警告,最稳妥的方式是在调用前确保sample的维度是1×inputDim,并且net是在相同特征顺序下训练的。如果在实际使用中发现预测结果完全不对但训练误差很低,先检查是不是这个维度匹配和归一化顺序出了问题。

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

这个项目开发过程中,我遇到了一些很典型的问题。下面按坑的深度排序,把现象、原因和解决办法一次说清楚。

5.1 多输出维度不匹配

现象:调用trainNetwork时报错,提示输出层和训练数据维度不一致。

原因:这是一个很低级的错误。regressionLayer会自动匹配输出层的神经元个数,但输出的Y必须是一个矩阵,且列数等于输出层神经元个数。如果你把Y定义成了列向量,但输出层写的是fullyConnectedLayer(2),系统尝试用只有一列的数据去匹配两个输出节点,自然报错。

解决:在训练前加一行断言检查:assert(size(Y, 2) == outputDim, '输出维度不匹配');。这个习惯能让你在项目变大时省下很多时间。

5.2 训练Loss不下降或震荡严重

现象:训练曲线像锯齿一样上下乱跳,或者从头到尾几乎没变化。

原因:这个原因按概率从高到低排列是:学习率太大、输入数据没归一化、网络结构太深导致梯度消失。

解决:我的排查路径是:先检查数据归一化是否做了,再看学习率,把InitialLearnRate从0.01降到0.001试试;如果还是不降,把隐藏层去掉一层看看是否存在结构性问题;最后实在不行再考虑换优化器或调批量大小。

5.3 GUI中训练时界面卡死

现象:点击训练后界面完全无响应,Windows甚至提示程序未响应。

原因:训练占用了UI线程。CPU密集型的训练任务和UI事件处理不在一个线程通道里时,界面就无法刷新。

解决:参考3.3节的parfeval方案。值得注意的是,使用并行计算时训练进度图可能不像平时那样自动弹出,需要你自己在afterEach回调里重新取数据并画图。

5.4 预测值完全不对但训练误差很小

现象:训练指标很漂亮,但一到GUI里输入新的样本,输出离真实值差到离谱。

原因:大概率是归一化反算时参数顺序搞混了,或者界面输入的特征顺序和训练数据特征顺序不一致。还有一个隐蔽原因:用户在GUI中填的编辑框数值没有经过str2double转换,字符串和数值做运算时MATLAB自动转了类型,结果错得莫名其妙。

解决:把预测调用封装成一个函数,确保只有一条数据通路。另外每次预测前打印一下输入向量,确认和自己预期一致。

5.5 训练时间长,数据读入慢

现象:数据文件很大(几万行以上),每次加载数据都要卡好几秒。

解决:尽量用readmatrix读纯数值数据,比readtable快得多。如果是持续增长的数据,也可以考虑datastore做分块读取,但在这个项目规模下配合parfeval异步加载已经完全够用了。

6. 项目心得与后续扩展建议

做了几个类似的MATLAB深度学习项目之后,我最大的体会是:算法模型的搭建反而是整个工程里最顺利的一环,真正花时间的地方都在维度管理、数据预处理和GUI交互设计上。DNN全连接网络本身并不深奥,但放到一个需要交付使用的系统里,需要考虑的问题就不只是网络结构了。

一个值得认真做的优化方向是把训练好的模型集成到Simulink仿真环境中。MATLAB在这方面有天然优势,通过load加载网络然后连接信号线就能完成离线预测。另一个方向是用deploytool把GUI打包成独立应用程序,这样对方电脑上不需要装MATLAB也能运行。我在最后交付时用了这个方案,用户反馈体验非常好。

最后再贡献一个小技巧:训练网络时加一行rng(0)固定随机种子,这在调参阶段特别重要。不固定随机种子的话,每次训练结果都有细微差异,你就很难判断是模型结构改进了还是运气变好了。锁定种子之后,每次改动单一变量就能得到确定的对比结论。

这个项目整体难度适中,适合作为深度学习入门的综合练习,也适合直接改造成自己的预测小工具。如果你在复现过程中碰到我上面没提到的问题,可以先从数据维度和归一化这两块入手排查,这两个地方占据了调试工作的大半。

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

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

VB6老系统接入OPC UA:基于COM互操作的稳定方案

简介:OPC UA客户端VB示例及配套工具包,面向工业自动化领域需要了解OPC UA通信机制的开发者与VB程序员,可用于快速搭建客户端原型、学习设备数据交换流程。压缩包共181个文件,包含VB源码工程(6个vb文件)、可…

作者头像 李华
网站建设 2026/9/20 14:27:13

ARINC 702A-6深度解析:飞行管理计算机系统的演进与工程实践

简介:ARINC 702A-6(2026)是AEEC发布的最新飞行管理计算机系统特性规范,面向航空电子系统设计、适航验证与机载软件研发人员,用于统一FMS的功能架构、接口协议、导航数据库和数据链通信要求。资源包内含1份PDF文档&…

作者头像 李华
网站建设 2026/9/20 14:26:53

轻量级CMS选型与实战:用Colibri搭建小型内容站的完整指南

接到一个内容站需求的时候,我第一反应是上WordPress。三十几个页面,一个团队博客,几个产品栏目,不上电商不上论坛,WordPress装上主题和插件之后,光后台更新就能让一台256MB的小机器吭哧半天。后来我把方案换…

作者头像 李华
网站建设 2026/9/20 14:25:19

基于STM32与AD620的心电信号采集系统设计与实现

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

作者头像 李华