[{"data":1,"prerenderedAt":1632},["ShallowReactive",2],{"\u002F2026-04-08":3,"\u002F2026-04-08-rel":138},{"id":4,"title":5,"body":6,"column":121,"date":122,"description":12,"extension":123,"hero_image":124,"meta":125,"navigation":126,"path":127,"seo":128,"series_id":124,"severity":124,"stem":129,"summary":130,"tags":131,"__hash__":137},"posts\u002F2026-04-08-设备在线状态为什么会飘.md","设备明明在线，状态却一直跳",{"type":7,"value":8,"toc":114},"minimark",[9,13,16,20,27,30,36,39,50,53,59,62,81,84,87,96,99,102,105,108,111],[10,11,12],"p",{},"MQTT 的连接状态和\"设备在线\"这个业务概念不是一回事。直接用连接\u002F断开事件驱动设备状态会导致频繁抖动——用户界面上设备一会儿在线一会儿离线，或者后台任务反复重试，都源于这个混淆。",[10,14,15],{},"问题的根源在于混淆了网络协议层和业务层的两个不同概念。MQTT broker 记录的\"连接\"是 TCP 会话的建立，而业务上的\"设备在线\"应该表达\"设备功能可用\"。两者之间隔着网络传输、消息队列、客户端重连等多个可能出错的环节。",[17,18,19],"h2",{"id":19},"三个源头",[10,21,22,26],{},[23,24,25],"strong",{},"第一个是网络抖动。"," 网络瞬时中断会触发MQTT连接断开，但通常在秒级内重新连上。MQTT客户端库一般都有自动重连机制，收到断开通知后立即尝试重连。如果业务逻辑依赖连接事件，这短暂的断开就被当作\"离线\"上报，导致状态反复切换。例如设备在某个Wi-Fi信号不稳定的位置，可能每分钟产生3-5次连接\u002F断开事件。用户看到的就是设备状态在一分钟内闪烁十几次。",[10,28,29],{},"这种抖动在移动设备上特别明显。设备从一个Wi-Fi热点移动到另一个、或从Wi-Fi切到蜂窝网络、再切回Wi-Fi，每一次切换都会产生一次短暂的网络中断。后台的指令重试、日志上报等任务也会反复启动和取消，浪费电量和网络带宽。",[10,31,32,35],{},[23,33,34],{},"第二个是遗嘱消息的延迟。"," MQTT 的 Last Will and Testament（LWT）机制是这样的：客户端连接时向服务端注册一条遗嘱消息，如果客户端异常断开（而不是正常关闭连接），服务端会在一个keep-alive超时周期后代为发布这条消息。超时时间由客户端的 keep-alive 参数决定，通常是60秒。即使是10秒的超时，对于用户感知来说也是明显的延迟——设备下线了10秒后才被标记离线。",[10,37,38],{},"更糟的是消息到达顺序问题。设备先由于网络抖动而异常掉线，服务端记录下这个事件但还在等待超时才能发 LWT。此时设备已经重新连上，上报了新的连接事件和\"我在线\"的心跳。但随后 LWT 消息才被投递，这条迟到的遗嘱消息被用来更新状态，刚刚上线的设备又被判成离线。这就是\"死而复生\"的情况。要修复它，需要确保消息的因果顺序被正确处理——新连接的消息必须总是覆盖旧连接的 LWT，而不是相反。",[10,40,41,44,45,49],{},[23,42,43],{},"第三个是重连时的会话交叠。"," ",[46,47,48],"span",{},"待补：会话标识的具体场景描述","。简单说，客户端断开后重新连接的过程中，旧连接和新连接在短时间内可能同时存活在服务端。MQTT 服务端通常用客户端 ID（client ID）来唯一标识一个逻辑客户端，但不区分\"同一个 ID 的第N次连接\"是旧连接还是新连接。如果处理不当，新连接的消息和旧连接的清理事件可能乱序处理：新连接发来\"我在线\"，但旧连接的\"断开\"事件此时才被处理，状态又变成离线。或者旧连接还没来得及发送 LWT，新连接就已经改变了状态，但后续 LWT 消息被错误地应用到了新连接之上。这会导致整个状态机陷入不一致。",[17,51,52],{"id":52},"三层防护",[10,54,55,58],{},[23,56,57],{},"第一层：去抖窗口。"," 不要在收到单个连接\u002F断开事件时立即改变设备状态。改为记录事件的时间戳，再用一个去抖窗口来过滤（典型值：5-15秒）。只有状态在窗口期内保持一致，才最终确认这个状态变更。这样可以吸收网络毛刺和短暂的异常断开。例如设备在5秒内出现\"连->断->连\"的抖动，去抖窗口可以忽略这个抖动，最终确认设备仍然在线。",[10,60,61],{},"但这只是表面文章。去抖窗口治标不治本——它隐藏了问题，但没有从根本上解决状态不一致的根源。如果关键业务逻辑（如指令下发）直接依赖这个抖动的状态，去抖5秒和去抖15秒的区别就可能是收到指令和没收到的区别。真正的防护靠后面两层。",[10,63,64,67,68,72,73,76,77,80],{},[23,65,66],{},"第二层：心跳而非连接事件。"," 这是关键转换。设备不依赖 MQTT 连接本身来表示\"在线\"，而是定期上报心跳消息。例如设备每30秒上报一条心跳消息到专用 topic（如 ",[69,70,71],"code",{},"toynet\u002Fdevice\u002F{deviceSn}\u002Fheartbeat","），服务端通过消费这个 topic 来更新设备的 ",[69,74,75],{},"lastHeartbeat"," 时间戳。设备在线的判据变成：",[69,78,79],{},"lastHeartbeat \u003C 现在 - 90秒"," 则离线，否则在线。",[10,82,83],{},"90秒是容错窗口的设计。设备每30秒上报一次，两个周期加上网络延迟最多60秒，保留30秒的余量。这意味着即便连接反复震荡，甚至一次心跳消息丢失，只要没有连续90秒没有心跳，设备就保持在线。这样既吸收了网络抖动，也给了消息传输足够的容错空间。",[10,85,86],{},"心跳本身是一条普通的业务消息，不绑定 TCP 连接的生命周期。即使 MQTT 连接因为网络抖动断开又重连了，只要心跳消息能被投递到，服务端就能正确判断设备的实际状态。这打破了\"连接 = 在线\"的一对一映射，引入了一个独立的信号源。",[10,88,89,44,92,95],{},[23,90,91],{},"第三层：会话标识隔离旧连接。",[46,93,94],{},"待补：具体的会话标识方案、实现细节","。原理是：每次连接时生成一个会话标识符（可以是 UUID、或连接时间戳+随机数），客户端在上报心跳和其他消息时携带这个会话 ID。服务端收到消息时记录当前连接的会话 ID，收到断开事件也记录对应的会话 ID。关键是：只有来自\"当前会话\"的消息和事件才被用来更新设备状态，来自旧会话的事件直接丢弃。",[10,97,98],{},"这防止了会话交叠期间的状态混乱。例如旧连接的 LWT 消息迟到时，服务端检查发现它对应的会话 ID 是\"session-old-123\"，而当前连接的会话 ID 是\"session-new-456\"，就忽略这条 LWT，不用它来改变设备状态。新连接发来的\"我在线\"（会话 ID：\"session-new-456\"）保持生效。这样即使 LWT 消息交错到达，也不会倒流状态。",[17,100,101],{"id":101},"落地要点",[10,103,104],{},"心跳超时的90秒数字不是随意设置。设备每30秒上报一次心跳，这意味着理想情况下服务端最多30秒收到一条。但网络不总是理想的——消息可能因为网络延迟延后10秒、或因为服务端消费队列繁忙而被延后处理15秒。设置90秒意味着：即便两个心跳周期（60秒）都加上网络和处理延迟的上界（30秒），还有30秒的最后安全边界。超出这个范围再判离线，误触发率控制在可接受的范围。",[10,106,107],{},"如果将超时设为30秒（仅一个周期），单次心跳延迟就可能触发误判。设为60秒可能看起来\"更稳妥\"，但代价是真实离线的设备需要等60秒才被发现，影响业务响应速度。90秒是在响应速度和稳定性之间的平衡。",[10,109,110],{},"指令下发时，如果设备在线（基于心跳判断）则立即尝试下发。如果设备离线，需要根据业务需求决定是否入队。如果选择入队（例如对关键指令），要清楚地告知前端\"设备离线，指令已记录但不保证立即送达\"。设备重新在线后，优先处理队列中的待发指令。这避免了用户\"我下发指令了为什么没反应\"的困惑。",[10,112,113],{},"实装时还有一个易忽视的坑：MQTT broker 和客户端的 keep-alive 参数要协调。MQTT keep-alive 是 TCP 连接保活的参数，客户端每隔 keep-alive 时间必须向 broker 发送某条消息（包括 PING），否则 broker 可能会关闭连接。如果 broker 的 keep-alive 是60秒，但业务层的心跳周期是30秒，这两者之间没有冲突。但如果心跳周期是90秒，而 keep-alive 是60秒，TCP 连接可能在接收心跳之前就被 broker 主动断开。通常的做法是让客户端 keep-alive 小于业务心跳周期的一半，或者心跳消息本身也被用作 keep-alive 信号。",{"title":115,"searchDepth":116,"depth":116,"links":117},"",2,[118,119,120],{"id":19,"depth":116,"text":19},{"id":52,"depth":116,"text":52},{"id":101,"depth":116,"text":101},"工程手记","2026-04-08","md",null,{},true,"\u002F2026-04-08",{"title":5,"description":12},"2026-04-08-设备在线状态为什么会飘","MQTT连接状态与业务在线状态不同步导致频繁抖动的原因分析，以及心跳机制、去抖、会话识别三层防护方案。",[132,133,134,135,136],"MQTT","设备在线","状态管理","心跳机制","IoT","RVsK4w0Hz-2_5oGMtxmT5-12lbO4BjITTpcG-XxE91A",[139,340,700],{"id":140,"title":141,"body":142,"column":121,"date":325,"description":326,"extension":123,"hero_image":124,"meta":327,"navigation":126,"path":328,"seo":329,"series_id":124,"severity":124,"stem":330,"summary":331,"tags":332,"__hash__":339},"posts\u002F2026-09-10-Ai项目群的工程化分水岭.md","AI 项目做多以后，真正难的是把边界接起来",{"type":7,"value":143,"toc":313},[144,151,154,157,161,166,176,179,182,198,201,205,224,227,231,240,243,247,250,253,275,278,282,285,288,291,294,297,300,304,307,310],[10,145,146,147,150],{},"这段时间回看 ",[69,148,149],{},"\u002FUsers\u002Fxingye\u002FAi"," 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",[10,152,153],{},"早期问题集中在“功能能不能跑起来”：语音推理、网页生成、桌面端启动、小程序页面、视频服务。进入平台化阶段以后，问题变成了另一组：任务是否可追踪，失败是否能恢复，费用是否能对账，版本是否能回滚，用户是否知道发生了什么。",[10,155,156],{},"这是一条从功能开发走向系统工程的分水岭。",[17,158,160],{"id":159},"一项目群已经形成几条清晰的产品线","一、项目群已经形成几条清晰的产品线",[162,163,165],"h3",{"id":164},"agent-与多租户平台","Agent 与多租户平台",[10,167,168,171,172,175],{},[69,169,170],{},"yun-claw"," 与 ",[69,173,174],{},"yun-claude"," 的记录显示，平台主线已经覆盖用户容器、模型接入、画布工作流、代理渠道、后台管理和运营工具。近期提交涉及画布自绘 Select、资产大图预览、代理激活码申请，以及渠道注册页的差异化展示。",[10,177,178],{},"这些功能看起来分属不同页面，底层却共享一套问题：用户身份、资源权限、异步任务和状态反馈必须保持一致。只改页面，不补状态契约，功能就会在跨页面操作时暴露断点。",[162,180,181],{"id":181},"内容生产流水线",[10,183,184,187,188,187,191,171,194,197],{},[69,185,186],{},"ai-write","、",[69,189,190],{},"instantlink-site",[69,192,193],{},"ai-auto-edit",[69,195,196],{},"ai-digital-human-workflow"," 分别覆盖写作、漫剧、自动剪辑和数字人视频。提交记录里反复出现几个关键词：任务网关、切片下载、共享卷、FFmpeg、提示词优化、失败提示和构建复现。",[10,199,200],{},"这说明内容生产已经不是单次调用模型，而是一条有中间产物的流水线。文本、图片、视频、音频和项目资产都有生命周期，任何一步的状态丢失都会让用户无法判断下一步该做什么。",[162,202,204],{"id":203},"交付网关与桌面端","交付、网关与桌面端",[10,206,207,187,210,187,213,187,216,219,220,223],{},[69,208,209],{},"newCodex",[69,211,212],{},"new-openclaw",[69,214,215],{},"openclaw",[69,217,218],{},"服务器管理"," 和 ",[69,221,222],{},"SSHTS"," 的记录，集中在更新、恢复、远程诊断、单实例生命周期和离线环境上。这里的核心指标不是“新版本发布了没有”，而是安装失败能否定位、更新中断能否恢复、客户机器能否留下可审计证据。",[10,225,226],{},"这也是为什么桌面端工程会逐渐靠近运维工程：安装器、后台服务、日志、配置和回滚必须一起设计。",[162,228,230],{"id":229},"网关计费与数据观察","网关、计费与数据观察",[10,232,233,171,236,239],{},[69,234,235],{},"NewAPI",[69,237,238],{},"new-api"," 的近期记录包括按模型统计 Token 消耗、扣费量、时间粒度聚合、昨日活跃用户和订单搜索。网关不只是转发请求，它还承担了模型选择、消耗记录、后台观察和问题追踪。",[10,241,242],{},"一旦计费数据进入用户体验，接口成功与业务成功就不再是同一个概念。请求返回 200，只能说明一次网络调用完成；扣费、产物落盘和任务终态是否一致，需要单独核对。",[17,244,246],{"id":245},"二git-记录里最有价值的变化是边界被写出来了","二、Git 记录里最有价值的变化，是“边界被写出来了”",[10,248,249],{},"多个项目的文档都在补同一类内容：部署手册、验收清单、测试报告、故障复盘、模型环境快照和用户协议。",[10,251,252],{},"这类文档的价值不在于把代码再讲一遍，而在于把系统边界写清楚：",[254,255,256,260,263,266,269,272],"ul",{},[257,258,259],"li",{},"输入是什么，哪些字段必须存在；",[257,261,262],{},"状态有哪些，什么条件才能进入下一状态；",[257,264,265],{},"外部服务超时后，如何判断请求是否已经执行；",[257,267,268],{},"产物写入失败时，是否允许重试；",[257,270,271],{},"更新中断后，哪个版本可以安全恢复；",[257,273,274],{},"用户看到的成功，是否和后台记录的成功一致。",[10,276,277],{},"当这些问题没有答案时，系统只能靠人工经验维持。一旦项目数量增加，经验就会变成不可复制的隐性依赖。",[17,279,281],{"id":280},"三下一阶段的重点不是再加一个入口","三、下一阶段的重点不是再加一个入口",[10,283,284],{},"从现有项目的提交和文档可以看到，下一阶段更值得投入的是四个基础能力。",[10,286,287],{},"第一，统一任务状态。不同项目都在处理异步任务，但状态命名、失败原因和重试策略不应各自发明。",[10,289,290],{},"第二，统一产物账本。一个任务产生了哪些文件、消耗了哪些资源、是否对用户可见，需要能从提交到终态完整追踪。",[10,292,293],{},"第三，统一故障出口。401、超时、部分成功、未知执行和版本回滚，应该有明确的用户反馈与后台证据。",[10,295,296],{},"第四，统一发布验收。构建通过不代表线上可用，应该把健康检查、代理路径、真实端口、关键日志和浏览器操作放到同一份验收清单里。",[10,298,299],{},"项目多以后，工程能力不再体现在“能写多少功能”，而体现在“能否让不同功能遵守同一套边界”。",[17,301,303],{"id":302},"四我会保留的判断","四、我会保留的判断",[10,305,306],{},"AI 项目最容易被低估的部分，不是模型调用，而是模型调用之后的系统。",[10,308,309],{},"生成只是起点。身份、任务、费用、存储、交付、恢复和反馈，决定了一个功能能不能进入真实使用。",[10,311,312],{},"当 Git 提交开始频繁出现“补状态”“补恢复”“补验收”“补审计”时，这不是工程变慢了，而是产品从演示阶段进入了可运营阶段。",{"title":115,"searchDepth":116,"depth":116,"links":314},[315,322,323,324],{"id":159,"depth":116,"text":160,"children":316},[317,319,320,321],{"id":164,"depth":318,"text":165},3,{"id":181,"depth":318,"text":181},{"id":203,"depth":318,"text":204},{"id":229,"depth":318,"text":230},{"id":245,"depth":116,"text":246},{"id":280,"depth":116,"text":281},{"id":302,"depth":116,"text":303},"2026-09-10","这段时间回看 \u002FUsers\u002Fxingye\u002FAi 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",{},"\u002F2026-09-10-ai",{"title":141,"description":326},"2026-09-10-Ai项目群的工程化分水岭","从多个 AI 项目的 Git 记录和设计文档看，工程难点已经从“能不能生成”转向任务、计费、交付、恢复与用户反馈能否形成闭环。",[333,334,335,336,337,338],"AI 平台","Agent","内容生产","网关","桌面端","工程化","DzOpOX92EsanPp8pLNTusbzBmKH3WOTf3qcaZAFmhM8",{"id":341,"title":342,"body":343,"column":121,"date":687,"description":347,"extension":123,"hero_image":124,"meta":688,"navigation":126,"path":689,"seo":690,"series_id":124,"severity":124,"stem":691,"summary":692,"tags":693,"__hash__":699},"posts\u002F2026-08-15-上线前我把整个仓库审了一遍.md","上线前，我把整个仓库审了一遍",{"type":7,"value":344,"toc":676},[345,348,351,355,358,379,382,385,389,392,395,398,401,405,408,411,477,480,487,491,497,500,503,510,514,517,528,531,535,538,547,550,563,566,569,573,576,583,589,592,596,599,604,621,626,640,643,646,650,653,673],[10,346,347],{},"上线前做了一次全量静态审查。",[10,349,350],{},"本文只讲方法和结论分布。审查报告本身不入库，也不在这里复述——那份报告带内网拓扑、密钥存放位置和尚未修复问题的文件行号证据，公开它就等于公开一份攻击者的地图。",[17,352,354],{"id":353},"一审查怎么进行","一、审查怎么进行",[10,356,357],{},"三个部分：",[359,360,361,367,373],"ol",{},[257,362,363,366],{},[23,364,365],{},"人工列清单。"," 先按「一个系统可能从哪些地方被打穿」列出检查面，而不是按目录扫。",[257,368,369,372],{},[23,370,371],{},"四个并行只读探查代理。"," 每个代理负责一组检查面，只读，不允许改任何文件。",[257,374,375,378],{},[23,376,377],{},"关键证据人工逐条复核。"," 代理解释现象可以，但「这条算不算风险、算多重」由人定。",[10,380,381],{},"第三步是必要的。代理很容易把「写法不理想」报成「高危」，也很容易漏掉需要跨文件才能看出的问题。它适合做的是穷举和取证，不适合定级。",[10,383,384],{},"审查方式记为：静态审查（人工 + 4 个并行只读探查代理）+ 关键证据人工逐条复核。",[17,386,388],{"id":387},"二只读约束的作用","二、只读约束的作用",[10,390,391],{},"让探查代理只能读，有两个好处。",[10,393,394],{},"一是没有副作用。审查过程中仓库保持原样，报告的结论可以对着同一个 commit 复现。",[10,396,397],{},"二是省掉了「代理顺手改一下」的干扰。审查和修改混在一起，会出现「报告说有问题的地方已经被改过」的状态，后续复核就无法对着证据走。",[10,399,400],{},"修改是审查之后单独一轮的事。",[17,402,404],{"id":403},"三20-条与四个级别","三、20 条与四个级别",[10,406,407],{},"产出 20 条风险，编号 R01 到 R20。每一条有：位置、触发条件、影响、修复优先级。",[10,409,410],{},"按修复优先级分四档：",[412,413,414,430],"table",{},[415,416,417],"thead",{},[418,419,420,424,427],"tr",{},[421,422,423],"th",{},"优先级",[421,425,426],{},"条数",[421,428,429],{},"排期",[431,432,433,445,456,467],"tbody",{},[418,434,435,439,442],{},[436,437,438],"td",{},"P0",[436,440,441],{},"2",[436,443,444],{},"本周内",[418,446,447,450,453],{},[436,448,449],{},"P1",[436,451,452],{},"6",[436,454,455],{},"两周内",[418,457,458,461,464],{},[436,459,460],{},"P2",[436,462,463],{},"5",[436,465,466],{},"一个月内",[418,468,469,472,475],{},[436,470,471],{},"P3",[436,473,474],{},"7",[436,476,466],{},[10,478,479],{},"另外有一个独立的「严重级别」列，按严重 \u002F 高 \u002F 中 \u002F 低分：1 \u002F 4 \u002F 7 \u002F 8。两列不对应是有意的——「有多严重」和「该多快修」是两件事。一条严重但需要停机才能修的问题，排期上反而不能放在本周。",[10,481,482,483,486],{},"整体评级：",[23,484,485],{},"高","。",[17,488,490],{"id":489},"四结论里最值得记的一句","四、结论里最值得记的一句",[492,493,494],"blockquote",{},[10,495,496],{},"未发现 SQL 注入、越权与 IDOR、存储型 XSS 等直接可利用的应用漏洞。",[10,498,499],{},"这一句和「整体评级高」摆在一起看，是这个月里最值得记的一条工程结论。",[10,501,502],{},"风险分布和直觉不一致。业务代码那一侧没查出可直接利用的漏洞——那部分每天在改、每天在跑、每天有人看。问题更多集中在业务代码之外：构建、部署、配置、凭据流转、依赖引入、运行时加固。",[10,504,505,506,509],{},"这些东西的共同点是",[23,507,508],{},"平时不跑","。部署脚本一天执行几次，Dockerfile 改一次放很久，集群配置是照着文档抄的。没有日常反馈的东西，问题会一直留在那里，而且不会有人因为「它今天没出事」而去核对它。",[17,511,513],{"id":512},"五修复的排期方式","五、修复的排期方式",[10,515,516],{},"路线图按三档排：P0 本周、P1 两周内、P2\u002FP3 一个月内。",[10,518,519,520,523,524,527],{},"排期依据不是严重级别，是两件事：",[23,521,522],{},"能不能立即修","，和",[23,525,526],{},"修它会不会动到线上","。一条严重但需要停机或改基础设施配置的问题，得等一个发布窗口；一条中等但改动只在一处代码里的问题，可以马上做。P2\u002FP3 是加固项，攒到下一个版本一起走。",[10,529,530],{},"这样做的好处是排期可执行。「两个月内修完 20 条」听起来完整，实际会卡在第一条需要停机的问题上。",[17,532,534],{"id":533},"六报告为什么不入库","六、报告为什么不入库",[10,536,537],{},"仓库的忽略规则里专门有一条：",[539,540,545],"pre",{"className":541,"code":543,"language":544},[542],"language-text","# 安全审查报告：含内网拓扑、密钥存放路径与未修复漏洞的 file:line 证据，绝不入库\n","text",[69,546,543],{"__ignoreMap":115},[10,548,549],{},"三条理由，最后一条最关键：",[254,551,552,555,558],{},[257,553,554],{},"内网拓扑",[257,556,557],{},"密钥存放位置",[257,559,560],{},[23,561,562],{},"未修复漏洞的精确定位",[10,564,565],{},"前两条是常识。第三条是这类文档的特例：一份「待修复清单」在修完之前，本身就是攻击说明书。而它又必须有精确定位，否则修不动。",[10,567,568],{},"所以这份文档的流转方式和代码相反。代码进仓库、留历史；它不进仓库，修完一条删一条，修完的结论改记到提交信息里。",[17,570,572],{"id":571},"七审查之后同一天修的东西","七、审查之后，同一天修的东西",[10,574,575],{},"审查和修复放在同一天做，这是这次的一个做法。",[10,577,578,579,582],{},"当天修的一批，主题集中在一类：",[23,580,581],{},"不报错的失败","。队列投递端断线后不重连、依赖返回的包装形状读错导致判定恒为假、入队不带权重插到最前面、参数格式不对被上游全部拒收、重试链被自己的去重压短、管理操作的幂等键缺失、用户可控的文件名直接进对象存储键、唯一漏掉来源校验的 IPC handler、并发删除后返回假成功、过期验证码被复活、临时文件不清理。",[10,584,585,586,486],{},"这些已经有单独一篇记录，这里只提它们的共性：",[23,587,588],{},"都不抛异常",[10,590,591],{},"审查报告里那些「不进仓库」的条目和这些「当天修完」的条目，其实是同一批检查过出来的。区别只在于前者需要动基础设施、要等窗口，后者可以立刻改。",[17,593,595],{"id":594},"八静态审查的边界","八、静态审查的边界",[10,597,598],{},"这一轮做的是静态审查，能覆盖和不能覆盖的东西都比较明确。",[10,600,601],{},[23,602,603],{},"能覆盖：",[254,605,606,609,612,615,618],{},[257,607,608],{},"配置与依赖：镜像标签、依赖来源、加密参数、权限配置。",[257,610,611],{},"错误处理：异常的吞掉与上抛、失败路径有没有留痕。",[257,613,614],{},"凭据流转：什么地方读、什么地方写、会不会进构建产物。",[257,616,617],{},"部署清单：运行用户、资源限制、网络策略、滚动更新参数。",[257,619,620],{},"代码层面的模式：路径拼接、命令拼接、输入校验的缺失。",[10,622,623],{},[23,624,625],{},"覆盖不到：",[254,627,628,631,634,637],{},[257,629,630],{},"运行时竞态。两个请求同时到、并发删除、租约过期，这些要在真实并发下才暴露。",[257,632,633],{},"真实的流量特征。哪些入口真的被外部打到、参数实际长什么样，静态看只能推测。",[257,635,636],{},"业务逻辑漏洞。越权取数据这类问题需要理解「这个人该不该看到这条」，而业务意图不在代码里。",[257,638,639],{},"组合链。单看每一步都合规，串起来才是问题——这类要动态测试或者真实对抗。",[10,641,642],{},"第三类是最容易被静态审查漏掉的：代码里所有校验都写了，但校验的规则和业务规则不一致。",[10,644,645],{},"所以静态审查的产出不是「系统安全了」，而是「在它能看见的那一面里，还剩哪些要做」。",[17,647,649],{"id":648},"九一次审查真正留下的东西","九、一次审查真正留下的东西",[10,651,652],{},"审完当天修掉的那批，留下的是代码。报告里那些没修的，留下的是三样东西：",[359,654,655,661,667],{},[257,656,657,660],{},[23,658,659],{},"一份带精确定位和优先级的清单","，不进仓库，逐条消掉。",[257,662,663,666],{},[23,664,665],{},"一个判断","：风险集中的地方和日常注意力集中的地方不一样。业务代码天天改，反而干净；构建部署配置平时没人动。",[257,668,669,672],{},[23,670,671],{},"一次复核的习惯","：审查的结论要能对着一个 commit 复现，所以探查只读、修改单独一轮。",[10,674,675],{},"第 2 条比第 1 条重要。清单会修完，但「平时不跑的东西没人核对」这件事不会自己消失。",{"title":115,"searchDepth":116,"depth":116,"links":677},[678,679,680,681,682,683,684,685,686],{"id":353,"depth":116,"text":354},{"id":387,"depth":116,"text":388},{"id":403,"depth":116,"text":404},{"id":489,"depth":116,"text":490},{"id":512,"depth":116,"text":513},{"id":533,"depth":116,"text":534},{"id":571,"depth":116,"text":572},{"id":594,"depth":116,"text":595},{"id":648,"depth":116,"text":649},"2026-08-15",{},"\u002F2026-08-15",{"title":342,"description":347},"2026-08-15-上线前我把整个仓库审了一遍","一次上线前的静态安全审查：四个并行只读代理加人工逐条复核，产出 20 条分级风险与三档修复路线图。本文记录方法，不复述条目。",[694,695,696,697,698],"安全审查","代码审查","风险分级","修复排期","Agent 协作","bnkC_2bMe8OfgB00HIfDUS9bdRwLUZb_WqDBvXtq__M",{"id":701,"title":702,"body":703,"column":121,"date":1618,"description":707,"extension":123,"hero_image":124,"meta":1619,"navigation":126,"path":1620,"seo":1621,"series_id":124,"severity":124,"stem":1622,"summary":1623,"tags":1624,"__hash__":1631},"posts\u002F2026-08-02-首页改成八屏.md","首页改成八屏",{"type":7,"value":704,"toc":1605},[705,708,714,717,721,724,735,744,747,750,758,765,771,777,841,844,863,868,873,876,882,886,889,892,900,903,906,912,915,943,946,1060,1063,1069,1073,1076,1092,1106,1109,1125,1128,1131,1135,1141,1144,1147,1153,1156,1206,1210,1213,1216,1219,1225,1228,1231,1236,1242,1246,1249,1252,1258,1262,1267,1308,1318,1321,1350,1355,1358,1408,1414,1417,1474,1486,1489,1493,1496,1499,1504,1507,1510,1515,1519,1522,1592,1595,1601],[10,706,707],{},"这个博客的首页原来是连续滚动。改成了整屏分页，一页一件事，共八屏：",[539,709,712],{"className":710,"code":711,"language":544},[542],"首屏 \u002F 自我介绍 01 \u002F 自我介绍 02 \u002F 做过的事 \u002F 精选复盘 \u002F 项目 \u002F 专栏 \u002F 联系\n",[69,713,711],{"__ignoreMap":115},[10,715,716],{},"一屏容纳是逐档实测调出来的，不是估的。",[17,718,720],{"id":719},"一吸附用谁的","一、吸附用谁的",[10,722,723],{},"第一个决定是吸附用哪套机制。",[10,725,726,727,730,731,734],{},"原生有 ",[69,728,729],{},"scroll-snap","，CSS 几行就能写。但这里用不了——页面上的平滑滚动是 Lenis 提供的，它用 JS 驱动 ",[69,732,733],{},"scrollTop","：",[492,736,737],{},[10,738,739,740,743],{},"吸附用 Lenis 自带的 Snap 而不是 CSS scroll-snap——Lenis 是用 JS 驱动 scrollTop 的，和原生吸附会互相抢控制权、滚起来发抖。Snap 挂在同一个 Lenis 实例上没有这个问题，",[69,741,742],{},"onSnapComplete"," 还能驱动指示器。",[10,745,746],{},"两个机制都想控制滚动位置，结果就是抖。",[10,748,749],{},"挂在同一个实例上之后，吸附完成后还能拿到回调，右侧的页码指示器直接跟着它更新。",[17,751,753,754,757],{"id":752},"二mandatory-是错的","二、",[69,755,756],{},"mandatory"," 是错的",[10,759,760,761,764],{},"第一版用 ",[69,762,763],{},"type: 'mandatory'","。用户反馈手感是「被抢走了」。",[10,766,767,768,770],{},"原因是 ",[69,769,756],{}," 在用户还在滑的时候就强行把页面拽向最近一页。滑动过程被插手，手感不是吸附，是失控。",[10,772,773,774,734],{},"改成 ",[69,775,776],{},"proximity",[539,778,782],{"className":779,"code":780,"language":781,"meta":115,"style":115},"language-ts shiki shiki-themes github-light github-dark","type: 'proximity',\ndistanceThreshold: '30%',\nduration: 1.1,\ndebounce: 500,\n","ts",[69,783,784,803,815,828],{"__ignoreMap":115},[46,785,788,792,796,800],{"class":786,"line":787},"line",1,[46,789,791],{"class":790},"sScJk","type",[46,793,795],{"class":794},"sVt8B",": ",[46,797,799],{"class":798},"sZZnC","'proximity'",[46,801,802],{"class":794},",\n",[46,804,805,808,810,813],{"class":786,"line":116},[46,806,807],{"class":790},"distanceThreshold",[46,809,795],{"class":794},[46,811,812],{"class":798},"'30%'",[46,814,802],{"class":794},[46,816,817,820,822,826],{"class":786,"line":318},[46,818,819],{"class":790},"duration",[46,821,795],{"class":794},[46,823,825],{"class":824},"sj4cs","1.1",[46,827,802],{"class":794},[46,829,831,834,836,839],{"class":786,"line":830},4,[46,832,833],{"class":790},"debounce",[46,835,795],{"class":794},[46,837,838],{"class":824},"500",[46,840,802],{"class":794},[10,842,843],{},"三条参数各管一件事：",[254,845,846,851,857],{},[257,847,848,850],{},[69,849,776],{}," 只在停下来且已经接近边界时才轻推一把。",[257,852,853,856],{},[69,854,855],{},"distanceThreshold: '30%'"," 定义「接近」——落点离边界不到 30% 视口高才吸附，停在页面中间就让它停着。",[257,858,859,862],{},[69,860,861],{},"debounce: 500"," 等惯性完全停下来再判断，不在滑行中途插手。",[10,864,865,867],{},[69,866,833],{}," 从 220 提到 500：",[492,869,870],{},[10,871,872],{},"debounce 太小会在惯性还没停时就抢着吸附，手感发涩。",[10,874,875],{},"实测结果：",[539,877,880],{"className":878,"code":879,"language":544},[542],"滑动全程 0 反向帧（原来会被拽回）\n停在离边界 306px（超过 30% 阈值）时保持不动\n停在边界附近才对齐整页\n",[69,881,879],{"__ignoreMap":115},[17,883,885],{"id":884},"三一屏装不装得下","三、一屏装不装得下",[10,887,888],{},"分页的前提是内容能装进一屏。这一条逐档量过。",[10,890,891],{},"首屏原高 921px：",[254,893,894,897],{},[257,895,896],{},"1440×900 超出 89px",[257,898,899],{},"1366×768 超出 201px",[10,901,902],{},"精选复盘 788px，在 1366×768 超出 88px。",[10,904,905],{},"两个方向可选：砍内容，或者按档收紧间距。选了后者，分三档：",[539,907,910],{"className":908,"code":909,"language":544},[542],"min-width: 1024px and max-height: 820px   深压\nmin-width: 1024px and max-height: 900px   只压首屏\nmin-width: 1024px and min-height: 1000px  放大间距吃大屏留白\n",[69,911,909],{"__ignoreMap":115},[10,913,914],{},"中间那档的调整方式值得记一笔。为了挤出 27px，第一版直接砍了字号：",[539,916,920],{"className":917,"code":918,"language":919,"meta":115,"style":115},"language-css shiki shiki-themes github-light github-dark","font-size: clamp(46px, 9.2vw, 132px)  →  clamp(30px, 5.4vw, 62px)\n","css",[69,921,922],{"__ignoreMap":115},[46,923,924,928,931,934,937,940],{"class":786,"line":787},[46,925,927],{"class":926},"s9eBZ","font-size",[46,929,930],{"class":794},": clamp(46px, 9",[46,932,933],{"class":790},".2vw",[46,935,936],{"class":794},", 132px)  →  clamp(30px, 5",[46,938,939],{"class":790},".4vw",[46,941,942],{"class":794},", 62px)\n",[10,944,945],{},"砍掉一半多。首屏标题是整页的主体，砍字号会让整屏塌下来。改成全部从内边距要：",[539,947,949],{"className":917,"code":948,"language":919,"meta":115,"style":115},"@media (min-width: 1024px) and (max-height: 900px) {\n  .page-hero :deep(.hero) { padding-top: 20px; padding-bottom: 24px; }\n  .page-hero :deep(.hero-foot) { margin-top: 26px; }\n}\n",[69,950,951,992,1032,1055],{"__ignoreMap":115},[46,952,953,957,960,963,965,968,971,974,977,979,982,984,987,989],{"class":786,"line":787},[46,954,956],{"class":955},"szBVR","@media",[46,958,959],{"class":794}," (",[46,961,962],{"class":824},"min-width",[46,964,795],{"class":794},[46,966,967],{"class":824},"1024",[46,969,970],{"class":955},"px",[46,972,973],{"class":794},") ",[46,975,976],{"class":955},"and",[46,978,959],{"class":794},[46,980,981],{"class":824},"max-height",[46,983,795],{"class":794},[46,985,986],{"class":824},"900",[46,988,970],{"class":955},[46,990,991],{"class":794},") {\n",[46,993,994,997,1000,1003,1006,1009,1011,1014,1016,1019,1022,1024,1027,1029],{"class":786,"line":116},[46,995,996],{"class":790},"  .page-hero",[46,998,999],{"class":794}," :deep(",[46,1001,1002],{"class":790},".hero",[46,1004,1005],{"class":794},") { ",[46,1007,1008],{"class":824},"padding-top",[46,1010,795],{"class":794},[46,1012,1013],{"class":824},"20",[46,1015,970],{"class":955},[46,1017,1018],{"class":794},"; ",[46,1020,1021],{"class":824},"padding-bottom",[46,1023,795],{"class":794},[46,1025,1026],{"class":824},"24",[46,1028,970],{"class":955},[46,1030,1031],{"class":794},"; }\n",[46,1033,1034,1036,1038,1041,1043,1046,1048,1051,1053],{"class":786,"line":318},[46,1035,996],{"class":790},[46,1037,999],{"class":794},[46,1039,1040],{"class":790},".hero-foot",[46,1042,1005],{"class":794},[46,1044,1045],{"class":824},"margin-top",[46,1047,795],{"class":794},[46,1049,1050],{"class":824},"26",[46,1052,970],{"class":955},[46,1054,1031],{"class":794},[46,1056,1057],{"class":786,"line":830},[46,1058,1059],{"class":794},"}\n",[10,1061,1062],{},"只有 700px 可用高度那一档才让一档字号。三档实测：",[539,1064,1067],{"className":1065,"code":1066,"language":544},[542],"1440×900    132px\n1920×1080   132px\n1366×768     92.9px\n",[69,1068,1066],{"__ignoreMap":115},[17,1070,1072],{"id":1071},"四检测判据写错过一次","四、检测判据写错过一次",[10,1074,1075],{},"判断页面有没有溢出，第一版用的是：",[539,1077,1079],{"className":779,"code":1078,"language":781,"meta":115,"style":115},"scrollHeight > clientHeight\n",[69,1080,1081],{"__ignoreMap":115},[46,1082,1083,1086,1089],{"class":786,"line":787},[46,1084,1085],{"class":794},"scrollHeight ",[46,1087,1088],{"class":955},">",[46,1090,1091],{"class":794}," clientHeight\n",[10,1093,1094,1095,1098,1099,219,1102,1105],{},"这个判据永远为假。页面用的是 ",[69,1096,1097],{},"min-height","，内容超了页面会自己长高——",[69,1100,1101],{},"scrollHeight",[69,1103,1104],{},"clientHeight"," 一起变大，比值不变。",[10,1107,1108],{},"改成直接比可用高度：",[539,1110,1112],{"className":779,"code":1111,"language":781,"meta":115,"style":115},"\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[69,1113,1114,1120],{"__ignoreMap":115},[46,1115,1116],{"class":786,"line":787},[46,1117,1119],{"class":1118},"sJ8bj","\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n",[46,1121,1122],{"class":786,"line":116},[46,1123,1124],{"class":1118},"\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[10,1126,1127],{},"这个错误值得单独说：判据本身写错了，所以「三档零溢出」这个结论在修正之前是不成立的。",[10,1129,1130],{},"修正之后三档均为 8\u002F8 页零溢出。",[17,1132,1134],{"id":1133},"五不分页的三种情况","五、不分页的三种情况",[539,1136,1139],{"className":1137,"code":1138,"language":544},[542],"窄屏（\u003C 1024px）\n矮视口（\u003C 620px）\n减弱动效（prefers-reduced-motion）\n",[69,1140,1138],{"__ignoreMap":115},[10,1142,1143],{},"三种都退回连续滚动。",[10,1145,1146],{},"前两种有实测依据：",[539,1148,1151],{"className":1149,"code":1150,"language":544},[542],"实测 390px 下自我介绍 1448px、精选复盘 1114px，强行一屏只会截断内容\n",[69,1152,1150],{"__ignoreMap":115},[10,1154,1155],{},"第三种是硬要求。整屏吸附会强制改变滚动位置，这和「尊重减弱动效偏好」直接冲突。CSS 里也要同步禁用：",[539,1157,1159],{"className":917,"code":1158,"language":919,"meta":115,"style":115},"@media (prefers-reduced-motion: reduce) {\n  .page { min-height: 0; }\n  .pager { display: none; }\n}\n",[69,1160,1161,1168,1185,1202],{"__ignoreMap":115},[46,1162,1163,1165],{"class":786,"line":787},[46,1164,956],{"class":955},[46,1166,1167],{"class":794}," (prefers-reduced-motion: reduce) {\n",[46,1169,1170,1173,1176,1178,1180,1183],{"class":786,"line":116},[46,1171,1172],{"class":790},"  .page",[46,1174,1175],{"class":794}," { ",[46,1177,1097],{"class":824},[46,1179,795],{"class":794},[46,1181,1182],{"class":824},"0",[46,1184,1031],{"class":794},[46,1186,1187,1190,1192,1195,1197,1200],{"class":786,"line":318},[46,1188,1189],{"class":790},"  .pager",[46,1191,1175],{"class":794},[46,1193,1194],{"class":824},"display",[46,1196,795],{"class":794},[46,1198,1199],{"class":824},"none",[46,1201,1031],{"class":794},[46,1203,1204],{"class":786,"line":830},[46,1205,1059],{"class":794},[17,1207,1209],{"id":1208},"六分页之后每页只剩半屏内容","六、分页之后，每页只剩半屏内容",[10,1211,1212],{},"八屏分配完之后，出现一个反向问题：每页内容太少。",[10,1214,1215],{},"实测 1440×900 下多数页只填了 47%~63%，「做过的事」那页 63% 是空的。",[10,1217,1218],{},"既然分了页，每页就得撑得住。逐页补实：",[539,1220,1223],{"className":1221,"code":1222,"language":544},[542],"做过的事：加页首概览条（项目数\u002F时间跨度\u002F人手），每项补详细说明、关键指标与技术标签。308px → 548px\n项目：3 张卡补到 6 张，两行三列。362px → 511px\n联系：补引言、about 与 timeline 两个入口、站点信息带。452px → 791px\n专栏：每张卡补该栏最新一篇标题。512px → 597px\n自我介绍 01：四张能力卡各补首组的真实条目。504px → 596px\n自我介绍 02：补技术栈横带。522px → 603px\n",[69,1224,1222],{"__ignoreMap":115},[10,1226,1227],{},"填充率从 47%~63% 提到 61%~100%。",[10,1229,1230],{},"同一轮里把两个区块的入场动画也补了常驻循环，因为：",[492,1232,1233],{},[10,1234,1235],{},"实测入场动画本来就在跑（轴线 scaleX 0→0.99、圆点带回弹、卡片 clip-path 从 78% 揭到 4%、指标读数往上跳），问题是太隐蔽：一次性、1 秒内结束、触发点又在区块刚露头时，等正眼看过去通常已经播完。",[539,1237,1240],{"className":1238,"code":1239,"language":544},[542],"触发点 top 88% → 95%，轴线 0.9s → 1.25s，圆点与节点错峰 0.11 → 0.16\n",[69,1241,1239],{"__ignoreMap":115},[17,1243,1245],{"id":1244},"七断点从-900-降到-820","七、断点从 900 降到 820",[10,1247,1248],{},"第一版把「矮视口」定在 900px 高。1440×900 的屏幕因此被当成矮视口，白白砍掉了列表摘要——精选复盘从 788px 缩到 389px。",[10,1250,1251],{},"而 1440×900 明明有空间。断点降到 820：",[539,1253,1256],{"className":1254,"code":1255,"language":544},[542],"820 以下   深压\n900 以下   只压首屏\n1000 以上  放大间距吃掉大屏留白\n",[69,1257,1255],{"__ignoreMap":115},[17,1259,1261],{"id":1260},"八两个-css-优先级问题","八、两个 CSS 优先级问题",[10,1263,1264],{},[23,1265,1266],{},"首屏被挤成一个框。",[539,1268,1270],{"className":917,"code":1269,"language":919,"meta":115,"style":115},".page { padding: 24px 0; }\n.page-hero { padding: 0; }\n",[69,1271,1272,1293],{"__ignoreMap":115},[46,1273,1274,1277,1279,1282,1284,1286,1288,1291],{"class":786,"line":787},[46,1275,1276],{"class":790},".page",[46,1278,1175],{"class":794},[46,1280,1281],{"class":824},"padding",[46,1283,795],{"class":794},[46,1285,1026],{"class":824},[46,1287,970],{"class":955},[46,1289,1290],{"class":824}," 0",[46,1292,1031],{"class":794},[46,1294,1295,1298,1300,1302,1304,1306],{"class":786,"line":116},[46,1296,1297],{"class":790},".page-hero",[46,1299,1175],{"class":794},[46,1301,1281],{"class":824},[46,1303,795],{"class":794},[46,1305,1182],{"class":824},[46,1307,1031],{"class":794},[10,1309,1310,1313,1314,1317],{},[69,1311,1312],{},".page-hero { padding: 0 }"," 写在媒体查询之前，被后面同优先级的 ",[69,1315,1316],{},".page { padding: ... }"," 覆盖了。首屏于是四边都留出内边距，看起来像一个框。",[10,1319,1320],{},"修法是在每一档里重新声明一次：",[539,1322,1324],{"className":917,"code":1323,"language":919,"meta":115,"style":115},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n.page-hero { padding: 0; }\n",[69,1325,1326,1331,1336],{"__ignoreMap":115},[46,1327,1328],{"class":786,"line":787},[46,1329,1330],{"class":1118},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n",[46,1332,1333],{"class":786,"line":116},[46,1334,1335],{"class":1118},"   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n",[46,1337,1338,1340,1342,1344,1346,1348],{"class":786,"line":318},[46,1339,1297],{"class":790},[46,1341,1175],{"class":794},[46,1343,1281],{"class":824},[46,1345,795],{"class":794},[46,1347,1182],{"class":824},[46,1349,1031],{"class":794},[10,1351,1352],{},[23,1353,1354],{},"标题被大屏规则压小。",[10,1356,1357],{},"大屏档里有一条：",[539,1359,1361],{"className":917,"code":1360,"language":919,"meta":115,"style":115},".page :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[69,1362,1363],{"__ignoreMap":115},[46,1364,1365,1367,1369,1372,1374,1376,1378,1381,1384,1387,1389,1392,1395,1398,1400,1403,1405],{"class":786,"line":787},[46,1366,1276],{"class":790},[46,1368,999],{"class":794},[46,1370,1371],{"class":790},".big",[46,1373,1005],{"class":794},[46,1375,927],{"class":824},[46,1377,795],{"class":794},[46,1379,1380],{"class":824},"clamp",[46,1382,1383],{"class":794},"(",[46,1385,1386],{"class":824},"34",[46,1388,970],{"class":955},[46,1390,1391],{"class":794},", ",[46,1393,1394],{"class":824},"3.4",[46,1396,1397],{"class":955},"vw",[46,1399,1391],{"class":794},[46,1401,1402],{"class":824},"56",[46,1404,970],{"class":955},[46,1406,1407],{"class":794},"); }\n",[10,1409,1410,1411,1413],{},"首屏主标题的类名也是 ",[69,1412,1371],{},"。1920×1080 下它从 132px 被压到 56px，首屏整个塌掉。",[10,1415,1416],{},"改成排除首屏：",[539,1418,1420],{"className":917,"code":1419,"language":919,"meta":115,"style":115},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n.page:not(.page-hero) :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[69,1421,1422,1427,1432],{"__ignoreMap":115},[46,1423,1424],{"class":786,"line":787},[46,1425,1426],{"class":1118},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n",[46,1428,1429],{"class":786,"line":116},[46,1430,1431],{"class":1118},"   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n",[46,1433,1434,1437,1439,1441,1444,1446,1448,1450,1452,1454,1456,1458,1460,1462,1464,1466,1468,1470,1472],{"class":786,"line":318},[46,1435,1436],{"class":790},".page:not",[46,1438,1383],{"class":794},[46,1440,1297],{"class":790},[46,1442,1443],{"class":794},") :deep(",[46,1445,1371],{"class":790},[46,1447,1005],{"class":794},[46,1449,927],{"class":824},[46,1451,795],{"class":794},[46,1453,1380],{"class":824},[46,1455,1383],{"class":794},[46,1457,1386],{"class":824},[46,1459,970],{"class":955},[46,1461,1391],{"class":794},[46,1463,1394],{"class":824},[46,1465,1397],{"class":955},[46,1467,1391],{"class":794},[46,1469,1402],{"class":824},[46,1471,970],{"class":955},[46,1473,1407],{"class":794},[10,1475,1476,1477,486,1480,1482,1483,1485],{},"两个问题的成因相同：",[23,1478,1479],{},"同一个类名在两种语义下被复用",[69,1481,1276],{}," 既是容器也是列表页的内边距来源，",[69,1484,1371],{}," 既是区块小标题也是首屏主标题。分开之后就没这个问题。",[10,1487,1488],{},"修完实测：hero 左 0、底 1、右 10（右侧那 10px 是站点滚动条本身的宽度），标题 132px 满值，8\u002F8 页仍零溢出。",[17,1490,1492],{"id":1491},"九加载页期间的一个时序漏洞","九、加载页期间的一个时序漏洞",[10,1494,1495],{},"这个站有一个启动加载页，首次访问时盖几秒。加载页盖着的时候，入场动画被暂停了——否则动画会在遮挡期间自己跑完。",[10,1497,1498],{},"副作用是页面切换的幕布动画也被冻住：",[492,1500,1501],{},[10,1502,1503],{},"实测（加载页 3s 时点击）：t=745 路由已切到 \u002Fcolumns 而幕布 display:none，t=1406 幕布才出现并扫过。",[10,1505,1506],{},"路由在没有幕布的情况下切换了，等加载页结束、时间线恢复之后，被冻住的补间才补播。",[10,1508,1509],{},"结论是补一行守卫：加载页盖着时直接放行，不放幕布。理由是：",[492,1511,1512],{},[10,1513,1514],{},"真实用户点不到——加载页是全屏遮罩。但浏览器前进\u002F后退、程序化跳转仍可能在窗口内触发导航。",[17,1516,1518],{"id":1517},"十整屏分页的账","十、整屏分页的账",[10,1520,1521],{},"这套改动最后收敛成一张数字表：",[412,1523,1524,1534],{},[415,1525,1526],{},[418,1527,1528,1531],{},[421,1529,1530],{},"项目",[421,1532,1533],{},"数值",[431,1535,1536,1544,1552,1560,1568,1576,1584],{},[418,1537,1538,1541],{},[436,1539,1540],{},"页数",[436,1542,1543],{},"8",[418,1545,1546,1549],{},[436,1547,1548],{},"吸附类型",[436,1550,1551],{},"proximity，阈值 30%，防抖 500ms",[418,1553,1554,1557],{},[436,1555,1556],{},"吸附时长",[436,1558,1559],{},"1.1s",[418,1561,1562,1565],{},[436,1563,1564],{},"分页生效条件",[436,1566,1567],{},"宽 ≥ 1024px 且 高 ≥ 620px 且 未开启减弱动效",[418,1569,1570,1573],{},[436,1571,1572],{},"溢出",[436,1574,1575],{},"三档均 0\u002F8",[418,1577,1578,1581],{},[436,1579,1580],{},"填充率",[436,1582,1583],{},"61%~100%（改前 47%~63%）",[418,1585,1586,1589],{},[436,1587,1588],{},"首屏标题",[436,1590,1591],{},"132px（1440×900、1920×1080）、92.9px（1366×768）",[10,1593,1594],{},"每一条都有一次实测在后面。这类改动没别的办法——「一屏装不装得下」这件事只能量，量完才知道该压间距还是该砍内容。",[10,1596,1597,1598,1600],{},"中间还有两处是判断上的错，不是量的问题：一是用 ",[69,1599,756],{}," 吸附（把用户的滑动抢走了），二是把「矮视口」定在 900（把有空间的屏幕当成没空间）。前者的修法是换模式，后者的修法是挪断点。",[1602,1603,1604],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":115,"searchDepth":116,"depth":116,"links":1606},[1607,1608,1610,1611,1612,1613,1614,1615,1616,1617],{"id":719,"depth":116,"text":720},{"id":752,"depth":116,"text":1609},"二、mandatory 是错的",{"id":884,"depth":116,"text":885},{"id":1071,"depth":116,"text":1072},{"id":1133,"depth":116,"text":1134},{"id":1208,"depth":116,"text":1209},{"id":1244,"depth":116,"text":1245},{"id":1260,"depth":116,"text":1261},{"id":1491,"depth":116,"text":1492},{"id":1517,"depth":116,"text":1518},"2026-08-02",{},"\u002F2026-08-02",{"title":702,"description":707},"2026-08-02-首页改成八屏","把博客首页从连续滚动改成八屏整屏分页。吸附用 Lenis 自带能力而不是 CSS 原生，断点按实测调整，每一档的溢出与填充率都有数字。",[1625,1626,1627,1628,1629,1630],"GSAP","Lenis","滚动吸附","响应式断点","页面结构","prefers-reduced-motion","2o9dKTJYPwCLxF__bLLN46O2x_0PMJoQgyZSxZpO428",1789212573221]