[{"data":1,"prerenderedAt":2216},["ShallowReactive",2],{"\u002F2026-06-13-obsidian":3,"\u002F2026-06-13-obsidian-rel":744},{"id":4,"title":5,"body":6,"column":729,"date":730,"description":12,"extension":731,"hero_image":732,"meta":733,"navigation":269,"path":734,"seo":735,"series_id":732,"severity":732,"stem":736,"summary":737,"tags":738,"__hash__":743},"posts\u002F2026-06-13-微信与Obsidian的智能捕获管线.md","让模型决定分类，但不让它碰文件系统",{"type":7,"value":8,"toc":717},"minimark",[9,13,18,21,29,40,43,65,68,71,108,111,126,130,133,144,147,167,170,173,179,297,309,314,320,323,328,339,342,345,375,378,381,384,387,440,446,452,465,468,471,474,480,555,561,614,619,624,627,630,633,639,650,657,661,664,667,678,681,684,687,707,710,713],[10,11,12],"p",{},"在微信里记笔记，内容要进 Obsidian 知识库，手工转换既慢又容易出错。写一个 Obsidian 插件把格式规则固定化，也无法适应微信和公众号笔记五花八门的样式。我决定把 Claude 加进来：收消息→分类→起标题→格式化→落盘，再回执通知。",[14,15,17],"h2",{"id":16},"核心架构claude-决策node-写盘","核心架构：Claude 决策，Node 写盘",[10,19,20],{},"首先明确职责边界，这是避免混乱的关键。",[10,22,23,24,28],{},"Claude 的工作是",[25,26,27],"strong",{},"纯决策","：给定一条消息和分类表，输出结构化结果——归入哪个分类、建新文档还是追加到现有文档、标题是什么、标签列表、正文格式化后长什么样。结果都用 JSON 表示。",[10,30,31,32,35,36,39],{},"Node 的工作是",[25,33,34],{},"确定性执行","：接收 Claude 的决策结果后，负责所有文件读写——新建文档、追加到已有文档、处理图片、管理去重、并发序列化。",[25,37,38],{},"不让 Claude 直接操盘文件系统","。",[10,41,42],{},"这个分工的好处：",[44,45,46,53,59],"ol",{},[47,48,49,52],"li",{},[25,50,51],{},"风险隔离","：LLM 的输出有概率不确定（分类可能不准），但文件操作必须确定。如果 Claude 直接写盘，分类抖动可能污染已有文档、重复建文件、覆盖历史记录。现在坏决策最多只会建错新文件，不会改坏既有内容。",[47,54,55,58],{},[25,56,57],{},"幂等与并发","：并发来的消息按消息 ID 去重，串行通过写入队列写盘。没有 LLM 的非决定性卡在关键路径上。",[47,60,61,64],{},[25,62,63],{},"可测试性","：Claude 的逻辑用假实现隔离测试，vault-writer 的逻辑用临时目录单测。",[14,66,67],{"id":67},"数据流与核心组件",[10,69,70],{},"单条消息的完整生命周期：",[44,72,73,79,85,91,97],{},[47,74,75,78],{},[25,76,77],{},"listener","：WeChatFerry 监听主号私聊，提取消息ID、文本、图片，构造 Capture 对象。",[47,80,81,84],{},[25,82,83],{},"dedup","：msgId 按已处理集合去重（持久化到 processed.json，防 WCF 重发导致重复落盘）。",[47,86,87,90],{},[25,88,89],{},"classifier","：遍历 vault 列出已有文档按分类分组，把消息文本+分类表描述发给 Claude。Claude 一次性决定归类、action(new|append)、新标题、标签、正文。失败或超时降级为 00-Inbox 原样保存。",[47,92,93,96],{},[25,94,95],{},"vault-writer","：按决策新建或追加文件。图片从临时区移到 vault 附件目录。所有操作走串行队列，天然排斥并发冲突。",[47,98,99,102,103,107],{},[25,100,101],{},"receipt","：回执主号：\"✓ 新建 → ",[104,105,106],"span",{},"01-技术","，标题：Redis 锁续期\"。",[10,109,110],{},"失败处理分两层：",[112,113,114,120],"ul",{},[47,115,116,119],{},[25,117,118],{},"第一层","：classifier 异常→降级到 Inbox 分类，原消息文本作为 body。",[47,121,122,125],{},[25,123,124],{},"第二层","：vault-writer 也失败→回执错误信息，原消息不丢（至少进了 dedup）。",[14,127,129],{"id":128},"claude-分类的提示词设计","Claude 分类的提示词设计",[10,131,132],{},"Claude 的 prompt 包三部分信息：",[134,135,140],"pre",{"className":136,"code":138,"language":139},[137],"language-text","【可选分类】\n- 01-技术: 技术笔记、踩坑、方案、命令、代码片段\n- 02-项目: 具体项目的记录、进展、待办\n...\n- 00-Inbox: 兜底：拿不准归哪类的进这里\n\n【各分类下已有文档标题】\n- 01-技术: Redis 分布式锁 | OTA 增量包大小优化 | Python 装饰器\n- 02-项目: Q2 KPI 指标 | 客户需求评审\n...\n\n【规则】\n1. 只能选上面列出的分类 key\n2. 若与某个\"已有文档标题\"主题相似且确信，用 action=append，targetFile 填该标题；否则 action=new\n3. 拿不准就 00-Inbox + action=new\n4. 只输出一个 ```json 代码块，字段：category, action(new|append), targetFile, title, tags(数组), body\n","text",[141,142,138],"code",{"__ignoreMap":143},"",[10,145,146],{},"关键设计点：",[112,148,149,155,161],{},[47,150,151,154],{},[25,152,153],{},"传已有标题清单","而不是全部文档：vault 大了之后这会撑爆 token，后续优化改向量检索 Top-N 候选。v1 库空，列全量可接受。",[47,156,157,160],{},[25,158,159],{},"action=append 时 targetFile 必须来自该分类的已有标题清单","：确保 append 不会创建新文件。如果 Claude 编造了不存在的标题，validator 改回 action=new。",[47,162,163,166],{},[25,164,165],{},"拿不准就 new","：错建新文件\u003C错污染已有文档。",[14,168,169],{"id":169},"文件写入的不可变原则",[10,171,172],{},"vault-writer 的核心逻辑：",[10,174,175,178],{},[25,176,177],{},"新建","：",[134,180,184],{"className":181,"code":182,"language":183,"meta":143,"style":143},"language-javascript shiki shiki-themes github-light github-dark","路径: \u003Cvault>\u002F\u003Ccategory>\u002F\u003C安全标题>.md\n内容:\n---\ntitle: Redis 分布式锁续期方案\ncategory: 01-技术\ntags: [redis, 分布式锁, 并发]\ncreated: 2026-06-13 14:30\nsource: wechat\n---\n\n\u003CClaude 格式化后的正文>\n\n![[20260613-1430-image.png]]\n","javascript",[141,185,186,217,223,229,235,241,247,253,259,264,271,286,291],{"__ignoreMap":143},[104,187,190,194,198,202,205,208,210,214],{"class":188,"line":189},"line",1,[104,191,193],{"class":192},"sScJk","路径",[104,195,197],{"class":196},"sVt8B",": \u003C",[104,199,201],{"class":200},"s9eBZ","vault",[104,203,204],{"class":196},">\u002F\u003C",[104,206,207],{"class":200},"category",[104,209,204],{"class":196},[104,211,213],{"class":212},"sj4cs","安全标题",[104,215,216],{"class":196},">.md\n",[104,218,220],{"class":188,"line":219},2,[104,221,222],{"class":196},"内容:\n",[104,224,226],{"class":188,"line":225},3,[104,227,228],{"class":196},"---\n",[104,230,232],{"class":188,"line":231},4,[104,233,234],{"class":196},"title: Redis 分布式锁续期方案\n",[104,236,238],{"class":188,"line":237},5,[104,239,240],{"class":196},"category: 01-技术\n",[104,242,244],{"class":188,"line":243},6,[104,245,246],{"class":196},"tags: [redis, 分布式锁, 并发]\n",[104,248,250],{"class":188,"line":249},7,[104,251,252],{"class":196},"created: 2026-06-13 14:30\n",[104,254,256],{"class":188,"line":255},8,[104,257,258],{"class":196},"source: wechat\n",[104,260,262],{"class":188,"line":261},9,[104,263,228],{"class":196},[104,265,267],{"class":188,"line":266},10,[104,268,270],{"emptyLinePlaceholder":269},true,"\n",[104,272,274,277,280,283],{"class":188,"line":273},11,[104,275,276],{"class":196},"\u003C",[104,278,279],{"class":212},"Claude",[104,281,282],{"class":192}," 格式化后的正文",[104,284,285],{"class":196},">\n",[104,287,289],{"class":188,"line":288},12,[104,290,270],{"emptyLinePlaceholder":269},[104,292,294],{"class":188,"line":293},13,[104,295,296],{"class":196},"![[20260613-1430-image.png]]\n",[112,298,299,302],{},[47,300,301],{},"文件名去除 Windows 非法字符、限长 80 字。",[47,303,304,305,308],{},"重名加紧凑时间后缀（",[141,306,307],{},"Redis 锁-1430.md","），不覆盖。",[10,310,311,178],{},[25,312,313],{},"追加",[134,315,318],{"className":316,"code":317,"language":139},[137],"原内容 \u003C不改一字>\n\n## 14:30 追加\n\n\u003C新增内容>\n\n![[img2.png]]\n",[141,319,317],{"__ignoreMap":143},[10,321,322],{},"只在文末加小节，绝不改写旧内容。追加同样走串行队列，多个消息对同一文档的追加不会交错。",[10,324,325,178],{},[25,326,327],{},"图片处理",[10,329,330,331,334,335,338],{},"从临时区移到 ",[141,332,333],{},"\u003Cvault>\u002F99-附件\u002F\u003C紧凑时间戳>-\u003C原文件名>.png","，正文用 wikilink 引用 ",[141,336,337],{},"![[20260613-1430-image.png]]","。Obsidian 自动刷新并识别嵌入。",[14,340,341],{"id":341},"去重与幂等",[10,343,344],{},"WCF 有重发行为，msgId 相同的消息可能来两次。processed.json 记录已处理的 msgId：",[134,346,350],{"className":347,"code":348,"language":349,"meta":143,"style":143},"language-json shiki shiki-themes github-light github-dark","[\"msg_id_1\", \"msg_id_2\", \"msg_id_3\"]\n","json",[141,351,352],{"__ignoreMap":143},[104,353,354,357,361,364,367,369,372],{"class":188,"line":189},[104,355,356],{"class":196},"[",[104,358,360],{"class":359},"sZZnC","\"msg_id_1\"",[104,362,363],{"class":196},", ",[104,365,366],{"class":359},"\"msg_id_2\"",[104,368,363],{"class":196},[104,370,371],{"class":359},"\"msg_id_3\"",[104,373,374],{"class":196},"]\n",[10,376,377],{},"listener 每条消息来时检查，命中过则忽略。标记操作在最后，确保消息全部处理成功才记录。",[10,379,380],{},"这设计下，网络抖动导致的消息重发、甚至小号掉线重连后的历史消息再次推送，都不会重复落盘。",[14,382,383],{"id":383},"中转站密钥隔离",[10,385,386],{},"Claude Code CLI 的调用走 spawn 子进程，环境变量注入：",[134,388,390],{"className":181,"code":389,"language":183,"meta":143,"style":143},"const env = {\n  ANTHROPIC_BASE_URL: config.baseUrl,      \u002F\u002F https:\u002F\u002Fkey.agtk.cn\n  ANTHROPIC_AUTH_TOKEN: config.authToken,  \u002F\u002F 用户自注册的 key\n};\nawait runCommand(nodePath, args, { timeoutMs, env });\n",[141,391,392,407,416,424,429],{"__ignoreMap":143},[104,393,394,398,401,404],{"class":188,"line":189},[104,395,397],{"class":396},"szBVR","const",[104,399,400],{"class":212}," env",[104,402,403],{"class":396}," =",[104,405,406],{"class":196}," {\n",[104,408,409,412],{"class":188,"line":219},[104,410,411],{"class":196},"  ANTHROPIC_BASE_URL: config.baseUrl,      ",[104,413,415],{"class":414},"sJ8bj","\u002F\u002F https:\u002F\u002Fkey.agtk.cn\n",[104,417,418,421],{"class":188,"line":225},[104,419,420],{"class":196},"  ANTHROPIC_AUTH_TOKEN: config.authToken,  ",[104,422,423],{"class":414},"\u002F\u002F 用户自注册的 key\n",[104,425,426],{"class":188,"line":231},[104,427,428],{"class":196},"};\n",[104,430,431,434,437],{"class":188,"line":237},[104,432,433],{"class":396},"await",[104,435,436],{"class":192}," runCommand",[104,438,439],{"class":196},"(nodePath, args, { timeoutMs, env });\n",[10,441,442,445],{},[141,443,444],{},".env"," 文件里存关键数据：",[134,447,450],{"className":448,"code":449,"language":139},[137],"ANTHROPIC_BASE_URL=https:\u002F\u002Fkey.agtk.cn\nANTHROPIC_AUTH_TOKEN=sk_xxxx\n",[141,451,449],{"__ignoreMap":143},[10,453,454,457,458,460,461,464],{},[141,455,456],{},".gitignore"," 排除 ",[141,459,444],{},"，不进代码库。自包含发行包也不带任何密钥——用户在 setup 时从 ",[141,462,463],{},"key.agtk.cn"," 自己取 key 粘贴，由客户自付费用。",[10,466,467],{},"这样做的风险隐含：内容会经第三方中转站再到 Claude（而非直连 claude.ai）。setup 里明确风险告知和确认。",[14,469,470],{"id":470},"配置的参数化",[10,472,473],{},"三个配置文件：",[10,475,476,479],{},[25,477,478],{},"config.yaml","：业务配置",[134,481,485],{"className":482,"code":483,"language":484,"meta":143,"style":143},"language-yaml shiki shiki-themes github-light github-dark","vault_path: \"D:\\\\Obsidian\\\\MyVault\"\nattachments_dir: \"99-附件\"\nowner_wxid: \"wxid_main\"           # 首条私聊自动绑定\nmodel: \"claude-opus-4-8\"          # 用户选择\nrequest_timeout_ms: 30000\n","yaml",[141,486,487,509,519,532,545],{"__ignoreMap":143},[104,488,489,492,495,498,501,504,506],{"class":188,"line":189},[104,490,491],{"class":200},"vault_path",[104,493,494],{"class":196},": ",[104,496,497],{"class":359},"\"D:",[104,499,500],{"class":212},"\\\\",[104,502,503],{"class":359},"Obsidian",[104,505,500],{"class":212},[104,507,508],{"class":359},"MyVault\"\n",[104,510,511,514,516],{"class":188,"line":219},[104,512,513],{"class":200},"attachments_dir",[104,515,494],{"class":196},[104,517,518],{"class":359},"\"99-附件\"\n",[104,520,521,524,526,529],{"class":188,"line":225},[104,522,523],{"class":200},"owner_wxid",[104,525,494],{"class":196},[104,527,528],{"class":359},"\"wxid_main\"",[104,530,531],{"class":414},"           # 首条私聊自动绑定\n",[104,533,534,537,539,542],{"class":188,"line":231},[104,535,536],{"class":200},"model",[104,538,494],{"class":196},[104,540,541],{"class":359},"\"claude-opus-4-8\"",[104,543,544],{"class":414},"          # 用户选择\n",[104,546,547,550,552],{"class":188,"line":237},[104,548,549],{"class":200},"request_timeout_ms",[104,551,494],{"class":196},[104,553,554],{"class":212},"30000\n",[10,556,557,560],{},[25,558,559],{},"categories.yaml","：分类表，改它即改 Claude 的分类依据",[134,562,564],{"className":482,"code":563,"language":484,"meta":143,"style":143},"- key: \"01-技术\"\n  desc: \"技术笔记、踩坑、方案、命令、代码片段\"\n- key: \"02-项目\"\n  desc: \"具体项目的记录、进展、待办\"\n...\n",[141,565,566,579,589,600,609],{"__ignoreMap":143},[104,567,568,571,574,576],{"class":188,"line":189},[104,569,570],{"class":196},"- ",[104,572,573],{"class":200},"key",[104,575,494],{"class":196},[104,577,578],{"class":359},"\"01-技术\"\n",[104,580,581,584,586],{"class":188,"line":219},[104,582,583],{"class":200},"  desc",[104,585,494],{"class":196},[104,587,588],{"class":359},"\"技术笔记、踩坑、方案、命令、代码片段\"\n",[104,590,591,593,595,597],{"class":188,"line":225},[104,592,570],{"class":196},[104,594,573],{"class":200},[104,596,494],{"class":196},[104,598,599],{"class":359},"\"02-项目\"\n",[104,601,602,604,606],{"class":188,"line":231},[104,603,583],{"class":200},[104,605,494],{"class":196},[104,607,608],{"class":359},"\"具体项目的记录、进展、待办\"\n",[104,610,611],{"class":188,"line":237},[104,612,613],{"class":192},"...\n",[10,615,616,618],{},[25,617,444],{},"：敏感信息（密钥、中转站地址）",[134,620,622],{"className":621,"code":449,"language":139},[137],[141,623,449],{"__ignoreMap":143},[10,625,626],{},"三层分离使得：业务逻辑能复用，分类策略能快速迭代（改 YAML 即可），敏感信息不泄露。",[14,628,629],{"id":629},"自包含发行包",[10,631,632],{},"最终产物是一个 zip，无需用户装 Node、不连公共 npm 源：",[134,634,637],{"className":635,"code":636,"language":139},[137],"obsidian-weix-0.1.0.zip\n├─ node\\node.exe              （便携 Node）\n├─ app\\\n│  ├─ src\\                      （核心代码）\n│  ├─ node_modules\\            （预装依赖）\n│  └─ templates\\               （配置模板）\n├─ install.bat\n└─ start.bat\n",[141,638,636],{"__ignoreMap":143},[10,640,641,642,645,646,649],{},"用户下载、解压、双击 ",[141,643,644],{},"install.bat","→setup 向导→粘贴 key→输入 vault 路径→风险确认→生成配置。然后双击 ",[141,647,648],{},"start.bat"," 启动。",[10,651,652,653,656],{},"构建机在 Windows x64 编译，执行 ",[141,654,655],{},"npm install","（得到目标平台的 native 产物），打包后上传到项目专用 CDN 分发。",[14,658,660],{"id":659},"为什么不是-obsidian-插件","为什么不是 Obsidian 插件",[10,662,663],{},"Obsidian 插件跑在浏览器沙盒里，无法直接调 WCF、spawn CLI、管理进程生命周期。插件能做的是提供 UI 让用户手工输入内容，或者轮询本地文件监听外部写入。",[10,665,666],{},"相比之下，独立 Node 进程的好处：",[44,668,669,672,675],{},[47,670,671],{},"常驻内存，实时监听 WCF 消息事件。",[47,673,674],{},"能 spawn Claude Code CLI 并注入中转站密钥（需要环境变量隔离）。",[47,676,677],{},"确定性串行写盘，无沙盒限制。",[10,679,680],{},"代价是多了一个进程，但换来的是解耦和可靠性。",[14,682,683],{"id":683},"工程检查清单",[10,685,686],{},"这套管线从设计到代码，需要覆盖的点：",[112,688,689,692,695,698,701,704],{},[47,690,691],{},"单元测试：文件名净化、时间格式化、Markdown 渲染、去重、分类降级、append 格式。",[47,693,694],{},"集成测试：classifier 对中转站打桩；vault-writer 写临时目录校验产物；并发投递验证队列串行。",[47,696,697],{},"幂等性：msgId 去重持久化，重发消息不重建文件。",[47,699,700],{},"并发安全：写入队列保证串行，追加操作不交错。",[47,702,703],{},"失败降级：Claude 超时 30s 后重试 1 次，仍失败降级 Inbox；vault-writer 失败也降级。",[47,705,706],{},"脱敏：密钥进 .env 不进代码库，配置项参数化。",[10,708,709],{},"写盘前必须问清自己：如果这条消息落地失败了，会不会丢掉？不会——最坏情况进 00-Inbox 原文保存。如果 Claude 分类抖动，会改坏其他文档吗？不会——append 时校验 targetFile 存在性，不存在改成 new。",[10,711,712],{},"这些约束叠加起来，保证了管线的韧性。",[714,715,716],"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 .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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}",{"title":143,"searchDepth":219,"depth":219,"links":718},[719,720,721,722,723,724,725,726,727,728],{"id":16,"depth":219,"text":17},{"id":67,"depth":219,"text":67},{"id":128,"depth":219,"text":129},{"id":169,"depth":219,"text":169},{"id":341,"depth":219,"text":341},{"id":383,"depth":219,"text":383},{"id":470,"depth":219,"text":470},{"id":629,"depth":219,"text":629},{"id":659,"depth":219,"text":660},{"id":683,"depth":219,"text":683},"工程手记","2026-06-13","md",null,{},"\u002F2026-06-13-obsidian",{"title":5,"description":12},"2026-06-13-微信与Obsidian的智能捕获管线","设计一套通过微信私聊向 Claude 发送内容，自动分类、起标题、格式化后落入本机 Obsidian 的管线，核心是让 Claude 只做决策，Node 确定性写盘。",[503,279,739,740,741,742],"Node.js","微信","知识管理","流水线","etSXBPunJdcpyR1gjx9AvLgkzRosbGDmUbZj8-W7kkY",[745,943,1298],{"id":746,"title":747,"body":748,"column":729,"date":928,"description":929,"extension":731,"hero_image":732,"meta":930,"navigation":269,"path":931,"seo":932,"series_id":732,"severity":732,"stem":933,"summary":934,"tags":935,"__hash__":942},"posts\u002F2026-09-10-Ai项目群的工程化分水岭.md","AI 项目做多以后，真正难的是把边界接起来",{"type":7,"value":749,"toc":917},[750,757,760,763,767,772,782,785,788,804,807,811,830,833,837,846,849,853,856,859,879,882,886,889,892,895,898,901,904,908,911,914],[10,751,752,753,756],{},"这段时间回看 ",[141,754,755],{},"\u002FUsers\u002Fxingye\u002FAi"," 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",[10,758,759],{},"早期问题集中在“功能能不能跑起来”：语音推理、网页生成、桌面端启动、小程序页面、视频服务。进入平台化阶段以后，问题变成了另一组：任务是否可追踪，失败是否能恢复，费用是否能对账，版本是否能回滚，用户是否知道发生了什么。",[10,761,762],{},"这是一条从功能开发走向系统工程的分水岭。",[14,764,766],{"id":765},"一项目群已经形成几条清晰的产品线","一、项目群已经形成几条清晰的产品线",[768,769,771],"h3",{"id":770},"agent-与多租户平台","Agent 与多租户平台",[10,773,774,777,778,781],{},[141,775,776],{},"yun-claw"," 与 ",[141,779,780],{},"yun-claude"," 的记录显示，平台主线已经覆盖用户容器、模型接入、画布工作流、代理渠道、后台管理和运营工具。近期提交涉及画布自绘 Select、资产大图预览、代理激活码申请，以及渠道注册页的差异化展示。",[10,783,784],{},"这些功能看起来分属不同页面，底层却共享一套问题：用户身份、资源权限、异步任务和状态反馈必须保持一致。只改页面，不补状态契约，功能就会在跨页面操作时暴露断点。",[768,786,787],{"id":787},"内容生产流水线",[10,789,790,793,794,793,797,777,800,803],{},[141,791,792],{},"ai-write","、",[141,795,796],{},"instantlink-site",[141,798,799],{},"ai-auto-edit",[141,801,802],{},"ai-digital-human-workflow"," 分别覆盖写作、漫剧、自动剪辑和数字人视频。提交记录里反复出现几个关键词：任务网关、切片下载、共享卷、FFmpeg、提示词优化、失败提示和构建复现。",[10,805,806],{},"这说明内容生产已经不是单次调用模型，而是一条有中间产物的流水线。文本、图片、视频、音频和项目资产都有生命周期，任何一步的状态丢失都会让用户无法判断下一步该做什么。",[768,808,810],{"id":809},"交付网关与桌面端","交付、网关与桌面端",[10,812,813,793,816,793,819,793,822,825,826,829],{},[141,814,815],{},"newCodex",[141,817,818],{},"new-openclaw",[141,820,821],{},"openclaw",[141,823,824],{},"服务器管理"," 和 ",[141,827,828],{},"SSHTS"," 的记录，集中在更新、恢复、远程诊断、单实例生命周期和离线环境上。这里的核心指标不是“新版本发布了没有”，而是安装失败能否定位、更新中断能否恢复、客户机器能否留下可审计证据。",[10,831,832],{},"这也是为什么桌面端工程会逐渐靠近运维工程：安装器、后台服务、日志、配置和回滚必须一起设计。",[768,834,836],{"id":835},"网关计费与数据观察","网关、计费与数据观察",[10,838,839,777,842,845],{},[141,840,841],{},"NewAPI",[141,843,844],{},"new-api"," 的近期记录包括按模型统计 Token 消耗、扣费量、时间粒度聚合、昨日活跃用户和订单搜索。网关不只是转发请求，它还承担了模型选择、消耗记录、后台观察和问题追踪。",[10,847,848],{},"一旦计费数据进入用户体验，接口成功与业务成功就不再是同一个概念。请求返回 200，只能说明一次网络调用完成；扣费、产物落盘和任务终态是否一致，需要单独核对。",[14,850,852],{"id":851},"二git-记录里最有价值的变化是边界被写出来了","二、Git 记录里最有价值的变化，是“边界被写出来了”",[10,854,855],{},"多个项目的文档都在补同一类内容：部署手册、验收清单、测试报告、故障复盘、模型环境快照和用户协议。",[10,857,858],{},"这类文档的价值不在于把代码再讲一遍，而在于把系统边界写清楚：",[112,860,861,864,867,870,873,876],{},[47,862,863],{},"输入是什么，哪些字段必须存在；",[47,865,866],{},"状态有哪些，什么条件才能进入下一状态；",[47,868,869],{},"外部服务超时后，如何判断请求是否已经执行；",[47,871,872],{},"产物写入失败时，是否允许重试；",[47,874,875],{},"更新中断后，哪个版本可以安全恢复；",[47,877,878],{},"用户看到的成功，是否和后台记录的成功一致。",[10,880,881],{},"当这些问题没有答案时，系统只能靠人工经验维持。一旦项目数量增加，经验就会变成不可复制的隐性依赖。",[14,883,885],{"id":884},"三下一阶段的重点不是再加一个入口","三、下一阶段的重点不是再加一个入口",[10,887,888],{},"从现有项目的提交和文档可以看到，下一阶段更值得投入的是四个基础能力。",[10,890,891],{},"第一，统一任务状态。不同项目都在处理异步任务，但状态命名、失败原因和重试策略不应各自发明。",[10,893,894],{},"第二，统一产物账本。一个任务产生了哪些文件、消耗了哪些资源、是否对用户可见，需要能从提交到终态完整追踪。",[10,896,897],{},"第三，统一故障出口。401、超时、部分成功、未知执行和版本回滚，应该有明确的用户反馈与后台证据。",[10,899,900],{},"第四，统一发布验收。构建通过不代表线上可用，应该把健康检查、代理路径、真实端口、关键日志和浏览器操作放到同一份验收清单里。",[10,902,903],{},"项目多以后，工程能力不再体现在“能写多少功能”，而体现在“能否让不同功能遵守同一套边界”。",[14,905,907],{"id":906},"四我会保留的判断","四、我会保留的判断",[10,909,910],{},"AI 项目最容易被低估的部分，不是模型调用，而是模型调用之后的系统。",[10,912,913],{},"生成只是起点。身份、任务、费用、存储、交付、恢复和反馈，决定了一个功能能不能进入真实使用。",[10,915,916],{},"当 Git 提交开始频繁出现“补状态”“补恢复”“补验收”“补审计”时，这不是工程变慢了，而是产品从演示阶段进入了可运营阶段。",{"title":143,"searchDepth":219,"depth":219,"links":918},[919,925,926,927],{"id":765,"depth":219,"text":766,"children":920},[921,922,923,924],{"id":770,"depth":225,"text":771},{"id":787,"depth":225,"text":787},{"id":809,"depth":225,"text":810},{"id":835,"depth":225,"text":836},{"id":851,"depth":219,"text":852},{"id":884,"depth":219,"text":885},{"id":906,"depth":219,"text":907},"2026-09-10","这段时间回看 \u002FUsers\u002Fxingye\u002FAi 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",{},"\u002F2026-09-10-ai",{"title":747,"description":929},"2026-09-10-Ai项目群的工程化分水岭","从多个 AI 项目的 Git 记录和设计文档看，工程难点已经从“能不能生成”转向任务、计费、交付、恢复与用户反馈能否形成闭环。",[936,937,938,939,940,941],"AI 平台","Agent","内容生产","网关","桌面端","工程化","DzOpOX92EsanPp8pLNTusbzBmKH3WOTf3qcaZAFmhM8",{"id":944,"title":945,"body":946,"column":729,"date":1285,"description":950,"extension":731,"hero_image":732,"meta":1286,"navigation":269,"path":1287,"seo":1288,"series_id":732,"severity":732,"stem":1289,"summary":1290,"tags":1291,"__hash__":1297},"posts\u002F2026-08-15-上线前我把整个仓库审了一遍.md","上线前，我把整个仓库审了一遍",{"type":7,"value":947,"toc":1274},[948,951,954,958,961,981,984,987,991,994,997,1000,1003,1007,1010,1013,1079,1082,1088,1092,1098,1101,1104,1111,1115,1118,1129,1132,1136,1139,1145,1148,1161,1164,1167,1171,1174,1181,1187,1190,1194,1197,1202,1219,1224,1238,1241,1244,1248,1251,1271],[10,949,950],{},"上线前做了一次全量静态审查。",[10,952,953],{},"本文只讲方法和结论分布。审查报告本身不入库，也不在这里复述——那份报告带内网拓扑、密钥存放位置和尚未修复问题的文件行号证据，公开它就等于公开一份攻击者的地图。",[14,955,957],{"id":956},"一审查怎么进行","一、审查怎么进行",[10,959,960],{},"三个部分：",[44,962,963,969,975],{},[47,964,965,968],{},[25,966,967],{},"人工列清单。"," 先按「一个系统可能从哪些地方被打穿」列出检查面，而不是按目录扫。",[47,970,971,974],{},[25,972,973],{},"四个并行只读探查代理。"," 每个代理负责一组检查面，只读，不允许改任何文件。",[47,976,977,980],{},[25,978,979],{},"关键证据人工逐条复核。"," 代理解释现象可以，但「这条算不算风险、算多重」由人定。",[10,982,983],{},"第三步是必要的。代理很容易把「写法不理想」报成「高危」，也很容易漏掉需要跨文件才能看出的问题。它适合做的是穷举和取证，不适合定级。",[10,985,986],{},"审查方式记为：静态审查（人工 + 4 个并行只读探查代理）+ 关键证据人工逐条复核。",[14,988,990],{"id":989},"二只读约束的作用","二、只读约束的作用",[10,992,993],{},"让探查代理只能读，有两个好处。",[10,995,996],{},"一是没有副作用。审查过程中仓库保持原样，报告的结论可以对着同一个 commit 复现。",[10,998,999],{},"二是省掉了「代理顺手改一下」的干扰。审查和修改混在一起，会出现「报告说有问题的地方已经被改过」的状态，后续复核就无法对着证据走。",[10,1001,1002],{},"修改是审查之后单独一轮的事。",[14,1004,1006],{"id":1005},"三20-条与四个级别","三、20 条与四个级别",[10,1008,1009],{},"产出 20 条风险，编号 R01 到 R20。每一条有：位置、触发条件、影响、修复优先级。",[10,1011,1012],{},"按修复优先级分四档：",[1014,1015,1016,1032],"table",{},[1017,1018,1019],"thead",{},[1020,1021,1022,1026,1029],"tr",{},[1023,1024,1025],"th",{},"优先级",[1023,1027,1028],{},"条数",[1023,1030,1031],{},"排期",[1033,1034,1035,1047,1058,1069],"tbody",{},[1020,1036,1037,1041,1044],{},[1038,1039,1040],"td",{},"P0",[1038,1042,1043],{},"2",[1038,1045,1046],{},"本周内",[1020,1048,1049,1052,1055],{},[1038,1050,1051],{},"P1",[1038,1053,1054],{},"6",[1038,1056,1057],{},"两周内",[1020,1059,1060,1063,1066],{},[1038,1061,1062],{},"P2",[1038,1064,1065],{},"5",[1038,1067,1068],{},"一个月内",[1020,1070,1071,1074,1077],{},[1038,1072,1073],{},"P3",[1038,1075,1076],{},"7",[1038,1078,1068],{},[10,1080,1081],{},"另外有一个独立的「严重级别」列，按严重 \u002F 高 \u002F 中 \u002F 低分：1 \u002F 4 \u002F 7 \u002F 8。两列不对应是有意的——「有多严重」和「该多快修」是两件事。一条严重但需要停机才能修的问题，排期上反而不能放在本周。",[10,1083,1084,1085,39],{},"整体评级：",[25,1086,1087],{},"高",[14,1089,1091],{"id":1090},"四结论里最值得记的一句","四、结论里最值得记的一句",[1093,1094,1095],"blockquote",{},[10,1096,1097],{},"未发现 SQL 注入、越权与 IDOR、存储型 XSS 等直接可利用的应用漏洞。",[10,1099,1100],{},"这一句和「整体评级高」摆在一起看，是这个月里最值得记的一条工程结论。",[10,1102,1103],{},"风险分布和直觉不一致。业务代码那一侧没查出可直接利用的漏洞——那部分每天在改、每天在跑、每天有人看。问题更多集中在业务代码之外：构建、部署、配置、凭据流转、依赖引入、运行时加固。",[10,1105,1106,1107,1110],{},"这些东西的共同点是",[25,1108,1109],{},"平时不跑","。部署脚本一天执行几次，Dockerfile 改一次放很久，集群配置是照着文档抄的。没有日常反馈的东西，问题会一直留在那里，而且不会有人因为「它今天没出事」而去核对它。",[14,1112,1114],{"id":1113},"五修复的排期方式","五、修复的排期方式",[10,1116,1117],{},"路线图按三档排：P0 本周、P1 两周内、P2\u002FP3 一个月内。",[10,1119,1120,1121,1124,1125,1128],{},"排期依据不是严重级别，是两件事：",[25,1122,1123],{},"能不能立即修","，和",[25,1126,1127],{},"修它会不会动到线上","。一条严重但需要停机或改基础设施配置的问题，得等一个发布窗口；一条中等但改动只在一处代码里的问题，可以马上做。P2\u002FP3 是加固项，攒到下一个版本一起走。",[10,1130,1131],{},"这样做的好处是排期可执行。「两个月内修完 20 条」听起来完整，实际会卡在第一条需要停机的问题上。",[14,1133,1135],{"id":1134},"六报告为什么不入库","六、报告为什么不入库",[10,1137,1138],{},"仓库的忽略规则里专门有一条：",[134,1140,1143],{"className":1141,"code":1142,"language":139},[137],"# 安全审查报告：含内网拓扑、密钥存放路径与未修复漏洞的 file:line 证据，绝不入库\n",[141,1144,1142],{"__ignoreMap":143},[10,1146,1147],{},"三条理由，最后一条最关键：",[112,1149,1150,1153,1156],{},[47,1151,1152],{},"内网拓扑",[47,1154,1155],{},"密钥存放位置",[47,1157,1158],{},[25,1159,1160],{},"未修复漏洞的精确定位",[10,1162,1163],{},"前两条是常识。第三条是这类文档的特例：一份「待修复清单」在修完之前，本身就是攻击说明书。而它又必须有精确定位，否则修不动。",[10,1165,1166],{},"所以这份文档的流转方式和代码相反。代码进仓库、留历史；它不进仓库，修完一条删一条，修完的结论改记到提交信息里。",[14,1168,1170],{"id":1169},"七审查之后同一天修的东西","七、审查之后，同一天修的东西",[10,1172,1173],{},"审查和修复放在同一天做，这是这次的一个做法。",[10,1175,1176,1177,1180],{},"当天修的一批，主题集中在一类：",[25,1178,1179],{},"不报错的失败","。队列投递端断线后不重连、依赖返回的包装形状读错导致判定恒为假、入队不带权重插到最前面、参数格式不对被上游全部拒收、重试链被自己的去重压短、管理操作的幂等键缺失、用户可控的文件名直接进对象存储键、唯一漏掉来源校验的 IPC handler、并发删除后返回假成功、过期验证码被复活、临时文件不清理。",[10,1182,1183,1184,39],{},"这些已经有单独一篇记录，这里只提它们的共性：",[25,1185,1186],{},"都不抛异常",[10,1188,1189],{},"审查报告里那些「不进仓库」的条目和这些「当天修完」的条目，其实是同一批检查过出来的。区别只在于前者需要动基础设施、要等窗口，后者可以立刻改。",[14,1191,1193],{"id":1192},"八静态审查的边界","八、静态审查的边界",[10,1195,1196],{},"这一轮做的是静态审查，能覆盖和不能覆盖的东西都比较明确。",[10,1198,1199],{},[25,1200,1201],{},"能覆盖：",[112,1203,1204,1207,1210,1213,1216],{},[47,1205,1206],{},"配置与依赖：镜像标签、依赖来源、加密参数、权限配置。",[47,1208,1209],{},"错误处理：异常的吞掉与上抛、失败路径有没有留痕。",[47,1211,1212],{},"凭据流转：什么地方读、什么地方写、会不会进构建产物。",[47,1214,1215],{},"部署清单：运行用户、资源限制、网络策略、滚动更新参数。",[47,1217,1218],{},"代码层面的模式：路径拼接、命令拼接、输入校验的缺失。",[10,1220,1221],{},[25,1222,1223],{},"覆盖不到：",[112,1225,1226,1229,1232,1235],{},[47,1227,1228],{},"运行时竞态。两个请求同时到、并发删除、租约过期，这些要在真实并发下才暴露。",[47,1230,1231],{},"真实的流量特征。哪些入口真的被外部打到、参数实际长什么样，静态看只能推测。",[47,1233,1234],{},"业务逻辑漏洞。越权取数据这类问题需要理解「这个人该不该看到这条」，而业务意图不在代码里。",[47,1236,1237],{},"组合链。单看每一步都合规，串起来才是问题——这类要动态测试或者真实对抗。",[10,1239,1240],{},"第三类是最容易被静态审查漏掉的：代码里所有校验都写了，但校验的规则和业务规则不一致。",[10,1242,1243],{},"所以静态审查的产出不是「系统安全了」，而是「在它能看见的那一面里，还剩哪些要做」。",[14,1245,1247],{"id":1246},"九一次审查真正留下的东西","九、一次审查真正留下的东西",[10,1249,1250],{},"审完当天修掉的那批，留下的是代码。报告里那些没修的，留下的是三样东西：",[44,1252,1253,1259,1265],{},[47,1254,1255,1258],{},[25,1256,1257],{},"一份带精确定位和优先级的清单","，不进仓库，逐条消掉。",[47,1260,1261,1264],{},[25,1262,1263],{},"一个判断","：风险集中的地方和日常注意力集中的地方不一样。业务代码天天改，反而干净；构建部署配置平时没人动。",[47,1266,1267,1270],{},[25,1268,1269],{},"一次复核的习惯","：审查的结论要能对着一个 commit 复现，所以探查只读、修改单独一轮。",[10,1272,1273],{},"第 2 条比第 1 条重要。清单会修完，但「平时不跑的东西没人核对」这件事不会自己消失。",{"title":143,"searchDepth":219,"depth":219,"links":1275},[1276,1277,1278,1279,1280,1281,1282,1283,1284],{"id":956,"depth":219,"text":957},{"id":989,"depth":219,"text":990},{"id":1005,"depth":219,"text":1006},{"id":1090,"depth":219,"text":1091},{"id":1113,"depth":219,"text":1114},{"id":1134,"depth":219,"text":1135},{"id":1169,"depth":219,"text":1170},{"id":1192,"depth":219,"text":1193},{"id":1246,"depth":219,"text":1247},"2026-08-15",{},"\u002F2026-08-15",{"title":945,"description":950},"2026-08-15-上线前我把整个仓库审了一遍","一次上线前的静态安全审查：四个并行只读代理加人工逐条复核，产出 20 条分级风险与三档修复路线图。本文记录方法，不复述条目。",[1292,1293,1294,1295,1296],"安全审查","代码审查","风险分级","修复排期","Agent 协作","bnkC_2bMe8OfgB00HIfDUS9bdRwLUZb_WqDBvXtq__M",{"id":1299,"title":1300,"body":1301,"column":729,"date":2202,"description":1305,"extension":731,"hero_image":732,"meta":2203,"navigation":269,"path":2204,"seo":2205,"series_id":732,"severity":732,"stem":2206,"summary":2207,"tags":2208,"__hash__":2215},"posts\u002F2026-08-02-首页改成八屏.md","首页改成八屏",{"type":7,"value":1302,"toc":2189},[1303,1306,1312,1315,1319,1322,1332,1341,1344,1347,1355,1362,1368,1374,1430,1433,1452,1457,1462,1465,1471,1475,1478,1481,1489,1492,1495,1501,1504,1531,1534,1647,1650,1656,1660,1663,1679,1693,1696,1711,1714,1717,1721,1727,1730,1733,1739,1742,1792,1796,1799,1802,1805,1811,1814,1817,1822,1828,1832,1835,1838,1844,1848,1853,1894,1904,1907,1936,1941,1944,1993,1999,2002,2059,2071,2074,2078,2081,2084,2089,2092,2095,2100,2104,2107,2177,2180,2186],[10,1304,1305],{},"这个博客的首页原来是连续滚动。改成了整屏分页，一页一件事，共八屏：",[134,1307,1310],{"className":1308,"code":1309,"language":139},[137],"首屏 \u002F 自我介绍 01 \u002F 自我介绍 02 \u002F 做过的事 \u002F 精选复盘 \u002F 项目 \u002F 专栏 \u002F 联系\n",[141,1311,1309],{"__ignoreMap":143},[10,1313,1314],{},"一屏容纳是逐档实测调出来的，不是估的。",[14,1316,1318],{"id":1317},"一吸附用谁的","一、吸附用谁的",[10,1320,1321],{},"第一个决定是吸附用哪套机制。",[10,1323,1324,1325,1328,1329,178],{},"原生有 ",[141,1326,1327],{},"scroll-snap","，CSS 几行就能写。但这里用不了——页面上的平滑滚动是 Lenis 提供的，它用 JS 驱动 ",[141,1330,1331],{},"scrollTop",[1093,1333,1334],{},[10,1335,1336,1337,1340],{},"吸附用 Lenis 自带的 Snap 而不是 CSS scroll-snap——Lenis 是用 JS 驱动 scrollTop 的，和原生吸附会互相抢控制权、滚起来发抖。Snap 挂在同一个 Lenis 实例上没有这个问题，",[141,1338,1339],{},"onSnapComplete"," 还能驱动指示器。",[10,1342,1343],{},"两个机制都想控制滚动位置，结果就是抖。",[10,1345,1346],{},"挂在同一个实例上之后，吸附完成后还能拿到回调，右侧的页码指示器直接跟着它更新。",[14,1348,1350,1351,1354],{"id":1349},"二mandatory-是错的","二、",[141,1352,1353],{},"mandatory"," 是错的",[10,1356,1357,1358,1361],{},"第一版用 ",[141,1359,1360],{},"type: 'mandatory'","。用户反馈手感是「被抢走了」。",[10,1363,1364,1365,1367],{},"原因是 ",[141,1366,1353],{}," 在用户还在滑的时候就强行把页面拽向最近一页。滑动过程被插手，手感不是吸附，是失控。",[10,1369,1370,1371,178],{},"改成 ",[141,1372,1373],{},"proximity",[134,1375,1379],{"className":1376,"code":1377,"language":1378,"meta":143,"style":143},"language-ts shiki shiki-themes github-light github-dark","type: 'proximity',\ndistanceThreshold: '30%',\nduration: 1.1,\ndebounce: 500,\n","ts",[141,1380,1381,1394,1406,1418],{"__ignoreMap":143},[104,1382,1383,1386,1388,1391],{"class":188,"line":189},[104,1384,1385],{"class":192},"type",[104,1387,494],{"class":196},[104,1389,1390],{"class":359},"'proximity'",[104,1392,1393],{"class":196},",\n",[104,1395,1396,1399,1401,1404],{"class":188,"line":219},[104,1397,1398],{"class":192},"distanceThreshold",[104,1400,494],{"class":196},[104,1402,1403],{"class":359},"'30%'",[104,1405,1393],{"class":196},[104,1407,1408,1411,1413,1416],{"class":188,"line":225},[104,1409,1410],{"class":192},"duration",[104,1412,494],{"class":196},[104,1414,1415],{"class":212},"1.1",[104,1417,1393],{"class":196},[104,1419,1420,1423,1425,1428],{"class":188,"line":231},[104,1421,1422],{"class":192},"debounce",[104,1424,494],{"class":196},[104,1426,1427],{"class":212},"500",[104,1429,1393],{"class":196},[10,1431,1432],{},"三条参数各管一件事：",[112,1434,1435,1440,1446],{},[47,1436,1437,1439],{},[141,1438,1373],{}," 只在停下来且已经接近边界时才轻推一把。",[47,1441,1442,1445],{},[141,1443,1444],{},"distanceThreshold: '30%'"," 定义「接近」——落点离边界不到 30% 视口高才吸附，停在页面中间就让它停着。",[47,1447,1448,1451],{},[141,1449,1450],{},"debounce: 500"," 等惯性完全停下来再判断，不在滑行中途插手。",[10,1453,1454,1456],{},[141,1455,1422],{}," 从 220 提到 500：",[1093,1458,1459],{},[10,1460,1461],{},"debounce 太小会在惯性还没停时就抢着吸附，手感发涩。",[10,1463,1464],{},"实测结果：",[134,1466,1469],{"className":1467,"code":1468,"language":139},[137],"滑动全程 0 反向帧（原来会被拽回）\n停在离边界 306px（超过 30% 阈值）时保持不动\n停在边界附近才对齐整页\n",[141,1470,1468],{"__ignoreMap":143},[14,1472,1474],{"id":1473},"三一屏装不装得下","三、一屏装不装得下",[10,1476,1477],{},"分页的前提是内容能装进一屏。这一条逐档量过。",[10,1479,1480],{},"首屏原高 921px：",[112,1482,1483,1486],{},[47,1484,1485],{},"1440×900 超出 89px",[47,1487,1488],{},"1366×768 超出 201px",[10,1490,1491],{},"精选复盘 788px，在 1366×768 超出 88px。",[10,1493,1494],{},"两个方向可选：砍内容，或者按档收紧间距。选了后者，分三档：",[134,1496,1499],{"className":1497,"code":1498,"language":139},[137],"min-width: 1024px and max-height: 820px   深压\nmin-width: 1024px and max-height: 900px   只压首屏\nmin-width: 1024px and min-height: 1000px  放大间距吃大屏留白\n",[141,1500,1498],{"__ignoreMap":143},[10,1502,1503],{},"中间那档的调整方式值得记一笔。为了挤出 27px，第一版直接砍了字号：",[134,1505,1509],{"className":1506,"code":1507,"language":1508,"meta":143,"style":143},"language-css shiki shiki-themes github-light github-dark","font-size: clamp(46px, 9.2vw, 132px)  →  clamp(30px, 5.4vw, 62px)\n","css",[141,1510,1511],{"__ignoreMap":143},[104,1512,1513,1516,1519,1522,1525,1528],{"class":188,"line":189},[104,1514,1515],{"class":200},"font-size",[104,1517,1518],{"class":196},": clamp(46px, 9",[104,1520,1521],{"class":192},".2vw",[104,1523,1524],{"class":196},", 132px)  →  clamp(30px, 5",[104,1526,1527],{"class":192},".4vw",[104,1529,1530],{"class":196},", 62px)\n",[10,1532,1533],{},"砍掉一半多。首屏标题是整页的主体，砍字号会让整屏塌下来。改成全部从内边距要：",[134,1535,1537],{"className":1506,"code":1536,"language":1508,"meta":143,"style":143},"@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",[141,1538,1539,1579,1619,1642],{"__ignoreMap":143},[104,1540,1541,1544,1547,1550,1552,1555,1558,1561,1564,1566,1569,1571,1574,1576],{"class":188,"line":189},[104,1542,1543],{"class":396},"@media",[104,1545,1546],{"class":196}," (",[104,1548,1549],{"class":212},"min-width",[104,1551,494],{"class":196},[104,1553,1554],{"class":212},"1024",[104,1556,1557],{"class":396},"px",[104,1559,1560],{"class":196},") ",[104,1562,1563],{"class":396},"and",[104,1565,1546],{"class":196},[104,1567,1568],{"class":212},"max-height",[104,1570,494],{"class":196},[104,1572,1573],{"class":212},"900",[104,1575,1557],{"class":396},[104,1577,1578],{"class":196},") {\n",[104,1580,1581,1584,1587,1590,1593,1596,1598,1601,1603,1606,1609,1611,1614,1616],{"class":188,"line":219},[104,1582,1583],{"class":192},"  .page-hero",[104,1585,1586],{"class":196}," :deep(",[104,1588,1589],{"class":192},".hero",[104,1591,1592],{"class":196},") { ",[104,1594,1595],{"class":212},"padding-top",[104,1597,494],{"class":196},[104,1599,1600],{"class":212},"20",[104,1602,1557],{"class":396},[104,1604,1605],{"class":196},"; ",[104,1607,1608],{"class":212},"padding-bottom",[104,1610,494],{"class":196},[104,1612,1613],{"class":212},"24",[104,1615,1557],{"class":396},[104,1617,1618],{"class":196},"; }\n",[104,1620,1621,1623,1625,1628,1630,1633,1635,1638,1640],{"class":188,"line":225},[104,1622,1583],{"class":192},[104,1624,1586],{"class":196},[104,1626,1627],{"class":192},".hero-foot",[104,1629,1592],{"class":196},[104,1631,1632],{"class":212},"margin-top",[104,1634,494],{"class":196},[104,1636,1637],{"class":212},"26",[104,1639,1557],{"class":396},[104,1641,1618],{"class":196},[104,1643,1644],{"class":188,"line":231},[104,1645,1646],{"class":196},"}\n",[10,1648,1649],{},"只有 700px 可用高度那一档才让一档字号。三档实测：",[134,1651,1654],{"className":1652,"code":1653,"language":139},[137],"1440×900    132px\n1920×1080   132px\n1366×768     92.9px\n",[141,1655,1653],{"__ignoreMap":143},[14,1657,1659],{"id":1658},"四检测判据写错过一次","四、检测判据写错过一次",[10,1661,1662],{},"判断页面有没有溢出，第一版用的是：",[134,1664,1666],{"className":1376,"code":1665,"language":1378,"meta":143,"style":143},"scrollHeight > clientHeight\n",[141,1667,1668],{"__ignoreMap":143},[104,1669,1670,1673,1676],{"class":188,"line":189},[104,1671,1672],{"class":196},"scrollHeight ",[104,1674,1675],{"class":396},">",[104,1677,1678],{"class":196}," clientHeight\n",[10,1680,1681,1682,1685,1686,825,1689,1692],{},"这个判据永远为假。页面用的是 ",[141,1683,1684],{},"min-height","，内容超了页面会自己长高——",[141,1687,1688],{},"scrollHeight",[141,1690,1691],{},"clientHeight"," 一起变大，比值不变。",[10,1694,1695],{},"改成直接比可用高度：",[134,1697,1699],{"className":1376,"code":1698,"language":1378,"meta":143,"style":143},"\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[141,1700,1701,1706],{"__ignoreMap":143},[104,1702,1703],{"class":188,"line":189},[104,1704,1705],{"class":414},"\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n",[104,1707,1708],{"class":188,"line":219},[104,1709,1710],{"class":414},"\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[10,1712,1713],{},"这个错误值得单独说：判据本身写错了，所以「三档零溢出」这个结论在修正之前是不成立的。",[10,1715,1716],{},"修正之后三档均为 8\u002F8 页零溢出。",[14,1718,1720],{"id":1719},"五不分页的三种情况","五、不分页的三种情况",[134,1722,1725],{"className":1723,"code":1724,"language":139},[137],"窄屏（\u003C 1024px）\n矮视口（\u003C 620px）\n减弱动效（prefers-reduced-motion）\n",[141,1726,1724],{"__ignoreMap":143},[10,1728,1729],{},"三种都退回连续滚动。",[10,1731,1732],{},"前两种有实测依据：",[134,1734,1737],{"className":1735,"code":1736,"language":139},[137],"实测 390px 下自我介绍 1448px、精选复盘 1114px，强行一屏只会截断内容\n",[141,1738,1736],{"__ignoreMap":143},[10,1740,1741],{},"第三种是硬要求。整屏吸附会强制改变滚动位置，这和「尊重减弱动效偏好」直接冲突。CSS 里也要同步禁用：",[134,1743,1745],{"className":1506,"code":1744,"language":1508,"meta":143,"style":143},"@media (prefers-reduced-motion: reduce) {\n  .page { min-height: 0; }\n  .pager { display: none; }\n}\n",[141,1746,1747,1754,1771,1788],{"__ignoreMap":143},[104,1748,1749,1751],{"class":188,"line":189},[104,1750,1543],{"class":396},[104,1752,1753],{"class":196}," (prefers-reduced-motion: reduce) {\n",[104,1755,1756,1759,1762,1764,1766,1769],{"class":188,"line":219},[104,1757,1758],{"class":192},"  .page",[104,1760,1761],{"class":196}," { ",[104,1763,1684],{"class":212},[104,1765,494],{"class":196},[104,1767,1768],{"class":212},"0",[104,1770,1618],{"class":196},[104,1772,1773,1776,1778,1781,1783,1786],{"class":188,"line":225},[104,1774,1775],{"class":192},"  .pager",[104,1777,1761],{"class":196},[104,1779,1780],{"class":212},"display",[104,1782,494],{"class":196},[104,1784,1785],{"class":212},"none",[104,1787,1618],{"class":196},[104,1789,1790],{"class":188,"line":231},[104,1791,1646],{"class":196},[14,1793,1795],{"id":1794},"六分页之后每页只剩半屏内容","六、分页之后，每页只剩半屏内容",[10,1797,1798],{},"八屏分配完之后，出现一个反向问题：每页内容太少。",[10,1800,1801],{},"实测 1440×900 下多数页只填了 47%~63%，「做过的事」那页 63% 是空的。",[10,1803,1804],{},"既然分了页，每页就得撑得住。逐页补实：",[134,1806,1809],{"className":1807,"code":1808,"language":139},[137],"做过的事：加页首概览条（项目数\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",[141,1810,1808],{"__ignoreMap":143},[10,1812,1813],{},"填充率从 47%~63% 提到 61%~100%。",[10,1815,1816],{},"同一轮里把两个区块的入场动画也补了常驻循环，因为：",[1093,1818,1819],{},[10,1820,1821],{},"实测入场动画本来就在跑（轴线 scaleX 0→0.99、圆点带回弹、卡片 clip-path 从 78% 揭到 4%、指标读数往上跳），问题是太隐蔽：一次性、1 秒内结束、触发点又在区块刚露头时，等正眼看过去通常已经播完。",[134,1823,1826],{"className":1824,"code":1825,"language":139},[137],"触发点 top 88% → 95%，轴线 0.9s → 1.25s，圆点与节点错峰 0.11 → 0.16\n",[141,1827,1825],{"__ignoreMap":143},[14,1829,1831],{"id":1830},"七断点从-900-降到-820","七、断点从 900 降到 820",[10,1833,1834],{},"第一版把「矮视口」定在 900px 高。1440×900 的屏幕因此被当成矮视口，白白砍掉了列表摘要——精选复盘从 788px 缩到 389px。",[10,1836,1837],{},"而 1440×900 明明有空间。断点降到 820：",[134,1839,1842],{"className":1840,"code":1841,"language":139},[137],"820 以下   深压\n900 以下   只压首屏\n1000 以上  放大间距吃掉大屏留白\n",[141,1843,1841],{"__ignoreMap":143},[14,1845,1847],{"id":1846},"八两个-css-优先级问题","八、两个 CSS 优先级问题",[10,1849,1850],{},[25,1851,1852],{},"首屏被挤成一个框。",[134,1854,1856],{"className":1506,"code":1855,"language":1508,"meta":143,"style":143},".page { padding: 24px 0; }\n.page-hero { padding: 0; }\n",[141,1857,1858,1879],{"__ignoreMap":143},[104,1859,1860,1863,1865,1868,1870,1872,1874,1877],{"class":188,"line":189},[104,1861,1862],{"class":192},".page",[104,1864,1761],{"class":196},[104,1866,1867],{"class":212},"padding",[104,1869,494],{"class":196},[104,1871,1613],{"class":212},[104,1873,1557],{"class":396},[104,1875,1876],{"class":212}," 0",[104,1878,1618],{"class":196},[104,1880,1881,1884,1886,1888,1890,1892],{"class":188,"line":219},[104,1882,1883],{"class":192},".page-hero",[104,1885,1761],{"class":196},[104,1887,1867],{"class":212},[104,1889,494],{"class":196},[104,1891,1768],{"class":212},[104,1893,1618],{"class":196},[10,1895,1896,1899,1900,1903],{},[141,1897,1898],{},".page-hero { padding: 0 }"," 写在媒体查询之前，被后面同优先级的 ",[141,1901,1902],{},".page { padding: ... }"," 覆盖了。首屏于是四边都留出内边距，看起来像一个框。",[10,1905,1906],{},"修法是在每一档里重新声明一次：",[134,1908,1910],{"className":1506,"code":1909,"language":1508,"meta":143,"style":143},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n.page-hero { padding: 0; }\n",[141,1911,1912,1917,1922],{"__ignoreMap":143},[104,1913,1914],{"class":188,"line":189},[104,1915,1916],{"class":414},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n",[104,1918,1919],{"class":188,"line":219},[104,1920,1921],{"class":414},"   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n",[104,1923,1924,1926,1928,1930,1932,1934],{"class":188,"line":225},[104,1925,1883],{"class":192},[104,1927,1761],{"class":196},[104,1929,1867],{"class":212},[104,1931,494],{"class":196},[104,1933,1768],{"class":212},[104,1935,1618],{"class":196},[10,1937,1938],{},[25,1939,1940],{},"标题被大屏规则压小。",[10,1942,1943],{},"大屏档里有一条：",[134,1945,1947],{"className":1506,"code":1946,"language":1508,"meta":143,"style":143},".page :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[141,1948,1949],{"__ignoreMap":143},[104,1950,1951,1953,1955,1958,1960,1962,1964,1967,1970,1973,1975,1977,1980,1983,1985,1988,1990],{"class":188,"line":189},[104,1952,1862],{"class":192},[104,1954,1586],{"class":196},[104,1956,1957],{"class":192},".big",[104,1959,1592],{"class":196},[104,1961,1515],{"class":212},[104,1963,494],{"class":196},[104,1965,1966],{"class":212},"clamp",[104,1968,1969],{"class":196},"(",[104,1971,1972],{"class":212},"34",[104,1974,1557],{"class":396},[104,1976,363],{"class":196},[104,1978,1979],{"class":212},"3.4",[104,1981,1982],{"class":396},"vw",[104,1984,363],{"class":196},[104,1986,1987],{"class":212},"56",[104,1989,1557],{"class":396},[104,1991,1992],{"class":196},"); }\n",[10,1994,1995,1996,1998],{},"首屏主标题的类名也是 ",[141,1997,1957],{},"。1920×1080 下它从 132px 被压到 56px，首屏整个塌掉。",[10,2000,2001],{},"改成排除首屏：",[134,2003,2005],{"className":1506,"code":2004,"language":1508,"meta":143,"style":143},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n.page:not(.page-hero) :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[141,2006,2007,2012,2017],{"__ignoreMap":143},[104,2008,2009],{"class":188,"line":189},[104,2010,2011],{"class":414},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n",[104,2013,2014],{"class":188,"line":219},[104,2015,2016],{"class":414},"   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n",[104,2018,2019,2022,2024,2026,2029,2031,2033,2035,2037,2039,2041,2043,2045,2047,2049,2051,2053,2055,2057],{"class":188,"line":225},[104,2020,2021],{"class":192},".page:not",[104,2023,1969],{"class":196},[104,2025,1883],{"class":192},[104,2027,2028],{"class":196},") :deep(",[104,2030,1957],{"class":192},[104,2032,1592],{"class":196},[104,2034,1515],{"class":212},[104,2036,494],{"class":196},[104,2038,1966],{"class":212},[104,2040,1969],{"class":196},[104,2042,1972],{"class":212},[104,2044,1557],{"class":396},[104,2046,363],{"class":196},[104,2048,1979],{"class":212},[104,2050,1982],{"class":396},[104,2052,363],{"class":196},[104,2054,1987],{"class":212},[104,2056,1557],{"class":396},[104,2058,1992],{"class":196},[10,2060,2061,2062,39,2065,2067,2068,2070],{},"两个问题的成因相同：",[25,2063,2064],{},"同一个类名在两种语义下被复用",[141,2066,1862],{}," 既是容器也是列表页的内边距来源，",[141,2069,1957],{}," 既是区块小标题也是首屏主标题。分开之后就没这个问题。",[10,2072,2073],{},"修完实测：hero 左 0、底 1、右 10（右侧那 10px 是站点滚动条本身的宽度），标题 132px 满值，8\u002F8 页仍零溢出。",[14,2075,2077],{"id":2076},"九加载页期间的一个时序漏洞","九、加载页期间的一个时序漏洞",[10,2079,2080],{},"这个站有一个启动加载页，首次访问时盖几秒。加载页盖着的时候，入场动画被暂停了——否则动画会在遮挡期间自己跑完。",[10,2082,2083],{},"副作用是页面切换的幕布动画也被冻住：",[1093,2085,2086],{},[10,2087,2088],{},"实测（加载页 3s 时点击）：t=745 路由已切到 \u002Fcolumns 而幕布 display:none，t=1406 幕布才出现并扫过。",[10,2090,2091],{},"路由在没有幕布的情况下切换了，等加载页结束、时间线恢复之后，被冻住的补间才补播。",[10,2093,2094],{},"结论是补一行守卫：加载页盖着时直接放行，不放幕布。理由是：",[1093,2096,2097],{},[10,2098,2099],{},"真实用户点不到——加载页是全屏遮罩。但浏览器前进\u002F后退、程序化跳转仍可能在窗口内触发导航。",[14,2101,2103],{"id":2102},"十整屏分页的账","十、整屏分页的账",[10,2105,2106],{},"这套改动最后收敛成一张数字表：",[1014,2108,2109,2119],{},[1017,2110,2111],{},[1020,2112,2113,2116],{},[1023,2114,2115],{},"项目",[1023,2117,2118],{},"数值",[1033,2120,2121,2129,2137,2145,2153,2161,2169],{},[1020,2122,2123,2126],{},[1038,2124,2125],{},"页数",[1038,2127,2128],{},"8",[1020,2130,2131,2134],{},[1038,2132,2133],{},"吸附类型",[1038,2135,2136],{},"proximity，阈值 30%，防抖 500ms",[1020,2138,2139,2142],{},[1038,2140,2141],{},"吸附时长",[1038,2143,2144],{},"1.1s",[1020,2146,2147,2150],{},[1038,2148,2149],{},"分页生效条件",[1038,2151,2152],{},"宽 ≥ 1024px 且 高 ≥ 620px 且 未开启减弱动效",[1020,2154,2155,2158],{},[1038,2156,2157],{},"溢出",[1038,2159,2160],{},"三档均 0\u002F8",[1020,2162,2163,2166],{},[1038,2164,2165],{},"填充率",[1038,2167,2168],{},"61%~100%（改前 47%~63%）",[1020,2170,2171,2174],{},[1038,2172,2173],{},"首屏标题",[1038,2175,2176],{},"132px（1440×900、1920×1080）、92.9px（1366×768）",[10,2178,2179],{},"每一条都有一次实测在后面。这类改动没别的办法——「一屏装不装得下」这件事只能量，量完才知道该压间距还是该砍内容。",[10,2181,2182,2183,2185],{},"中间还有两处是判断上的错，不是量的问题：一是用 ",[141,2184,1353],{}," 吸附（把用户的滑动抢走了），二是把「矮视口」定在 900（把有空间的屏幕当成没空间）。前者的修法是换模式，后者的修法是挪断点。",[714,2187,2188],{},"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":143,"searchDepth":219,"depth":219,"links":2190},[2191,2192,2194,2195,2196,2197,2198,2199,2200,2201],{"id":1317,"depth":219,"text":1318},{"id":1349,"depth":219,"text":2193},"二、mandatory 是错的",{"id":1473,"depth":219,"text":1474},{"id":1658,"depth":219,"text":1659},{"id":1719,"depth":219,"text":1720},{"id":1794,"depth":219,"text":1795},{"id":1830,"depth":219,"text":1831},{"id":1846,"depth":219,"text":1847},{"id":2076,"depth":219,"text":2077},{"id":2102,"depth":219,"text":2103},"2026-08-02",{},"\u002F2026-08-02",{"title":1300,"description":1305},"2026-08-02-首页改成八屏","把博客首页从连续滚动改成八屏整屏分页。吸附用 Lenis 自带能力而不是 CSS 原生，断点按实测调整，每一档的溢出与填充率都有数字。",[2209,2210,2211,2212,2213,2214],"GSAP","Lenis","滚动吸附","响应式断点","页面结构","prefers-reduced-motion","2o9dKTJYPwCLxF__bLLN46O2x_0PMJoQgyZSxZpO428",1789212573193]