news 2026/9/14 13:56:18

iii 服务扩展指南:以 Worker 为单元构建系统能力,用 Trigger 与 Function 组合任意服务

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
iii 服务扩展指南:以 Worker 为单元构建系统能力,用 Trigger 与 Function 组合任意服务

iii 服务扩展指南:以 Worker 为单元构建系统能力,用 Trigger 与 Function 组合任意服务

【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii

iii 的核心设计原则是:系统中的每一个新能力,都是通过创建一个 Worker 来加入的——队列、调度器、HTTP 边缘、浏览器标签页、Agent、CRM 集成、沙箱,无一例外。本指南基于docs/0-19-0/creating-workers/index.mdx.skill.md及其同节文档,讲解 Worker / Trigger / Function 三大核心概念、Worker 的脚手架、连接、生命周期与清单(manifest)配置,以及自定义 Trigger 类型的完整流程。读完本文,你将掌握"如何为 iii 系统新增一种能力"的标准路径:写一个 Worker,注册 Trigger 与 Function,让 Engine 完成路由,而 Engine 本身永远不需要修改。

Worker 就是服务:iii 的能力单元模型

在 iii 中,Worker 是能力的单元,也是服务的本体。当你需要新的行为时,正确做法是编写一个新的 Worker(或从 registry 安装一个已有的 Worker),而不是去给 Engine 打补丁、加插件或 fork 它。

Engine 是一个固定的协调者:

  • 它负责在 Worker 之间路由调用(invocation);
  • 它维护着一张"实时注册表"(live registry),记录每个 Worker 当前提供了什么。

所有让 iii 系统"变得有用"的逻辑都运行在 Worker 里。这也是为什么"如何给 iii 加 X?"这类问题几乎总有一个标准答案:添加一个 Worker。如果某项能力缺失,缺口由 Worker 填补,而不是由修改 iii 本身来填补。

关于 Worker、Trigger、Function 与 Engine 的完整心智模型,参见 docs/0-19-0/understanding-iii/index.mdx;而"如何动手构建 Worker"则继续阅读本节的 Workers、Triggers 与 Functions 三篇文档。

四个基本构件

理解 iii 只需要四个概念,其余一切皆是变体:

构件角色关键事实
Worker承载工作的进程能连上 Engine 并注册 Trigger 与 Function 的"任何东西",可运行在笔记本、容器、浏览器标签页或 microVM 上,任何能打开 WebSocket 的语言都可以
Trigger触发工作的原因有一个 type(http、cron、队列消息、状态变更、另一个 Function 调用trigger)、一份 config(路径、调度、队列)和一个它要调用的function_id
Function工作本身Worker 内部的具名处理器,接收 payload、返回结果;标识符遵循service::name约定,跨语言、跨重启保持稳定
Engine协调者接受 Worker 连接、维护可用 Function 与 Trigger 的实时注册表,把调用路由到当前提供目标 Function 的那个 Worker

Worker 隔离与"任何语言、任何运行时"

Worker 被设计为相互独立的进程:一个 Worker 崩溃不会影响其他 Worker。Engine 与每个 Worker 通过独立的 WebSocket 连接,只把调用路由到当前已连接的 Worker;某个 Worker 崩溃、重启或网络分区时,它的 Function 与 Trigger 会在断开连接时从路由表中移除,其余 Worker 继续提供服务。

Worker 的契约足够小——只要能使用 WebSocket 和 JSON,就能实现。Python Worker 与 TypeScript Worker 可以是不同语言、不同运行时、甚至不同机器上的独立进程,互不感知对方上下文,一切由 Engine 协调。这就是"任何语言、任何运行时"在实践中的含义。

一、构建 Worker:脚手架、连接与生命周期

1. 脚手架一个新 Worker

iii worker init可以从零创建一个独立 Worker,命令会写入一个语言专属的项目目录(包含已安装的 iii SDK、一份iii.worker.yaml清单,以及可替换的示例 Function 与 Trigger 注册代码):

# 交互式:提示选择语言 iii worker init my-worker # 完全脚本化:传 --language 跳过提示 iii worker init my-worker --language typescript

支持的--language取值:typescriptts)、javascriptjs)、pythonpy)、rustrs)。位置参数NAME是目标目录名,可用--directory覆盖。注意:

  • 对已包含 iii Worker(存在.iii/worker.ini)的目录重复执行iii worker init不会做任何改动;
  • 默认对非空目录会失败,需加--allow-non-empty才能在任意非空目录中脚手架。

如果不想从零编写,而是安装 registry 中已有的 Worker,则使用iii worker add(详见 docs/0-19-0/using-iii/workers.mdx)。

2. 连接 Engine:唯一的耦合点是连接字符串

Worker 通过 WebSocket 连接 Engine。约定是使用环境变量III_URL设置 Engine 地址,也可以显式传给register_worker连接字符串是 Worker 与所加入 iii 实例之间的唯一耦合,因此 Worker 进程可以部署在网络可达的任何位置。

从源码看,Node SDK(sdk/packages/node/iii/src/iii.ts)的地址解析优先级为:显式地址 >III_URL环境变量 > 默认值ws://127.0.0.1:49134DEFAULT_ENGINE_URL刻意使用 IPv4 回环地址,避免localhost解析到::1而 Engine 只监听 IPv4 时连不上)。此外,SDK 会优先采用III_WORKER_NAME环境变量(由 supervisor 为 Compose 托管 Worker 注入)作为 Worker 名,其次才用代码内显式名称或hostname:pid兜底。

// Node / TypeScript import { registerWorker } from "iii-sdk"; const url = process.env.III_URL; if (!url) throw new Error("III_URL must be set"); const worker = registerWorker(url, { workerName: "my-worker", workerDescription: "One-line summary of what this worker does", });
# Python import os from iii import register_worker, InitOptions worker = register_worker( os.environ.get("III_URL"), InitOptions( worker_name="my-worker", worker_description="One-line summary of what this worker does", ), )
// Rust use iii_sdk::{InitOptions, WorkerMetadata, register_worker}; let url = std::env::var("III_URL").expect("III_URL must be set"); let worker = register_worker( &url, InitOptions { metadata: Some(WorkerMetadata { name: "my-worker".into(), description: Some("One-line summary of what this worker does".into()), ..Default::default() }), ..Default::default() }, );

3. Worker 生命周期状态机

Worker 连接后经过一组有限状态:connecting → connected → available / busy → disconnected

  • connecting:WebSocket 握手阶段;
  • connected:Worker 已加入 Engine 的注册表;
  • available/busy:描述 Worker 当前是否正在处理调用;
  • disconnected:WebSocket 关闭后的终态。

Engine 会跟踪这些状态转移,并通过 discovery 函数向其他 Worker 与工具暴露,让系统其余部分可以做出反应。

4. 检查实时注册表

想查看当前连接了哪些 Worker,可以调用engine::*::list系列 Function 获取注册表快照:

Function返回内容
engine::workers::list每个已连接 Worker 及其指标
engine::functions::list每个已注册 Function,可用include_internal过滤
engine::triggers::list每个已注册 Trigger,可用include_internal过滤
engine::trigger-types::list每个已公布的 Trigger type 及其 config / call schema

这些 Function 由 Engine 内置的engine_fnWorker 提供(见 engine/src/workers/engine_fn/mod.rs 与 engine/src/workers/registry.rs)。调用示例:

// engine::functions::list const { functions } = await worker.trigger({ function_id: "engine::functions::list", payload: { include_internal: false }, });

5. 处理断开与优雅关闭

当 Worker 的 WebSocket 关闭时,Engine 自动清理:它的 Function 与 Trigger 离开实时注册表,正在处理中的调用被取消。in-flight 请求会收到invocation_stopped错误——请像处理"取消"一样捕获它;在拥有该 Function 的 Worker 重连之前重试都会失败。推荐的恢复方式是订阅 Engine 的 discovery 事件:

Trigger触发时机
engine::workers-available有 Worker 连接或断开
engine::functions-available有 Function 被注册或注销
worker.registerFunction( "discovery::on-functions", async (data: { event: string; functions: { function_id: string }[] }) => { // `functions` 是变更后的完整快照 const ids = data.functions.map((f) => f.function_id); }, ); worker.registerTrigger({ type: "engine::functions-available", function_id: "discovery::on-functions", config: {}, });

优雅关闭则调用 SDK 的shutdown:Engine 移除该 Worker 的 Function 与 Trigger、发出engine::workers-availableworker_disconnected事件,并用invocation_stopped取消针对它们的 in-flight 调用。若进程被强制退出,Engine 在发现 socket 断开后也会达到同样的状态,但优雅关闭让这一过程确定且更快。这在一次性 / 临时 Worker(Kubernetes Job、serverless 容器、定时脚本)中尤其有用:连接、注册、干完活、shutdown()退出。

二、Worker 清单:iii.worker.yaml 完整字段

iii.worker.yaml位于 Worker 根目录,告诉 iii 如何配置运行时、安装依赖、启动进程并透传配置。Engine 在从config.yaml启动托管 Worker 以及iii worker start/stop/restart时读取它(详见 docs/0-19-0/creating-workers/worker-manifest.mdx)。

注意:直接运行 Worker 进程(例如node ./myworker/src/index.js不需要iii.worker.yaml。用 SDK 连接 Engine 的 Worker,无论是否由 iii 启动,行为完全一致——清单只是"如何启动"的元数据。

name: math-worker description: Evaluate math expressions over iii functions. runtime: # 作为 Worker rootfs 的基础 OCI 镜像,可覆盖以锁定版本 base_image: docker.io/iiidev/python:latest scripts: install: "pip install -e ." start: "watchfiles 'python src/math_worker.py'"

字段速查:

  • name(必填):必须满足 registry 的 Worker 命名规则;Worker 不能把自己列为依赖。
  • runtime.base_image(可选):覆盖默认 OCI rootfs。必须是合法 OCI 引用(字母数字加. _ - / : @ +,最长 512 字符);非法引用会被丢弃并告警,回退到默认镜像。例如oven/bun:1ghcr.io/astral-sh/uv:bookworm-slim
  • scripts:生命周期脚本。setup在沙箱供给时运行一次(装系统级依赖),install安装项目依赖,start启动 Worker 进程。省略scripts时,Engine 会依据runtime.kindruntime.package_manager推断installstart。常用示例:
scripts: setup: "apt-get update && apt-get install -y build-essential" install: "npm install" start: "npx tsx watch src/index.ts" # Node 开发热重载
  • env:注入 Worker 进程的环境变量映射(键值均为字符串)。III_URLIII_ENGINE_URL会被静默过滤——连接 URL 由 Engine 自行设置。
  • dependencies<worker-name>: <semver 范围>映射,按 registry 解析。每条范围须为合法 semver 需求(如^1.2~0.5.0>=2 <3);重复键是错误;不能依赖自身;prerelease 范围语法上可接受,但默认 resolver 只提供稳定版本,所以最终会以version_not_found失败。
dependencies: iii-http: "^0.19" iii-state: "^0.19"
  • resources(可选):沙箱的 CPU 与内存请求,超出上限会被钳制(cap 内)。cpus默认2、上限4memory默认2048MiB、上限4096MiB。对 bundle 类 Worker,超限时安装会发出W182 BundleResourceClamped告警。
resources: cpus: 2 memory: 2048

三、编写 Function:Worker 贡献的能力

Worker 通过注册 Function 向系统贡献能力。每个 Function 有一个service::name形式的id、一个接收 payload 并返回结果的 handler,以及可选的请求/响应 JSON Schema(详见 docs/0-19-0/creating-workers/functions.mdx)。

注册 Function

// Node / TypeScript worker.registerFunction("math::add", async (payload: { a: number; b: number }) => { return { c: payload.a + payload.b }; });
# Python def add_handler(payload: dict) -> dict: return {"c": payload["a"] + payload["b"]} worker.register_function("math::add", add_handler)
// Rust worker.register_function(RegisterFunction::new("math::add", |input: AddInput| { Ok(serde_json::json!({ "c": input.a + input.b })) }));

附加请求 / 响应 Schema

Schema 与 Function 一并存储,会出现在 iii console、iii trigger --help等地方。注意:运行时校验目前尚不支持——附加的 Schema 仅作契约文档(供调用方、Agent 与 console 阅读),Engine 不会拒绝不符合 Schema 的 payload 或返回值。Node 传入原生 JSON Schema 对象;Python 可直接传 dict;Rust 则通过schemars::JsonSchema从入参/出参结构体自动生成。

HTTP 可调用 Function:把外部端点注册为普通 Function

除了进程内 handler,你还可以注册一个外部 HTTP 端点作为 Function:Engine 在该 Function 被调用时发起 HTTP 请求,Worker 只需声明端点。这适合把现有 API Gateway、webhook、serverless 平台(Lambda、Azure Functions、Google Cloud Functions)或任意第三方 API 暴露为常规 iii Function——注册后它和任何其他 Function 一样,可被worker.triggeriii trigger以及任意 Trigger type(queue、cron、state、http)调用。

HttpInvocationConfig字段:

字段类型默认值说明
urlstring(必填)Engine 在调用时请求的端点
method"GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE""POST"HTTP 方法
timeout_msnumber30000单请求超时(毫秒)
headersRecord<string, string>{}每次调用附加的请求头
authHttpAuthConfig(无)bearerhmacapi_key之一,配合token_key/secret_key/value_key

安全要点token_keysecret_keyvalue_key指定的是环境变量的名字而非密钥本身。Engine 在注册时从其自身进程环境解析,密钥只留在 Engine 主机上,绝不会通过 SDK 的 WebSocket 传输。

错误处理上,Engine 把调用 payload 作为 JSON 请求体发送,任何非 2xx 响应或网络错误都被视为调用失败并回传给调用方。HTTP 调用的 Function 会出现在engine::functions::list中,与进程内 handler 一样可通过 console 发现。

返回值、错误与注销

Function 要么返回值(由 handler 负责匹配文档化的 response schema),要么返回错误。handler 内抛出的错误会作为 invocation error 传给调用方,并附带 Worker 的堆栈:Node 转发error.stack,Python 转发traceback.format_exc(),Rust 转发底层错误的堆栈。Engine 不会吞掉它们。可以用这个区别表达"预期失败"(返回结构化错误值)与"非预期失败"(throw / raise / 返回Err)。

registerFunction返回一个带unregister()方法的句柄,可在运行时把 Function 从 Engine 移除;Worker 断开时其全部 Function 会被自动移除,挂起的调用报错退出。

四、编写 Trigger:绑定事件源,或发布自己的 Trigger 类型

Trigger 告诉 iii "何时调用哪个 Function"。一个 Trigger 由三部分组成:type(事件种类,如httpcron)、config(该类型的具体细节,如 HTTP path 或 cron 表达式)、function_id(要调用的 Function)。还可以指定可选的condition_function_id:Trigger 触发时,Engine 先用相同 payload 调用条件函数,返回真值才执行 handler,否则跳过调用——"何时做"(Trigger)与"做什么"(Function)由此保持分离(详见 docs/0-19-0/creating-workers/triggers.mdx)。

绑定既有 Trigger 类型

大多数时候,Worker 是 Trigger 的消费者:把函数绑定到其他 Worker 已发布的类型上,如http(来自 iii-http,把函数暴露为端点)、cron(来自 iii-cron,按计划执行)、队列消息(iii-queue)、state(iii-state,响应数据变更)。绑定使用worker.registerTrigger({ type, function_id, config })

// Node / TypeScript worker.registerTrigger({ type: "http", function_id: "math::add", config: { api_path: "/math/add", http_method: "POST" }, });

注册时发布该 Trigger type 的 Worker 必须在线,否则注册失败。config的形状由各 Trigger type 自行定义,记录在发布该类型的 Worker 文档中。

给 Trigger 附加元数据

每次绑定都可带一个可选的metadataJSON 对象(由消费者在注册时设置)。Engine 原样存储,并在两处呈现:

  1. 发布方 Worker 的TriggerHandler.registerTrigger(config)回调通过config.metadata看到它——发布方可据此做优先级提示、审计标签、路由键等记账;
  2. engine::triggers::list会在每条TriggerInfo上返回它,console 与做 discovery 的 Worker 都能读取。

注意区分:metadata由消费者在每个绑定上设置(自由标签袋);schemas由发布方声明 Trigger type 时设置(描述消费者交互的 JSON 形状,如http类型的绑定 config 是{ api_path, http_method },调用 payload 是{ method, headers, query_params, body })。

发布自己的 Trigger 类型:mini-http 完整示例

当你的 Worker 想成为事件发布方(HTTP 请求、webhook、文件变更、数据库更新)时,需要声明自己的 Trigger type。一个 Trigger type 由两部分组成:

  1. 一个字符串id(消费者绑定时的type: "mini-http");
  2. 一份由你的 Worker 在进程内维护的 per-binding 路由表。Engine 的注册表只是规范地记录绑定(engine::triggers::list返回的正是它),但 Engine 不负责分发——它把网络上任何消费者 Worker 的 bind/unbind 以回调形式转发给发布方 Worker,由发布方决定如何处理。

启动时用worker.registerTriggerType({ id, description }, handler)声明。你需要实现的TriggerHandler接口暴露两个回调:registerTrigger(config)(有消费者绑定时运行,config携带绑定实例的id、消费者的function_id和符合该类型形状的config,存进路由表)与unregisterTrigger(config)(解绑时从路由表删除)。

下面是一个迷你版 iii-http 的完整示例:声明mini-http类型,维护bindings表,并在收到 HTTP 请求时查找匹配绑定:

// Node / TypeScript import { registerWorker } from "iii-sdk"; import type { TriggerConfig, TriggerHandler } from "iii-sdk"; const url = process.env.III_URL; if (!url) throw new Error("III_URL must be set"); const worker = registerWorker(url); type MiniHttpConfig = { api_path: string; // 前导斜杠,如 "/orders" http_method?: "GET" | "POST" | "PUT" | "DELETE"; }; const bindings = new Map<string, TriggerConfig<MiniHttpConfig>>(); const httpHandler: TriggerHandler<MiniHttpConfig> = { async registerTrigger(config) { bindings.set(config.id, config); }, async unregisterTrigger(config) { bindings.delete(config.id); }, }; worker.registerTriggerType( { id: "mini-http", description: "Routes HTTP requests to bound functions" }, httpHandler, );

Python 版使用worker.register_trigger_type(RegisterTriggerTypeInput(id="mini-http", description=...), HttpHandler());Rust 版使用RegisterTriggerType::new("mini-http", "Routes HTTP requests to bound functions", HttpHandler::default())

分发事件:没有特殊的 "fire" API

当底层事件源送来内容(一个入站 HTTP 请求、一次 cron 滴答、一次 webhook 命中)时,发布方 Worker 从bindings表里查匹配项,然后用普通的worker.trigger(...)调用每个匹配函数——没有任何特殊 API:

// 在 Worker 的 HTTP listener 内部,method+path 匹配到 bindings 表项之后: const binding = bindings.get(matchedTriggerId); await worker.trigger({ function_id: binding.function_id, payload: { method, headers, body }, });

每次分发时,Engine 会评估消费者的config与可选的condition_function_id,再把匹配的调用路由到绑定函数,并把结果返回给调用方。

附加 Schema 与注销 Trigger Type

Trigger type 可携带两个可选 JSON Schema:trigger_request_format(消费者绑定时传入的 per-bindingconfig的 schema)与call_request_format(触发时你交付给绑定函数的 payload 的 schema)。它们会喂给 iii console、Agent 可读的 skills 和engine::trigger-types::list输出,让消费者知道该发什么、会收到什么。运行时校验同样尚未支持,Schema 只是契约文档。

需要在不重启 Worker 的情况下下线某个类型(底层资源进入维护模式、feature flag 关闭、滚动更换 schema)时,可调用worker.unregisterTriggerType(...)(Node / Python 传完整输入对象但只用其id字段,Rust 只传id字符串;Node 的registerTriggerType还会返回带.unregister()快捷方法的TriggerTypeRef)。Worker 断开时其公布的所有 Trigger type 会自动移除,因此这一步只在 Worker 保持连接时需要。

五、源码印证:SDK 与 Engine 的落地实现

以上 API 并非纸面设计,仓库中均有对应实现:

  • SDK 连接与地址解析:Node SDK 的registerWorkerDEFAULT_ENGINE_URL实现在 sdk/packages/node/iii/src/iii.ts(第 87–117 行附近的resolveWorkerName/resolveAddress,地址优先级为显式参数 >III_URL>ws://127.0.0.1:49134)。同一文件还定义了 Worker 注册时的命名空间冲突处理:WORKER_NAMESPACE_CONFLICT(同名 Worker 已在线上,连接被关闭,致命)与FUNCTION_NAMESPACE_CONFLICT(同命名空间内函数 id 冲突,连接保持,非致命)。
  • Engine 注册表engine::workers::listengine::functions::listengine::triggers::listengine::trigger-types::list等 discovery Function 由 engine/src/workers/engine_fn/mod.rs 提供,注册表核心逻辑在 engine/src/workers/registry.rs。
  • Worker 清单解析iii.worker.yaml的解析与校验在 iii-compose crate 的 crates/iii-compose/src/manifest.rs 中体现,Engine 侧对scripts.startbase_image等字段的处理可在 engine/src 的 Worker 管理相关模块中找到。

结语:从概览到实战的完整路径

回顾本节的完整脉络:Worker 是能力单元(workers.mdx)解决"如何写一个能连上 Engine 并部署的服务";Trigger(triggers.mdx)解决"什么事件触发这些 Function 运行";Function(functions.mdx)解决"Worker 贡献哪些可调用能力";配套的 worker-manifest.mdx 与 workers-registry.mdx 分别覆盖清单字段与发布/安装到 registry 的机制。

实践要点回顾:

  1. 新能力 = 新 Worker:脚手架用iii worker init,安装现成的用iii worker add
  2. 连接只需一个 WebSocket 地址(III_URL),Worker 可在任何语言、任何位置运行;
  3. registerFunction让函数可被worker.trigger/iii trigger直接调用(每个注册函数天然获得直接调用能力);
  4. 一个 Function 可绑定多个 Trigger:同一个函数可同时被 HTTP 请求、cron 调度与队列消息触发,函数代码不变;
  5. 需要新事件源时,用registerTriggerType发布自己的 Trigger type,Engine 只转发绑定回调,路由由你的 Worker 在进程内维护;
  6. 注册表是可观测的:engine::*::list系列函数与engine::*‑available事件让系统拓扑对每个 Worker 透明。

基于此,任何"如何给 iii 加 X?"的问题,答案都指向同一条路径:写一个 Worker

【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

微信小程序生鲜商城源码拆解:从全局配置到模板复用

简介&#xff1a;这套微信小程序生鲜商城项目附带完整截图与可运行源码&#xff0c;适合小程序入门开发者、前端学习者及电商项目实训人员&#xff0c;解决从页面设计到功能逻辑实现缺少完整参考的问题。资源压缩包共39个文件&#xff0c;大小仅576KB&#xff0c;其中15张png截…

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

Qt5.14.2 aarch64静态交叉编译完整手册与踩坑实录

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

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

Pytorch车牌识别实战:CNN+BiLSTM+CTC端到端方案解析

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

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

CMSIS-4遗产代码库的静态工程评测:迁移前如何摸清底细

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

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

SpringBoot家装预算系统开发实战

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

作者头像 李华