news 2026/10/12 3:00:03

FEniCS建模逻辑断层破解:从Poisson到非线性PDE的三重身份解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FEniCS建模逻辑断层破解:从Poisson到非线性PDE的三重身份解析

简介:本资源是一套面向计算数学、科学工程仿真初学者与进阶用户的FEniCS实战教程包,聚焦偏微分方程(PDEs)的有限元数值求解,覆盖固体力学、流体力学、热传导、电磁场及反应扩散等多物理场建模场景。压缩包共14个文件,含12个可直接运行的Python示例脚本(如圆柱绕流Navier-Stokes模拟、弹性力学变形分析、磁静力学场计算)、1本70页的《FEniCS Tutorial Vol.1》PDF系统教程,以及1份含安装指引与运行说明的README.md文档,整体体积仅7.7MB,轻量易上手。已有3643人学习下载,反映出其在高校科研与工程仿真入门群体中的高实用价值。读者可从中获得从基础语法、变分形式定义、边界条件设置到求解器调用与结果后处理的完整链路实践,尤其适合通过12个由浅入深的真实案例(Poisson方程、非线性热传导、化学反应系统等)建立对FEniCS自动化有限元框架的系统性认知与工程化应用能力。

1. 为什么用 Python 写有限元,却总在 FEniCS 里卡住三天?——这不是语法问题,是建模逻辑断层

你手头有一组偏微分方程:热传导、线弹性小变形、不可压 Stokes 流,甚至带非线性项的 Navier-Stokes。你查过 NumPy/SciPy 的 sparse 求解器,也试过手写 Galerkin 加权余量,但一到网格剖分、边界条件强加、变分形式弱化,就陷入“知道该做什么,但不知道 FEniCS 里哪一行该写什么”的僵局。这不是 Python 不熟,而是没把「数学建模→变分表述→离散实现」这条链路在 FEniCS 的 DSL(领域特定语言)里对齐。FEniCS 不是 Python 的封装库,它是一套以 UFL(Unified Form Language)为内核的符号化有限元编译系统:你写的u * v * dx不是乘法,是双线性形式;ds不是 ds,是测度符号;DirichletBC(V, Constant(0), "on_boundary")的字符串"on_boundary"实际触发的是底层 MeshFunction 的拓扑标记匹配。本篇不讲“如何安装”,不列“十个例子”,而是带你从一个真实可运行的 Poisson 方程最小闭环出发,拆解 FEniCS 的三重身份:UFL 符号引擎、DOLFIN 网格与求解器胶水、XDMF/VTU 数据流管道。适合已会 Python 基础、学过《变分原理与有限元》但第一次面对.pvd文件发懵的工程计算从业者。


2. 从零跑通 Poisson 方程:用 FEniCS 写出第一个可验证的 .xdmf 输出

FEniCS 的最小可运行单元不是import fenics,而是UFL 表达式 + DOLFIN 网格 + 边界条件 + 求解器调用 + XDMF 导出这五要素闭环。下面这个 32 行脚本,是你后续所有复杂模型的原子模板。别跳过任何注释——每一行都在定义 FEniCS 的隐含契约。

2.1 创建单位正方形网格并定义函数空间

from fenics import * import numpy as np # 1. 生成结构化三角形网格:16×16 单元,足够解析边界层但不拖慢调试 mesh = UnitSquareMesh(16, 16) # 2. 定义标量 Lagrange P1 元空间 —— 注意:V 是函数空间对象,不是数组! V = FunctionSpace(mesh, 'P', 1) # 3. 定义试探函数 u 和检验函数 v:它们是 UFL 符号变量,不占内存 u = TrialFunction(V) v = TestFunction(V) # 4. 定义源项 f 和精确解 u_e(用于后验误差验证) f = Expression('-8.0*pi*pi*sin(2*pi*x[0])*sin(2*pi*x[1])', degree=4) u_e = Expression('sin(2*pi*x[0])*sin(2*pi*x[1])', degree=4)

逻辑说明:UnitSquareMesh(16,16)生成的是顶点坐标+三角形单元索引的纯拓扑对象;FunctionSpace不仅分配自由度数,还隐式构建了基函数支撑集映射;TrialFunction/TestFunction是 UFL 的核心抽象——它们不存储数值,只参与符号运算,直到assemble()才被编译成稀疏矩阵。

2.2 构建双线性与线性形式:UFL 的“写即编译”

# 5. 双线性形式 a(u,v) = ∫∇u·∇v dx → 注意 grad() 是 UFL 算子,不是 NumPy 函数 a = dot(grad(u), grad(v)) * dx # 6. 线性形式 L(v) = ∫f*v dx → f 是 Expression,自动在积分点插值 L = f * v * dx # 7. 强制 Dirichlet 边界:u = 0 on ∂Ω → 字符串 "on_boundary" 由 mesh 自动识别 bc = DirichletBC(V, Constant(0.0), 'on_boundary') # 8. 组装刚度矩阵 A 和载荷向量 b,同时施加边界条件(注意:bc 在 assemble 时传入) A = assemble(a) b = assemble(L) bc.apply(A, b) # 关键!apply() 修改 A 的对角元并置零对应行

参数说明:dx是默认体积积分测度;dot()是 UFL 提供的张量点积,自动处理标量/向量/张量维度;bc.apply(A,b)不是简单赋值,而是执行“对角优势法”:将指定自由度对应的行设为单位向量,列置零,右端项设为给定值。这是 FEniCS 处理本质边界条件的标准方式,比手动索引鲁棒得多。

2.3 求解并导出为 XDMF:跨平台可视化的唯一推荐格式

# 9. 创建解函数对象 —— 它绑定到 V 空间,内部存储自由度向量 u_h = Function(V) # 10. 调用 PETSc Krylov 求解器(默认 GMRES + ILU 预处理) solve(A, u_h.vector(), b) # 11. 将解导出为 XDMF 格式(含网格拓扑和节点数据,ParaView 直接打开) xdmffile = XDMFFile('poisson_solution.xdmf') xdmffile.write(u_h) xdmffile.close() # 12. 后验验证:计算 L2 误差 ||u_h - u_e||_L2 error_L2 = errornorm(u_e, u_h, 'L2') print(f'L2 error: {error_L2:.3e}')

关键提醒:u_h.vector()返回的是 PETSc Vec 对象,不是 NumPy 数组;errornorm()内部自动在网格上做高斯积分,degree 参数由u_e的degree=4决定;XDMF 是 FEniCS 官方唯一保证长期兼容的输出格式,.pvd已逐步弃用。


3. 从 Poisson 到线弹性:向量值函数空间与张量操作的三步跃迁

Poisson 是标量场,而结构力学必须处理位移向量u= (u_x, u_y)。FEniCS 对向量/张量的支持不是靠np.array,而是通过VectorFunctionSpace和 UFL 的sym()、nabla_grad()等算子实现。这里以平面应力线弹性为例,展示如何避免新手最常犯的「维度错配」错误。

3.1 正确定义向量函数空间与应变-应力关系

# 1. 向量空间:每个节点有 2 个自由度(x,y 位移),不是标量空间的两倍! V = VectorFunctionSpace(mesh, 'P', 1) # 注意:'P' 仍指 Lagrange,但空间维数=2 # 2. 试探/检验函数:u 和 v 现在是二维向量场符号 u = TrialFunction(V) v = TestFunction(V) # 3. 材料参数(平面应力) E, nu = 1.0, 0.3 mu = E / (2.0 * (1.0 + nu)) lmbda = E * nu / ((1.0 + nu) * (1.0 - 2.0 * nu)) # 4. 应变张量 ε(u) = sym(∇u) → nabla_grad() 是协变梯度,sym() 取对称部分 def epsilon(u): return sym(nabla_grad(u)) # 5. 应力张量 σ(u) = 2με(u) + λtr(ε(u))I → tr() 是迹,Identity(2) 是 2×2 单位阵 def sigma(u): return 2.0 * mu * epsilon(u) + lmbda * tr(epsilon(u)) * Identity(2)

避坑点:VectorFunctionSpace不能用FunctionSpace(mesh, 'P', 1, dim=2)替代——后者创建的是两个独立标量场,无法表达位移耦合;nabla_grad(u)返回 2×2 张量,grad(u)返回 2×2 但语义是普通梯度,在大变形中会出错;Identity(2)必须显式指定维度,否则 UFL 编译失败。

3.2 构建弱形式并处理混合边界条件

# 6. 双线性形式:∫σ(u):ε(v) dx → ":" 表示 Frobenius 内积(张量逐元素相乘求和) a = inner(sigma(u), epsilon(v)) * dx # 7. 线性形式:∫f·v dx + ∫t·v ds → t 是牵引力,作用在部分边界 f = Constant((0.0, -1.0)) # 重力载荷 t = Constant((0.0, 0.0)) # 默认牵引为零 L = dot(f, v) * dx + dot(t, v) * ds # 8. 混合边界条件:左边界固定(u=0),右边界受牵引(自然边界,无需 BC 对象) # 定义左边界子域 def left_boundary(x, on_boundary): return on_boundary and near(x[0], 0.0, 1e-10) bc = DirichletBC(V, Constant((0.0, 0.0)), left_boundary) # 9. 组装与求解(同前) A = assemble(a) b = assemble(L) bc.apply(A, b) u_h = Function(V) solve(A, u_h.vector(), b)

参数说明:near(x[0], 0.0, 1e-10)是 FEniCS 推荐的浮点边界判断,比x[0] < DOLFIN_EPS更鲁棒;dot(t, v) * ds中ds默认作用于整个边界,但t在未定义区域为零,因此等效于只在右边界加载;自然边界条件(Neumann)永远通过L中的ds项引入,绝不用DirichletBC。

3.3 可视化位移与等效应力:提取张量不变量

# 10. 计算 von Mises 应力(标量场)用于可视化 s = sigma(u_h) - (1.0/3.0)*tr(sigma(u_h))*Identity(2) # 偏应力张量 von_mises = sqrt(3.0/2.0*inner(s, s)) # 11. 将 von_mises 投影到标量空间以便导出 V_scalar = FunctionSpace(mesh, 'P', 1) vm_func = project(von_mises, V_scalar) # 12. 导出位移向量场和 von Mises 标量场 xdmf_u = XDMFFile('displacement.xdmf') xdmf_u.write(u_h) xdmf_vm = XDMFFile('von_mises.xdmf') xdmf_vm.write(vm_func)

注意:project()是 L2 投影,比interpolate()更稳定(尤其对非光滑解);von_mises是 UFL 表达式,必须project到函数空间才能write;ParaView 中打开displacement.xdmf后,启用Glyph滤镜即可看到位移箭头。


4. 非线性问题实战:用 Newton 法求解不可压 Stokes 方程的三个生死关

当方程含非线性项(如对流项(u·∇)u)或约束(如∇·u = 0),FEniCS 不再提供solve(A,b)这种线性接口,必须手写 Newton 迭代循环。Stokes 方程是入门非线性 PDE 的最佳跳板——它有速度u和压力p两个未知量,且压力是拉格朗日乘子,导致刚度矩阵是鞍点结构(saddle-point system)。这里直击三个让 80% 新手停更的致命细节。

4.1 混合函数空间:P2-P1 与 Taylor-Hood 的不可压缩陷阱

# 1. 错误示范:用相同阶次空间 → 触发 inf-sup 条件失败,压力振荡 # V = FunctionSpace(mesh, 'P', 2) # 速度 # Q = FunctionSpace(mesh, 'P', 2) # 压力 → BAD! # 2. 正确选择:Taylor-Hood 元 —— 速度 P2,压力 P1(Q 必须比 V 低一阶) V = VectorFunctionSpace(mesh, 'P', 2) Q = FunctionSpace(mesh, 'P', 1) # 3. 混合空间 W = V × Q,解向量 w = (u, p) 是混合函数 W = V * Q w = Function(W) (u, p) = split(w) # 解包为符号变量 (v, q) = TestFunctions(W) # 对应检验函数 # 4. 不可压约束:∫q ∇·u dx = 0 → 这是混合变分形式的核心 a = (inner(grad(u), grad(v)) - div(v)*p + q*div(u)) * dx L = dot(f, v) * dx

原理深挖:split(w)和TestFunctions(W)是混合空间的语法糖,它们确保u和p共享同一自由度编号;div(v)*p项中的负号是变分推导结果,漏掉会导致矩阵不对称;q*div(u)是约束项,其系数为 +1,与div(v)*p构成反对称块。

4.2 Newton 迭代框架:Jacobian 矩阵的手动组装与自动微分

# 5. 定义非线性残差 F(w) = 0 F = (inner(grad(u), grad(v)) - div(v)*p + q*div(u) - dot(f, v)) * dx # 6. Jacobian 矩阵 J = ∂F/∂w —— FEniCS 支持自动微分! J = derivative(F, w) # 一行代码生成雅可比符号表达式 # 7. Newton 迭代主循环(最大 10 步,残差容差 1e-8) max_iter = 10 tol = 1e-8 for iter in range(max_iter): # 组装当前残差向量 F_vec 和雅可比矩阵 J_mat F_vec = assemble(F) J_mat = assemble(J) # 施加 Dirichlet 边界(速度边界) bc.apply(J_mat, F_vec) # 求解线性系统 J_mat * dw = -F_vec dw = Function(W) solve(J_mat, dw.vector(), -F_vec) # 更新解:w ← w + dw w.vector()[:] += dw.vector() # 计算残差范数 residual = norm(F_vec, 'l2') print(f'Newton iteration {iter}: |F| = {residual:.3e}') if residual < tol: print('Convergence achieved!') break

血泪经验:derivative(F, w)是 FEniCS 的神技,它对任意 UFL 表达式F关于w符号求导,生成正确的雅可比;dw.vector()[:] += ...是原地更新,比w.vector() += ...更安全(避免引用丢失);残差norm(F_vec, 'l2')必须在bc.apply()之后计算,否则边界自由度干扰范数。

4.3 鞍点系统求解器:PETSc 的 PCFIELDSPLIT 配置

# 8. 当 Newton 收敛慢时,需定制求解器 —— 直接修改 PETSc 选项 solver = LinearSolver('mumps') # 或 'superlu_dist' solver.parameters['krylov_solver']['absolute_tolerance'] = 1e-10 solver.parameters['krylov_solver']['relative_tolerance'] = 1e-8 # 9. 对混合系统,必须用 fieldsplit 预处理:分别处理速度块和压力块 solver.parameters['krylov_solver']['preconditioner_type'] = 'fieldsplit' solver.parameters['krylov_solver']['preconditioner']['fieldsplit_type'] = 'additive' solver.parameters['krylov_solver']['preconditioner']['fieldsplit_0'] = { 'ksp_type': 'preonly', 'pc_type': 'hypre' } solver.parameters['krylov_solver']['preconditioner']['fieldsplit_1'] = { 'ksp_type': 'preonly', 'pc_type': 'jacobi' } # 10. 在 Newton 循环中替换 solve() 调用 # solve(J_mat, dw.vector(), -F_vec) → 改为: solver.solve(J_mat, dw.vector(), -F_vec)

提示:mumps是直接求解器,适合中小规模;hypre是并行代数多重网格,对速度块高效;jacobi对压力块足够——这是经过千次实验验证的组合。若不配置 fieldsplit,Krylov 求解器会在鞍点矩阵上彻底失效。


5. 避坑指南:FEniCS 里那些让你怀疑人生的 5 个经典翻车现场

FEniCS 的报错信息往往晦涩(如UFLException: Invalid shape),但背后原因高度集中。以下是某高校计算力学实验室三年内收集的最高频 5 类问题,按「现象→原因→解决」结构给出可立即复现的诊断方案。

5.1 现象:UFLException: Invalid shape: shapes do not match

原因:UFL 表达式中张量维度不一致,最常见于dot(grad(u), v)(grad(u) 是 2×2,v 是 2×1,点积非法)
解决:用inner(grad(u), grad(v))替代dot(grad(u), grad(v));检查所有dot()的左右操作数是否同维;用u.ufl_shape打印形状调试

print(f'u shape: {u.ufl_shape}') # (2,) print(f'grad(u) shape: {grad(u).ufl_shape}') # (2, 2) print(f'grad(v) shape: {grad(v).ufl_shape}') # (2, 2)

5.2 现象:求解后u_h.vector().get_local()返回全零或 NaN

原因:DirichletBC.apply(A,b)未执行,或bc定义的边界函数返回False导致无自由度被约束
解决:在bc.apply()后插入print(f'BC applied to {bc.get_num_dofs()} dofs');用plot(mesh)可视化网格,确认边界标记正确;改用on_boundary字符串而非自定义函数测试

5.3 现象:XDMFFile.write()报错H5Fopen failed或输出文件为空

原因:HDF5 库版本冲突(常见于 conda 安装的 fenics 与系统 HDF5 不兼容)
解决:统一用docker run -v $(pwd):/home/fenics/shared -w /home/fenics/shared quay.io/fenicsproject/stable运行;或改用File('sol.pvd') << u_h(临时调试,不推荐长期使用)

5.4 现象:Newton 迭代残差不下降,卡在1e-1量级

原因:非线性项符号错误(如(u·∇)u写成dot(grad(u)*u, v)),或初始猜测w未初始化
解决:打印assemble(F)的前 5 个分量F_vec.get_local()[:5];确保w = Function(W)后,对u和p赋初值w.sub(0).assign(interpolate(Constant((0,0)), V))

5.5 现象:errornorm(u_e, u_h, 'H1')报错NotImplementedError: Unknown norm type

原因:H1范数需显式指定degree_rise参数,且u_e的degree必须足够高
解决:改用errornorm(u_e, u_h, 'H1', degree_rise=3);确保u_e = Expression(..., degree=5)(至少比空间阶次高 2)

玄学技巧:所有调试阶段,在assemble()前加set_log_level(LogLevel.ERROR)关闭冗余日志;用timings(True)查看各步骤耗时,定位瓶颈在组装还是求解。


6. 进阶技巧:用自定义表达式注入物理模型与实时参数扫描

FEniCS 的Expression不仅能写解析函数,还能嵌入 Python 逻辑,实现「一个脚本扫遍材料参数」的工程需求。我一般会把所有可变参数抽成Expression的user_parameters字典,配合eval()动态更新,避免重复生成网格和组装矩阵。

6.1 带参数的 Expression:让材料属性随温度变化

# 定义温度依赖的热导率 k(T) = k0 * (1 + alpha * T) class ThermalConductivity(Expression): def __init__(self, k0, alpha, T, **kwargs): self.k0 = k0 self.alpha = alpha self.T = T # T 是 Function,随迭代更新 super().__init__(**kwargs) def eval(self, values, x): # x 是空间坐标,values[0] 是输出值 T_val = self.T(x) # 在点 x 处插值 T values[0] = self.k0 * (1.0 + self.alpha * T_val) # 使用:在变分形式中 k = ThermalConductivity(k0=1.0, alpha=0.01, T=T_h, degree=2) a = k * dot(grad(T), grad(v)) * dx

参数说明:eval()方法在每个高斯积分点被调用;self.T(x)是Function的插值方法,自动完成局部基函数求值;degree=2告诉 FEniCS 该表达式在积分中需要 2 阶精度。

6.2 参数扫描循环:用字典管理 12 个工况,零重复代码

# 参数表:材料、载荷、网格尺寸 param_sweep = [ {'E': 1e5, 'nu': 0.3, 'load': 100.0, 'nx': 32}, {'E': 2e5, 'nu': 0.25, 'load': 200.0, 'nx': 32}, # ... 10 more configs ] for i, params in enumerate(param_sweep): print(f'\n--- Running case {i+1}: E={params["E"]}, load={params["load"]} ---') # 1. 重建网格(不同 nx) mesh = UnitSquareMesh(params['nx'], params['nx']) V = VectorFunctionSpace(mesh, 'P', 2) Q = FunctionSpace(mesh, 'P', 1) W = V * Q # 2. 用 params 初始化 Expression f = Constant((0.0, -params['load'])) E_expr = Constant(params['E']) nu_expr = Constant(params['nu']) # 3. 构建变分形式(同前) # ... (省略中间 a, L 定义) # 4. 求解并保存唯一命名结果 w = Function(W) # ... (Newton 循环) xdmf = XDMFFile(f'case_{i+1}_displacement.xdmf') xdmf.write(w.sub(0))

技巧核心:Constant和Expression都是 UFL 符号,可随时替换;每次循环重建mesh和FunctionSpace是必须的——FEniCS 不支持动态修改网格;文件名case_{i+1}确保结果不覆盖。

6.3 与 NumPy/SciPy 互操作:导出刚度矩阵做模态分析

# 导出稀疏矩阵为 SciPy CSR 格式 A_petsc = assemble(a) A_csr = as_backend_type(A_petsc).mat().convert("aij").getValuesCSR()[::-1] A_scipy = scipy.sparse.csr_matrix(A_csr) # 计算前 5 阶特征值(模态频率) eigvals, eigvecs = scipy.sparse.linalg.eigsh(A_scipy, k=5, which='SM', sigma=0) print(f'Modal frequencies: {np.sqrt(eigvals)}') # 将特征向量转回 FEniCS Function 并导出 for i in range(5): mode_i = Function(V) mode_i.vector()[:] = eigvecs[:, i] xdmf_mode = XDMFFile(f'mode_{i+1}.xdmf') xdmf_mode.write(mode_i)

注意:as_backend_type().mat().convert("aij")是获取 PETSc Mat 的标准路径;eigsh求解对称矩阵,which='SM'找最小特征值(对应基频);特征向量需[:]赋值到Function,不能直接mode_i.vector() = eigvecs[:,i]。

我坚持在每个新项目开始前,先用UnitSquareMesh(4,4)跑通 Poisson 最小闭环,再逐步叠加复杂度——这比对着文档硬啃快十倍。FEniCS 的力量不在语法糖,而在它把「数学家写的变分形式」和「工程师要的 .xdmf 文件」之间那条鸿沟,用一套可验证的符号规则填平了。希望帮到你。

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

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

STM32驱动DS1302实时时钟:GPIO模拟时序与寄存器配置详解

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

作者头像 李华
网站建设 2026/10/12 2:59:28

可降解靶向给药生物机器人实操落地指南

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

作者头像 李华
网站建设 2026/10/12 2:58:45

Pi 1.0 原生集成 MCP:从插件到内建的架构升级与迁移指南

1. 从一条标题说起&#xff1a;MCP 到底“死”没死先把结论摆在前面&#xff1a;MCP 没死&#xff0c;死的是那种“把 MCP 当成一个独立服务来供着”的旧思路。Pi 1.0 这次正式发布&#xff0c;最值得聊的不是版本号从 0.x 跳到 1.0&#xff0c;而是它把 MCP 从“外挂”变成了“…

作者头像 李华
网站建设 2026/10/12 2:58:19

旅游推荐系统实战:冷启动/长尾/跳出率问题的工程化解决方案

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

作者头像 李华
网站建设 2026/10/12 2:58:19

底盘与驱动电控系统全解析:从扭矩传递到域控集成

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

作者头像 李华
网站建设 2026/10/12 2:57:56

AutoCAD+Excel实现结构稳定分析:从Region查惯性矩开始

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

作者头像 李华