Egg + TypeScript 项目接入 egg-mongoose:从类型声明到 CRUD 的完整教程
1. Egg TypeScript 项目里 egg-mongoose 到底解决什么问题如果你正在写 Egg TypeScript 的后端又想让 MongoDB 的读写像调用本地方法一样顺手那 egg-mongoose 就是那个把两者粘起来的插件。它做的事情很朴素在 Egg 启动时帮你建立 MongoDB 连接把连接实例挂到app.mongoose上再让app/model目录下的文件自动被加载成 Model。你不需要在每个 Service 里手动mongoose.connect也不用担心连接池被重复创建。适合谁适合已经用npm init egg --typets初始化过项目、手里有一个能连通的 MongoDB本地 Docker 或云端都行、并且希望 Model 和 Service 分层清晰的人。如果你还在用 JavaScript 写 Egg这篇的 TS 类型声明部分同样能帮你把this.ctx.model.User的提示补全只是写法要换成对应的.d.ts。我试过在一个订单系统里直接裸用 mongoose结果是每个 Service 都要 import 一次连接热重载时连接数还会涨。换成 egg-mongoose 后连接生命周期交给框架管Service 只关心查询逻辑代码量少了一截。下面按“初始化插件 → 补类型声明 → 定义 Model 与 Service → 本地 CRUD 验证”的顺序走一遍每一步都给可复制的配置。先明确一个检索词Egg TypeScript 集成 egg-mongoose 的完整配置与 CRUD 验证。这篇文章就是围绕它展开从config/plugin.ts到typings再到一次真实的本地请求。在开始之前确认三件事Node.js 建议 14.x 以上MongoDB 已运行本地mongodb://127.0.0.1:27017或云端连接串项目已用 TS 模板初始化。如果 MongoDB 还没起用 Docker 最快docker pull mongo:latest docker run -d --name egg-mongo -p 27017:27017 mongo:latest启动后用 Navicat 或mongosh能连上127.0.0.1:27017就说明服务正常。这一步别跳过后面连接失败十有八九是这里没起。2. 安装 egg-mongoose 并补齐 TypeScript 类型声明装插件本身一条命令npm i egg-mongoose --save但 TypeScript 项目里光装插件不够。Egg 的 TS 模板默认不认识app.mongoose、app.model、ctx.model这些属性编辑器会飘红。所以第二步是补类型声明。先启用插件。config/plugin.ts里加上// config/plugin.ts import { EggPlugin } from egg; const plugin: EggPlugin { mongoose: { enable: true, package: egg-mongoose, }, }; export default plugin;注意 TS 模板导出的是default不是module.exports。如果你从旧教程复制了module.exports {...}在 TS 项目里会报类型不匹配改成上面这样。接着配置连接。config/config.default.ts// config/config.default.ts import { EggAppConfig, EggAppInfo, PowerPartial } from egg; export default (appInfo: EggAppInfo) { const config {} as PowerPartialEggAppConfig; config.mongoose { client: { url: mongodb://127.0.0.1:27017/egg_mongoose_demo, options: { useNewUrlParser: true, useUnifiedTopology: true, }, }, }; return { ...config, }; };这里config.mongoose在默认类型里是没有的所以下一步必须补声明否则config.mongoose这行就报错。在项目根目录建typings/index.d.tsTS 模板通常已有typings目录写入// typings/index.d.ts import mongoose from mongoose; import { EggAppConfig } from egg; declare module egg { interface EggAppConfig { mongoose: { client?: { url: string; options?: mongoose.ConnectOptions; }; clients?: Recordstring, { url: string; options?: mongoose.ConnectOptions; }; }; } interface Application { mongoose: typeof mongoose; model: { User: mongoose.Modelany; Order: mongoose.Modelany; }; } interface Context { model: { User: mongoose.Modelany; Order: mongoose.Modelany; }; } }这段声明做了三件事让config.mongoose合法让app.mongoose有 mongoose 的类型让app.model.User和ctx.model.User在 Service/Controller 里有提示。Modelany里的any后面可以换成具体的接口类型比如IUser提示会更精确。如果你用多数据库配置改成clientsconfig.mongoose { clients: { db1: { url: mongodb://127.0.0.1:27017/db1, options: { useNewUrlParser: true, useUnifiedTopology: true }, }, db2: { url: mongodb://127.0.0.1:27017/db2, options: { useNewUrlParser: true, useUnifiedTopology: true }, }, }, };多库时 Model 里要用app.mongooseDB.get(db1)拿连接这个后面 Model 部分会写。补完声明后重启一次 TS 服务VS Code 里CtrlShiftP→ Restart TS Serverapp.mongoose的飘红应该消失。这一步是 TS 项目最容易卡住的地方很多人装完插件发现类型报错就以为插件不兼容其实只是声明没补。3. 定义 Model 与 Service 分层可复制的配置片段Egg 约定 Model 放app/modelService 放app/service。egg-mongoose 会自动加载app/model下的文件文件名即 Model 名首字母大写。先建用户模型app/model/user.ts// app/model/user.ts import { Application } from egg; export default (app: Application) { const mongoose app.mongoose; const Schema mongoose.Schema; const UserSchema new Schema({ username: { type: String, required: true, unique: true }, password: { type: String, required: true }, status: { type: Number, default: 1 }, createdAt: { type: Date, default: Date.now }, }); return mongoose.model(User, UserSchema, user); };第三个参数user指定集合名。不写的话 mongoose 会按users复数化容易和已有集合对不上建议显式写。订单模型app/model/order.ts// app/model/order.ts import { Application } from egg; export default (app: Application) { const mongoose app.mongoose; const Schema mongoose.Schema; const OrderSchema new Schema({ order_id: { type: String, required: true }, uid: { type: Number }, total_price: { type: Number }, createdAt: { type: Date, default: Date.now }, }); return mongoose.model(Order, OrderSchema, order); };多数据库场景下Model 要指定连接// app/model/user.ts多库版 import { Application } from egg; export default (app: Application) { const mongoose app.mongoose; const Schema mongoose.Schema; const conn app.mongooseDB.get(db1); const UserSchema new Schema({ username: { type: String, required: true }, password: { type: String, required: true }, }); return conn.model(User, UserSchema); };Service 层app/service/user.ts把 CRUD 收在这里// app/service/user.ts import { Service } from egg; export default class UserService extends Service { async getUserList(page 1, pageSize 10) { const skip (page - 1) * pageSize; return this.ctx.model.User.find({}) .sort({ createdAt: -1 }) .skip(skip) .limit(pageSize); } async addUser(userData: { username: string; password: string; status?: number }) { const user new this.ctx.model.User(userData); return user.save(); } async updateUser(id: string, updateData: Recordstring, unknown) { return this.ctx.model.User.updateOne({ _id: id }, { $set: updateData }); } async deleteUser(id: string) { return this.ctx.model.User.deleteOne({ _id: id }); } }Controller 层app/controller/user.ts// app/controller/user.ts import { Controller } from egg; export default class UserController extends Controller { async index() { const { ctx } this; const { page 1, pageSize 10 } ctx.query; const userList await ctx.service.user.getUserList(Number(page), Number(pageSize)); ctx.body { code: 200, data: userList, msg: 获取用户列表成功 }; } async create() { const { ctx } this; const result await ctx.service.user.addUser(ctx.request.body); ctx.body { code: 200, data: result, msg: 添加用户成功 }; } async update() { const { ctx } this; const { id } ctx.params; const result await ctx.service.user.updateUser(id, ctx.request.body); ctx.body { code: 200, data: result, msg: 更新用户成功 }; } async destroy() { const { ctx } this; const { id } ctx.params; const result await ctx.service.user.deleteUser(id); ctx.body { code: 200, data: result, msg: 删除用户成功 }; } }路由app/router.ts// app/router.ts import { Application } from egg; export default (app: Application) { const { router, controller } app; router.get(/users, controller.user.index); router.post(/users, controller.user.create); router.put(/users/:id, controller.user.update); router.delete(/users/:id, controller.user.destroy); };这里有个坑Egg 默认开启 CSRF 保护POST/PUT/DELETE 请求不带 token 会返回 403。开发阶段可以临时关掉在config/config.default.ts里加config.security { csrf: { enable: false, }, };生产环境别这么干应该走ctx.csrf拿 token 再带上。或者针对单条路由关router.post(/users, controller.user.create, { csrf: false });配置片段到这里就齐了config/plugin.ts启用插件config/config.default.ts配连接typings/index.d.ts补类型app/model定义 Schemaapp/service收 CRUDapp/router.ts暴露接口。这套结构的好处是 Controller 只做参数转发Service 只做数据操作Model 只做结构定义职责不混。4. 本地启动与 CRUD 请求验证类型提示是否真的生效配置写完启动项目npm run dev看到egg started on http://127.0.0.1:7001就说明连接和插件加载都过了。如果启动时报Cannot read property Schema of undefined说明app.mongoose没挂上回去检查config/plugin.ts是否 enable以及typings是否补了Application.mongoose。先测新增。用 curlcurl -X POST http://127.0.0.1:7001/users \ -H Content-Type: application/json \ -d {username:john_doe1,password:password1231,status:1}返回类似{ code: 200, data: { _id: 665f1a2b3c4d5e6f7a8b9c0d, username: john_doe1, password: password1231, status: 1, createdAt: 2024-06-04T10:00:00.000Z, __v: 0 }, msg: 添加用户成功 }拿到_id后测查询curl http://127.0.0.1:7001/users应该返回刚才那条记录。再测更新curl -X PUT http://127.0.0.1:7001/users/665f1a2b3c4d5e6f7a8b9c0d \ -H Content-Type: application/json \ -d {status:0}返回modifiedCount: 1说明更新成功。最后测删除curl -X DELETE http://127.0.0.1:7001/users/665f1a2b3c4d5e6f7a8b9c0d返回deletedCount: 1即删除成功。类型提示是否生效看两个地方在app/service/user.ts里敲this.ctx.model.User.时编辑器应该弹出find、findOne、updateOne等方法敲this.ctx.model.User.find({时参数类型应该是FilterQuery。如果没提示说明typings/index.d.ts没被 TS 识别检查tsconfig.json的include是否包含typings/**/*.d.ts。分页查询也顺手验一下curl http://127.0.0.1:7001/users?page1pageSize5Service 里的skip和limit会生效返回条数不超过 5。关联查询用aggregate的$lookup在 Controller 里直接调const orderResult await ctx.model.Order.aggregate([ { $lookup: { from: order_item, localField: order_id, foreignField: order_id, as: items, }, }, { $match: { total_price: { $gte: 90 } } }, ]);from写集合名不是 Model 名。$match放在$lookup后面可以过滤结果放在前面能减少关联数据量按场景选。到这里一次完整的本地 CRUD 请求就验证完了连接建立、Model 加载、Service 调用、类型提示、增删改查全部走通。5. 常见报错排查401、local proxy failed、reading choices 与 OAuth接入过程中最容易撞上的几类报错逐个说清楚。MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017。这是连接被拒不是代码问题。先确认 MongoDB 容器在跑docker ps看egg-mongo是否 Up。如果容器在跑还报这个检查config.mongoose.client.url里的端口和容器映射端口是否一致。Docker 默认映射27017:27017如果你改过宿主机端口url 也要跟着改。Cannot read property Schema of undefined。app.mongoose没挂上。三个检查点config/plugin.ts里mongoose.enable是否为truepackage是否为egg-mongoosetypings/index.d.ts里是否声明了Application.mongoose。TS 项目里如果只补了Context.model没补Application.mongooseModel 文件里app.mongoose就是 undefined。401 Unauthorized / local proxy failed。如果你在 Service 里调用了外部 API比如模型服务401 通常是 Key 没带或带错。检查请求头里的Authorization是否为Bearer 你的KeyKey 是否过期。local proxy failed一般是本地代理配置和实际网络环境不匹配检查HTTP_PROXY/HTTPS_PROXY环境变量是否指向了一个不可用的地址清掉再试。这类外部调用建议把 Base URL、Key、Model ID 三件套写全缺一个都会 401。reading choices of undefined。这个报错常见于调用模型接口后解析响应体时response.choices为 undefined。原因通常是响应体不是预期的 JSON 结构可能是错误信息被当成正常响应返回了。排查方法先把原始响应console.log出来看status和body。如果是 4xx/5xx先解决状态码问题如果是 200 但结构不对检查请求的model参数是否拼错或者接口版本是否匹配。OAuth 相关报错。如果你用 OAuth 方式接入外部服务报invalid_grant或redirect_uri_mismatch检查回调地址是否和注册时填的一致token 是否已过期。OAuth 的 token 有有效期过期后要重新走授权流程不能复用旧 token。CSRF token missing。POST/PUT/DELETE 返回 403body 里提示missing csrf token。开发阶段在config.security.csrf.enable设为false或者请求时带上ctx.csrf返回的 token。生产环境必须带 token别关。Model 找不到this.ctx.model.User is not a constructor。app/model/user.ts文件名和mongoose.model(User, ...)的第一个参数要对应。文件名user.ts对应User如果写成users.ts但 model 名是User加载会失败。另外确认app/model目录在tsconfig.json的编译范围内。类型提示不生效。this.ctx.model.User没有方法提示。检查typings/index.d.ts是否在tsconfig.json的include里改完声明后重启 TS Server确认egg的版本和types/egg匹配。如果用的是egg-mongoose自带的类型可能和手写声明冲突以手写声明为准。多数据库时 model 挂错连接。app.mongooseDB.get(db1)返回 undefined说明config.mongoose.clients里没有db1或者拼写不一致。检查配置里的 key 和get的参数是否完全一致大小写敏感。这些报错里连接类和类型类占大多数。连接类看 Docker 和 url类型类看typings和tsconfig。把这两块理顺剩下的就是业务逻辑问题。6. 从本地验证到长期编码把接入流程固化下来本地 CRUD 跑通只是第一步。真正在项目里用还有几件事值得做。第一把typings/index.d.ts里的Modelany换成具体接口。比如定义IUserexport interface IUser { username: string; password: string; status: number; createdAt: Date; }然后model.User: mongoose.ModelIUser。这样find返回的文档就有字段提示updateOne的$set也会校验字段名写错字段编译期就能发现。第二Service 层加错误处理。现在addUser里user.save()如果 username 重复会抛E11000 duplicate key error直接冒到 Controller 会返回 500。在 Service 里 try-catch转成业务错误码async addUser(userData: { username: string; password: string }) { try { const user new this.ctx.model.User(userData); return await user.save(); } catch (err: any) { if (err.code 11000) { this.ctx.throw(409, 用户名已存在); } throw err; } }第三敏感信息走环境变量。config.mongoose.client.url里的密码不要硬编码用process.env.MONGO_URLurl: process.env.MONGO_URL || mongodb://127.0.0.1:27017/egg_mongoose_demo,本地开发用默认值线上注入环境变量。第四常用查询字段加索引。username已经unique: true自带唯一索引。如果经常按status过滤在 Schema 里加index: true或者用UserSchema.index({ status: 1 })。索引在数据量大时对查询速度影响明显。第五分页查询的skip在深分页时性能会下降。数据量上万后skip(10000).limit(10)会扫描前 10000 条。可以改用基于_id的游标分页find({ _id: { $gt: lastId } }).limit(10)性能稳定。如果你后续要接模型服务做 AI 能力比如给订单加智能分类外部调用的 Base URL、Key、Model ID 三件套要配全Key 走环境变量别写进代码。模型对话调试可以用网页版快速验证参数长期跑编码任务或 Agent 则适合用 Coding Plan 这类按量方案避免每次手动换 Key。接入文档里对配置项和错误码有更细的说明遇到本文没覆盖的报错可以去查。API Keys 管理页可以创建和轮换 Key建议给不同环境用不同的 Key方便排查和回收。整套流程走下来核心就三块插件启用和连接配置、类型声明补齐、Model/Service 分层。把这三块固化到项目模板里下次新项目直接复制十分钟就能跑通 CRUD。真正花时间的不是配置本身而是遇到报错时知道去哪查——连接问题看 Docker 和 url类型问题看 typings 和 tsconfig业务问题看 Service 的 try-catch。

相关新闻

SSM+Vue+MySQL实现小型企业客户关系管理系统设计与开发

SSM+Vue+MySQL实现小型企业客户关系管理系统设计与开发

毕业设计年年有人做,做的题目也是年年都撞车。但有个现象挺有意思:哪怕同样是“客户关系管理系统”这个题目,有人能做出一个答辩时让老师眼前一亮、代码结构干净利落、还能讲清楚每个技术点为什么这样用的项目;也有人忙活两个月&a…

2026/10/12 14:33:58 阅读更多 →
Python入门实操指南:从环境搭建到爬虫数据分析的避坑手册

Python入门实操指南:从环境搭建到爬虫数据分析的避坑手册

1. 先搞清楚一件事:Python 到底能干啥小宇第一天跑过来问我:"我想学 Python,但大家都说 Python 能做好多事,到底能做什么?" 我反问他一句:"你先告诉我,你学 Python 是想解决问题…

2026/10/13 17:32:31 阅读更多 →
Spring Boot整合SSM的农产品电商交易平台设计与实现

Spring Boot整合SSM的农产品电商交易平台设计与实现

最近找我聊这个项目的人不少,尤其是准备毕业设计或者课程设计的同学,十个里有六七个都在做电商类系统,而其中农产品特产交易平台又是命中率最高的方向之一。题目“springboot_ssm846农产品特产品网络交易平台设计与实现”看着有点程序员式的随…

2026/10/13 20:13:27 阅读更多 →

最新新闻

Python Plotly交互式数据可视化:从快速出图到精细定制

Python Plotly交互式数据可视化:从快速出图到精细定制

做数据分析的同学都有一个共同感受:数据算完之后,最头疼的不是处理逻辑,而是怎么把结果变成一张别人一眼能看懂的图。这两年我用得最顺手的工具就是 Plotly,一个典型的 Python 交互式绘图库。用 Plotly 创建交互式图表&#xff0c…

2026/10/13 22:03:47 阅读更多 →
基于Spring Boot和Vue的电影订票管理系统全栈项目

基于Spring Boot和Vue的电影订票管理系统全栈项目

每年做课程设计或者毕业设计的季节,总有人问我"有没有一个既能体现技术栈又不算太复杂、最好还带完整界面的项目"。老实说,这类需求里出镜率最高的就是各种"管理系统",而其中电影订票管理系统又是一直没凉过的经典选题。…

2026/10/13 22:03:47 阅读更多 →
机器学习集成学习实战:三大流派与工程应用

机器学习集成学习实战:三大流派与工程应用

集成学习这几年在机器学习面试和实际工程里都是绕不开的东西。不管你是刚开始学模型,还是已经在跑实验,迟早会遇到一个问题:单个模型的效果到了瓶颈,怎么再往上提一点?很多人第一反应是换更复杂的模型,但更…

2026/10/13 22:03:47 阅读更多 →
WEKA数据挖掘入门:从ARFF格式到J48决策树与交叉验证实战

WEKA数据挖掘入门:从ARFF格式到J48决策树与交叉验证实战

简介:一份面向数据挖掘初学者与课程学习者的WEKA中文详细教程PPT,围绕怀卡托智能分析环境(WEKA)的开源特性、获得ACM SIGKDD奖项的里程碑地位及常见应用场景展开,适合作为高校数据挖掘、机器学习课程的配套讲义或自学入…

2026/10/13 22:03:47 阅读更多 →
支付系统设计与落地:状态机、幂等、对账与避坑指南

支付系统设计与落地:状态机、幂等、对账与避坑指南

线下业务上线支付系统的那段时间,几乎每天都要和数据、异常单、渠道对账单较劲。当时踩过的坑、重构过的状态机、跟渠道商务来回确认的限额规则,后来都沉淀成了这套经验。今天不聊架构图,也不念文档,就实打实拆一下“支付系统”从…

2026/10/13 22:03:47 阅读更多 →
同花顺日线文件解析:从二进制乱码到可回测的OHLCV表

同花顺日线文件解析:从二进制乱码到可回测的OHLCV表

简介:这份资源面向金融数据分析初学者与量化爱好者,聚焦同花顺日线二进制文件(.day)的解析问题。压缩包仅4KB,内含2个文件:一个以600000为代码的日线样本文件,以及一份约20行的Python解析脚本&a…

2026/10/13 22:02:46 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 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/13 22:01:27 阅读更多 →
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/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练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/12 18:11:17 阅读更多 →