news 2026/9/30 1:21:41

新大陆物联网赛项C#开发:工程骨架、Token鉴权与数据闭环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
新大陆物联网赛项C#开发:工程骨架、Token鉴权与数据闭环

1. 工程骨架先立住:新大陆赛项C#项目的目录约定与依赖取舍

很多人第一次接触新大陆物联网技能赛的C#部分,卡住的地方往往不是"不会写代码",而是"项目建起来之后不知道往哪放、依赖怎么引、配置写在哪"。我见过不少选手,登录接口调通了,界面也能跑,结果到了评委现场要看工程结构,打开一看所有代码全堆在 Form1.cs 里,两千多行,方法名还是 button1_Click、button2_Click。功能是能跑,但后面只要赛题一改需求,自己都不敢动。所以这一步我不讲虚的,直接说怎么搭。

新建工程这一步,我的建议是 Visual Studio 里选"Windows 窗体应用(.NET Framework)",目标框架定在 .NET Framework 4.7.2 或者 4.8。为什么不选 .NET 6/8 的 WinForms?两个现实原因:一是比赛机房里装的往往是 VS2019 或者 VS2022 配 .NET Framework 的离线包,平台给出的示例工程、SDK、DLL 也基本都是 Framework 版本编译的;二是 NuGet 在机房断网状态下要从本地缓存恢复,Framework 的老包缓存命中率高得多。另外要避开 .NET Framework 4.0 及以下,那些版本对 async/await、HttpClient、TLS 1.2 的支持都很别扭,很多平台接口强制 HTTPS 或者 TLS 1.2,老框架上会直接抛"基础连接已关闭"。

目录结构我固定用这一套,这几年没换过:

NewlandIotDemo/ ├── Forms/ 界面层,一个业务一个窗体 ├── Models/ 接口返回实体、请求实体 ├── Services/ 接口封装、设备逻辑 ├── Utils/ 辅助类:配置读写、日志、格式转换 ├── Config/ 本地配置文件 └── libs/ 第三方或平台提供的不走 NuGet 的 DLL

分层不是为了好看,是为了排错时能快速定位。举个真实场景:模拟器明明在推数据,界面上就是不刷新。如果代码全堆在一起,你得从头顺着读;如果分了层,直接去 Services 里看一眼请求的 URL 和参数,两分钟就能判断是"请求没发出去"还是"发出去但解析错了"。

依赖方面,JSON 序列化我首选 Newtonsoft.Json(Json.NET)。理由很实在:新大陆平台的接口返回是 JSON,字段大小写经常不统一,Newtonsoft 用[JsonProperty("Name")]这种特性可以精准映射,容错比 System.Text.Json 宽松,遇到数字被写成字符串的情况也能自己写转换器兜住。HttpClient 直接用 .NET 自带的,不需要 RestSharp 之类的额外库——机房断网时少一个依赖就少一个坑。

这里有一个非常关键的细节:HttpClient 一定要做成静态单例,不要在每个方法里new HttpClient()。每次 new 都会新建一个连接池,频繁请求会大量占用本地端口,程序跑十几分钟就开始报"无法连接到远程服务器",这个坑我在练习阶段踩过两次,当时还以为是平台限流。

public static class ApiClient { public static readonly HttpClient Http = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip }) { Timeout = TimeSpan.FromSeconds(10) }; }

配置文件建议用 App.config 的 appSettings 节点,把平台地址、账号、密码、设备编号都放进去,代码里用ConfigurationManager.AppSettings["..."]读。赛题最怕的就是"账号换了要改代码",把可变的东西全部外置,改配置不改逻辑,这才是拿分的写法。

提示:如果平台给了 DLL 而没有给 NuGet 包,把 DLL 放进 libs 目录后在引用里添加,并且务必把该引用的"复制到本地"设为 True,否则换台机器运行就报"找不到程序集"。

2. 鉴权链路的完整拆解:Token 的获取、携带与失效重连

新大陆物联网云平台的接口体系是典型的"先登录换凭证,再拿凭证访问业务接口"。这一步看起来简单,实际上它是整篇里最容易出连锁问题的地方——Token 拿不到,后面所有接口全是白搭;Token 拿对了但携带方式错了,返回的还是认证失败。

先说登录这一环。请求方式是 POST,请求体里放账号和密码,返回的 JSON 外层一般会有状态码和消息,真正的数据在类似 ResultObj 这样的字段里,里面装着 AccessToken 之类的字符串凭证。不同平台的字段名可能是 AccessToken、access_token、token,大小写也不一定统一,所以我的做法是实体类里挂多个 JsonProperty 兜底,或者干脆先用 JObject 打一次日志,把原始 JSON 打印出来看清楚再定实体。

public class ApiResult<T> { public int Status { get; set; } public string Msg { get; set; } public T ResultObj { get; set; } } public class LoginResult { [JsonProperty("AccessToken")] public string AccessToken { get; set; } [JsonProperty("ExpireTime")] public long ExpireTime { get; set; } }

凭证的携带方式,常见有两种:一种是放在请求头里,比如AccessToken: xxxxx或者标准的Authorization: Bearer xxxxx;另一种是拼在查询字符串里。这两种的差别很实际:放请求头更规范,但如果凭证里含有+、/、=这些 Base64 常见字符,拼到 URL 里就必须做 URL 编码,否则服务端解出来是错的,表现就是"同一个 Token,Postman 里能用,代码里就 401"。所以我的建议是能放请求头就放请求头,实在要拼 URL,用Uri.EscapeDataString()处理一遍。

封装上我习惯做一个统一入口,所有业务请求都从这里走,这样加请求头、加日志、加异常处理只写一次:

public static async Task<T> GetAsync<T>(string relativeUrl) { var req = new HttpRequestMessage(HttpMethod.Get, relativeUrl); req.Headers.TryAddWithoutValidation("AccessToken", TokenStore.AccessToken); var resp = await ApiClient.Http.SendAsync(req); var text = await resp.Content.ReadAsStringAsync(); Logger.Info($"{relativeUrl} -> {(int)resp.StatusCode} {text}"); if (!resp.IsSuccessStatusCode) throw new ApiException((int)resp.StatusCode, text); return JsonConvert.DeserializeObject<T>(text); }

注意TryAddWithoutValidation这个细节。有些 Token 里出现了不符合 HTTP 头规范字符的情况,用Add会直接抛异常,用TryAddWithoutValidation才塞得进去。这个坑排查起来特别费时间,因为异常信息和服务端返回完全对不上。

接下来是失效重连,这是第二个容易翻车的地方。凭证一般有有效期,练习时可能一两个小时没事,正式比赛连续跑三四个小时,中途 Token 过期,界面突然全部报错。正确做法是在统一入口里捕获认证类错误(HTTP 401 或者业务状态码表示未登录),触发一次重新登录,然后只重试一次。绝不能写成递归重试,账号密码错的时候会瞬间打出几百次请求,有的平台会临时封禁来源。

刷新 Token 还要考虑并发。如果你的程序里有两个定时器同时在拉数据,Token 一过期,两个线程会同时判断"需要重新登录",结果重复登录两次,前一次的 Token 被后一次的覆盖,其中一个请求就带着已经作废的凭证发出去了。解决方式很简单,加一把锁:

private static readonly SemaphoreSlim LoginLock = new SemaphoreSlim(1, 1); public static async Task EnsureLoginAsync() { if (!TokenStore.IsExpired) return; await LoginLock.WaitAsync(); try { if (TokenStore.IsExpired) // 双重检查,别人可能已经登过了 await DoLoginAsync(); } finally { LoginLock.Release(); } }

还有一个习惯性的错误:在每个按钮的点击事件里都调一次登录。这样写功能上没问题,但毫无必要,还会让接口调用记录变得很乱,排错时根本看不出哪次登录对应哪次业务请求。登录只做一次,Token 放在静态存储里,全程序共享。

注意:调试阶段一定要把每次请求的完整 URL、状态码、返回体写进日志文件。凭经验说,物联网赛项里 70% 的"功能不生效",答案都在这份日志里,而不是在界面代码里。

3. 模拟器侧怎么配:把"设备"和"传感器"提前定义清楚

C# 端能不能顺利取到数据,一半取决于平台侧配置对不对。很多选手把时间全花在写代码上,结果平台里设备和传感器的定义是乱的,ApiTag 随便起名,后面代码里写了一堆硬编码,赛题一变就全废。

平台侧的操作顺序是固定的:新建项目 → 在项目下新建设备 → 在设备下添加传感器(也叫数据点、数据流)→ 用模拟器绑定这台设备并开始上报。这四步里,第三步最容易被敷衍,但它决定了后面所有代码。

传感器定义的核心字段是ApiTag,它是设备内某个传感器的唯一标识,也是 C# 端取数和下发命令时使用的钥匙。名称可以叫"温度",但 ApiTag 我建议用纯英文、短、无空格,比如Temp、Humi、Led、Motor。原因是 ApiTag 会出现在 URL 路径、JSON 字段和代码常量里,中文或者带空格的 ApiTag 在三处地方都要转义,多一次转义就多一次出错机会。

传感器的数据类型也要提前想清楚,因为它直接决定 C# 实体类字段用什么类型:

数据类型典型场景C# 对应处理
开关量LED、继电器、风扇反序列化为字符串后判断 "1"/"0"/"true"
模拟量温度、湿度、光照、电压尽量用 double,显示时格式化保留一位或两位小数
字符串/枚举工作模式、状态码直接 string,避免强行转数字
GPS/复合值经纬度、坐标用对象或字符串拆解,别硬塞进 double

模拟器的作用是替代真实硬件,它的行为逻辑是"按你设定的周期,把你填的数值推到平台"。这就带来一个很重要的认知差异:模拟器不会校验物理合理性。你可以把温度设成 9999,湿度设成 -300,平台照样接收并存储。所以拿模拟器测出来的数据去验证业务逻辑是可以的,但千万别用它去验证"阈值告警"这类依赖真实量程的功能,否则上线到真设备时判据全是错的。

上传周期这一项,我的经验值是 2 到 5 秒一次。设成 1 秒甚至更短,短时间看着很爽,但历史数据接口很快会返回成千上万条记录,界面卡死、内存飙升,而且平台对请求频率一般有限制,超了会返回错误。设成 30 秒又太慢,调试时点一下刷新等半天,效率很低。2 到 5 秒是调试期最舒服的区间,正式演示前再调回贴近真实设备的频率。

模拟器还有一个要留意的点:数据的时间戳有两种来源,一是设备上报时间,二是平台接收时间。这两者在模拟器上差得不明显,但在真实设备上,尤其是设备本地时钟不准或者时区设置不对的情况下,可能相差 8 小时。你在 C# 端做时间过滤、画趋势图的时候,如果发现数据"跑到未来去了"或者"少了最近一段时间",先去核对这一项,别急着怀疑自己的代码。

最后提醒一句关于设备状态的事。有些平台要求设备先处于"激活"或"在线"状态,数据接口才会返回内容。模拟器没启动、或者长时间没上报,设备会被标记为离线,这时候即使你请求的参数完全正确,返回的也可能是空数组。所以调试顺序应该是:先在平台页面确认模拟器在正常上报、图表上有数据跳动,再去调 C# 端。

4. C# 端读取传感器数据:从 JSON 字段到界面控件的映射

平台侧数据动起来了,接下来才是写代码的正戏。新大陆平台的数据读取一般提供几种粒度:查某个设备下所有传感器的最新值、查某个传感器的最新值、查某个传感器的历史数据(带时间范围)。实战里用得最多的是第一种,一次请求把所有数据点拿回来,界面一次性刷新。

返回结构通常是一个数组,每个元素代表一个传感器的当前状态,里面包含名称、ApiTag、当前值、单位、记录时间等字段。大致长这样:

JSON 字段含义C# 处理建议
Name传感器显示名界面上显示,不要参与逻辑判断
ApiTag唯一标识逻辑判断的唯一依据
Value当前值类型不定,建议先当 JToken 处理
Unit单位拼接显示,注意 null
RecordTime记录时间解析后转换为本地时间

关于 Value 这个字段我要多讲两句,它是实操中翻车率最高的一处。同一个接口,开关量返回"1"这样的字符串,模拟量返回26.5这样的数字,某些复合类型甚至返回数组。如果你直接把实体类的 Value 定义成 double,遇到字符串就会抛 JsonReaderException,整个列表一条都解析不出来——表现就是"接口明明返回了数据,界面上什么都没有"。

我的处理方式是先解析成 JToken,再按实际类型转换:

public class SensorItem { public string Name { get; set; } public string ApiTag { get; set; } public JToken Value { get; set; } public string Unit { get; set; } public DateTime? RecordTime { get; set; } public double AsDouble() { if (Value == null) return 0; return double.TryParse(Value.ToString(), out var d) ? d : 0; } public bool AsBool() { var s = Value?.ToString(); return s == "1" || string.Equals(s, "true", StringComparison.OrdinalIgnoreCase); } }

界面绑定我一般用 BindingList 配 DataGridView。BindingList 的好处是支持自动通知刷新,你只需重新赋一次 DataSource,表格就更新了。如果字段名是英文的,可以在 DataGridView 的列定义里手动设置 HeaderText,或者用 DisplayName 特性配合自定义列,让评委看到的中文列头和代码解耦。

定时刷新这一块有个经典问题:跨线程访问控件。我的做法是用 WinForms 的 Timer,它的 Tick 事件本身就在 UI 线程上触发,只要在 await 之后不写ConfigureAwait(false),await 回来之后仍然回到 UI 线程,可以直接操作控件,不需要 Invoke。

private bool _busy; private async void refreshTimer_Tick(object sender, EventArgs e) { if (_busy) return; // 防止上一次还没回来又发一次 _busy = true; try { var list = await DeviceService.GetLatestAsync(DeviceId); grid.DataSource = new BindingList<SensorItem>(list); lblLastUpdate.Text = "更新于 " + DateTime.Now.ToString("HH:mm:ss"); } catch (Exception ex) { lblLastUpdate.Text = "读取失败:" + ex.Message; } finally { _busy = false; } }

这里有两个小设计值得说一下。一个是_busy标志位,因为 Timer 的事件是 async void,如果网络慢,上一次请求还没返回下一次就触发了,请求会越堆越多,最后界面假死。加个标志位,同一时刻只有一个请求在路上,简单有效。另一个是把异常就地捕获并显示到状态栏,而不是让它抛到顶层。比赛现场网络波动是常态,程序崩掉比显示一行"读取失败"严重得多。

如果要做历史曲线,思路是先请求一段时间范围的历史数据,把返回的数组按 RecordTime 排序,然后绑定给图表控件。注意排序别用字符串排,先把时间解析成 DateTime 再排,否则跨天的时候顺序会乱。

5. 上行与下行闭环:命令下发、执行回查与状态同步

只读数据只能拿一半分。真正的物联网应用是双向的:读传感器是一路,控制执行器是另一路。新大陆平台的命令下发接口通常接收设备编号、目标传感器的 ApiTag、命令类型和命令值这几个参数。

public class ControlCmd { public long DeviceId { get; set; } public string ApiTag { get; set; } public string CmdType { get; set; } // 具体取值以平台文档为准 public string CmdValue { get; set; } }

有几个实操细节必须提醒。第一,命令值建议统一用字符串传。开关量传"1"和"0",模拟量传"50",这样客户端只负责拼字符串,服务端自己按类型转换,能避开很多序列化层的类型争议。第二,ApiTag 写错时接口很可能返回成功,因为平台只是把你的命令投递到设备消息队列,并不会去校验这个标识到底存不存在。表现就是"提示下发成功,设备一动不动"。所以下发功能必须有回查,这是判断成败的唯一标准。

回查的方式就是下发完等一两秒,再调一次读取接口,看对应 ApiTag 的值有没有变。这个动作我建议做成自动的:

public static async Task<bool> SetAndVerifyAsync(long deviceId, string apiTag, string value) { var ok = await DeviceService.SendCmdAsync(new ControlCmd { DeviceId = deviceId, ApiTag = apiTag, CmdType = "switch", CmdValue = value }); if (!ok) return false; for (int i = 0; i < 3; i++) { await Task.Delay(800); var now = await DeviceService.GetSensorAsync(deviceId, apiTag); if (now.AsBool() == (value == "1")) return true; } return false; }

这段代码里Task.Delay(800)不是凑数,而是给设备侧留执行时间。模拟器响应很快,真实执行机构(继电器、电机)可能有几百毫秒的机械延迟,立刻回查大概率读到旧值,误判为失败,然后你又重发一次,最后变成"命令风暴"。

第三个细节是界面状态同步。开关按钮点下去之后,不要立刻把按钮文案改成"已开启",而要等回查确认成功再改。中间这段时间按钮应该是禁用状态或者显示"执行中"。这个小交互在评分表里往往单独占分,因为它体现了对异步操作的完整理解。

再讲一个并发上的注意点。如果界面上有多个控制按钮,用户手快连点几下,或者你一边在轮询读取,一边在批量下发,请求会交叉。除了前面说的SemaphoreSlim限流,还可以在业务层对同一个设备加互斥,保证同一设备的读写是顺序的。物联网场景里,读到的数据和设备真实状态本来就是弱一致的,你把请求顺序理顺,弱一致的范围就小很多,界面看起来就"跟得上"。

命令下发一般还有频率限制。别在代码里写循环疯狂发命令测边界,一是可能被服务端限流返回错误,二是日志里会混入大量噪声,排查真正问题时被淹没。要压测就单独写个测试方法,不跑在正式演示的代码路径上。

6. 排查链路实录:接口返回 200 却拿不到数据的几种真相

这一节我按排查顺序写,因为这才是真正省时间的地方。遇到"读不到数据",不要一头扎进自己的代码,按下面这条链路走,基本十分钟内能定位。

第一步,看平台页面。登录云平台,打开对应项目下的设备详情,看最近上报时间和数据曲线。如果平台上就是空的,那问题百分之百在设备侧或模拟器侧,跟 C# 无关。这一步能帮你省掉一半的无效调试。

第二步,看请求本身。把每次请求的完整 URL、请求头、返回体写进日志。这一条我在前面强调过,这里再强调一次,因为太多人的日志只打了"请求失败"四个字。

第三步,对照下面这张表逐项排除:

现象大概率原因处理方式
HTTP 401 / 403凭证缺失、过期、携带方式错检查请求头字段名,确认是否需要 URL 编码,触发重新登录
HTTP 200 但数组为空设备离线、ApiTag 不存在、时间范围过滤掉了数据先在平台确认在线状态,再核对 ApiTag 拼写
HTTP 404资源路径拼错,设备编号或项目编号写错把 URL 抄进浏览器或调试工具单独验证一次
返回了数据但界面空白反序列化抛异常被 catch 吞掉,Value 类型不匹配看日志里的异常栈,把 Value 改成 JToken
数值全为 0解析时 double.TryParse 失败走了默认值打印原始字符串,检查是否有单位后缀
时间显示差 8 小时服务端 UTC,本地未转换解析后统一 ToLocalTime
跑十几分钟后全部超时HttpClient 每次 new 导致端口耗尽改成静态单例
中文乱码响应内容编码未按 charset 处理用 HttpClient 直接读字符串,别自己按字节拼

重点说一下"HTTP 200 但数组为空"这个情况,因为它最具迷惑性。我遇到过的三个具体原因分别是:设备长时间没上报被平台标记离线,这时候接口不报错,只是不返回数据;ApiTag 里多了一个看不见的全角空格,肉眼完全看不出来,是复制粘贴带进去的;历史数据接口带了默认的时间范围参数,而模拟器的时间戳和本地时间不一致,导致查询范围落空。三个原因都不是代码问题,但都会让你以为是自己写错了。

还有一个容易被忽略的现象:程序单跑正常,多开一个窗体或者多开一个定时器就开始报错。这通常是并发问题。表现是偶发的连接被关闭或者返回异常数据。处理方式是把所有网络请求收敛到一个带信号量限流的服务类里,最大并发设为 1 到 2,不要指望服务端无限扛并发。

最后说浮点精度。温度 26.5 在 JSON 里可能被写成 26.499999999999996,直接显示到界面上很难看。显示前统一格式化,value.ToString("F1")或者ToString("0.0"),既好看又避免评委觉得数据异常。这个属于展示细节,但比赛里展示细节就是分数。

7. 备赛资料与练习路径:我整理速查表的方法

原始文章结尾说"还有一些资料链接自取",但链接这东西有时效性,我更想分享的是怎么自己攒出一套比链接更有用的东西。因为赛项文档、平台开发文档、接口说明这些东西,真正用起来你会发现"查得到"和"查得快"是两件事。

我的做法是建一张自己的速查表,分成三块。第一块是接口清单,列出每个接口的方法、路径、关键参数、返回示例,全部用手抄或者自己请求一遍的结果填进去,而不是复制文档里的描述。自己跑过一遍的接口,才真正知道它返回什么。第二块是 ApiTag 对照表,把设备上每个传感器的名字、ApiTag、数据类型、单位、量程列出来,贴在显示器边上。调试时眼睛扫一眼就能写代码,不用来回切页面。第三块是错误码和异常对照表,就是上一节那张排查表的个人版本,每次踩到新坑就加一行。

文档的获取渠道,按优先级排是:平台自带的开发文档和在线接口调试页面,这个是第一手资料;平台提供的示例工程或 SDK,哪怕是用别的语言写的,看它怎么拼请求、怎么带凭证,价值极高;再有就是历年的赛项规程和技术文件,它规定了评分点,你写的功能要对得上评分表,而不是自己觉得好就行。

关于 SDK 里没有文档的 DLL,如果允许的话,可以用反编译工具看一下内部的类型和方法签名,尤其是方法上的注释。但这条要谨慎使用,一是要遵守相关使用规定,二是别把时间大量花在逆向一个你其实可以直接用 HTTP 调的平台接口上。我个人的取舍是:能用公开的 HTTP 接口搞定,就不去折腾闭源组件,可控性差太多。

练习路径我建议按这个节奏走:先用模拟器把"登录、读数据、显示、下发、回查"这五步各自跑通一遍,每一步单独写个按钮验证;然后把五步串成一个完整流程;最后加入异常分支,故意断网、故意填错密码、故意让 Token 过期,看看程序能不能优雅地提示而不是崩溃。这三轮下来,基本功能就稳了。

还有一个很实用的小习惯:把每次调试的请求和响应存成文件,按日期归档。赛前一周回顾的时候,翻这些文件比翻脑子里的记忆靠谱得多,很多当时觉得"记住了"的字段名和参数格式,过两周就模糊了。

我在实际操作中最大的体会是,这个赛项里真正拉开差距的不是谁写的代码更花哨,而是谁的链路更完整、异常处理更到位、配置和结构更清晰。模拟器只是替代了硬件,它没有替代你对整个数据链路的理解,把上行、下行、异常这三条线都想清楚了,换任何平台、任何设备,套路都是通的。

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

指纹芯片选型:整机系统级协同设计指南

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

作者头像 李华
网站建设 2026/9/30 1:20:52

PLC调试90个实战坑:从编程到电气设计的避坑笔记

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

作者头像 李华
网站建设 2026/9/30 1:19:38

pandas时间列处理核心:dt模块原理与工程实践

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

作者头像 李华
网站建设 2026/9/30 1:19:24

Linux虚拟机发行版选型避坑指南:Arch/Debian/RHEL实战差异

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

作者头像 李华
网站建设 2026/9/30 1:19:19

Pixel刷机卡在WiFi设置页?四种方案跳过设置向导校验

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

作者头像 李华
网站建设 2026/9/30 1:18:53

ESP32 接入大模型 API 不算 AI 硬件:八大工程坑与系统设计

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

作者头像 李华