<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://mstzuomu.space/</id>
  <title type="text">左沐の手册</title>
  <subtitle type="text">热爱是拯救无趣人生的唯一途径</subtitle>
  <updated>2026-10-05T00:00:00.000Z</updated>
  <author><name>陌殊途左沐</name></author>
  <link rel="alternate" href="https://mstzuomu.space/"/>
  <link rel="self" href="https://mstzuomu.space/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-19/</id>
      <title type="text">导航栏 UI 改造全记录：卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板（附完整复现指南）</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-19/"/>
      <summary type="text">一次连续迭代的完整复盘——从卡片边框体系、悬浮胶囊导航，到三段结构拆分、下滑收拢动画，再到 PC 收拢态汉堡的控件快捷面板与&quot;行点击原位弹卡&quot;的两处根因修复，追加 PC 搜索控件图标化与收拢动画卡顿的逐帧诊断修复。每个批次改了哪些文件、为什么这么改、踩了哪些坑（CSS 层序、正圆公式、包含块、Vite 缓存、Playwright headless 动画冻结），附 git 重放与手工重做两条复现路径及验收清单</summary>
      <content type="html"><![CDATA[<section><h1>导航栏 UI 改造全记录：卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板（附完整复现指南）<a href="#导航栏-ui-改造全记录卡片边框悬浮胶囊三段收拢与-pc-收拢态快捷面板附完整复现指南"><span>#</span></a></h1><p>本文记录 Firefly 博客一次连续的导航栏/卡片 UI 改造：<strong>六个已合并提交 + 一条被回滚的实验分支</strong>，时间跨度 2026-10-04 至 2026-10-05。写给两类读者：</p><ul>
<li><strong>未来的维护者（包括新会话的 AI 助手）</strong>：读完应知道每一批改了什么、为什么、当时的验证结论与未尽事项；</li>
<li><strong>希望复现的访客</strong>：文末给出两条复现路径——git 一次重放，或按批次手工重做，全部关键代码在正文中可照抄。</li>
</ul><hr /><section><h2>〇、概览：最终形态与提交清单<a href="#〇概览最终形态与提交清单"><span>#</span></a></h2><p>改造完成后的导航栏最终形态：</p><ol>
<li><strong>卡片边框体系</strong>：开启 <code>enable-card-border</code> 后，全站卡片与导航栏是 2px 纯黑（暗色自动切白）边框，粗细可在显示设置面板用 0–5px 滑块实时调节，持久化到 <code>localStorage</code>。</li>
<li><strong>悬浮胶囊导航栏</strong>：高度从 4.5rem 压扁到 4rem，圆角 9999px，整条导航栏通过 wrapper 的 <code>padding-top: 0.75rem</code> 悬浮于顶部之下，间隙条点击穿透。</li>
<li><strong>三段独立胶囊 + 下滑收拢动画</strong>：导航栏拆为左（logo/站名）、中（菜单）、右（控件）三个独立胶囊；向下滚动超过 80px 时左右两段帘式收起为<strong>正圆</strong>，中段菜单常显不收；上滑或回顶恢复。</li>
<li><strong>PC 收拢态快捷面板</strong>：收拢后右段只剩一个汉堡按钮（PC 端此时才显示），点击弹出「控件快捷面板」——搜索/音乐/播放/显示设置/亮暗五行；点任意行<strong>导航栏保持收拢</strong>，原控件的子卡片在原位弹出。移动端汉堡仍开导航链接菜单，两菜单互斥。</li>
<li><strong>移动端菜单样式可配置</strong>：<code>siteConfig.nav.mobileMenuStyle</code> 支持 <code>"card"</code>（旧版浮层卡片，默认）与 <code>"drawer"</code>（全屏抽屉）。</li>
<li><strong>PC 搜索图标化</strong>：PC 端导航栏不再内嵌搜索输入框，与移动端统一为一个搜索图标；点击弹出搜索卡片，打开后光标自动落入卡内输入框。</li>
<li><strong>收拢动画卡顿修复</strong>：PC 右段收拢成圆不再「弹一下→冻结→猛缩」——汉堡由 <code>display</code> 切换改为 width 连续过渡，控件组 <code>max-width</code> 去掉 30rem 死区；亮暗图标切主题时交叉淡化（第十三节）。</li>
</ol>

<table><thead><tr><th>提交</th><th>日期</th><th>内容</th><th>规模</th></tr></thead><tbody><tr><td><code>74680341</code></td><td>10-04</td><td>卡片边框默认 2px 纯黑，显示设置新增边框粗细滑块</td><td>11 文件 +107/−4</td></tr><tr><td><code>2a00a602</code></td><td>10-04</td><td>导航栏改为悬浮胶囊样式并压扁高度</td><td>4 文件 +33/−25</td></tr><tr><td><code>f6b3d060</code></td><td>10-04</td><td>导航栏接入卡片边框，边框色亮黑暗白跟随主题</td><td>2 文件 +16/−12</td></tr><tr><td><code>b7cce682</code></td><td>10-05</td><td>三段拆分、收拢动画、移动菜单配置、PC 快捷面板（含”原位弹卡”修复）</td><td>17 文件 +500/−157</td></tr><tr><td><code>04197b82</code></td><td>10-05</td><td>PC 搜索收成图标按钮，输入统一走搜索面板（第十二节）</td><td>4 文件 +37/−72</td></tr><tr><td><code>d7d3b62c</code></td><td>10-05</td><td>修复 PC 收拢动画卡顿（第十三节）</td><td>2 文件 +29/−20</td></tr></tbody></table><p>基线提交为 <code>020533ee</code>（其后是 <code>74680341</code>），全部改动可用 <code>git diff 020533ee..d7d3b62c</code> 一次性查看。</p><p>另有一次<strong>液态玻璃（liquid glass）导航栏原型</strong>：做出可运行版本后按要求整体回滚删除（含 <code>index.html</code> 原型与 <code>banner-home.css</code> 改动），未进入任何提交，仅在此备忘——若将来重做，需从零开始，git 历史里没有可恢复的痕迹。</p><hr /></section><section><h2>一、环境与验证基线<a href="#一环境与验证基线"><span>#</span></a></h2><ul>
<li><strong>技术栈</strong>：Astro 7 + Svelte 5 + TypeScript，包管理 <strong>pnpm</strong>（<code>preinstall</code> 强制），样式为 Tailwind v4 + 分层 CSS（<code>navbar.css</code>、<code>main.css</code> 等以 <code>@import</code> 进主入口，<strong>未分层</strong>）。</li>
<li><strong>本地开发</strong>：<code>pnpm dev</code>（默认 <code>http://localhost:4321</code>，绑 <code>::1</code>，测试用 <code>localhost</code> 不要用 <code>127.0.0.1</code>）。</li>
<li><strong>检查三件套</strong>（每批改完必跑）：</li>
</ul><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>biome</span><span> </span><span>check</span><span> </span><span>&lt;改动文件&gt;</span><span>   </span><span># 格式/静态检查（只读，不用 pnpm lint——会波及无关文件）</span></div></div><div><div><div>2</div></div><div><span>pnpm</span><span> </span><span>type-check</span><span>               </span><span># tsc --noEmit --isolatedDeclarations</span></div></div><div><div><div>3</div></div><div><span>pnpm</span><span> </span><span>check</span><span>                    </span><span># Astro 诊断；基线 = 12 个存量错误（anime.astro 11 + vndb.astro 1）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>pnpm check</code> 的验收标准是「<strong>仍为 12，无新增</strong>」，不是零错误。</p><ul>
<li><strong>验证分工</strong>：AI 负责代码层与自动化断言；视觉验收由人完成。自动化用 Playwright（本文第八节列出它在本项目的三个特有坑）。</li>
</ul><hr /></section><section><h2>二、批次一：卡片边框体系（<code>74680341</code>）<a href="#二批次一卡片边框体系74680341"><span>#</span></a></h2><section><h3>目标<a href="#目标"><span>#</span></a></h3><p>把「卡片边框」从主题色细分线（<code>--line-divider</code>，1px 半透明）改为<strong>可调粗细的纯黑描边</strong>，默认 2px，并在显示设置面板提供滑块。</p></section><section><h3>改动明细<a href="#改动明细"><span>#</span></a></h3><p><strong>1. <code>src/styles/main.css</code></strong> — 三条边框规则统一接变量：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 开启边框和阴影效果时的样式（宽度由 --card-border-width 控制，默认 2px，可在显示设置面板调节） */</span></div></div><div><div><div>2</div></div><div><span>.enable-card-border</span><span> </span><span>.card-base</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>@apply transition-all duration-300 border border-black shadow-xs;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>border-width: </span><span>var</span><span>(</span><span>--card-border-width</span><span>, </span><span><span>2</span><span>px</span></span><span>);</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div><span>/* .card-base-transparent、.btn-card 同理，共三处 */</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>2. <code>src/utils/setting-utils.ts</code></strong> — 新增四个函数（+51 行）：<code>getDefaultCardBorderWidth()</code> 返回 <code>2</code>；<code>getStoredCardBorderWidth()</code> 读 <code>localStorage.cardBorderWidth</code> 并 clamp 到 0–5；<code>applyCardBorderWidthToDocument(width)</code> 写 <code>document.documentElement.style.setProperty("--card-border-width", ...)</code>；<code>setCardBorderWidth(width)</code> 持久化 + 应用。</p><p><strong>3. <code>src/layouts/Layout.astro</code></strong> — 首屏内联脚本（FOUC 防护）中追加：读 <code>localStorage.cardBorderWidth</code>，校验 0–5 后在解析 HTML 期间就把 <code>--card-border-width</code> 写到 <code>&lt;html&gt;</code> 上，避免滑块值加载后闪一下默认边框。</p><p><strong>4. <code>src/components/controls/DisplaySettingsIntegrated.svelte</code></strong> — 「卡片样式」区块新增滑块（+36 行）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>let cardBorderWidth = $state(getStoredCardBorderWidth());</span></div></div><div><div><div>2</div></div><div><span>// ...</span></div></div><div><div><div>3</div></div><div><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"rounded-md bg-(--btn-regular-bg) p-2"</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"flex items-center justify-between mb-1"</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"text-xs font-medium ..."</span><span>&gt;</span><span>{</span><span>i18n</span><span><span>(</span><span>I18nKey</span><span>.</span></span><span>cardBorderWidth</span><span>)</span><span>}</span><span>&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"text-xs ..."</span><span>&gt;</span><span>{</span><span>cardBorderWidth</span><span>}</span><span>px&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>&lt;</span><span>input</span><span> </span><span>type</span><span>=</span><span>"range"</span><span> </span><span>min</span><span>=</span><span>{</span><span>0</span><span>}</span><span> </span><span>max</span><span>=</span><span>{</span><span>5</span><span>}</span><span> </span><span>step</span><span>=</span><span>{</span><span>0.5</span><span>}</span><span> </span><span>value</span><span>=</span><span>{</span><span>cardBorderWidth</span><span>}</span></div></div><div><div><div>9</div></div><div><span>        </span><span>oninput</span><span>=</span><span>{</span><span><span>(</span><span>e</span><span>) </span></span><span>=&gt;</span><span><span> { </span><span>cardBorderWidth</span><span> </span></span><span>=</span><span> </span><span>Number</span><span><span>(</span><span>e</span><span>.</span></span><span>currentTarget</span><span>.</span><span>value</span><span>); }</span><span>}</span></div></div><div><div><div>10</div></div><div><span>        </span><span>class</span><span>=</span><span>"slider w-full overlay-slider"</span><span> /&gt;</span></div></div><div><div><div>11</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>同时接入三处状态机：<code>$effect(() =&gt; setCardBorderWidth(cardBorderWidth))</code> 实时生效；<code>cardSettingsIsDefault</code> 加入宽度默认判断；<code>resetCardSettings()</code> 恢复默认宽度并 <code>requestAnimationFrame(refreshAllRangeProgress)</code> 刷新滑块进度条。</p><p><strong>5. i18n</strong> — <code>I18nKey.cardBorderWidth</code> 键 + 7 个语言文件（en/ja/ko/ru/zh_CN/zh_TW…）各加一行，中文为「边框粗细」。</p></section><section><h3>复现要点<a href="#复现要点"><span>#</span></a></h3><p>按上面 1→5 顺序做即可。注意 <code>clampNumber</code> 是 <code>setting-utils.ts</code> 里已有的工具函数，直接复用；滑块 <code>step={0.5}</code>，但正圆公式依赖的 CSS 变量能接受小数。</p><hr /></section></section><section><h2>三、批次二：悬浮胶囊化（<code>2a00a602</code>）<a href="#三批次二悬浮胶囊化2a00a602"><span>#</span></a></h2><section><h3>目标<a href="#目标-1"><span>#</span></a></h3><p>导航栏从「贴顶方角条」改为「悬浮圆角胶囊」，并整体压扁一档。</p></section><section><h3>改动明细<a href="#改动明细-1"><span>#</span></a></h3><p><strong>1. 高度 4.5rem → 4rem</strong>（三处 + 联动）：</p><ul>
<li><code>HeaderTopRow.astro</code>：fixed 态类名 <code>h-18</code> → <code>h-16</code>；</li>
<li><code>Navbar.astro</code>：容器 <code>h-18</code> → <code>h-16</code>；</li>
<li><code>layout-base.css</code>：<code>#top-row</code> 的 sticky/fixed 固定高度 <code>4.5rem</code> → <code>4rem</code>（两处 + 移动端一处），banner 首页占位 <code>calc(var(--banner-height-home) - 4.5rem)</code> → <code>- 4rem</code>。</li>
</ul><p><strong>2. 圆角全模式统一 9999px</strong>（<code>navbar.css</code>）：bar 本体、<code>#navbar</code> 阴影载体、以及 semi/semifull/亮暗各壁纸态的 <code>!important</code> 圆角覆盖，全部由 <code>0 0 0.75rem 0.75rem</code> 改为 <code>9999px</code>。</p><p><strong>3. 悬浮间距放 wrapper</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 间距放在 wrapper 的 padding-top 而非 bar 的 margin：</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>navbar-hidden 的 -translate-y-full 按 wrapper 自身高度平移，</span></div></div><div><div><div>3</div></div><div><span><span>   </span></span><span>padding 计入高度，下移多少就能完整隐藏多少，不会在顶部残留 12px 边缘。</span></div></div><div><div><div>4</div></div><div><span><span>   </span></span><span>pointer-events：间隙条是 wrapper 的 padding，整体设为 none 让点击穿透到下层，</span></div></div><div><div><div>5</div></div><div><span><span>   </span></span><span>bar 自身设回 auto（未分层选择器优先于 Tailwind 工具类） */</span></div></div><div><div><div>6</div></div><div><span>#navbar-wrapper</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>padding-top: </span><span><span>0.75</span><span>rem</span></span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>pointer-events: </span><span>none</span><span>;</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>配套给 bar（<code>#navbar&gt;div</code>）加 <code>pointer-events: auto</code>——否则悬浮间隙条会把点击吃掉，导航栏上的按钮和下拉面板全部点不到。这两行是一对，<strong>必须同时改</strong>。</p><p><strong>4. 移动端成形</strong>（<code>&lt;768px</code>）：组件里 top-row 是 <code>px-0</code>，bar 会贴屏幕边缘，胶囊两端没留空就不成形——删掉旧的「移动端去圆角」规则，改为给 <code>#top-row</code> 补 <code>padding-left/right: 0.75rem</code>。</p></section><section><h3>为什么间距不能用 margin<a href="#为什么间距不能用-margin"><span>#</span></a></h3><p>见上面代码注释：<code>-translate-y-full</code> 的隐藏动画按 wrapper 自身高度平移，margin 不计入 wrapper 高度，会残留一条边；padding 计入，隐藏才干净。</p><hr /></section></section><section><h2>四、批次三：亮黑暗白边框（<code>f6b3d060</code>）<a href="#四批次三亮黑暗白边框f6b3d060"><span>#</span></a></h2><section><h3>目标<a href="#目标-2"><span>#</span></a></h3><p>纯黑边框在暗色主题下不可见，需要<strong>亮色纯黑、暗色纯白</strong>地跟随主题，并让导航栏与卡片共用同一套边框变量。</p></section><section><h3>改动明细<a href="#改动明细-2"><span>#</span></a></h3><p><strong>1. <code>src/styles/variables.styl</code></strong> — 亮暗两段各加一个变量：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 亮色段 */</span></div></div><div><div><div>2</div></div><div><span>--card-border-color: black</span></div></div><div><div><div>3</div></div><div><span>/* 暗色段 */</span></div></div><div><div><div>4</div></div><div><span>--card-border-color: white</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>2. <code>src/styles/main.css</code></strong> — 三处卡片规则 <code>border-black</code> 改 <code>border-(--card-border-color)</code>；导航栏规则改为：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.enable-card-border</span><span> </span><span>#navbar</span><span> </span><span>&gt;</span><span> </span><span>div</span><span>:not</span><span>(</span><span>.absolute</span><span>)</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>@apply transition-all duration-300 border shadow-xs;</span></div></div><div><div><div>3</div></div><div><span>    </span><span>/* 与卡片边框同款：颜色跟随 --card-border-color（亮黑/暗白），宽度跟随 --card-border-width 滑块</span></div></div><div><div><div>4</div></div><div><span><span>       </span></span><span>（!important 压过 navbar.css 的未分层 1px 透明边框） */</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>border-color: </span><span>var</span><span>(</span><span>--card-border-color</span><span>) </span><span>!important</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>border-width: </span><span>var</span><span>(</span><span>--card-border-width</span><span>, </span><span><span>2</span><span>px</span></span><span>) </span><span>!important</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>3. 删除两组「防闪现」旧规则</strong>：原实现里 banner 玻璃态与 fullscreen 模式各有 <code>border: none !important</code>，目的是防止切页瞬间边框从无到有闪一下。边框改为刻意设计的常驻元素后，「闪现」的定义变了——<strong>常驻即无闪现</strong>，过渡期由 <code>.is-wallpaper-transitioning</code> 的透明边框规则兜底。因此删除 banner 的 <code>border: none</code> 组，fullscreen 只保留 <code>outline: none</code>。删除行全部有意，复现时不要当作误删恢复。</p></section><section><h3>本批次踩坑：Vite dev 编译缓存丢更新<a href="#本批次踩坑vite-dev-编译缓存丢更新"><span>#</span></a></h3><p><strong>现象</strong>：改了 <code>variables.styl</code> 暗色白值 + <code>main.css</code> 导航栏引用，磁盘文件正确，浏览器里暗色边框仍是黑的，刷新无效。</p><p><strong>根因</strong>：dev server 同一轮里各写入了两次文件，Vite watcher 只吃到了每个文件的<strong>第一次写入</strong>，第二次的失效事件丢失——served 编译产物残缺。</p><p><strong>处置</strong>：重存文件踢一脚强制重编译（或重启 <code>pnpm dev</code>）。诊断手段：<code>curl http://localhost:4321/src/styles/xxx</code> 直接取模块编译产物，对比磁盘源码。</p><p><strong>结论</strong>：纯本地 dev 现象。Cloudflare 构建从磁盘全新编译，线上不复现。以后遇到「CSS 改了不生效/只生效一半」，先重启 <code>pnpm dev</code>。</p><hr /></section></section><section><h2>五、批次四：三段拆分与下滑收拢动画（<code>b7cce682</code> 上半）<a href="#五批次四三段拆分与下滑收拢动画b7cce682-上半"><span>#</span></a></h2><section><h3>5.1 结构：三段独立胶囊<a href="#51-结构三段独立胶囊"><span>#</span></a></h3><p><strong><code>src/components/layout/Navbar.astro</code></strong>：</p><ul>
<li><code>#navbar</code> 改为 <code>grid grid-cols-[1fr_auto_1fr]</code>（类名里保留 <code>z-50</code> 等），三个直接子 div 各带 <code>.navbar-seg</code>：
<ul>
<li><code>.navbar-seg-left</code>（<code>justify-self-start</code>）：logo + 站名，站名包一层 <code>&lt;span class="navbar-title-text"&gt;</code>；</li>
<li><code>.navbar-seg-center</code>（<code>col-start-2</code>，<code>hidden lg:flex</code>）：菜单 <code>DropdownMenu</code> 列表，是否居中由 <code>siteConfig.nav.menuAlign</code> 决定；</li>
<li><code>.navbar-seg-right</code>（<code>col-start-3 justify-self-end</code>）：内含 <code>.navbar-right-controls</code> 控件组（搜索、音乐、播放、显示设置、亮暗 + 各浮层面板）与组外的汉堡按钮 <code>#nav-menu-switch</code>。</li>
</ul>
</li>
<li>原先「面板挂在 body 级 / 控件散放」的结构改为<strong>面板随控件进右段</strong>（音乐面板等成为控件组子元素）。</li>
</ul><p><strong><code>src/styles/navbar.css</code></strong>：</p><ul>
<li>全部原 <code>#navbar&gt;div</code> 表面规则（背景、透明边框、毛玻璃 <code>::before</code>）retarget 为 <code>#navbar&gt;.navbar-seg</code>，并在其上补一行注释掉的 <code>position: relative</code>——<strong>它同时是内部浮层面板的定位包含块</strong>（后文第八节第 3 条会再用到）；</li>
<li>sticky 滚动投影从元素 <code>box-shadow</code> 移到 <code>::after</code> 伪元素（各壁纸态规则会覆盖元素级阴影，伪元素不参与那场竞争）；</li>
<li>移动端 top-row 补左右留边（承接批次二第 4 条）。</li>
</ul><p><strong><code>src/styles/main.css</code></strong>：31 处选择器从 <code>&gt;div</code> retarget 到 <code>&gt;.navbar-seg</code>；边框规则同步接 <code>--card-border-color</code> / <code>--card-border-width</code>。</p><p><strong><code>src/styles/layout-base.css</code></strong>：top-row 高度相关 calc 基准同步为 4rem。</p></section><section><h3>5.2 动画：下滑收拢为正圆<a href="#52-动画下滑收拢为正圆"><span>#</span></a></h3><p><strong>触发（<code>src/utils/scroll-utils.ts</code>）</strong>——把滚动方向 delta 从 dynamic 分支上提为共用计算：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>delta</span><span> </span><span>=</span><span><span> </span><span>scrollTop</span><span> </span></span><span>-</span><span><span> </span><span>lastScrollTop</span><span>;</span></span></div></div><div><div><div>2</div></div><div><span><span>lastScrollTop</span><span> </span></span><span>=</span><span><span> </span><span>scrollTop</span><span>;</span></span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>// 三段胶囊收拢：向下滚动且超过 80px 时左右段收成圆形，上滑或回顶恢复</span></div></div><div><div><div>5</div></div><div><span>if</span><span><span> (</span><span>navbarElement</span><span>) {</span></span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>operations</span><span>.</span><span>push</span><span>(() </span><span>=&gt;</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>navbarElement</span><span>.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"navbar-collapsed"</span><span><span>, </span><span>scrollTop</span><span> </span></span><span>&gt;</span><span> </span><span>80</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>delta</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span>);</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>语义：<strong>收拢 = 向下滚且 &gt;80px；恢复 = 上滑或回顶</strong>。中段菜单永不参与。</p><p><strong>切页清态（<code>src/utils/swup-transitions.ts</code>）</strong>：astro 换页钩子里 <code>navbar.classList.remove("navbar-collapsed")</code>——收拢态不跨页残留，新页滚动逻辑按新滚动位置重新判断。</p><p><strong>形变规则（<code>src/styles/navbar.css</code>）</strong>：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 站名帘式收起 */</span></div></div><div><div><div>2</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>.navbar-seg-left</span><span> </span><span>.navbar-title-text</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>max-width: </span><span>0</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div><span>/* logo 外边距归零（图标盒固定 1.75rem，保证两种 logo 形态收拢宽度一致） */</span></div></div><div><div><div>7</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>.navbar-seg-left</span><span> </span><span>.navbar-logo</span><span> { margin-right: </span><span>0</span><span>; }</span></div></div><div><div><div>8</div></div><div><span>/* 左右段内边距收成正圆所需的值 */</span></div></div><div><div><div>9</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>.navbar-seg-left</span><span> {</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>padding-left: </span><span>calc</span><span>(</span><span><span>1.125</span><span>rem</span></span><span><span> </span><span>-</span><span> </span></span><span>var</span><span>(</span><span>--card-border-width</span><span>, </span><span><span>2</span><span>px</span></span><span>));</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>padding-right: </span><span>calc</span><span>(</span><span><span>1.125</span><span>rem</span></span><span><span> </span><span>-</span><span> </span></span><span>var</span><span>(</span><span>--card-border-width</span><span>, </span><span><span>2</span><span>px</span></span><span>));</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div><div><div><div>13</div></div><div><span>.navbar-right-controls</span><span> { max-width: </span><span><span>30</span><span>rem</span></span><span><span>; overflow: </span><span>hidden</span><span>; </span></span><span>/* ...过渡见批次六 */</span><span> }</span></div></div><div><div><div>14</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>.navbar-right-controls</span><span> { max-width: </span><span>0</span><span>; }</span></div></div><div><div><div>15</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>.navbar-seg-right</span><span> {</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>padding-left: </span><span>calc</span><span>(</span><span><span>0.625</span><span>rem</span></span><span><span> </span><span>-</span><span> </span></span><span>var</span><span>(</span><span>--card-border-width</span><span>, </span><span><span>2</span><span>px</span></span><span>));</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>padding-right: </span><span>calc</span><span>(</span><span><span>0.625</span><span>rem</span></span><span><span> </span><span>-</span><span> </span></span><span>var</span><span>(</span><span>--card-border-width</span><span>, </span><span><span>2</span><span>px</span></span><span>));</span></div></div><div><div><div>18</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><strong>正圆公式推导</strong>（<code>box-sizing: border-box</code>，高度 4rem 含边框）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>宽度 = padding-left + 内容 + padding-right + 2×边框宽  ≡ 高度 4rem</span></div></div><div><div><div>2</div></div><div><span>左段内容 = logo 图标盒 1.75rem</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>→ padding = (4rem − 2×边框 − 1.75rem) / 2 = 1.125rem − 边框宽</span></div></div><div><div><div>4</div></div><div><span>右段内容 = 汉堡按钮 2.75rem</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>→ padding = (4rem − 2×边框 − 2.75rem) / 2 = 0.625rem − 边框宽</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两个注意点：<strong>边框宽取变量</strong> <code>--card-border-width</code>，用户用滑块调粗后圆依然精确；<strong>rem 在 <code>&lt;768px</code> 是 14px</strong>（根元素 <code>text-[14px] md:text-[16px]</code>），写死 px 会在移动端错位——所以全部用 rem + calc。</p><p><strong>汉堡按钮显隐（PC 专用）</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 移动端常显；≥1024px 仅收拢态显示 */</span></div></div><div><div><div>2</div></div><div><span>@media</span><span> (min-width: </span><span><span>1024</span><span>px</span></span><span>) {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>#navbar</span><span>:not</span><span>(</span><span>.navbar-collapsed</span><span>) </span><span>.navbar-seg-right</span><span> </span><span>#nav-menu-switch</span><span> {</span></div></div><div><div><div>4</div></div><div><span><span>        </span></span><span>display: </span><span>none</span><span>;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这里有个关键决策：最初把 <code>lg:hidden!</code> 写在按钮的类名上，结果 <strong>Tailwind 分层内 important 在层序反转下必胜</strong>（utilities 层的 <code>!important</code> 压过未分层的普通规则），收拢态想让汉堡出现的覆盖规则永远赢不了——表现为 PC 收拢后右段无图标、只剩细条、点不到。解法是<strong>放弃 important 角力</strong>：按钮类名去掉 <code>lg:hidden!</code>，显隐完全交给上面这条普通规则，由特异性与层序自然接管。</p></section><section><h3>5.3 本批次的行为语义（验收用）<a href="#53-本批次的行为语义验收用"><span>#</span></a></h3><ul>
<li>向下滚（&gt;80px）：左段收成圆（logo 存活、站名消失）、右段收成圆（只剩汉堡）、中段菜单纹丝不动；</li>
<li>上滑或回顶：全部恢复；</li>
<li>切页：立即恢复完整态；</li>
<li>PC 非收拢态：汉堡隐藏；PC 收拢态：汉堡显示；移动端：汉堡始终显示。</li>
</ul><hr /></section></section><section><h2>六、批次五：移动端菜单样式可配置（<code>b7cce682</code> 一部分）<a href="#六批次五移动端菜单样式可配置b7cce682-一部分"><span>#</span></a></h2><section><h3>目标<a href="#目标-3"><span>#</span></a></h3><p>上游主题把移动端汉堡菜单重构成了全屏抽屉（MD3 侧滑 + 遮罩），但旧版「锚定导航栏下方右侧的圆角卡片」更贴合本博客的胶囊风格——做成配置项，两种都保留。</p></section><section><h3>改动明细<a href="#改动明细-3"><span>#</span></a></h3><p><strong>1. 配置项</strong>（<code>src/config/siteConfig.ts</code> + <code>src/types/siteConfig.ts</code>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>nav</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>    </span><span>// 移动端汉堡菜单样式："card" 浮层卡片（旧版）/"drawer" 全屏抽屉（上游重构版）</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>mobileMenuStyle</span><span>: </span><span>"card"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>// 类型：mobileMenuStyle?: "card" | "drawer";</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>2. <code>src/components/layout/NavMenuPanel.astro</code></strong> 双分支渲染：</p><ul>
<li><code>card</code> 分支：复刻历史版本（<code>git show a65c6576^:src/components/layout/NavMenuPanel.astro</code> 可拿到原貌），根类带 <code>nav-menu--card</code> 标记，样式为 <code>fixed right-4 top-21</code> 圆角浮层卡片、<code>max-h-[80vh] overflow-y-auto</code>；</li>
<li><code>drawer</code> 分支：保留全屏 <code>fixed inset-0</code> 抽屉，所有抽屉专属样式规则加 <code>.nav-menu--drawer</code> 作用域，避免两个分支互相污染。</li>
</ul><p><strong>3. 关闭机制</strong>（<code>src/utils/layout-init.ts</code>）：卡片模式依赖 click-outside 关闭；抽屉模式面板全屏覆盖、点击恒在面板内、该监听永不触发——<strong>两种模式都注册</strong>注册即可，无副作用。代码注释里写明了这一点。</p></section><section><h3>切换方法<a href="#切换方法"><span>#</span></a></h3><p>改 <code>siteConfig.ts</code> 的 <code>mobileMenuStyle</code> 为 <code>"drawer"</code> 重启 dev 即可切到抽屉版。</p><hr /></section></section><section><h2>七、批次六：PC 收拢态快捷面板与「原位弹卡」修复（<code>b7cce682</code> 下半）<a href="#七批次六pc-收拢态快捷面板与原位弹卡修复b7cce682-下半"><span>#</span></a></h2><p>这是整个系列里迭代轮次最多的一块，经历了「实现 → 验收发现逻辑不对 → 定位两处根因 → 修复」的完整过程，值得完整记录。</p><section><h3>7.1 需求<a href="#71-需求"><span>#</span></a></h3><p>收拢态下 PC 端右段只剩汉堡（中段菜单已常显导航链接，所以汉堡不该再开链接菜单），期望：</p><ol>
<li>汉堡点击打开**「右段被隐藏控件」的快捷面板**——搜索/音乐/播放/显示设置/亮暗五行；</li>
<li>点击任意行，<strong>导航栏保持收拢</strong>，弹出「原来点击控件时会弹出的那张子卡片」（搜索面板、音乐面板、显示设置、亮暗菜单）；</li>
<li>移动端行为不变：汉堡开导航链接菜单；两菜单互斥。</li>
</ol></section><section><h3>7.2 快捷面板本体<a href="#72-快捷面板本体"><span>#</span></a></h3><p><strong><code>Navbar.astro</code></strong> 右段新增：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"navbar-quick-panel"</span></div></div><div><div><div>2</div></div><div><span>     </span><span>class</span><span>=</span><span>"float-panel float-panel-closed absolute top-full right-0 mt-2 w-max min-w-52 p-1.5 z-50"</span></div></div><div><div><div>3</div></div><div><span>     </span><span>data-floating-panel</span><span> </span><span>data-floating-panel-trigger</span><span>=</span><span>"nav-menu-switch"</span></div></div><div><div><div>4</div></div><div><span>     </span><span>inert</span><span> </span><span>aria-hidden</span><span>=</span><span>"true"</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>&lt;</span><span>button</span><span> </span><span>data-quick-action</span><span>=</span><span>"search"</span><span>&gt;…&lt;/</span><span>button</span><span>&gt;   </span><span>&lt;!-- 音乐/播放/显示/亮暗同构，按配置条件渲染 --&gt;</span></div></div><div><div><div>6</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>要点：<code>w-max</code> 必不可少——面板锚在收拢后只有约 64px 宽的右段里，绝对定位的收缩宽度会被包含块钳制，没有 <code>w-max</code> 就会被压扁；<code>data-floating-panel</code> 让它接入统一的 inert/Esc 管理。</p><p><strong>分流逻辑</strong>（<code>loadButtonScript</code> 内汉堡 onclick）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>let</span><span><span> </span><span>isDesktop</span><span> </span></span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>matchMedia</span><span>(</span><span>"(min-width: 1024px)"</span><span>).</span><span>matches</span><span>;</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>isDesktop</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>quickPanel</span><span>) {</span></span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>quickPanel</span><span>.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"float-panel-closed"</span><span>);</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>navPanel</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>navPanel</span><span>.</span></span><span>classList</span><span>.</span><span>add</span><span>(</span><span>"float-panel-closed"</span><span>);   </span><span>// 互斥</span></div></div><div><div><div>5</div></div><div><span>} </span><span>else</span><span> </span><span>if</span><span><span> (</span><span>navPanel</span><span>) {</span></span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>navPanel</span><span>.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"float-panel-closed"</span><span>);</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>quickPanel</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>quickPanel</span><span>.</span></span><span>classList</span><span>.</span><span>add</span><span>(</span><span>"float-panel-closed"</span><span>);</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>7.3 第一版的 bug 与两处根因<a href="#73-第一版的-bug-与两处根因"><span>#</span></a></h3><p><strong>现象</strong>：在快捷面板里逐个点搜索/音乐/显示设置/亮暗——<strong>导航栏重新展开，子卡片却不弹出</strong>。</p><p><strong>根因一（展开）</strong>：行点击处理器里有一句 <code>navbar.classList.remove("navbar-collapsed")</code>。它的本意是「控件组收拢时被 <code>opacity: 0</code> 藏住了，展开才能看见子卡」——方向就错了，需求是保持收拢。</p><p><strong>根因二（子卡秒关）</strong>：<code>layout-init.ts</code> 用 <code>setClickOutsideToClose</code> 给每个面板注册了 <strong>document 级点击关闭监听</strong>，它注册得比快捷面板的行点击处理器<strong>更晚</strong>。DOM 事件按注册顺序派发，于是同一个点击事件里：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>行点击处理器：打开子卡 ✓</span></div></div><div><div><div>2</div></div><div><span>→ 后注册的 click-outside 监听器继续跑：点击目标（快捷面板的行）在子卡之外 → 把刚打开的子卡关掉 ✗</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两口锅叠加，用户看到的就是「只展开了导航栏，什么卡都没弹」。</p><p><strong>修复（<code>Navbar.astro</code> 行点击处理器重写）</strong>：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>document</span><span>.</span></span><span>addEventListener</span><span>(</span><span>"click"</span><span>, </span><span>function</span><span><span> (</span><span>event</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>(</span><span>event</span><span>.</span></span><span>target</span><span> </span><span>instanceof</span><span> </span><span>Element</span><span>)) </span><span>return</span><span>;</span></div></div><div><div><div>3</div></div><div><span>    </span><span>const</span><span> </span><span>row</span><span> </span><span>=</span><span><span> </span><span>event</span><span>.</span></span><span>target</span><span>.</span><span>closest</span><span>(</span><span>"#navbar-quick-panel [data-quick-action]"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>row</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>event</span><span>.</span><span>stopImmediatePropagation</span><span>();   </span><span>// ← 挡掉注册更晚的 document click-outside，防同事件秒关</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>    </span><span>const</span><span> </span><span>action</span><span> </span><span>=</span><span><span> </span><span>row</span><span>.</span></span><span>getAttribute</span><span>(</span><span>"data-quick-action"</span><span>);</span></div></div><div><div><div>8</div></div><div><span>    </span><span>const</span><span> </span><span>triggerByAction</span><span> </span><span>=</span><span> { </span><span>search</span><span>:</span><span> </span><span>"search-switch"</span><span>, </span><span>music</span><span>:</span><span> </span><span>"music-player-switch"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>                              </span><span>video</span><span>:</span><span> </span><span>"bg-player-toggle"</span><span>, </span><span>display</span><span>:</span><span> </span><span>"display-settings-switch"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>                              </span><span>theme</span><span>:</span><span> </span><span>"scheme-switch"</span><span> };</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>    </span><span>// 有子卡的行：关快捷卡、换弹子卡；video 只切换播放没有子卡，快捷卡留着当状态面板</span></div></div><div><div><div>13</div></div><div><span>    </span><span>if</span><span><span> (</span><span>action</span><span> </span></span><span>!==</span><span> </span><span>"video"</span><span>) {</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>"navbar-quick-panel"</span><span>)?.</span><span>classList</span><span>.</span><span>add</span><span>(</span><span>"float-panel-closed"</span><span>);</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>document</span><span>.</span><span>getElementById</span><span><span>(</span><span>triggerByAction</span><span>[</span><span>action</span><span>])?.</span></span><span>click</span><span>();   </span><span>// 程序化点击原控件按钮</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>    </span><span>// 搜索子卡在桌面端复用面板内输入框（见 7.5）；inert 在微任务里解除，焦点延到宏任务再给</span></div></div><div><div><div>19</div></div><div><span>    </span><span>if</span><span><span> (</span><span>action</span><span> </span></span><span>===</span><span> </span><span>"search"</span><span>) {</span></div></div><div><div><div>20</div></div><div><span>        </span><span>setTimeout</span><span>(() </span><span>=&gt;</span><span> {</span></div></div><div><div><div>21</div></div><div><span><span>            </span></span><span>document</span><span>.</span><span>querySelector</span><span>(</span><span>"#search-bar-inside input"</span><span>)?.</span><span>focus</span><span>();</span></div></div><div><div><div>22</div></div><div><span><span>        </span></span><span>}, </span><span>0</span><span>);</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>24</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><code>stopImmediatePropagation</code> 的安全性论证（复现时按此检查）：<code>Navbar.astro</code> 自带的面板关闭监听注册在本处理器<strong>之前</strong>，此刻子卡尚未打开、必为空操作；<code>layout-init</code> 的监听注册在<strong>之后</strong>，被挡掉正是目的；两者的忽略列表本就不依赖这次事件。无论两侧注册顺序如何翻转，结论都成立。</p></section><section><h3>7.4 收拢态子卡可见性：<code>:has</code> 守卫<a href="#74-收拢态子卡可见性has-守卫"><span>#</span></a></h3><p>子卡（搜索/音乐/显示/亮暗面板）是控件组 <code>.navbar-right-controls</code> 的<strong>后代</strong>，而收拢规则给控件组加了 <code>opacity: 0</code>——<strong>opacity 作用于整棵子树</strong>，绝对定位的子卡也会一起隐身。第一版「展开导航栏」的错误修复就是在绕这个问题。</p><p>正确解法是把「形变」与「淡出」拆成两条规则，淡出加守卫（<code>navbar.css</code>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 形变：始终收拢——flow 子元素（按钮们）被 max-width:0 + overflow:hidden 裁掉 */</span></div></div><div><div><div>2</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>.navbar-right-controls</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>max-width: </span><span>0</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div><div><div><div>5</div></div><div><span>/* 淡出：仅在没有任何子浮层面板打开时启用 */</span></div></div><div><div><div>6</div></div><div><span>#navbar</span><span><span>.navbar-collapsed</span><span>:not</span></span><span>(</span><span>:has</span><span>(</span><span>.navbar-right-controls</span><span> </span><span><span>.float-panel</span><span>:not</span></span><span>(</span><span>.float-panel-closed</span><span>))) </span><span>.navbar-right-controls</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两个前提成立才有效，复现时缺一不可：</p><ul>
<li><strong><code>max-width: 0</code> 裁不到子卡</strong>：子卡是绝对定位，包含块是 <code>position: relative</code> 的 <code>.navbar-seg</code>（不是静态的控件组），静态祖先的 <code>overflow: hidden</code> 不裁切「包含块在其外部」的绝对定位后代；</li>
<li><strong><code>:has</code> 状态即时</strong>：<code>float-panel-closed</code> 类名一变，<code>:has</code> 匹配结果同步翻转（已实测：class 移除当帧 <code>matches()</code> 即为 false），无需额外 JS 状态同步。</li>
</ul><p>附带收益：<strong>「子卡开着时再下滚，面板随控件组一起被藏起」这个此前登记在案的边缘情况被一并修掉了</strong>——现在子卡开着时收拢，守卫命中、控件组不淡出，面板保持可见可交互。</p></section><section><h3>7.5 搜索子卡在桌面端的输入问题<a href="#75-搜索子卡在桌面端的输入问题"><span>#</span></a></h3><p>PC 正常态的搜索输入在导航栏内的 <code>#search-bar</code>（<code>hidden lg:flex</code>），收拢后被帘式收起；而搜索面板里的 <code>#search-bar-inside</code> 是给移动端的（<code>lg:hidden</code>）。若不处理，PC 点搜索行会弹出一张<strong>没有输入框</strong>的空卡。</p><p>修复（<code>navbar.css</code>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 收拢态从快捷面板唤起搜索时，面板内给移动端用的输入框顶上 */</span></div></div><div><div><div>2</div></div><div><span>#navbar</span><span>.navbar-collapsed</span><span> </span><span>#search-bar-inside</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>display: </span><span>flex</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>未分层规则对 Tailwind utilities 层的普通 <code>lg:hidden</code> 自然占优，不需要 important。加上 7.2 里 <code>setTimeout</code> 自动聚焦，PC 收拢态点搜索 = 面板弹出、光标已就位、直接打字出结果（dev 环境 Pagefind 走 mock 数据，<code>npm run build &amp;&amp; npm preview</code> 才能测真搜索）。回顶恢复后该规则因 <code>:not(.navbar-collapsed)</code> 失效，输入框回到 <code>lg:hidden</code>，无残留。</p><blockquote><p><strong>注意：本节方案已被第十二节取代（2026-10-05 追加批次）</strong>：PC 内嵌的 <code>#search-bar</code> 已整体删除，<code>#search-bar-inside</code> 去掉 <code>lg:hidden</code> 全端常驻——本节的收拢态补丁规则随之变成死规则、已删。历史过程保留在此供参考，复现以第十二节为准。</p></blockquote></section><section><h3>7.6 video 行的误关与忽略名单<a href="#76-video-行的误关与忽略名单"><span>#</span></a></h3><p>video 行没有子卡，它程序化 <code>.click()</code> 的是 <code>#bg-player-toggle</code>——<strong>这个合成 click 会冒泡到 document</strong>，被快捷面板自己的 click-outside 监听判定为「点外面」，把快捷面板关了（卡面板行无所谓，反正主动关；video 行就变成了「点一下菜单自己消失」）。</p><p>修复（<code>src/utils/layout-init.ts</code>）——把五个控件触发按钮全部加进快捷面板的忽略名单：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>setClickOutsideToClose</span><span>(</span><span>"navbar-quick-panel"</span><span>, [</span></div></div><div><div><div>2</div></div><div><span>    </span><span>"navbar-quick-panel"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"nav-menu-switch"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"search-switch"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>"music-player-switch"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"bg-player-toggle"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>"display-settings-switch"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>"scheme-switch"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>]);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>理由写进了注释：这些按钮的程序化点击不算「点外面」。真实用户点不到它们（收拢态下被裁切），所以不影响任何正常交互。</p></section><section><h3>7.7 配套 i18n<a href="#77-配套-i18n"><span>#</span></a></h3><p>快捷面板两个新行名需要文案：<code>I18nKey.displaySettings</code>（显示设置）、<code>I18nKey.lightDarkMode</code>（亮暗模式），7 个语言文件各加两行。</p></section><section><h3>7.8 本批次最终行为矩阵（自动化实测通过）<a href="#78-本批次最终行为矩阵自动化实测通过"><span>#</span></a></h3>

<table><thead><tr><th>操作</th><th>期望结果</th></tr></thead><tbody><tr><td>PC 下滚收拢 → 点汉堡</td><td>快捷面板弹开，导航链接菜单关闭（互斥）</td></tr><tr><td>点搜索行</td><td>导航栏<strong>保持收拢</strong>；快捷卡关；搜索卡开、<code>inert</code> 解除、焦点在面板输入框；控件组 <code>max-width: 0</code> 但 <code>opacity: 1</code></td></tr><tr><td>点音乐/显示/亮暗行</td><td>同上（子卡各自弹出），导航栏保持收拢</td></tr><tr><td>点 video 行</td><td>快捷卡<strong>保持打开</strong>（无子卡），播放状态切换</td></tr><tr><td>子卡打开时点外部</td><td>子卡关，控件组 <code>opacity</code> 回 0，导航栏仍收拢</td></tr><tr><td>快捷卡打开时点外部</td><td>快捷卡关</td></tr><tr><td>回顶</td><td>导航栏恢复完整、汉堡隐藏、面板内输入框回 <code>lg:hidden</code></td></tr><tr><td>移动端点汉堡</td><td>开导航链接菜单，快捷卡不开；再点关闭</td></tr></tbody></table><hr /></section></section><section><h2>八、跨批次踩坑合集<a href="#八跨批次踩坑合集"><span>#</span></a></h2><p>改造过程中沉淀的九条硬结论，单独成节，任何一条都值得后来者抄走：</p><p><strong>1. Tailwind 分层与 important 的层序反转。</strong> 本项目 <code>navbar.css</code> 等样式文件未分层，Tailwind utilities 在 <code>@layer utilities</code> 里。普通规则未分层胜分层；但 utilities 里的 <code>!important</code> 反转层序、必胜未分层的普通覆盖。想覆盖 <code>lg:hidden!</code> 这类带 bang 的工具类，靠写更狠的 important 是死路——<strong>把 bang 从类名上删掉，改用一条普通规则</strong>才是正解（汉堡显隐即是案例）。</p><p><strong>2. border-box 下的正圆公式。</strong> 高度含边框时，宽度分量必须把边框计入再对半分，且内边距要写成 <code>calc(半径差 - var(--card-border-width))</code>，滑块调粗边框圆才不破。rem 别写死 px：根字号在移动端是 14px。</p><p><strong>3. 绝对定位子卡的包含块是 <code>.navbar-seg</code>，不是控件组。</strong> <code>position: relative</code> 挂在段上（且段有 <code>overflow-visible!</code>），静态控件组的 <code>overflow: hidden</code> / <code>max-width: 0</code> 裁不到子卡——这是「收拢态弹子卡」方案能成立的几何前提。改结构时若把 <code>position: relative</code> 挪走或给段加上 overflow，整套都会塌。</p><p><strong>4. <code>opacity</code> 作用于整棵子树，无法被后代覆盖。</strong> 「祖先把 opacity 设 0、子卡单独显示」在 CSS 里无解，只能拆规则（<code>:has</code> 守卫）或挪 DOM。同理 <code>visibility</code> 可以被后代重写、<code>opacity</code> 不行——别选错属性。</p><p><strong>5. Vite dev 编译缓存会丢同轮多次写入。</strong> 症状：磁盘正确、served 残缺。处置：重启 <code>pnpm dev</code>；诊断：<code>curl</code> 模块产物对比源码。线上构建从磁盘全新编译，不受影响。</p><p><strong>6. Playwright headless 的 <code>evaluate</code> 期间不泵帧。</strong> 长时间驻留在 <code>page.evaluate</code> 的 async 代码里做 <code>setTimeout</code> 采样，CSS 过渡的 <code>currentTime</code> 会冻结在 0、<code>getComputedStyle</code> 读到旧值——<strong>全部是探针自伤，不是页面 bug</strong>。本次曾因此误判 <code>:has</code> 守卫失效，排查了数轮。正确姿势：用 <code>page.waitForFunction</code>（客户端 rAF 轮询会泵帧）等过渡完成，或读数前 <code>document.getAnimations().forEach(a =&gt; a.finish())</code> 强制取目标值，或穿插 <code>page.screenshot</code> 触发渲染。判定标准：<code>getAnimations()</code> 里 <code>playState: "running"</code> 但 <code>currentTime</code> 恒为 0，即冻结而非停滞。</p><p><strong>7. <code>floating-panel-utils</code> 的分工（<code>src/utils/floating-panel-utils.ts</code>）。</strong> <code>data-floating-panel-trigger</code> 只做 aria/inert/焦点同步，<strong>不绑定点击</strong>（多面板共享同一 trigger 安全）；面板 class 变化由 MutationObserver 同步 <code>inert</code> 并在开→关时派发 <code>floating-panel:close</code>（不冒泡，要在 capture 阶段或直接挂面板上监听）；<code>setClickOutsideToClose(panel, ignores)</code> 是<strong>无条件</strong>的 document 点击监听——不检查面板是否打开，直接加 <code>float-panel-closed</code>；忽略列表按 id 做 DOM 包含判断，与面板开闭状态无关。理解这三条才能推演「同事件内先开后关」类时序 bug。</p><p><strong>8. swup 4 事件。</strong> 可靠的是 <code>astro:before-swap</code> / <code>astro:page-load</code>；<code>swup:contentReplaced</code> 是死事件（<code>NavMenuPanel.astro</code> 里有一处历史遗留监听，本次未动，改事件前先处理它）。</p><p><strong>9. 工具纪律。</strong> <code>pnpm lint</code>（Biome safe-fix）会波及无关文件，本系列全程只用只读的 <code>npx biome check &lt;files&gt;</code>；Playwright 的 element screenshot 会被动画卡 stability，交互诊断用 <code>run_code_unsafe</code> 里 <code>page.setViewportSize</code> + <code>evaluate</code>。</p><hr /></section><section><h2>九、如何复现<a href="#九如何复现"><span>#</span></a></h2><section><h3>路径 A：git 一次重放（推荐）<a href="#路径-agit-一次重放推荐"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>git</span><span> </span><span>clone</span><span> </span><span>https://github.com/jmqsOOOtatoba/FireflyPriWeb.git</span></div></div><div><div><div>2</div></div><div><span>cd</span><span> </span><span>FireflyPriWeb</span></div></div><div><div><div>3</div></div><div><span>git</span><span> </span><span>checkout</span><span> </span><span>020533ee</span><span>            </span><span># 系列起点（最后一批之前的基线）</span></div></div><div><div><div>4</div></div><div><span>git</span><span> </span><span>cherry-pick</span><span> </span><span>74680341</span><span> </span><span>2a00a602</span><span> </span><span>f6b3d060</span><span> </span><span>b7cce682</span><span> </span><span>04197b82</span><span> </span><span>d7d3b62c</span></div></div><div><div><div>5</div></div><div><span># 若目标仓库没有这几个对象，先从原仓库 fetch，或直接：</span></div></div><div><div><div>6</div></div><div><span># git diff 020533ee..d7d3b62c | git apply</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>pnpm</span><span> </span><span>install</span></div></div><div><div><div>9</div></div><div><span>pnpm</span><span> </span><span>dev</span><span>                        </span><span># http://localhost:4321</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p>注：<code>b7cce682</code> 依赖 <code>74680341</code> 引入的 <code>--card-border-width</code> 与 <code>f6b3d060</code> 的 <code>--card-border-color</code>，顺序不能乱；<code>04197b82</code>（搜索图标化）依赖 <code>b7cce682</code> 的三段结构；<code>d7d3b62c</code>（卡顿修复）建立在 <code>b7cce682</code> 的收拢动画之上，放最后。<code>9e8a7908</code>（18 文章补 author 字段）与本系列无关，可跳过。</p></blockquote></section><section><h3>路径 B：按批次手工重做<a href="#路径-b按批次手工重做"><span>#</span></a></h3><p>按本文第二、三、四、五、六、七节的「改动明细」顺序执行，每节末尾的复现要点即 checklist：</p><ol>
<li>边框体系（第二节）：<code>main.css</code> 三规则 + <code>setting-utils.ts</code> 四函数 + <code>Layout.astro</code> 内联应用 + 显示设置滑块 + i18n；</li>
<li>悬浮胶囊（第三节）：高度 4rem 三处联动 + 9999px 圆角 + wrapper padding/pointer-events 对 + 移动端留边；</li>
<li>亮黑暗白（第四节）：<code>--card-border-color</code> 变量 + 导航栏 <code>!important</code> 接管 + 删两组防闪现规则；</li>
<li>三段与收拢（第五节）：Navbar 结构拆分 + navbar.css/main.css retarget + scroll-utils delta 上提 + 正圆公式 + 汉堡显隐普通规则；</li>
<li>移动菜单配置（第六节）：<code>mobileMenuStyle</code> 配置 + NavMenuPanel 双分支 + click-outside 双注册；</li>
<li>快捷面板（第七节）：quick panel markup + 分流 + 行点击处理器（含 <code>stopImmediatePropagation</code>）+ <code>:has</code> 守卫拆规则 + 搜索输入框放开（该补丁后被第十二节取代）+ 忽略名单 + i18n 两键；</li>
<li>搜索图标化（第十二节）：删 PC 内嵌 <code>#search-bar</code> + 图标按钮去 <code>lg:hidden!</code> + 面板输入框去 <code>lg:hidden</code> + 双关键词合并 + <code>togglePanel</code> PC 自动聚焦 + 删收拢态补丁与死忽略项；</li>
<li>收拢卡顿修复（第十三节）：汉堡 <code>display</code> 切换改 width/visibility 连续过渡 + 控件组 <code>max-width</code> 30rem→14rem 去死区 + 删淡入 keyframes + 亮暗图标交叉淡化。</li>
</ol></section><section><h3>复现后验收清单<a href="#复现后验收清单"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>biome</span><span> </span><span>check</span><span> </span><span>src/</span><span>            </span><span># 0 错</span></div></div><div><div><div>2</div></div><div><span>pnpm</span><span> </span><span>type-check</span><span>                 </span><span># 0 错</span></div></div><div><div><div>3</div></div><div><span>pnpm</span><span> </span><span>check</span><span>                      </span><span># 12 个存量错误（anime.astro 11 + vndb.astro 1），无新增</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>浏览器人工验收（顺序即第七节 7.8 矩阵）：</p><ol>
<li>PC 下滚 &gt;80px → 左右段收成正圆、中段不动、汉堡出现；</li>
<li>点汉堡 → 快捷卡弹开、链接菜单不开；</li>
<li>逐行点搜索/音乐/显示/亮暗 → <strong>导航栏不展开</strong>、对应子卡原位弹出、搜索卡光标已就位；</li>
<li>点外部/Esc → 子卡关、仍收拢；再点外部 → 快捷卡关；</li>
<li>video 行 → 快捷卡不关、背景播放切换；</li>
<li>回顶 → 全部恢复、汉堡消失（含第十二节时：面板内输入框全端常驻，不再有 <code>lg:hidden</code> 切换）；</li>
<li>移动端（&lt;1024px）→ 汉堡开链接菜单、与快捷卡互斥；</li>
<li>显示设置调边框粗细滑块 → 卡片与导航栏边框实时变粗细，刷新后保持；切暗色 → 边框变白；</li>
<li>含第十二节时：PC 正常态右段为纯图标排布，点搜索图标 → 卡片弹出、光标自动就位。</li>
</ol><hr /></section></section><section><h2>十、最终验证记录（2026-10-05）<a href="#十最终验证记录2026-10-05"><span>#</span></a></h2>

<table><thead><tr><th>检查项</th><th>结果</th></tr></thead><tbody><tr><td><code>npx biome check</code>（改动文件）</td><td>通过，无 fix</td></tr><tr><td><code>pnpm type-check</code></td><td>通过</td></tr><tr><td><code>pnpm check</code></td><td>12 errors（= 基线，无新增）</td></tr><tr><td>Playwright 行为断言（PC 收拢/五行/外部点击/video/回顶/移动端分流）</td><td>全部通过</td></tr><tr><td>视觉验收</td><td>由人工完成，验收通过</td></tr></tbody></table><p>自动化断言里踩到的探针陷阱见第八节第 6 条——<strong>先证明探针可信，再相信断言</strong>。</p><hr /></section><section><h2>十一、提交记录<a href="#十一提交记录"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>9e8a7908  更新18文章作者                      （与本系列无关）</span></div></div><div><div><div>2</div></div><div><span>d7d3b62c  fix: 修复PC收拢动画卡顿，汉堡改width连续过渡，控件组max-width去死区</span></div></div><div><div><div>3</div></div><div><span>04197b82  feat: PC端搜索收成图标按钮，输入统一走搜索面板</span></div></div><div><div><div>4</div></div><div><span>b7cce682  feat: PC收拢态汉堡改开控件快捷面板，行点击原位弹出子控件卡</span></div></div><div><div><div>5</div></div><div><span>f6b3d060  feat: 导航栏接入卡片边框，边框色改为亮黑暗白跟随主题</span></div></div><div><div><div>6</div></div><div><span>2a00a602  feat: 导航栏改为悬浮胶囊样式并压扁高度</span></div></div><div><div><div>7</div></div><div><span>74680341  feat: 卡片边框默认 2px 纯黑，显示设置新增边框粗细滑块</span></div></div><div><div><div>8</div></div><div><span>020533ee  更新公告动态                        （系列起点）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>提交粒度说明：前三个是独立小批次、逐批验收逐批提交；<code>b7cce682</code> 因三段结构、收拢动画、菜单配置、快捷面板四个特性在同一批文件里交织（<code>Navbar.astro</code> 一个文件承载三者），拆分提交的手术成本高于收益，故合为一笔、用 commit body 分条说明。</p><hr /></section><section><h2>十二、追加批次：PC 搜索收成图标按钮（<code>04197b82</code>，2026-10-05）<a href="#十二追加批次pc-搜索收成图标按钮04197b822026-10-05"><span>#</span></a></h2><section><h3>目标<a href="#目标-4"><span>#</span></a></h3><p>PC 端右段的内嵌搜索输入框（<code>#search-bar</code>，常态宽 10rem、聚焦展开到 15rem）太占地方，改为与移动端一致：<strong>只留一个搜索图标，点击弹出搜索卡片，输入统一在卡内完成</strong>。</p></section><section><h3>改动明细（4 文件 +37/−72）<a href="#改动明细4-文件-3772"><span>#</span></a></h3><p><strong>1. <code>src/components/controls/Search.svelte</code></strong>（主体）：</p><ul>
<li>删除 PC 内嵌输入框 <code>#search-bar</code>（连带 <code>#search-input-desktop</code>、<code>handleDesktopFocus</code>、focus-return 钩子）；</li>
<li><code>#search-switch</code> 图标按钮去掉 <code>lg:hidden!</code>——注意这是第八节第 1 条的正解：<strong>删掉 bang 让显示自然接管</strong>，不是写更狠的 important 去压；</li>
<li>面板内输入框 <code>#search-bar-inside</code> 去掉 <code>lg:hidden</code>，桌面/移动共用；</li>
<li><code>keywordDesktop</code>/<code>keywordMobile</code> 双关键词状态合并为单个 <code>keyword</code>（桌面输入框没了，双状态就是死代码），<code>setPanelVisibility</code>/<code>search</code> 同步去掉 <code>isDesktop</code> 参数；</li>
<li><code>data-floating-panel-trigger</code> 从 <code>"search-switch search-input-desktop"</code> 收窄为 <code>"search-switch"</code>；</li>
<li><code>togglePanel</code> 打开面板时若视口 ≥1024px，<code>setTimeout(0)</code> 把焦点送进卡内输入框（inert 由 MutationObserver 在微任务解除，焦点必须延到宏任务——与 7.2 同理）；移动端刻意<strong>不</strong>自动聚焦，避免一点开就弹键盘。</li>
</ul><p><strong>2. <code>src/styles/navbar.css</code></strong>：删除 7.5 的收拢态补丁 <code>#navbar.navbar-collapsed #search-bar-inside { display: flex; }</code>——输入框全端常驻后它成了死规则。</p><p><strong>3. <code>src/utils/layout-init.ts</code></strong>：search-panel 的 click-outside 忽略名单删掉已不存在的 <code>"search-bar"</code>。</p><p><strong>4. <code>src/components/layout/Navbar.astro</code></strong>：只更新快捷面板搜索行的过时注释（不再有「收拢态放开 lg」这回事）。</p></section><section><h3>为什么面板落位不用改<a href="#为什么面板落位不用改"><span>#</span></a></h3><p>搜索面板的定位规则（<code>navbar.css</code> 的 <code>--navbar-panel-inset</code>、<code>&lt;768px</code> 贴右段右缘）本来就同时服务桌面与移动——PC 旧方案里输入框虽在导航栏内，面板弹出位置也是同一套规则。图标化后直接复用，实测 1440px 视口面板 <code>left≈861 / right≈1341</code>，正好挂在右胶囊下方不出屏。</p></section><section><h3>验证（2026-10-05，Playwright MCP 实测）<a href="#验证2026-10-05playwright-mcp-实测"><span>#</span></a></h3>

<table><thead><tr><th>检查项</th><th>结果</th></tr></thead><tbody><tr><td>Biome / type-check / <code>pnpm check</code></td><td>通过 / 通过 / 12 errors（= 基线无新增）</td></tr><tr><td>PC：<code>#search-bar</code> 不复存在、图标可见、面板默认关</td><td>✓</td></tr><tr><td>PC 点图标 → 面板开、inert 解除、<strong>光标就位</strong>、打字出 mock 结果</td><td>✓</td></tr><tr><td>PC 点外部 → 面板关</td><td>✓</td></tr><tr><td>下滚收拢 → 汉堡 → 快捷面板搜索行 → <strong>保持收拢</strong>、子卡原位弹出、光标就位</td><td>✓</td></tr><tr><td>移动端回归：图标开卡、不自动聚焦、汉堡开链接菜单</td><td>✓</td></tr><tr><td>视觉截图：右胶囊纯图标排布、面板不出屏</td><td>✓</td></tr></tbody></table><p>复现：检出 <code>b7cce682</code> 后按上面四条改动明细重做即可，验收按表格逐行过。</p><hr /></section></section><section><h2>十三、追加批次：PC 收拢动画卡顿修复（<code>d7d3b62c</code>，2026-10-05）<a href="#十三追加批次pc-收拢动画卡顿修复d7d3b62c2026-10-05"><span>#</span></a></h2><section><h3>现象与最初假设<a href="#现象与最初假设"><span>#</span></a></h3><p>PC 端下滚触发右段收拢成圆时，肉眼可见一次「卡顿」；同一套动画在移动端却顺滑。最初的假设是：右端图标从亮暗切换变成汉堡、两图标形状不同，切换瞬间没有过渡动画，想用 anime.js 做 SVG 路径变形。</p><p><strong>假设被逐帧数据否了</strong>——收拢过程根本不触发太阳/月亮互换（那只发生在切主题时），且 moon 与 hamburger 的 SVG path 不同构，anime.js（v3）做不了任意路径变形。真凶在布局层。</p></section><section><h3>诊断：逐帧实测抓到两条根因<a href="#诊断逐帧实测抓到两条根因"><span>#</span></a></h3><p>探针：页面内 rAF 采样右段/控件组/汉堡三元素的 <code>getBoundingClientRect().width</code>（先 <code>page.bringToFront()</code>——窗口遮挡时 rAF 掉到 1fps，见第八节第 6 条同源教训）。</p><p>修复前曲线（收拢 class 挂上在 t=165ms）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>t=165   汉堡 display:none→flex 单帧出现：seg +44px     ←—— 反向弹出（PC 独有）</span></div></div><div><div><div>2</div></div><div><span>t=165+  控件组 max-width 从 480px 才开始降，内容 220px</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>在被钳到 224px 之前宽度纹丝不动：冻结 ~60ms    ←—— 空转死区</span></div></div><div><div><div>4</div></div><div><span>t=236+  220px 全挤进剩余时长：单帧 -57、-26、-22px 猛缩 ←—— 卡顿感主体</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><strong>汉堡 <code>display</code> 单帧切换</strong>：非收拢态 PC 汉堡 <code>display: none</code> 不占位，收拢 class 一挂立刻 +2.75rem，右胶囊首帧向外弹。移动端汉堡常显、宽度不变——与「移动端没问题」的观察完全吻合。</li>
<li><strong><code>.navbar-right-controls</code> 的 <code>max-width: 30rem</code> 死区</strong>：实际内容只有最多 5 个 2.75rem 控件 = 220px，过渡前段（480→224px）对用宽毫无影响，动画空转后余量猛缩，速度曲线呈「冻结→暴冲→拖尾」。</li>
</ol></section><section><h3>改动明细（2 文件 +29/−20）<a href="#改动明细2-文件-2920"><span>#</span></a></h3><p><strong>1. <code>src/styles/navbar.css</code></strong>（历史注释分「一改 important」「二改卡顿」两段保留）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 汉堡：display:none → width/visibility 连续过渡 */</span></div></div><div><div><div>2</div></div><div><span>.navbar-seg-right</span><span> </span><span>#nav-menu-switch</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>overflow: </span><span>hidden</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>transition:</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>width </span><span><span>0.36</span><span>s</span></span><span> </span><span>cubic-bezier</span><span>(</span><span>0.22</span><span>, </span><span>1</span><span>, </span><span>0.36</span><span>, </span><span>1</span><span>),</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>opacity </span><span><span>0.25</span><span>s</span></span><span><span> </span><span>ease</span><span>,</span></span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>visibility </span><span><span>0.36</span><span>s</span></span><span>;</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div><span>@media</span><span> (min-width: </span><span><span>1024</span><span>px</span></span><span>) {</span></div></div><div><div><div>10</div></div><div><span>    </span><span>#navbar</span><span>:not</span><span>(</span><span>.navbar-collapsed</span><span>) </span><span>.navbar-seg-right</span><span> </span><span>#nav-menu-switch</span><span> {</span></div></div><div><div><div>11</div></div><div><span><span>        </span></span><span>width: </span><span>0</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>        </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>visibility: </span><span>hidden</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>pointer-events: </span><span>none</span><span>;</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>三个要点：<strong>未分层 <code>width: 0</code> 自然压过 utilities 层的 <code>w-11</code></strong>（层序反转只对 <code>!important</code> 生效，这里不需要 bang）；<strong><code>visibility</code> 随行过渡</strong>（宽度播完才真正隐藏），非收拢态不进 Tab 焦点与 a11y 树；<strong>正圆公式不受影响</strong>——收拢态媒体查询不生效，内容宽仍是 <code>w-11</code> = 2.75rem。</p><ul>
<li><code>.navbar-right-controls</code> 的 <code>max-width: 30rem → 14rem</code>（13.75rem 实际内容 + 0.25rem 余量），死区归零；注释里写明了原值的空转机制。</li>
<li>删除 <code>navbar-collapse-fade</code> 淡入动画与 keyframes：opacity 已由 transition 接管；该动画还会让移动端常显的汉堡每次收拢从 0 重闪一遍（历史遗留瑕疵，一并消灭）。</li>
</ul><p><strong>2. <code>src/components/controls/LightDarkSwitch.svelte</code></strong>：太阳/月亮两个图标容器加 <code>transition-opacity duration-300</code>——这是「图标切换没过渡」假设里唯一真实存在的问题（切主题时 <code>class:opacity-0</code> 硬切），与收拢无关，顺手修掉。</p></section><section><h3>修复后曲线（同探针复测）<a href="#修复后曲线同探针复测"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>控件组 ctr: 220 → 171 → 107 → 63 → 36 → 20 → … → 0    单调平滑减速</span></div></div><div><div><div>2</div></div><div><span>汉堡   ham:   0 →  10 →  23 → 32 → 37 → 40 → … → 44   从零长出、同步交接</span></div></div><div><div><div>3</div></div><div><span>终态   seg = 64px 正圆；首帧无反向弹出、无冻结</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>验证（2026-10-05，Playwright MCP 实测）<a href="#验证2026-10-05playwright-mcp-实测-1"><span>#</span></a></h3>

<table><thead><tr><th>检查项</th><th>结果</th></tr></thead><tbody><tr><td>Biome / type-check / <code>pnpm check</code></td><td>通过 / 通过 / 12 errors（= 基线无新增）</td></tr><tr><td>收拢首帧无 +44px 跳变、无冻结</td><td>✓（逐帧差分只剩单调递减的正常减速帧）</td></tr><tr><td>展开回顶：汉堡 width 0 + visibility hidden、右段回 236px</td><td>✓</td></tr><tr><td>移动端：汉堡常显（38.5px = 14px 根 × 2.75rem）、收拢不闪、点击开菜单</td><td>✓</td></tr><tr><td>视觉验收</td><td>由人工完成，验收通过</td></tr></tbody></table><p>复现：检出 <code>04197b82</code> 后按改动明细重做。<strong>rAF 逐帧采样 + <code>bringToFront</code> 这套诊断法可复用于任何「肉眼卡顿但说不清」的动画排查——先量化，再动手。</strong></p><hr /></section></section><section><h2>结语与未尽事项<a href="#结语与未尽事项"><span>#</span></a></h2><p>做完的：边框体系、悬浮胶囊、三段收拢、快捷面板与原位弹卡、PC 搜索图标化、收拢动画卡顿修复，全部合入主干并上线（推送即触发 Cloudflare 构建）。</p><p>未尽与备忘：</p><ul>
<li><strong>液态玻璃原型已回滚</strong>，git 无痕，重做需从零；</li>
<li><strong><code>swup:contentReplaced</code> 死事件</strong>遗留一处（<code>NavMenuPanel.astro</code>），本次刻意未动；</li>
<li><strong>流程教训</strong>：批次验收通过前不提交推送、一次性授权不跨批次沿用——最后一笔 <code>b7cce682</code> 是在验收确认前推的，虽未返工，但顺序不对，下不为例。</li>
</ul><p>对后来者最重要的一句话：<strong>这次所有「看起来是页面 bug」的问题里，只有两处是真 bug（同事件 click-outside 秒关、合成 click 误关快捷卡），其余怪现象全部来自探针与 dev 缓存</strong>——先怀疑测量工具，再怀疑自己的代码。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-18/</id>
      <title type="text">博客性能优化全记录：Lighthouse 报告拆解到帧率根因排查（A–F 六项实战）</title>
      <published>2026-10-04T00:00:00.000Z</published>
      <updated>2026-10-04T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-18/"/>
      <summary type="text">从两份 Lighthouse 对比报告出发，完整记录六项性能优化的排查方法、根因定位与修复代码：字体子集化、封面图尺寸 API、CSS 内联、强制重排归零、以及&quot;只有 Chrome 掉帧&quot;的浏览器设置级根因，附全部可复现命令与工具脚本</summary>
      <content type="html"><![CDATA[<section><h1>博客性能优化全记录：Lighthouse 报告拆解到帧率根因排查（A–F 六项实战）<a href="#博客性能优化全记录lighthouse-报告拆解到帧率根因排查af-六项实战"><span>#</span></a></h1><p>2026-10-03，用 Chrome / Edge 无痕模式各跑了一份本博客的 Lighthouse 报告。两份报告揭出一批共性问题，外加一个诡异现象：<strong>同一个站，Chrome 滚动掉帧、Edge 完全正常</strong>（两者同为 Chromium 154）。随后完成了 A–F 六项优化/排查，最终 Lighthouse 的强制重排审计从稳定 0 分变为多次满分，Chrome 掉帧也以「浏览器设置」定案。</p><p>本文是完整复盘：<strong>每项改了什么、为什么这么改、怎么验证的、踩了哪些坑</strong>。既是给未来维护者的存档，也希望能成为一份可照抄的性能排查教程。</p><hr /><section><h2>〇、初始状态（两份报告的关键数字）<a href="#〇初始状态两份报告的关键数字"><span>#</span></a></h2>

<table><thead><tr><th>指标</th><th>Chrome</th><th>Edge</th><th>说明</th></tr></thead><tbody><tr><td>性能分</td><td>0.71</td><td>0.70</td><td>两边一致</td></tr><tr><td>LCP</td><td><strong>4.6s</strong></td><td><strong>5.1s</strong></td><td>最大失分项</td></tr><tr><td>FCP / TBT / CLS</td><td>1.5s / 0ms / 0.003</td><td>1.6s / 0ms / 0.007</td><td>主线程本身很干净</td></tr><tr><td>总传输量</td><td><strong>5,349 KiB</strong></td><td>同</td><td>严重超标</td></tr><tr><td>渲染阻塞 CSS</td><td>估省 220ms</td><td>估省 250ms</td><td>3 个 stylesheet link</td></tr><tr><td>强制重排</td><td>~92ms（score 0）</td><td>~200ms</td><td>有内联脚本热点</td></tr><tr><td>图片可省</td><td>~1,160 KiB</td><td>~1,163 KiB</td><td>封面原图直出</td></tr></tbody></table><p>问题清单（即本文的 A–F）：</p><ul>
<li><strong>A</strong> 字体：一个 3.1MB 的 TTF 在预加载</li>
<li><strong>B</strong> 封面图：330KB 原图 JPEG 直出，尺寸超显示需求 3 倍</li>
<li><strong>C</strong> 三个第三方源没有 preconnect</li>
<li><strong>D</strong> 3 个渲染阻塞的 stylesheet</li>
<li><strong>E</strong> 加载期强制重排（forced reflow）</li>
<li><strong>F</strong> 只有 Chrome 掉帧、Edge 不掉帧</li>
</ul><hr /></section><section><h2>A、字体子集化：3.1MB TTF → 212KB WOFF2<a href="#a字体子集化31mb-ttf--212kb-woff2"><span>#</span></a></h2><section><h3>现象<a href="#现象"><span>#</span></a></h3><p>Lighthouse 列出的最大单体资源：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/_astro/fonts/cc84406608407d93.ttf</span></div></div><div><div><div>2</div></div><div><span>resourceSize: 3,293,520 B（3.1MB）</span></div></div><div><div><div>3</div></div><div><span>transferSize: 约 2,088,846 B（压缩后仍有 2MB）</span></div></div><div><div><div>4</div></div><div><span>且带 rel="preload" 高优先级，与首屏图片抢带宽</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>根因<a href="#根因"><span>#</span></a></h3><p>项目里<strong>本来就有一整套字体子集化流水线</strong>（<code>scripts/subset-fonts.ts</code>，<code>pnpm build</code> 会执行），它扫描 <code>dist/</code> 全部 HTML 收集实际用到的字符，生成 woff2 子集并替换引用、删除原文件。但配置里只登记了一个装饰字体：</p><div><figure><figcaption><span>src/config/fontConfig.ts</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>subsetFonts</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>"--font-greatvibes"</span><span>: { </span><span>extraChars</span><span>:</span><span> </span><span>""</span><span> },  </span><span>// 只有它</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div><div><div><div>4</div></div><div><span>// 而全站正文用的是（fontConfig.selected）：</span></div></div><div><div><div>5</div></div><div><span><span>selected</span><span>: [</span></span><span>"--font-hanyi-wenhei"</span><span>],          </span><span>// ← 没登记，直接漏网</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>子集脚本会跳过「未在 selected/区域字体中引用」的项，于是每次构建都打印 skip，原始 TTF 原样进了部署包。</p></section><section><h3>修复<a href="#修复"><span>#</span></a></h3><p><code>src/config/fontConfig.ts</code> 增加一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>subsetFonts</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>"--font-greatvibes"</span><span>: { </span><span>extraChars</span><span>:</span><span> </span><span>""</span><span> },</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// 全站正文主字体（selected 引用），不配子集化会原样输出 3.1MB TTF</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"--font-hanyi-wenhei"</span><span>: { </span><span>extraChars</span><span>:</span><span> </span><span>""</span><span> },</span></div></div><div><div><div>5</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>无需改脚本——<code>pnpm build</code> 链路里已有 <code>astro build &amp;&amp; npx tsx scripts/subset-fonts.ts</code>。</p></section><section><h3>验证与注意点<a href="#验证与注意点"><span>#</span></a></h3><ul>
<li>构建日志出现 <code>Generating subset ... 211.7 KB, original: 3216.3 KB, saved 93.4%</code></li>
<li><code>dist/_astro/fonts/</code> 下生成 <code>e3c41cafdc6bb33b.woff2</code>（211.7KB），原始 TTF 被脚本删除</li>
<li>全站 53 个页面的 <code>@font-face</code> 与 <code>rel=preload</code> 均已替换为 <code>type="font/woff2"</code>，HTML 中 <code>.ttf</code> 引用为 0</li>
<li>浏览器实测：<code>document.fonts</code> 加载成功、中文渲染无豆腐块</li>
</ul><blockquote><p><strong>已知取舍</strong>：子集只包含构建时 HTML 里可见的字符。密码/加密文章解密后的字符不在其中，这些页面正文会回退到 <code>fallbacks</code>（PingFang/雅黑）。介意可在 <code>extraChars</code> 手动补字。</p></blockquote><hr /></section></section><section><h2>C、preconnect：三个第三方源提前建连（顺手先做了）<a href="#cpreconnect三个第三方源提前建连顺手先做了"><span>#</span></a></h2><section><h3>现象<a href="#现象-1"><span>#</span></a></h3><p>报告写明「未预连接任何源」：图床 <code>tu.mstzuomu.space</code>、统计 <code>cloud.umami.is</code>、<code>static.cloudflareinsights.com</code> 都是 HTML 解析到一半才发现的，连接建立偏晚。</p></section><section><h3>修复<a href="#修复-1"><span>#</span></a></h3><p><code>src/layouts/Layout.astro</code> 的 <code>&lt;head&gt;</code> 最顶部（<code>&lt;meta charset&gt;</code> 之后）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!-- 提前建立第三方连接：图床图片、umami 统计、Cloudflare beacon 都在 HTML 解析中途才被发现 --&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"preconnect"</span><span> </span><span>href</span><span>=</span><span>"https://tu.mstzuomu.space"</span><span> /&gt;</span></div></div><div><div><div>3</div></div><div><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"preconnect"</span><span> </span><span>href</span><span>=</span><span>"https://static.cloudflareinsights.com"</span><span> /&gt;</span></div></div><div><div><div>4</div></div><div><span>{analyticsConfig?.umamiAnalytics?.websiteId &amp;&amp; (</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"preconnect"</span><span> </span><span>href</span><span>=</span><span>"https://cloud.umami.is"</span><span> /&gt;</span></div></div><div><div><div>6</div></div><div><span>)}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意：<strong>这三行会改变 HTML 的行号</strong>——后文 E 项定位 Lighthouse 坐标时会看到所有行号整体偏移，此为预期内现象。</p><hr /></section></section><section><h2>B、封面图：接入自建图床的尺寸处理 API<a href="#b封面图接入自建图床的尺寸处理-api"><span>#</span></a></h2><section><h3>现象与根因<a href="#现象与根因"><span>#</span></a></h3><p>封面走远程 <code>&lt;img&gt;</code> 直出：<code>src/components/common/CoverImage.astro</code> 对所有 <code>http(s)</code> 图片输出裸 <code>&lt;img&gt;</code>，不经过 Astro 的优化管道。单张封面 <strong>2169×569 JPEG 约 330KB</strong>，实际显示只有 1350×300——格式（该转 WebP）和尺寸（超 3 倍）双重浪费，Lighthouse 估算 5 张可省 1.16MB。</p></section><section><h3>排查：图床支持什么转换？<a href="#排查图床支持什么转换"><span>#</span></a></h3><p>图床是自建的 <strong>CloudFlare-ImgBed</strong>（CF Workers + Telegram 存储）。探测过程：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 1. 直接试尺寸参数 → 403</span></div></div><div><div><div>2</div></div><div><span>curl</span><span> </span><span>"https://tu.mstzuomu.space/file/文章/xxx.jpg?width=1656&amp;fallback=original"</span></div></div><div><div><div>3</div></div><div><span># → 403 "Image resizing is disabled"   # 功能存在但默认关闭！</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span># 2. 查官方文档（cfbed.sanyue.de/api/file.html）</span></div></div><div><div><div>6</div></div><div><span>#    支持 width/height/fit/fallback 参数；JPEG/PNG/WebP 源 Worker 部署可处理；</span></div></div><div><div><div>7</div></div><div><span>#    开启路径：dashboard → 系统设置 → 安全设置 → 访问管理 → 图片尺寸处理</span></div></div><div><div><div>8</div></div><div><span>#    允许尺寸：留空 = 任意合法值</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span># 3. 后台开启后复测</span></div></div><div><div><div>11</div></div><div><span>width</span><span>=</span><span>1656</span><span> </span><span>→</span><span> </span><span>141KB</span><span>   </span><span>（原</span><span> </span><span>330KB，-57%）</span></div></div><div><div><div>12</div></div><div><span>width</span><span>=</span><span>828</span><span>  </span><span>→</span><span> </span><span>48.5KB</span><span>  </span><span>（-85%）</span></div></div><div><div><div>13</div></div><div><span>format</span><span>=</span><span>webp</span><span> </span><span>→</span><span> </span><span>被忽略（该版本只改尺寸不转格式）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>博客端实现（4 个文件）<a href="#博客端实现4-个文件"><span>#</span></a></h3><p><strong>1. 配置开关</strong>（<code>src/config/siteConfig.ts</code>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>imageOptimization</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>// ...原有配置</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// 支持尺寸处理的远程图床域名（后台需开启"图片尺寸处理"）</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>resizeHosts</span><span>: [</span><span>"tu.mstzuomu.space"</span><span>],</span></div></div><div><div><div>5</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>2. 工具函数</strong>（<code>src/utils/image-utils.ts</code>）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>RESIZE_WIDTHS</span><span> </span><span>=</span><span> [</span><span>828</span><span>, </span><span>1656</span><span><span>] </span><span>as</span><span> </span></span><span>const</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>canResizeRemote</span><span><span>(</span><span>src</span></span><span>:</span><span> </span><span>string</span><span>)</span><span>:</span><span> </span><span>boolean</span><span> {</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>/</span><span>^</span><span><span>https</span><span>?</span><span>:</span><span>\/\/</span><span>/</span></span><span>.</span><span>test</span><span><span>(</span><span>src</span><span>)) </span></span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>hosts</span><span> </span><span>=</span><span><span> </span><span>siteConfig</span><span>.</span></span><span>imageOptimization</span><span>?.</span><span>resizeHosts</span><span> </span><span>||</span><span> [];</span></div></div><div><div><div>6</div></div><div><span>  </span><span>if</span><span><span> (</span><span>hosts</span><span>.</span></span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>7</div></div><div><span>  </span><span>try</span><span> { </span><span>return</span><span><span> </span><span>hosts</span><span>.</span></span><span>includes</span><span>(</span><span>new</span><span> </span><span>URL</span><span><span>(</span><span>src</span><span>).</span></span><span>hostname</span><span>); }</span></div></div><div><div><div>8</div></div><div><span>  </span><span>catch</span><span> { </span><span>return</span><span> </span><span>false</span><span>; }</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>buildResizeUrl</span><span><span>(</span><span>src</span></span><span>:</span><span> </span><span>string</span><span><span>, </span><span>width</span></span><span>:</span><span> </span><span>number</span><span>)</span><span>:</span><span> </span><span>string</span><span> {</span></div></div><div><div><div>12</div></div><div><span>  </span><span>try</span><span> {</span></div></div><div><div><div>13</div></div><div><span>    </span><span>const</span><span> </span><span>u</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span><span>(</span><span>src</span><span>);</span></span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>u</span><span>.</span><span>searchParams</span><span>.</span><span>set</span><span>(</span><span>"width"</span><span>, </span><span>String</span><span><span>(</span><span>width</span><span>));</span></span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>u</span><span>.</span><span>searchParams</span><span>.</span><span>set</span><span>(</span><span>"fallback"</span><span>, </span><span>"original"</span><span>);</span></div></div><div><div><div>16</div></div><div><span>    </span><span>return</span><span><span> </span><span>u</span><span>.</span></span><span>toString</span><span>();</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>} </span><span>catch</span><span> { </span><span>return</span><span><span> </span><span>src</span><span>; }</span></span></div></div><div><div><div>18</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><strong>3. 渲染</strong>（<code>CoverImage.astro</code> 远程分支）：白名单域名输出 srcset 双档 + 真实显示宽度的 sizes + 回退数据属性：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const resizeSizes = preview</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>? "(max-width: 768px) 100vw, 770px"    // 列表卡桌面约 680-770px</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>: "(max-width: 768px) 100vw, 100vw";   // 详情页封面全宽</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>&lt;</span><span>img</span></div></div><div><div><div>6</div></div><div><span>  </span><span>src</span><span>=</span><span>{</span><span>remoteImgSrc</span><span>}</span><span>                          /* 1656 </span><span>档</span><span> */</span></div></div><div><div><div>7</div></div><div><span>  </span><span>srcset</span><span>=</span><span>{</span><span>resizeSrcset</span><span>}</span><span>                       /* "…828 828</span><span>w</span><span>, …1656 1656</span><span>w</span><span>" */</span></div></div><div><div><div>8</div></div><div><span>  </span><span>sizes</span><span>=</span><span>{</span><span><span>useResize</span><span> </span></span><span>?</span><span><span> </span><span>resizeSizes</span><span> </span></span><span>:</span><span> </span><span>undefined</span><span>}</span></div></div><div><div><div>9</div></div><div><span>  </span><span>data-resize-fallback</span><span>=</span><span>{</span><span><span>useResize</span><span> </span></span><span>?</span><span><span> </span><span>remoteSrc</span><span> </span></span><span>:</span><span> </span><span>undefined</span><span>}</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>...</span></div></div><div><div><div>11</div></div><div><span>/&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><code>sizes</code> 有个坑：本地图沿用的旧值写死了桌面 <code>320px</code>，单档产物无影响，但双档 srcset 下 <strong>Retina 屏会错选 828w 导致发糊</strong>——所以尺寸变体必须用真实显示宽度（770px）单独给一套。</p></blockquote><p><strong>4. 保险丝</strong>（<code>CoverImage.astro</code> 脚本的 <code>onError</code>）：尺寸版加载失败（如图床后台开关被关）时一次性回退无参数原图：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>resizeFallback</span><span> </span><span>=</span><span><span> </span><span>img</span><span>.</span></span><span>dataset</span><span>.</span><span>resizeFallback</span><span>;</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>resizeFallback</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>img</span><span>.</span></span><span>dataset</span><span>.</span><span>resizeRetried</span><span>) {</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>img</span><span>.</span><span>dataset</span><span>.</span><span>resizeRetried</span><span> </span><span>=</span><span> </span><span>'true'</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>img</span><span>.</span><span>addEventListener</span><span>(</span><span>'load'</span><span><span>, </span><span>hideLoading</span><span>, { </span></span><span>once</span><span>:</span><span> </span><span>true</span><span> });</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>img</span><span>.</span><span>addEventListener</span><span>(</span><span>'error'</span><span><span>, </span><span>onError</span><span>, { </span></span><span>once</span><span>:</span><span> </span><span>true</span><span> });</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>img</span><span>.</span><span>removeAttribute</span><span>(</span><span>'srcset'</span><span>);</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>img</span><span>.</span><span>src</span><span> </span><span>=</span><span><span> </span><span>resizeFallback</span><span>;</span></span></div></div><div><div><div>8</div></div><div><span>  </span><span>return</span><span>;</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>验证<a href="#验证"><span>#</span></a></h3><ul>
<li>浏览器 Network：封面请求全部带 <code>?width=828</code>（DPR1 选小档），加载成功</li>
<li>保险丝实测：手动把 src 改成 404 → 自动回退原图 → 加载成功、无错误态、不循环</li>
<li>mi-fds 域名的 2 张封面不在白名单，保持原样输出（预期行为）</li>
</ul><hr /></section></section><section><h2>D、CSS 内联：3 个渲染阻塞 link 归零<a href="#dcss-内联3-个渲染阻塞-link-归零"><span>#</span></a></h2><section><h3>现象<a href="#现象-2"><span>#</span></a></h3><p>首页 head 里 3 个阻塞请求：<code>Layout.css</code>（原始 196KB / 传输 28.7KB）、<code>MainGridLayout.css</code>、全局 CSS，Lighthouse 估算拖慢首屏 220–250ms——浏览器必须等它们下载解析完才画第一笔。</p></section><section><h3>修复<a href="#修复-2"><span>#</span></a></h3><p><code>astro.config.mjs</code> <strong>顶层</strong>（Astro 配置，不是 <code>vite.build</code>！）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Astro 构建行为：全部 CSS 内联进 HTML（非 vite.build）</span></div></div><div><div><div>2</div></div><div><span><span>build</span><span>: {</span></span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>inlineStylesheets</span><span>: </span><span>"always"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><strong>踩坑记录</strong>：我第一次把这个键写进了 <code>vite: { build: { ... } }</code> 块里——那是 Vite 的配置，不认识 <code>inlineStylesheets</code>，<strong>静默无效</strong>，白构建一轮。判断依据是检查产物里 <code>&lt;link rel="stylesheet"&gt;</code> 是否归零。</p></blockquote></section><section><h3>前置安全检查<a href="#前置安全检查"><span>#</span></a></h3><p>内联前必须确认三个 CSS 没有 <code>url()</code> 引用（否则内联到嵌套路径页面会相对路径断裂）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># 扫描 dist/_astro/*.css 的 url( 引用</span></div></div><div><div><div>2</div></div><div><span># 结果：三个阻塞 CSS 均为 0 处 → 内联无风险</span></div></div><div><div><div>3</div></div><div><span># （带 url() 的 KaTeX/fancybox 是按需加载的非阻塞样式，不在范围）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>验证与取舍<a href="#验证与取舍"><span>#</span></a></h3><ul>
<li>首页 <code>&lt;link rel="stylesheet"&gt;</code> 数量：<strong>3 → 0</strong></li>
<li>HTML 原始体积 370KB → 608KB（+237KB ≈ 三者之和；gzip 后约 +38KB）</li>
<li>文章页直连、swup 切页、视觉截图均正常</li>
<li><strong>取舍</strong>：CSS 不再单独缓存，每多看一页 HTML 多带约 38KB——换来首屏少 3 个请求，对博客访问形态是划算的</li>
<li>残余：文章页 body 尾部还有个 8.9KB 的 <code>twikoo-custom.css</code>（评论区样式，解析位置在正文后，不挡首屏）和 fancybox 两个运行时注入的 link，都不在阻塞路径上</li>
</ul><hr /></section></section><section><h2>E、强制重排归零（本文最重的一节）<a href="#e强制重排归零本文最重的一节"><span>#</span></a></h2><section><h3>方法论：怎么抓到强制重排的真凶<a href="#方法论怎么抓到强制重排的真凶"><span>#</span></a></h3><p>强制重排 = JS 在脚本执行中读取几何/样式，浏览器被迫当场同步全量结算。<strong>坐标解读是最大的坑</strong>，先立规矩：</p><p><strong>1. 本地跑 Lighthouse 单审计</strong>（线上报告的行号会随每次构建漂移，必须在当前产物上重测）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 用 Edge 当 Lighthouse 的浏览器（本机没装独立 Chrome 时）</span></div></div><div><div><div>2</div></div><div><span>$env:CHROME_PATH</span><span><span> </span><span>=</span><span> </span></span><span>"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"</span></div></div><div><div><div>3</div></div><div><span><span>npx </span><span>--</span><span>yes lighthouse </span></span><span>"http://127.0.0.1:8931/"</span><span> `</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>--</span><span>only</span><span>-</span><span>audits</span><span>=</span><span>forced</span><span>-</span><span>reflow</span><span>-</span><span>insight </span><span>--</span><span>output</span><span>=</span><span>json </span><span>--</span><span>output</span><span>-</span><span>path</span><span>=</span><span>lh.json `</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>--</span><span>chrome</span><span>-</span><span>flags</span><span>=</span><span>"--headless=new"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>2. 以 CDP Tracing 全栈为准</strong>（Lighthouse 只给聚合后的 line/column，且行号 0-based、列号时而脚本内相对——单独解坐标会绕死）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// Playwright run_code：抓带堆栈的 Layout/UpdateLayoutTree 事件</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>cats</span><span> </span><span>=</span><span> </span><span>'disabled-by-default-devtools.timeline,'</span></div></div><div><div><div>3</div></div><div><span>  </span><span>+</span><span> </span><span>'disabled-by-default-devtools.timeline.stack,'</span></div></div><div><div><div>4</div></div><div><span>  </span><span>+</span><span> </span><span>'disabled-by-default-devtools.timeline.frame'</span><span>;</span></div></div><div><div><div>5</div></div><div><span>// Tracing.start(ReportEvents) → goto + 等待 → Tracing.end</span></div></div><div><div><div>6</div></div><div><span>// 过滤 name 为 Layout/UpdateLayoutTree 且有 stackTrace 的事件，</span></div></div><div><div><div>7</div></div><div><span>// 按 dur 排序；stack[0] 是最内层帧 = 真正读取者</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>3. 模拟 Lighthouse 的测量条件</strong>（否则数字对不上）：Lighthouse 默认<strong>移动视口 + 4 倍 CPU 降速</strong>——</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>await</span><span><span> </span><span>client</span><span>.</span></span><span>send</span><span>(</span><span>'Emulation.setDeviceMetricsOverride'</span><span>,</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>{ </span><span>width</span><span>:</span><span> </span><span>412</span><span>, </span><span>height</span><span>:</span><span> </span><span>823</span><span>, </span><span>deviceScaleFactor</span><span>:</span><span> </span><span>2.625</span><span>, </span><span>mobile</span><span>:</span><span> </span><span>true</span><span> });</span></div></div><div><div><div>3</div></div><div><span>await</span><span><span> </span><span>client</span><span>.</span></span><span>send</span><span>(</span><span>'Emulation.setCPUThrottlingRate'</span><span>, { </span><span>rate</span><span>:</span><span> </span><span>4</span><span> });</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>六个根因与对应修复<a href="#六个根因与对应修复"><span>#</span></a></h3><section><h4>E1. 解析期读 <code>window.innerWidth</code>（移动端最大单点）<a href="#e1-解析期读-windowinnerwidth移动端最大单点"><span>#</span></a></h4><p>读视口宽度需要先确定滚动条状态，<strong>移动模拟下会触发首次全量布局</strong>。三个热点全是这一个病：</p>

<table><thead><tr><th>位置</th><th>代价（移动+4x）</th><th>修复</th></tr></thead><tbody><tr><td>PostPage 立即执行脚本</td><td>~84ms</td><td>移动/桌面默认布局本就相同，宽度分支冗余，只按 <code>localStorage</code> 偏好恢复</td></tr><tr><td>壁纸 <code>sync()</code></td><td>~26ms</td><td><code>window.matchMedia('(min-width: 1024px)').matches</code></td></tr><tr><td>ScrollDownIndicator <code>updatePosition</code></td><td>~31ms</td><td>同上改 matchMedia</td></tr></tbody></table></section><section><h4>E2. <code>window.pageYOffset || document.documentElement.scrollTop</code> 老式写法<a href="#e2-windowpageyoffset--documentdocumentelementscrolltop-老式写法"><span>#</span></a></h4><p>加载时 <code>pageYOffset</code> 为 0（假值）→ 短路求值走到 <code>documentElement.scrollTop</code> → 几何读取强制布局。<strong>5 处统一改 <code>window.scrollY</code></strong>（滚动位置干净时读它不强制）：</p><ul>
<li><code>ScrollDownIndicator.astro</code> / <code>Navbar.astro</code> / <code>BackToTop.astro</code></li>
<li><code>fullscreen-wallpaper-utils.ts</code>（×2）</li>
<li>另外 <code>scroll-utils.ts</code> 和 <code>grid-layout-utils.ts</code> 里还有不带 <code>||</code> 的直接读法，一并处理</li>
</ul></section><section><h4>E3. <code>document.fonts.ready</code> getter 本身就强制样式结算<a href="#e3-documentfontsready-getter-本身就强制样式结算"><span>#</span></a></h4><p>移动端单次可达 300ms+。注意 <strong><code>typeof document.fonts.ready</code> 这个 guard 本身就访问了 getter</strong>——把判断整体挪进首绘后：</p><div><figure><figcaption><span>FontSetup.astro</span></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span><span> (</span><span>document</span><span>.</span></span><span>fonts</span><span>) {                    </span><span>// 只判断对象存在（不触发）</span></div></div><div><div><div>2</div></div><div><span>  </span><span>afterFirstPaint</span><span>(() </span><span>=&gt;</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>document</span><span>.</span><span>fonts</span><span>.</span><span>ready</span><span>.</span><span>then</span><span>(</span><span>...</span><span>)        </span><span>// getter 访问推迟到 FCP 后</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h4>E4. PostPage 的 transition 开关体操<a href="#e4-postpage-的-transition-开关体操"><span>#</span></a></h4><p>原代码注释自己写着「<strong>强制重排后恢复过渡动画</strong>」——<code>transition:none → 换类 → container.offsetHeight（故意强制）→ 恢复</code>。</p><p>关键认知：<strong>脚本在解析期执行，元素还没有计算样式，transition 根本不可能触发</strong>——体操在初始加载场景纯属多余。改为纯类名写入；即便补上 rAF 恢复过渡的版本，Lighthouse 仍会把异步归因的帧级工作记账 ~84ms（实测两版都 ~84）。首绘后的切页注入场景由 <code>performance.getEntriesByType('paint')</code> 检测后跳过体操。</p></section><section><h4>E5. 读写交错（改了 DOM 又立刻查询）<a href="#e5-读写交错改了-dom-又立刻查询"><span>#</span></a></h4><ul>
<li><strong>壁纸 <code>sync()</code></strong>：拆两阶段——读阶段用 <code>getElementsByClassName</code>/<code>getElementsByTagName</code>（纯 DOM 查询，不触发样式结算）收集全部 slot+tpl，写阶段只插入；<code>materialize</code> 改为插入前从 <code>frag</code> 取占位层，删掉插入后的 <code>slot.querySelector</code></li>
<li><strong>歌词高亮 <code>updateLrcHighlight</code></strong>：先缓存 <code>offsetTop/clientHeight</code> 几何值，再改 class，滚动用缓存值</li>
<li><strong>封面动画重启的 <code>void ui.cover.offsetWidth</code></strong>、<strong><code>PRIMARY_COLOR</code> 顶层 <code>getComputedStyle</code></strong>：都挪到首绘门闩后</li>
</ul></section><section><h4>E6. 新工具 <code>src/utils/after-first-paint.ts</code>（本轮核心基建）<a href="#e6-新工具-srcutilsafter-first-paintts本轮核心基建"><span>#</span></a></h4><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 设计要点（缺一不可，都有实测依据）：</span></div></div><div><div><div>2</div></div><div><span>// 1. 轮询 performance.getEntriesByType('paint') 中的 first-contentful-paint，</span></div></div><div><div><div>3</div></div><div><span>//    而不是双 rAF——弱性能设备首帧可能被丢弃，rAF 链会在真实绘制前跑完；</span></div></div><div><div><div>4</div></div><div><span>// 2. 每帧只放行一个回调（串行队列）——所有回调挤同一帧会互相弄脏：</span></div></div><div><div><div>5</div></div><div><span>//    前一个的写入让后一个的读取再次强制结算（实测 Waves 读前序回调的脏样式 29-31ms）；</span></div></div><div><div><div>6</div></div><div><span>// 3. 超时兜底 120 帧，防 paint 条目异常时回调永不执行。</span></div></div><div><div><div>7</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>afterFirstPaint</span><span>(</span><span>cb</span><span>:</span><span> () </span><span>=&gt;</span><span> </span><span>void</span><span>)</span><span>:</span><span> </span><span>void</span><span> { </span><span>/* 见仓库源码 */</span><span> }</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>加载期读取者全部改走它：滚动初始化、分类栏、波浪、壁纸视差/模糊、侧栏几何缓存、网格列数、fonts.ready、音乐封面动画重启……（共 14 个文件，见 commit <code>d6ceae0a</code>）</p></section></section><section><h3>排查过程中的三个大坑（后人必读）<a href="#排查过程中的三个大坑后人必读"><span>#</span></a></h3><ol>
<li><strong>MCP 浏览器窗口被遮挡 = 测量全部失真</strong>：遮挡窗口的 Chromium 会<strong>暂停绘制 + rAF 压到 2fps</strong>，表现为 paint 条目永远为空、所有门闩「不触发」、滚动功能「全坏」。<code>page.bringToFront()</code> 一秒复活（101fps + paint 条目齐全）。<strong>做帧率/绘制相关测量前必须先置前窗口。</strong></li>
<li><strong>先做基线 A/B 再怀疑自己</strong>：修到中途滚动类效果全灭，用 <code>git stash</code> 丢弃改动构建基线版同环境实测——<strong>基线一模一样</strong>，证明非本次回归（是环境问题），避免了整段回滚。</li>
<li><strong>PowerShell 引号吞噬</strong>：复杂内联脚本（python <code>-c</code>、正则带引号）会被啃得面目全非，一律落盘成临时脚本文件执行。</li>
</ol></section><section><h3>结果<a href="#结果"><span>#</span></a></h3>

<table><thead><tr><th></th><th>基线</th><th>最终</th></tr></thead><tbody><tr><td>Lighthouse forced-reflow</td><td>每次 score 0，~121ms</td><td><strong>多次 score 1 / 0.0ms</strong>，最差 18.6ms</td></tr><tr><td>移动+4x CDP 实测</td><td>381–481ms</td><td>主要热点归零</td></tr><tr><td>残余</td><td>—</td><td>仅 Astro 水合运行时（client.js，8–16ms，库代码）</td></tr></tbody></table><p>改动：14 文件修改 + 1 新文件，+280/−121，commit <code>d6ceae0a</code>。</p><hr /></section></section><section><h2>F、只有 Chrome 掉帧？——浏览器设置级根因<a href="#f只有-chrome-掉帧浏览器设置级根因"><span>#</span></a></h2><section><h3>症状与排查设计<a href="#症状与排查设计"><span>#</span></a></h3><p>同机、同站、同引擎（Chromium 154），<strong>只有真 Chrome 掉帧，Edge 正常</strong>。这个「同引擎不同表现」本身就是最强线索：嫌疑必然在<strong>两者各自的运行环境</strong>，而非站点代码。</p><p><strong>第一轮：站点侧压力矩阵</strong>（本机 Chromium，前台窗口）</p>

<table><thead><tr><th>组合</th><th>结果</th></tr></thead><tbody><tr><td>1440×900 DPR1 滚动</td><td>99.9 FPS，0 掉帧</td></tr><tr><td>1728×1080 DPR1.5 + 文章页</td><td>99.9 FPS，0 掉帧</td></tr><tr><td>+ CPU 4 倍降速 + 全屏模糊渐变</td><td>99.9 FPS，0 掉帧</td></tr></tbody></table><p>站点在默认设置的 Chrome 系浏览器下<strong>压不出掉帧</strong>。</p><p><strong>第二轮：真 Chrome vs 真 Edge 同机对打</strong>（决定性实验）</p><p>用 Playwright 分别以临时 profile 启动两个<strong>真实浏览器 exe</strong>，跑完全相同的滚动帧率 harness（4 秒三角波滚动，统计帧间隔）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 核心：launchPersistentContext + executablePath，各自临时 user-data-dir</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>ctx</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>chromium</span><span>.</span></span><span>launchPersistentContext</span><span><span>(</span><span>tempDir</span><span>, {</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>executablePath</span><span>:</span><span> </span><span>"C:/Program Files/Google/Chrome/Application/chrome.exe"</span><span>, </span><span>// 或 msedge.exe</span></div></div><div><div><div>4</div></div><div><span>  </span><span>headless</span><span>:</span><span> </span><span>false</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>viewport</span><span>:</span><span> { </span><span>width</span><span>:</span><span> </span><span>1728</span><span>, </span><span>height</span><span>:</span><span> </span><span>1080</span><span> }, </span><span>deviceScaleFactor</span><span>:</span><span> </span><span>1.5</span><span>,</span></div></div><div><div><div>6</div></div><div><span>});</span></div></div><div><div><div>7</div></div><div><span>// 页面内 harness：rAF 记录帧间隔，每帧 scrollTo 三角波滚动 4s，</span></div></div><div><div><div>8</div></div><div><span>// 统计 fps / jank(&gt;25ms) / jank(&gt;50ms) / worst / p95</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>结果：<strong>Chrome 99.9 FPS / Edge 99.9 FPS，双双零掉帧</strong>。<br />
这一步同时排除了「Windows 每应用 GPU 指派」（系统级设置对 exe 生效，测试用的就是你的真 Chrome）。</p><p><strong>第三轮：读真 Chrome 的配置文件</strong>（只读排查，不用开浏览器）</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># Chrome:  %LOCALAPPDATA%\Google\Chrome\User Data\Local State</span></div></div><div><div><div>2</div></div><div><span># Edge:    %LOCALAPPDATA%\Microsoft\Edge\User Data\Local State</span></div></div><div><div><div>3</div></div><div><span><span>json.</span><span>load</span><span>(</span><span>...</span><span>)[</span></span><span>"hardware_acceleration_mode"</span><span>]</span></div></div></code></pre><div><div></div><div></div></div></figure></div>

<table><thead><tr><th></th><th>hardware_acceleration_mode</th></tr></thead><tbody><tr><td><strong>你的 Chrome</strong></td><td><strong><code>{"enabled": false}</code> ← 硬件加速是关的！</strong></td></tr><tr><td>你的 Edge</td><td>键不存在（未动过 = 默认开），<code>previous: true</code></td></tr></tbody></table><p>扩展数 0、退出状态正常——<strong>唯一差异就是这个开关</strong>。</p></section><section><h3>根因与修复<a href="#根因与修复"><span>#</span></a></h3><p><strong>Chrome 关闭了硬件加速</strong> → 6 层 backdrop-filter 毛玻璃、全屏壁纸滚动模糊渐变（blurRamp）、波浪 canvas 的全部合成工作由 <strong>CPU 软渲染</strong> → 滚动掉帧；Edge 默认开 → GPU 合成 → 流畅。</p><p>修复（用户操作，一分钟）：</p><ol>
<li><code>chrome://settings/system</code> → 开启「使用硬件加速模式」→ 重启 Chrome</li>
<li><code>chrome://gpu</code> 确认 Canvas / Compositing / Rasterization / Video Decode 全部 <strong>Hardware accelerated</strong></li>
<li>实测确认恢复正常 ✅</li>
</ol><blockquote><p>为什么我的自动化测试一直测不出问题：<strong>新开的 profile 用默认值（硬件加速开）</strong>，测的是健康态的 Chrome。真凶藏在你日常 profile 的 Local State 里。</p></blockquote><hr /></section></section><section><h2>五、成果总表<a href="#五成果总表"><span>#</span></a></h2>

<table><thead><tr><th>项</th><th>根因</th><th>修复</th><th>效果</th></tr></thead><tbody><tr><td>A 字体</td><td>主字体没登记子集化</td><td><code>subsetFonts</code> +1 行</td><td>3.1MB → <strong>212KB（-93%）</strong></td></tr><tr><td>C 连接</td><td>三源无 preconnect</td><td>head +3 行</td><td>图床/统计早建连</td></tr><tr><td>B 封面</td><td>远程图裸出 + 图床功能未开</td><td>图床开关 + srcset 双档 + 回退保险丝</td><td>单张 330KB → <strong>48.5–141KB</strong></td></tr><tr><td>D CSS</td><td>3 个阻塞 link</td><td><code>inlineStylesheets: "always"</code></td><td>阻塞请求 <strong>3 → 0</strong>，首屏约 -250ms</td></tr><tr><td>E 重排</td><td>innerWidth/scrollTop/fonts.ready/体操/读写交错</td><td>FCP 串行门闩 + 14 文件修复</td><td>forced-reflow <strong>score 0 → 多次 1 / 0ms</strong></td></tr><tr><td>F 掉帧</td><td><strong>Chrome 硬件加速被关</strong></td><td>浏览器设置一键开启</td><td>Chrome 恢复流畅 ✅</td></tr></tbody></table><p>相关提交：<code>3691383a</code>(A+C) → <code>f875aef9</code>(D) → <code>d6ceae0a</code>(E)，另有若干 chore 数据刷新。</p><hr /></section><section><h2>六、可复用的工具箱<a href="#六可复用的工具箱"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 1. 单审计 Lighthouse（本机没独立 Chrome 时用 Edge）</span></div></div><div><div><div>2</div></div><div><span>$env:CHROME_PATH</span><span><span> </span><span>=</span><span> </span></span><span>"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"</span></div></div><div><div><div>3</div></div><div><span><span>npx </span><span>--</span><span>yes lighthouse </span></span><span>"http://127.0.0.1:8931/"</span><span><span> </span><span>--</span><span>only</span><span>-</span><span>audits</span><span>=</span><span>forced</span><span>-</span><span>reflow</span><span>-</span><span>insight `</span></span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>--</span><span>output</span><span>=</span><span>json </span><span>--</span><span>output</span><span>-</span><span>path</span><span>=</span><span>lh.json </span><span>--</span><span>chrome</span><span>-</span><span>flags</span><span>=</span><span>"--headless=new"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 2. CDP 带栈追踪骨架（Playwright run_code）</span></div></div><div><div><div>2</div></div><div><span>// cats 含 disabled-by-default-devtools.timeline.stack；</span></div></div><div><div><div>3</div></div><div><span>// Tracing.start(ReportEvents) → goto → end；</span></div></div><div><div><div>4</div></div><div><span>// 事件过滤 Layout/UpdateLayoutTree + stackTrace，stack[0]=最内层帧</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 3. 滚动帧率 harness（浏览器控制台可直接粘贴）</span></div></div><div><div><div>2</div></div><div><span>// rAF 记帧间隔 + 4s 三角波 scrollTo → fps/jank25/jank50/worst/p95</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># 4. 浏览器硬件加速状态（只读）</span></div></div><div><div><div>2</div></div><div><span>import</span><span> json</span></div></div><div><div><div>3</div></div><div><span><span>ls </span><span>=</span><span> json.</span><span>load</span><span>(</span></span><span>open</span><span>(</span><span>r</span><span><span>"C:</span><span>\U</span><span>sers</span><span>\&lt;</span><span>你&gt;\AppData</span><span>\L</span><span>ocal</span><span>\G</span><span>oogle</span><span>\C</span><span>hrome</span><span>\U</span><span>ser Data</span><span>\L</span><span>ocal State"</span></span><span>))</span></div></div><div><div><div>4</div></div><div><span>print</span><span><span>(ls.</span><span>get</span><span>(</span></span><span>"hardware_acceleration_mode"</span><span>))  </span><span># {"enabled": False} 即被关闭</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>七、测量经验清单<a href="#七测量经验清单"><span>#</span></a></h2><ol>
<li>Lighthouse 默认<strong>移动视口 + 4x CPU 降速</strong>，数字比桌面裸测大数倍——跨环境对比先对齐测量条件</li>
<li>做绘制/帧率测量前 <strong><code>page.bringToFront()</code></strong>——被遮挡的 Chromium 窗口暂停绘制、rAF 降到 2fps、paint 条目为空</li>
<li>Lighthouse 的 line/column 只当粗定位，<strong>真凶以 CDP Tracing 的完整栈为准</strong></li>
<li>改性能代码中途若行为异常，先 <code>git stash</code> 建<strong>基线同环境对照</strong>，再决定是不是自己的锅</li>
<li>复杂内联脚本别塞 PowerShell 命令行——引号会被啃，落盘执行</li>
<li>同引擎双浏览器表现不一致 → 优先怀疑<strong>各自的设置与 profile</strong>，直接读 <code>Local State</code> / <code>Preferences</code> JSON</li>
</ol><p>（完）</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-17/</id>
      <title type="text">Firefly 博客实战：从零搭建任意设备可用的发布台（Vercel + GitHub API）</title>
      <published>2026-10-03T00:00:00.000Z</published>
      <updated>2026-10-03T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-17/"/>
      <summary type="text">静态博客只能守在电脑前发文章？本文完整复盘如何用 Vercel 无框架函数 + GitHub API 搭一套带登录的发布后台——发文、改稿、删除、预览全部在浏览器里完成，附我们真实踩过的五个部署坑与安全边界设计，照着做可以从零复刻。</summary>
      <content type="html"><![CDATA[<section><h1>Firefly 博客实战：从零搭建任意设备可用的发布台（Vercel + GitHub API）<a href="#firefly-博客实战从零搭建任意设备可用的发布台vercel--github-api"><span>#</span></a></h1><blockquote><p>静态博客的一切内容都是 Git 仓库里的 Markdown 文件，代价是：想发篇文章，你得坐在装好开发环境的电脑前。这篇讲我们怎么把”发布”这件事拆出来，做成一个<strong>任何设备打开浏览器就能用</strong>的独立系统——发文、改稿、删除、预览，全部不用碰 git。</p></blockquote><section><h2>一、需求与架构选型<a href="#一需求与架构选型"><span>#</span></a></h2><p>目标拆开来是三句话：</p><ol>
<li>博客本体<strong>一行架构都不动</strong>（继续是纯静态站 + 自动构建）</li>
<li>发布系统<strong>独立部署</strong>，有登录，手机能用</li>
<li>发布动作最终<strong>落回 Git 仓库</strong>，触发既有的自动构建上线</li>
</ol><p>这就是所谓的”前后端分离”在静态博客语境下的含义：<strong>博客 = 展示层（静态产物），发布台 = 独立的前后端（编辑界面 + API）</strong>，中间靠 GitHub 仓库衔接。</p><div><div><div><div><span><p>口令 + 内容</p></span></div><div><span><p>Contents API</p></span></div><div><span></span></div><div><span></span></div><div><span><p>发布台前端<br /><br />(浏览器/手机)</p></span></div><div><span><p>Vercel API 函数<br /><br />鉴权·命名·拼 frontmatter</p></span></div><div><span><p>GitHub 仓库 master</p></span></div><div><span><p>自动构建<br /><br />(Actions / Cloudflare Pages)</p></span></div><div><span><p>博客上线</p></span></div>
</div><div><div><span><p>口令 + 内容</p></span></div><div><span><p>Contents API</p></span></div><div><span></span></div><div><span></span></div><div><span><p>发布台前端<br /><br />(浏览器/手机)</p></span></div><div><span><p>Vercel API 函数<br /><br />鉴权·命名·拼 frontmatter</p></span></div><div><span><p>GitHub 仓库 master</p></span></div><div><span><p>自动构建<br /><br />(Actions / Cloudflare Pages)</p></span></div><div><span><p>博客上线</p></span></div>
</div></div></div><p>为什么走 Git 而不是数据库？因为博客的内容层（content collections、文件名序号、frontmatter 约定）全部建立在文件上，搬进数据库等于推翻重建；而发布频率很低的个人博客，<strong>每次发布等 3~5 分钟构建完全可以接受</strong>。这条路只动”发布入口”，不动”内容模型”，风险最低。</p></section><section><h2>二、准备工作<a href="#二准备工作"><span>#</span></a></h2><p>开始前你需要：</p><ol>
<li><strong>一个静态博客仓库</strong>（本文以 Firefly / Astro 为例，其他静态站同理）</li>
<li><strong>Vercel 账号</strong>（ Hobby 免费版足够）</li>
<li><strong>GitHub Fine-grained PAT</strong>（精细访问令牌）：
<ul>
<li>打开 GitHub → <strong>Settings</strong>（设置）→ <strong>Developer settings</strong> → <strong>Personal access tokens</strong> → <strong>Fine-grained tokens</strong> → <strong>Generate new token</strong></li>
<li><strong>Repository access</strong>（仓库范围）→ Only select repositories → 只勾你的博客仓库</li>
<li><strong>Permissions</strong>（权限）→ Repository permissions → <strong>Contents</strong> 设为 <strong>Read and write</strong>，其他一律不动</li>
<li>生成后<strong>立刻复制</strong>，只显示这一次</li>
</ul>
</li>
<li><strong>一个发布口令</strong>：自己想一串随机字符串（比如 <code>firefly-xxxx-xxxx</code>），它是发布台的登录密码，后面存进 Vercel 环境变量</li>
</ol><blockquote><p>PAT 只授单仓库的 Contents 写权限，就算泄露，损失也被锁死在”有人能往你博客仓库提交内容”这一件事上——这是整套设计里最关键的一条安全边界。</p></blockquote></section><section><h2>三、后端：五个函数就是全部<a href="#三后端五个函数就是全部"><span>#</span></a></h2><p>发布台后端不用任何框架，Vercel 支持<strong>纯函数</strong>：在项目目录下建 <code>api/</code> 文件夹，每个 <code>.js</code> 文件就是一个 HTTP 端点。最终结构：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>publisher/</span></div></div><div><div><div>2</div></div><div><span>├── index.html          # 发布台单页（登录/发布/管理三视图）</span></div></div><div><div><div>3</div></div><div><span>├── app.js              # 前端逻辑</span></div></div><div><div><div>4</div></div><div><span>├── package.json        # {"type":"module"}，加一个空 build 脚本</span></div></div><div><div><div>5</div></div><div><span>├── vercel.json         # 锁定输出目录（防根配置串扰，见第五节）</span></div></div><div><div><div>6</div></div><div><span>├── lib/shared.js       # 端点公共工具</span></div></div><div><div><div>7</div></div><div><span>└── api/</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>├── auth.js         # POST   登录校验</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>├── publish.js      # POST   发布新内容</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>├── articles.js     # GET    内容列表（管理页用）</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>└── article.js      # GET/PUT/DELETE  单篇读、存、删</span></div></div></code></pre><div><div></div><div></div></div></figure></div><section><h3>3.1 鉴权：每个请求都要过口令<a href="#31-鉴权每个请求都要过口令"><span>#</span></a></h3><p>口令存环境变量 <code>PUBLISH_TOKEN</code>，前端登录成功后把口令放在请求头 <code>x-publish-token</code> 里带上，服务端逐次校验。比较要用常数时间函数，避免时序侧信道：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> </span><span>crypto</span><span> </span><span>from</span><span> </span><span>"node:crypto"</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>safeEqual</span><span><span>(</span><span>a</span><span>, </span><span>b</span><span>) {</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>ab</span><span> </span><span>=</span><span><span> </span><span>Buffer</span><span>.</span></span><span>from</span><span>(</span><span>String</span><span><span>(</span><span>a</span><span> </span></span><span>??</span><span> </span><span>""</span><span>), </span><span>"utf8"</span><span>);</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>bb</span><span> </span><span>=</span><span><span> </span><span>Buffer</span><span>.</span></span><span>from</span><span>(</span><span>String</span><span><span>(</span><span>b</span><span> </span></span><span>??</span><span> </span><span>""</span><span>), </span><span>"utf8"</span><span>);</span></div></div><div><div><div>6</div></div><div><span>  </span><span>if</span><span><span> (</span><span>ab</span><span>.</span></span><span>length</span><span> </span><span>!==</span><span><span> </span><span>bb</span><span>.</span></span><span>length</span><span>) </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>7</div></div><div><span>  </span><span>return</span><span><span> </span><span>crypto</span><span>.</span></span><span>timingSafeEqual</span><span><span>(</span><span>ab</span><span>, </span><span>bb</span><span>);</span></span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>// 每个端点的第一段逻辑</span></div></div><div><div><div>11</div></div><div><span>const</span><span> </span><span>publishToken</span><span> </span><span>=</span><span><span> </span><span>process</span><span>.</span></span><span>env</span><span>.</span><span>PUBLISH_TOKEN</span><span>;</span></div></div><div><div><div>12</div></div><div><span>if</span><span> (</span><span>!</span><span>safeEqual</span><span><span>(</span><span>req</span><span>.</span></span><span>headers</span><span>[</span><span>"x-publish-token"</span><span><span>], </span><span>publishToken</span><span>)) {</span></span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>res</span><span>.</span><span>statusCode</span><span> </span><span>=</span><span> </span><span>401</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>res</span><span>.</span><span>end</span><span>(</span><span>JSON</span><span>.</span><span>stringify</span><span>({ </span><span>ok</span><span>:</span><span> </span><span>false</span><span>, </span><span>error</span><span>:</span><span> </span><span>"口令错误"</span><span> }));</span></div></div><div><div><div>15</div></div><div><span>  </span><span>return</span><span>;</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>会话策略用 <strong>sessionStorage</strong>（存标签页内存，关浏览器即失效）而不是 localStorage，就实现了”每次打开发布台都要登录”。</p></section><section><h3>3.2 发布端点：命名、frontmatter、提交三件事<a href="#32-发布端点命名frontmatter提交三件事"><span>#</span></a></h3><p>发布端点收到 <code>{ type, title, content, ...可选字段 }</code> 后做三件事：</p><p><strong>① 自动命名</strong>——这是”文件名序号”类博客的关键。发文章前先调 GitHub Contents API 列目录，正则抓所有 <code>数字+文章.md</code> 取最大值 +1：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>res</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span></div></div><div><div><div>2</div></div><div><span>  </span><span>`https://api.github.com/repos/</span><span>${</span><span>REPO</span><span>}</span><span>/contents/src/content/posts/txt?ref=</span><span>${</span><span>BRANCH</span><span>}</span><span>`</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{ </span><span>headers</span><span>:</span><span> { </span><span>Authorization</span><span>:</span><span> </span><span>`Bearer </span><span>${</span><span>token</span><span>}</span><span>`</span><span> } },</span></div></div><div><div><div>4</div></div><div><span>);</span></div></div><div><div><div>5</div></div><div><span>const</span><span> </span><span>entries</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>res</span><span>.</span></span><span>json</span><span>();</span></div></div><div><div><div>6</div></div><div><span>let</span><span><span> </span><span>max</span><span> </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>7</div></div><div><span>for</span><span> (</span><span>const</span><span> </span><span>e</span><span> </span><span>of</span><span><span> </span><span>entries</span><span>) {</span></span></div></div><div><div><div>8</div></div><div><span>  </span><span>const</span><span> </span><span>m</span><span> </span><span>=</span><span> /</span><span>^</span><span>(</span><span>\d</span><span><span>+</span><span>)文章</span><span>\.</span><span>md</span></span><span>$</span><span>/</span><span>.</span><span>exec</span><span><span>(</span><span>e</span><span>.</span></span><span>name</span><span>);</span></div></div><div><div><div>9</div></div><div><span>  </span><span>if</span><span><span> (</span><span>m</span><span>) </span><span>max</span><span> </span></span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>max</span><span><span>(</span><span>max</span><span>, </span></span><span>Number</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]));</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div><div><div><div>11</div></div><div><span>const</span><span> </span><span>path</span><span> </span><span>=</span><span> </span><span>`src/content/posts/txt/</span><span>${</span><span>max</span><span> </span><span>+</span><span> </span><span>1</span><span>}</span><span>文章.md`</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>更新公告按日期命名（<code>更新公告20261003.md</code>）、动态按时间戳命名（<code>20261003-1217.md</code>），同名冲突就追加 <code>-2</code> 后缀。<strong>命名规则永远现场查仓库算，不要缓存</strong>，这样本地手动加了文件它也不会撞车。</p><p><strong>② 拼 frontmatter</strong>——两条容易翻车的经验：</p><ul>
<li><strong>字符串一律用 <code>JSON.stringify</code> 转义</strong>：标题里的引号、冒号、特殊字符，<code>JSON.stringify</code> 输出的就是合法 YAML 双引号字符串，手写拼接迟早被一个带 <code>:</code> 的标题弄炸</li>
<li><strong>日期裸写、不要引号</strong>：<code>published: 2026-10-03</code> 会被 YAML 解析成 Date 对象（Zod schema 的 <code>z.date()</code> 才认）；写成 <code>published: "2026-10-03"</code> 就成了字符串，构建直接报 schema 错误</li>
</ul><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>lines</span><span> </span><span>=</span><span> [</span><span>"---"</span><span>, </span><span>`title: </span><span>${</span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>title</span><span>)</span></span><span>}</span><span>`</span><span>, </span><span>`published: </span><span>${</span><span>now</span><span>.</span><span>date</span><span>}</span><span>`</span><span>];</span></div></div><div><div><div>2</div></div><div><span>// 约定"留空不写"：可选字段没填就完全不出现在 frontmatter 里</span></div></div><div><div><div>3</div></div><div><span>if</span><span><span> (</span><span>fields</span><span>.</span></span><span>tags</span><span>.</span><span>length</span><span><span>) </span><span>lines</span><span>.</span></span><span>push</span><span>(</span><span>`tags: </span><span>${</span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>fields</span></span><span>.</span><span>tags</span><span>)</span><span>}</span><span>`</span><span>);</span></div></div><div><div><div>4</div></div><div><span>if</span><span><span> (</span><span>fields</span><span>.</span></span><span>slug</span><span><span>) </span><span>lines</span><span>.</span></span><span>push</span><span>(</span><span>`slug: </span><span>${</span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>fields</span></span><span>.</span><span>slug</span><span>)</span><span>}</span><span>`</span><span>);</span></div></div><div><div><div>5</div></div><div><span><span>lines</span><span>.</span></span><span>push</span><span>(</span><span>"---"</span><span>, </span><span>""</span><span><span>, </span><span>content</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>③ 提交到仓库</strong>——用 Contents API 的创建接口，内容 base64 后 PUT 到目标分支：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>await</span><span> </span><span>fetch</span><span>(</span><span>`https://api.github.com/repos/</span><span>${</span><span>REPO</span><span>}</span><span>/contents/</span><span>${</span><span>path</span><span>}</span><span>`</span><span>, {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>method</span><span>:</span><span> </span><span>"PUT"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>headers</span><span>:</span><span> { </span><span>Authorization</span><span>:</span><span> </span><span>`Bearer </span><span>${</span><span>token</span><span>}</span><span>`</span><span>, </span><span>"Content-Type"</span><span>:</span><span> </span><span>"application/json"</span><span> },</span></div></div><div><div><div>4</div></div><div><span>  </span><span>body</span><span>:</span><span> </span><span>JSON</span><span>.</span><span>stringify</span><span>({</span></div></div><div><div><div>5</div></div><div><span>    </span><span>message</span><span>:</span><span> </span><span>`post: </span><span>${</span><span>title</span><span>}</span><span>`</span><span>,            </span><span>// 提交信息，进 git 历史一眼能看懂</span></div></div><div><div><div>6</div></div><div><span>    </span><span>content</span><span>:</span><span><span> </span><span>Buffer</span><span>.</span></span><span>from</span><span><span>(</span><span>markdown</span><span>, </span></span><span>"utf8"</span><span>).</span><span>toString</span><span>(</span><span>"base64"</span><span>),</span></div></div><div><div><div>7</div></div><div><span>    </span><span>branch</span><span>:</span><span> </span><span>"master"</span><span>,                     </span><span>// 推到构建监听的分支</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>}),</span></div></div><div><div><div>9</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>提交成功即返回”已提交，3~5 分钟后生效”——到这一步，剩下的事全交给博客既有的自动构建，发布台<strong>不需要知道构建是否存在、是否成功</strong>。</p></section><section><h3>3.3 管理端点：读、存、删<a href="#33-管理端点读存删"><span>#</span></a></h3><ul>
<li><strong>列表</strong>（<code>articles.js</code>）：调 Git Trees API（<code>/git/trees/master?recursive=1</code>）一次拿到全仓库文件树，过滤出内容目录的 <code>.md</code>；再并发拉各文件的 frontmatter 头部解析出标题/日期/草稿标记，拼成列表</li>
<li><strong>读取</strong>（<code>article.js</code> GET）：Contents API 返回 base64 正文和 <strong>sha</strong>（文件指纹）</li>
<li><strong>保存</strong>（PUT）：必须带上刚才读到的 <code>sha</code>，GitHub 据此判断文件有没有被别人动过——<strong>sha 对不上返回 409</strong>，提示”文件已被他人改动，请刷新重试”，两个人同时改稿就不会互相覆盖</li>
<li><strong>删除</strong>（DELETE）：同样要 sha，删除即一次 commit，走同样的构建上线</li>
</ul><p>三条铁律写在端点入口：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// ① 路径白名单：只允许内容目录的 markdown，防任意文件被改/删</span></div></div><div><div><div>2</div></div><div><span>function</span><span> </span><span>isSafeContentPath</span><span><span>(</span><span>p</span><span>) {</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>.</span></span><span>includes</span><span>(</span><span>".."</span><span>) </span><span>||</span><span><span> </span><span>p</span><span>.</span></span><span>startsWith</span><span>(</span><span>"/"</span><span>)) </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>return</span><span> /</span><span>^</span><span><span>src</span><span>\/</span><span>content</span><span>\/</span><span>(posts</span><span>|</span><span>dynamic)</span><span>\/</span></span><span>.</span><span><span>+</span><span>\.</span><span>(md</span><span>|</span><span>mdx)</span></span><span>$</span><span>/</span><span>.</span><span>test</span><span><span>(</span><span>p</span><span>);</span></span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div><span>// ② 内容不能为空</span></div></div><div><div><div>7</div></div><div><span>// ③ sha 必须提供（先读后改，不允许盲写）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section><section><h2>四、前端：一个单页，三个视图<a href="#四前端一个单页三个视图"><span>#</span></a></h2><p>前端不需要框架，一个 <code>index.html</code> + 一个 <code>app.js</code> 足够，视图切换就是显隐：</p><ol>
<li><strong>登录视图</strong>：口令输入框 → 调 <code>POST /api/auth</code> 校验 → 成功后口令存 sessionStorage，进主界面</li>
<li><strong>发布视图</strong>：内容类型分段器（文章/公告/动态）+ 标题 + 正文 + 可选字段折叠区。可选字段<strong>照抄你博客 schema 的字段表</strong>，顺序一致、留空不写，这样发布产物和手写文件完全同构</li>
<li><strong>管理视图</strong>：列表（按类型分组、显示草稿/置顶徽章）→ 点条目进编辑器（全文含 frontmatter 在一个 textarea 里改）→ 预览 / 保存 / 删除</li>
</ol><p>预览用 <a href="https://github.com/markedjs/marked" target="_blank">marked</a> 做客户端渲染，把它的 UMD 产物拷进 <code>vendor/</code> 本地引用（<strong>别用 CDN，国内不稳</strong>）。渲染前先用正则剥掉 frontmatter 单独展示，正文丢给 <code>marked.parse()</code> 即可。要认清预览的边界：它是<strong>近似预览</strong>——自定义指令、代码高亮、排版样式最终以博客构建后的效果为准。</p><p>关键交互别忘了：</p><ul>
<li>删除必须<strong>二次确认</strong>（弹窗里带标题和路径，“不可撤销”写明白）</li>
<li>401 统一处理：任何请求收到 401 都踢回登录页</li>
<li>发布成功后清空”一对一”字段（标题、正文、slug、封面、密码……），保留可复用的（标签、分类、作者），避免下一篇带着上一篇的封面发出去</li>
</ul></section><section><h2>五、部署：五个步骤与五个坑<a href="#五部署五个步骤与五个坑"><span>#</span></a></h2><section><h3>步骤 1：导入仓库，设 Root Directory<a href="#步骤-1导入仓库设-root-directory"><span>#</span></a></h3><p>Vercel → <strong>Add New Project</strong> → 导入你的博客仓库 → <strong>Settings → General → Root Directory</strong> 填 <code>publisher</code>（根目录）。不填的话 Vercel 会去构建整个博客本体，必错。</p></section><section><h3>步骤 2：环境变量<a href="#步骤-2环境变量"><span>#</span></a></h3><p><strong>Settings → Environment Variables</strong> 加两条：</p>

<table><thead><tr><th>Key</th><th>Value</th></tr></thead><tbody><tr><td><code>GITHUB_TOKEN</code></td><td>刚才的 fine-grained PAT</td></tr><tr><td><code>PUBLISH_TOKEN</code></td><td>你自己定的发布口令</td></tr></tbody></table></section><section><h3>坑 ①：<code>Command "build" not found</code><a href="#坑-command-build-not-found"><span>#</span></a></h3><p>Vercel 对 monorepo 会在仓库根装依赖（正常），但构建命令跑在 Root Directory 里——你的子目录 <code>package.json</code> 没有 <code>build</code> 脚本就报这个。<strong>修法</strong>：补一个空脚本 <code>"build": "echo ok"</code>（或对应包管理器的等价物）。</p></section><section><h3>坑 ②：<code>No Output Directory named "dist" found</code><a href="#坑-no-output-directory-named-dist-found"><span>#</span></a></h3><p>这个最阴险：<strong>仓库根遗留的 <code>vercel.json</code></strong>（写着 <code>buildCommand</code>、<code>outputDirectory: dist</code>）会被套用到你的发布台项目上，而且 <strong>vercel.json 优先级高于界面设置</strong>，在界面上怎么改都没用。<strong>双保险修法</strong>（两头都堵死）：</p><ul>
<li>子目录放自己的 <code>vercel.json</code>：<code>{ "outputDirectory": "." }</code></li>
<li><code>build</code> 脚本顺便产出一份 <code>dist/</code>（<code>mkdir -p dist &amp;&amp; cp index.html dist/</code>），满足根配置的 <code>dist</code> 预期</li>
</ul><p>排查时先看报错信息里的配置来源指向哪，再决定动哪边的 <code>vercel.json</code>——<strong>别去改博客本体的根配置</strong>，那是博客自己的部署遗产。</p></section><section><h3>步骤 3：部署<a href="#步骤-3部署"><span>#</span></a></h3><p>点 <strong>Deploy</strong>（部署），等到 <strong>Ready</strong>（就绪）。</p></section><section><h3>坑 ③：打开部署地址跳到 <code>vercel.com/login</code><a href="#坑-打开部署地址跳到-vercelcomlogin"><span>#</span></a></h3><p>新项目默认开着 <strong>Deployment Protection</strong>（部署保护）——Vercel 认证拦住了所有访客。到 <strong>Settings → Deployment Protection → Vercel Authentication</strong>（Vercel 身份验证）关掉（或只留预览环境保护）。发布台自己有口令把关，页面加 <code>noindex</code> 即可放心公开。</p></section><section><h3>步骤 4：绑定自有子域名<a href="#步骤-4绑定自有子域名"><span>#</span></a></h3><p><strong>坑 ④</strong>：<code>*.vercel.app</code> 在国内被 DNS 污染，<strong>不绑域名基本打不开</strong>。</p><ol>
<li><strong>Settings → Domains</strong> → <strong>Add Existing</strong>（添加已有域名）→ 输入你的子域名（如 <code>pub.example.com</code>）</li>
<li>到 DNS 服务商（如 Cloudflare）加一条 <code>CNAME</code>：名称填子域，值照 Vercel 显示的填</li>
<li>建议开启 CDN 代理（橙云）——解析走代理边缘，避开污染</li>
</ol><p><strong>坑 ⑤</strong>：配完 Vercel 会报 <strong>Proxy Detected</strong>（检测到代理）警告——<strong>可以无视</strong>。它只是抱怨自己的防护工具在代理后面不好使，对口令保护的个人发布页毫无影响，而代理恰恰是国内能稳定访问的关键。</p></section><section><h3>步骤 5：验证<a href="#步骤-5验证"><span>#</span></a></h3><p>打开你的子域名 → 登录 → 发一条测试内容 → 确认：仓库出现新 commit → 自动构建触发 → 博客页面出现这条内容。三环都绿，系统就通了。</p></section></section><section><h2>六、安全边界清单<a href="#六安全边界清单"><span>#</span></a></h2>

<table><thead><tr><th>边界</th><th>做法</th></tr></thead><tbody><tr><td>登录</td><td>口令存环境变量；每请求校验；sessionStorage 关浏览器失效</td></tr><tr><td>GitHub 凭据</td><td>fine-grained PAT，<strong>单仓库</strong>、<strong>仅 Contents 读写</strong>、定期轮换</td></tr><tr><td>文件操作</td><td>路径白名单锁死在内容目录的 <code>.md/.mdx</code>，拒绝 <code>..</code></td></tr><tr><td>并发</td><td>读写带 sha，冲突返回 409 而不是覆盖</td></tr><tr><td>暴露面</td><td>页面 <code>noindex</code>；错误信息只说”口令错误”，不回显任何环境变量</td></tr><tr><td>仓库纪律</td><td>发布台写的文件和本地手写完全同构，<strong>本地改动前先 <code>git pull</code></strong>，正常 push 不会互相覆盖</td></tr></tbody></table></section><section><h2>七、成本与额度<a href="#七成本与额度"><span>#</span></a></h2><p>整套系统跑在免费层就够：Vercel Hobby 免费版（函数请求量对个人博客是天文数字）、GitHub API 每小时 5000 次调用（一次发布约 3~5 次请求）、marked 本地化零外部依赖。唯一的”成本”是每年记得给 PAT 续期。</p></section><section><h2>八、可以继续加的东西<a href="#八可以继续加的东西"><span>#</span></a></h2><ul>
<li><strong>图片上传</strong>：API 接收文件转存到图床或仓库 <code>images/</code> 目录，正文插外链</li>
<li><strong>构建状态</strong>：发布后轮询 Actions / Cloudflare 构建 API，把”构建中→上线”做进回执</li>
<li><strong>草稿箱</strong>：发布时勾 <code>draft: true</code>，列表里单独分组，改完一键转正</li>
<li><strong>移动端 PWA</strong>：加个 manifest，“添加到主屏幕”后体验接近原生 App</li>
</ul><p>到这里，你就有了一套<strong>自己写、自己控、任何设备都能用</strong>的博客发布系统：写在任何地方的 Markdown，贴进发布台，剩下的交给自动化。</p></section><section><h2>开源项目<a href="#开源项目"><span>#</span></a></h2><p>这套发布台已经整理成开箱即用的模板仓库，Fork 一键部署或下载拷进你博客的 <code>publisher/</code> 目录，照着 README 走完环境变量和 Vercel 配置就能用，路径与文件命名全部可用环境变量覆盖：</p><a href="https://github.com/jmqsOOOtatoba/firefly-publisher" target="_blank"><div><div><div><div></div><div>jmqsOOOtatoba</div></div><div>/</div><div>firefly-publisher</div></div><div></div></div><div>给静态博客装一个随时随地发文的发布台：口令登录、发文/改稿/删除/预览，Vercel + GitHub API，路径命名全可配置</div><div><div>—</div><div>—</div><div>MIT</div><span>JavaScript</span></div></a></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-16/</id>
      <title type="text">Win11 资源管理器大小列只显示 KB？开启 KB / MB / GB 自适应显示完整教程</title>
      <published>2026-10-03T00:00:00.000Z</published>
      <updated>2026-10-03T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-16/"/>
      <summary type="text">Win11 详细信息视图的大小列长期以来一律用 KB 显示，4 GB 的文件写成 4,194,304 KB 根本没法看。微软其实已经原生支持自适应单位了，本文给出先更新系统、再用 ViveTool 强制开启（功能 ID 61014711）的完整教程，含回滚方法与常见问题。</summary>
      <content type="html"><![CDATA[<section><h1>Win11 资源管理器大小列只显示 KB？开启 KB / MB / GB 自适应显示完整教程<a href="#win11-资源管理器大小列只显示-kb开启-kb--mb--gb-自适应显示完整教程"><span>#</span></a></h1><blockquote><p>Windows 11 的大小列动辄一长串 KB 数字，肉眼完全无法换算。好消息是微软已经原生支持自适应单位了；坏消息是灰度推送可能轮不到你。本文给出从”先试免费零风险的更新”到”ViveTool 强制开启功能 ID 61014711”的完整方案，附回滚方法。</p></blockquote><section><h2>问题<a href="#问题"><span>#</span></a></h2><p>Windows 11 的文件资源管理器切换到”详细信息”视图后，大小列长期以来<strong>一律用 KB 显示</strong>：一个 4 GB 的文件写成 <code>4,194,304 KB</code>，几十 GB 的文件就是一长串数字，肉眼完全无法换算，只能点开属性才能确认实际大小。</p></section><section><h2>背景：微软已经原生支持了<a href="#背景微软已经原生支持了"><span>#</span></a></h2><p>2026 年 7 月起，微软开始推送这个改进：</p><ul>
<li><strong>7 月可选更新 KB5101684</strong>：24H2 升到 Build 26100.8973，25H2 升到 Build 26200.8973，详细信息视图的文件大小改为按实际大小自适应显示 KB / MB / GB；</li>
<li><strong>8 月累积更新 KB5121003</strong>：24H2 到 26100.9168，25H2 到 26200.9168，该功能随安全更新正式向所有用户推送；</li>
<li>在更早的 25H2 Beta / Experimental 预览版（26220.8370、26300.8376）中，这个功能默认隐藏，功能 ID 为 <strong>61014711</strong>，需要手动开启。</li>
</ul><p>也就是说：<strong>先检查 Windows 更新，装完再重启资源管理器，很可能什么都不用改就好了。</strong></p><p>但我在自己的机器上实测（25H2，Build 26200.9168，已包含 8 月更新）：装了更新、重启了资源管理器，大小列<strong>依然全是 KB</strong>。原因是这类功能采用 CFR（受控功能放量）分批推送，同一个版本号的机器生效时间可能不同。这时就轮到 ViveTool 出场。</p></section><section><h2>方法一：先更新系统（免费、零风险）<a href="#方法一先更新系统免费零风险"><span>#</span></a></h2><ol>
<li>设置 → Windows 更新 → 检查更新，安装 2026 年 7 月及之后的所有更新；</li>
<li>打开任务管理器（Ctrl+Shift+Esc）→ 找到”Windows 资源管理器” → 右键”重新启动”；</li>
<li>打开任意有大文件的文件夹，切到详细信息视图，看大小列是否已显示 MB / GB。</li>
</ol><p>生效了就不用往下看了。</p></section><section><h2>方法二：ViveTool 强制开启（更新后仍是 KB 时用）<a href="#方法二vivetool-强制开启更新后仍是-kb-时用"><span>#</span></a></h2><p>ViveTool 是微软隐藏功能（Feature ID）的命令行开关工具。</p><section><h3>1. 下载 ViveTool<a href="#1-下载-vivetool"><span>#</span></a></h3><p>任选其一：</p><ul>
<li>
<p><strong>winget（推荐）</strong>：终端执行</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>winget install --id thebookisclosed.Vive --accept-package-agreements --accept-source-agreements</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
<li>
<p><strong>手动下载</strong>：GitHub 仓库已从 <code>thebookisclosed/ViVeTool</code> 改名为 <strong><code>thebookisclosed/ViVe</code></strong>（旧地址会 404），到其 Releases 下载 <code>ViVeTool-v0.x.x-IntelAmd.zip</code>，解压到任意目录。</p>
</li>
</ul><blockquote><p>注意：如果你的网络直连 GitHub 超时，下载需要走代理；winget 的下载进程不读系统代理，卡住不动时请换手动下载 + 代理，或用 <code>curl.exe -x http://127.0.0.1:&lt;代理端口&gt; -L -o vivetool.zip &lt;下载地址&gt;</code>。</p></blockquote></section><section><h3>2. 以管理员身份执行<a href="#2-以管理员身份执行"><span>#</span></a></h3><p><strong>必须管理员</strong>（写注册表需要提权，否则会报”拒绝访问”）。打开一个<strong>管理员</strong>终端，执行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ViVeTool.exe /enable /id:61014711</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>验证是否写入成功：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ViVeTool.exe /query /id:61014711</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看到 <code>State : Enabled</code> 即成功。</p></section><section><h3>3. 重启资源管理器<a href="#3-重启资源管理器"><span>#</span></a></h3><p>任务管理器 → Windows 资源管理器 → 重新启动。再看大文件的大小列，此时应该显示 <code>1.25 MB</code>、<code>3.72 GB</code> 这样的自适应单位了。</p></section></section><section><h2>如何恢复原状<a href="#如何恢复原状"><span>#</span></a></h2><p>ViveTool 的修改只是加了一条功能覆盖，随时可逆：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ViVeTool.exe /disable /id:61014711</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后重启资源管理器即可回到 KB 显示。系统后续大版本更新也可能自动重置该覆盖。</p></section><section><h2>常见问题<a href="#常见问题"><span>#</span></a></h2><ul>
<li><strong>执行 /enable 报”拒绝访问”</strong>：终端不是管理员权限，右键”以管理员身份运行”再执行。</li>
<li><strong>开启了、重启了还是 KB</strong>：确认系统已装 2026 年 7 月之后的更新——ViveTool 只是把系统里已有的功能代码”翻到开”，如果系统里根本没有这段代码（更新太旧），开启也无效。</li>
<li><strong>对文件夹有效吗？</strong>：大小列对文件夹本身仍不显示大小（这是另一回事，需要第三方工具计算），本功能针对的是文件。</li>
<li><strong>只影响详细信息视图</strong>：图标、平铺等其他视图不显示大小数字，自然不受影响。</li>
</ul></section><section><h2>总结<a href="#总结"><span>#</span></a></h2>

<table><thead><tr><th>方案</th><th>适用场景</th><th>风险</th></tr></thead><tbody><tr><td>装 2026 年 7 月后更新</td><td>功能已随更新推送</td><td>无</td></tr><tr><td>ViveTool + ID 61014711</td><td>已是新版本但被灰度卡住</td><td>低，可随时 disable 回滚</td></tr></tbody></table><p>亲测环境：Windows 11 25H2（Build 26200.9168），更新后仍显示 KB，用 ViveTool 0.3.4 开启 61014711 并重启资源管理器后生效。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-8/</id>
      <title type="text">更新公告20261003</title>
      <published>2026-10-03T00:00:00.000Z</published>
      <updated>2026-10-03T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-8/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20261003<a href="#更新公告20261003"><span>#</span></a></h2><p>1.网站版本更新到6.16.8</p><p>2.新增<a href="https://azuma.mstzuomu.space/projects/firefly-publisher/" target="_blank"><strong>firefly-publisher</strong></a>发布后台：口令鉴权登录，支持文章列表、编辑、预览、删除，填好内容一键自动命名并提交到GitHub发布，手机等任意设备都能发文；已开源并加入项目页，搭建教程可见文章<a href="https://azuma.mstzuomu.space/posts/azuma.zuomu-blog-17/" target="_blank"><strong>Firefly 博客实战：从零搭建任意设备可用的发布台</strong></a></p><p>3.重构了MD语法下的哔哩哔哩卡片、音乐播放器、PDF阅读器三个组件，B站卡片信息改为API获取，加载更稳定</p><p>4.更新了天气API与侧边栏天气样式</p><p>5.重构了不蒜子访客统计，并修正了历史统计数据的偏移问题</p><p>6.更新了主页背景图</p><p>7.导航栏新增Umami、Cloudflare外链入口</p><p>8.修复了评论区表情被截断的问题，修复了同天发布文章顺序错乱的问题，修正了文章10的内容错误</p><p>9.发布了《Codex 重装后三大故障修复全记录》等多篇新文章，更新了文章封面</p></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-15/</id>
      <title type="text">Codex 重装后三大故障修复全记录：CC Switch 报错、走官方通道、聊天记录丢失</title>
      <published>2026-10-02T00:00:00.000Z</published>
      <updated>2026-10-02T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-15/"/>
      <summary type="text">一次「Codex 删了重装」引发三个环环相扣的故障：CC Switch 报 requireStack、Codex 打到 api.openai.com 401、历史聊天记录不显示。完整根因分析与修复过程。</summary>
      <content type="html"><![CDATA[<section><h1>Codex 重装后三大故障修复全记录：CC Switch 报错、走官方通道、聊天记录丢失<a href="#codex-重装后三大故障修复全记录cc-switch-报错走官方通道聊天记录丢失"><span>#</span></a></h1><blockquote><p>环境：Windows 11 · Node v24.19.0 · npm 全局 Codex CLI 0.160.0 · Codex Desktop（ChatGPT 桌面版）26.928 · CC Switch（供应商切换工具）</p><p>一次「Codex 包名有问题，删了重装」的操作，引发了三个看似独立、实则环环相扣的故障。本文完整记录排查与修复过程，供遇到同类问题的朋友参考。</p></blockquote><section><h2>故障现象<a href="#故障现象"><span>#</span></a></h2><p>重装 Codex 之后，出现了三个问题：</p><ol>
<li><strong>CC Switch 设置页报错</strong>：Codex 卡片显示「已安装 · 无法运行」，错误信息为 <code>requireStack: [] } Node.js v24.19.0</code>，底部提示「请检查运行环境」。</li>
<li><strong>Codex 启动走的还是官方通道</strong>：打开 Codex 桌面版直接进 ChatGPT 登录页；CLI 调用则返回 <code>401 Unauthorized ... url: https://api.openai.com/v1/responses</code>，完全没有走 CC Switch 配置的 MiMo 中转。</li>
<li><strong>历史聊天记录消失</strong>：旧的会话记录在桌面版里一条都看不到。</li>
</ol><p>三个问题各有一个根因，下面逐个拆解。</p><hr /></section><section><h2>问题一：CC Switch 报「无法运行」<a href="#问题一cc-switch-报无法运行"><span>#</span></a></h2><section><h3>根因<a href="#根因"><span>#</span></a></h3><p>CC Switch 检测 Codex 是否可用的方式，是执行 npm 全局目录下的 <code>codex.cmd</code>。而重装过程中，<strong>npm 全局目录里 <code>@openai/codex</code> 的包体被删空了，只留下了启动壳</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>%APPDATA%\npm\node_modules\@openai\     ← 目录还在，里面 0 个文件</span></div></div><div><div><div>2</div></div><div><span>%APPDATA%\npm\codex.cmd                 ← 启动壳还在，指向不存在的包</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>执行 <code>codex.cmd</code> 时报：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Error: Cannot find module '...\node_modules\@openai\codex\bin\codex.js'</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>code: 'MODULE_NOT_FOUND',</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>requireStack: []</span></div></div><div><div><div>4</div></div><div><span>Node.js v24.19.0</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>requireStack: []</code> 就是截图里那行报错的来源——<strong>不是 Node 环境坏了，是包没了</strong>。</p></section><section><h3>修复<a href="#修复"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>npm install </span><span>-</span><span>g </span></span><span>@openai</span><span><span>/</span><span>codex</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>⚠️ <strong>关键坑</strong>：Codex 的 npm 包采用「主包 + 平台二进制」分离结构，主包 <code>@openai/codex</code> 很小，真正的可执行文件在 optionalDependencies 里（如 <code>@openai/codex-win32-x64</code>）。国内直连官方 registry 经常在下载平台包时超时，结果是<strong>主包装上了但运行仍报错</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Error: Missing optional dependency @openai/codex-win32-x64.</span></div></div><div><div><div>2</div></div><div><span>Reinstall Codex: npm install -g @openai/codex@latest</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>解决办法是换国内镜像单独补装平台包：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>npm install </span><span>-</span><span>g </span></span><span>"@openai/codex-win32-x64@npm:@openai/codex@0.160.0-win32-x64"</span><span><span> </span><span>--</span><span>registry</span><span>=</span><span>https:</span><span>//</span></span><span>registry.npmmirror.com</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>验证：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>codex </span><span>--</span><span>version    </span></span><span># codex-cli 0.160.0</span></div></div><div><div><div>2</div></div><div><span>codex doctor       </span><span># 应全部通过</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section></section><section><h2>问题二：Codex 走回官方通道（401）<a href="#问题二codex-走回官方通道401"><span>#</span></a></h2><section><h3>根因<a href="#根因-1"><span>#</span></a></h3><p>CC Switch 的工作原理是把供应商配置写进 <code>~/.codex/config.toml</code>。正常应写入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>model_provider</span><span> = </span><span>"custom"</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>[</span><span>model_providers</span><span>.</span><span>custom</span><span>]</span></div></div><div><div><div>4</div></div><div><span>name</span><span> = </span><span>"xiaomi_mimo"</span></div></div><div><div><div>5</div></div><div><span>base_url</span><span> = </span><span>"https://api.xiaomimimo.com/v1"</span></div></div><div><div><div>6</div></div><div><span>wire_api</span><span> = </span><span>"responses"</span></div></div><div><div><div>7</div></div><div><span>requires_openai_auth</span><span> = </span><span>true</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>但实际打开 <code>config.toml</code> 发现：<strong><code>model_provider</code> 和整个 <code>[model_providers.custom]</code> 表丢失了</strong>，取而代之的是一堆无效的顶层键（<code>base_url</code>、<code>wire_api</code>、<code>experimental_bearer_token</code> 写在了顶层——TOML 里这些键 Codex 根本不认，启动时会警告 <code>unrecognized configuration settings</code>）。</p><p>没有 <code>model_provider</code> 指示，Codex 就用默认的 OpenAI 官方 provider，于是打到 <code>api.openai.com</code> 返回 401；桌面版则因为没有有效凭证直接弹登录页。</p><p><strong>为什么会被写坏？</strong> 时间线是：</p><ol>
<li>今早 Codex 桌面版启动时重写了 <code>config.toml</code>（9&lt;35&gt;）；</li>
<li>CC Switch 把这份<strong>已经不含 provider 段的 config 回读成了自己的供应商模板</strong>（双向同步的副作用）；</li>
<li>之后每次切换供应商，CC Switch 用坏模板写回 <code>config.toml</code>——<strong>越修越坏，切换一次覆盖一次</strong>。</li>
</ol><p>证据：CC Switch 数据库（<code>~/.cc-switch/cc-switch.db</code> 的 <code>providers</code> 表）里存的模板已经是坏的，而当天早些时候的数据库备份（9&lt;29&gt;）里配置还是完整的。</p></section><section><h3>修复（三处一起改）<a href="#修复三处一起改"><span>#</span></a></h3><p>改任何一处之前，<strong>先备份 CC Switch 数据库</strong>：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>Copy-Item</span><span><span> ~\.cc</span><span>-</span><span>switch\cc</span><span>-</span><span>switch.db ~\.cc</span><span>-</span><span>switch\backups\db_backup_manual.db</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li><strong>修模板（根因）</strong>：用 SQLite 打开 <code>~/.cc-switch/cc-switch.db</code>，找到 <code>providers</code> 表中当前 Codex 供应商的 <code>settings_config</code> JSON 字段，删掉无效顶层键，补回 <code>model_provider = "custom"</code> 和 <code>[model_providers.custom]</code> 表。</li>
<li><strong>修 <code>~/.codex/config.toml</code></strong>：同样处理。</li>
<li><strong>重建 <code>~/.codex/auth.json</code></strong>（如果也丢了）：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{ </span><span>"OPENAI_API_KEY"</span><span>: </span><span>"你的中转 Key"</span><span> }</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p>教训：<strong>只修 config.toml 不修数据库模板是没用的</strong>，下次切换就会被覆盖回去。必须修源头。</p></blockquote><hr /></section></section><section><h2>问题三：恢复的聊天记录不显示<a href="#问题三恢复的聊天记录不显示"><span>#</span></a></h2><section><h3>背景<a href="#背景"><span>#</span></a></h3><p>Codex 的会话记录是 JSONL 文件，按日期存放在：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>~/.codex/sessions/YYYY/MM/DD/rollout-&lt;时间&gt;-&lt;uuid&gt;.jsonl</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>把旧的 <code>2026/</code> 文件夹整个复制回 <code>~/.codex/sessions/</code> 后，文件明明在，桌面版侧边栏却一条都不显示。</p></section><section><h3>根因<a href="#根因-2"><span>#</span></a></h3><p>桌面版的会话列表不是实时扫描文件夹的，而是读本地索引库 <code>~/.codex/state_5.sqlite</code> 的 <code>threads</code> 表。首次启动时有一个 <strong>backfill 扫描</strong>流程负责把 rollout 文件导入这张表，扫完后在 <code>backfill_state</code> 表里记：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>(</span><span>1</span><span>, </span><span>'complete'</span><span>, </span><span>NULL</span><span><span>, </span><span>&lt;</span><span>时间戳</span><span>&gt;</span><span>, </span><span>&lt;</span><span>时间戳</span><span>&gt;</span><span>)</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>新装的 Codex 在 9&lt;09&gt; 第一次启动时扫过一次——当时 sessions 目录还是空的（聊天记录是之后才复制进去的），扫描结果标记为 <code>complete</code>，之后永远不会再扫。</strong></p></section><section><h3>修复<a href="#修复-1"><span>#</span></a></h3><ol>
<li><strong>完全退出</strong> ChatGPT 桌面版（托盘也要退出，避免写库冲突）；</li>
<li>备份后清空 <code>backfill_state</code> 表，强制下次启动重扫：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> sqlite3, shutil</span></div></div><div><div><div>2</div></div><div><span><span>shutil.</span><span>copy2</span><span>(</span></span><span>r</span><span><span>"C:</span><span>\U</span><span>sers</span><span>\&lt;</span><span>你&gt;</span><span>\.</span><span>codex\state_5.sqlite"</span></span><span>,</span></div></div><div><div><div>3</div></div><div><span>             </span><span>r</span><span><span>"C:</span><span>\U</span><span>sers</span><span>\&lt;</span><span>你&gt;</span><span>\.</span><span>codex\state_5.sqlite.bak"</span></span><span>)</span></div></div><div><div><div>4</div></div><div><span><span>con </span><span>=</span><span> sqlite3.</span><span>connect</span><span>(</span></span><span>r</span><span><span>"C:</span><span>\U</span><span>sers</span><span>\&lt;</span><span>你&gt;</span><span>\.</span><span>codex\state_5.sqlite"</span></span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>con.</span><span>execute</span><span>(</span></span><span>"DELETE FROM backfill_state"</span><span>)</span></div></div><div><div><div>6</div></div><div><span><span>con.</span><span>commit</span><span>()</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>重新打开桌面版，启动时自动重扫，历史记录全部回来了。</li>
</ol><hr /></section></section><section><h2>修复顺序建议<a href="#修复顺序建议"><span>#</span></a></h2><p>如果你同时遇到这三个问题，按这个顺序修，避免互相干扰：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>① npm 包（codex --version 能跑）</span></div></div><div><div><div>2</div></div><div><span><span>        </span></span><span>↓</span></div></div><div><div><div>3</div></div><div><span>② CC Switch 数据库模板 + config.toml + auth.json（走通中转）</span></div></div><div><div><div>4</div></div><div><span><span>        </span></span><span>↓</span></div></div><div><div><div>5</div></div><div><span>③ 退出桌面版 → 重置 backfill_state → 重启（恢复列表）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>验证清单<a href="#验证清单"><span>#</span></a></h2><ul>
<li> <code>codex --version</code> 输出版本号</li>
<li> <code>codex doctor</code> 无致命错误</li>
<li> CC Switch 设置页 Codex 卡片无警告</li>
<li> <code>codex exec "你好"</code> 走中转正常回复（不应出现 <code>api.openai.com</code> 的 401）</li>
<li> 桌面版侧边栏能看到历史会话</li>
</ul></section><section><h2>经验教训<a href="#经验教训"><span>#</span></a></h2><ol>
<li><strong><code>requireStack: []</code> ≠ 环境坏了</strong>：十有八九是包文件缺失，先 <code>ls</code> 看包目录是不是空的。</li>
<li><strong>npm 装 Codex 国内务必配镜像</strong>：平台二进制包体积大，官方 registry 容易半途超时，留下「装了一半」的残局。</li>
<li><strong>双向同步类工具有「污染源头」风险</strong>：CC Switch 会把 app 重写过的 config 回读成模板，坏一次就循环放大。修的时候一定要连它的数据库模板一起修。</li>
<li><strong>文件在 ≠ 能显示</strong>：现代客户端基本都有本地索引库，恢复数据文件后要找到对应的「重扫/重建索引」开关。</li>
<li><strong>改数据库前先备份</strong>：本文所有数据库操作都留了备份，出问题可随时回滚。</li>
</ol><hr /><p><em>本文基于一次真实排障记录整理，涉及的具体厂商（CC Switch / MiMo 中转）仅为实例，同类「供应商切换工具 + Codex」组合的排查思路通用。</em></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-14/</id>
      <title type="text">评论区回复时表情面板被截断：一次 overflow 裁剪排查实录</title>
      <published>2026-10-01T00:00:00.000Z</published>
      <updated>2026-10-01T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-14/"/>
      <summary type="text">主评论框的表情菜单好好的，一到回复就少了半截？不是 z-index 的锅，是祖先容器的 overflow 在裁。完整复现步骤、根因分析与修复代码。</summary>
      <content type="html"><![CDATA[<section><h1>评论区回复时表情面板被截断：一次 overflow 裁剪排查实录<a href="#评论区回复时表情面板被截断一次-overflow-裁剪排查实录"><span>#</span></a></h1><blockquote><p>主评论框点开表情菜单一切正常，回复别人的评论时表情按钮本来就靠下，点开后面板下半截连同底部的分类 tab 全被容器”吃”掉了。第一反应是层级（z-index）不够，实际查下来跟层级半点关系没有——是祖先容器的 <code>overflow</code> 在裁。本文记录完整复现步骤、排查过程和修复方案，无论你用的是不是我这套主题/评论系统，都能照着修。</p></blockquote><section><h2>现象与复现<a href="#现象与复现"><span>#</span></a></h2><p>复现条件很稳定，按下面步骤一定能碰到：</p><ol>
<li>打开任意一篇文章，滚到评论区；</li>
<li>在<strong>顶部主评论框</strong>点表情按钮：表情面板完整弹出，颜文字 / Emoji / Bilibili 分类 tab 都在——一切正常；</li>
<li>找到一条已有评论，点它的<strong>回复</strong>，回复框出现在评论列表靠下的位置（贴近评论卡片底部）；</li>
<li>再点回复框的表情按钮：面板弹是弹出来了，但<strong>下半截被整齐地切掉</strong>，底部分类 tab 直接消失，切分类无从下手。</li>
</ol><p>主评论框正常、回复框遭殃，很容易让人怀疑”回复框的 z-index 低了”。但无论你把表情面板的 <code>z-index</code> 调到多大都没用——<strong>z-index 管的是叠放顺序，管不了裁剪</strong>。一个绝对定位浮层能不能完整显示，取决于它<strong>全部祖先</strong>的 <code>overflow</code> 设置，任何一层 <code>overflow: hidden</code> 都会在那层的边界上把它齐刷刷切掉。</p></section><section><h2>排查：顺着 DOM 向上找 overflow<a href="#排查顺着-dom-向上找-overflow"><span>#</span></a></h2><p>打开 DevTools，用元素选择器选中被截断的表情面板，沿着祖先链逐层看计算样式里的 <code>overflow</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>表情面板（position: absolute，本身没问题）</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>└─ 评论卡片 #post-comments          ← overflow: hidden  ❌ 第一层裁剪</span></div></div><div><div><div>3</div></div><div><span><span>       </span></span><span>└─ 主内容列 wrapper             ← overflow: hidden  ❌ 第二层裁剪</span></div></div><div><div><div>4</div></div><div><span><span>            </span></span><span>└─ #content-wrapper / main ← 无 overflow        ✅</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>本站（Firefly 衍生主题 + Twikoo）具体是这两处：</p><p><strong>第一层：评论卡片。</strong> <code>src/components/comment/index.astro</code> 里的 <code>#post-comments</code>，类名里带着 <code>overflow-hidden</code>，而且它继承的 <code>.card-base</code> 基类本身也带 <code>overflow: hidden</code>（为了圆角裁剪）。主评论框没事，是因为它在卡片顶部，面板向下展开 200 多 px 依然在卡片内部；回复框贴近卡片底部，面板需要溢出卡片才能显示完整——正好撞在裁剪边界上。</p><p><strong>第二层：主内容列。</strong> <code>src/utils/responsive-utils.ts</code> 的 <code>generateMainContentClasses()</code> 给内容列 push 了 <code>overflow-hidden</code>。就算修掉卡片那一层，面板接着会被这一层继续裁，所以两层要一起改。</p><blockquote><p>同样的问题临渊羡鱼也踩过并写过一篇很详细的分析（<a href="https://x1anyu.cn/posts/16/" target="_blank">评论区表情面板被裁、切换还抖，原因在 overflow</a>，他修的是 Waline + Firefly，还给上游提了 PR）。评论系统不同，根因完全一致：主题为了圆角在祖先链上套的 <code>overflow: hidden</code>，把评论系统绝对定位的浮层关在了里面。</p></blockquote></section><section><h2>关键陷阱：为什么不能直接 overflow-y: visible<a href="#关键陷阱为什么不能直接-overflow-y-visible"><span>#</span></a></h2><p>第一反应可能是把裁剪层改成”横向还裁、纵向放开”。但这里有个规范级的坑：</p><p><strong>只要一个轴是 <code>visible</code>，另一个轴是 <code>hidden</code> / <code>auto</code> / <code>scroll</code> / <code>clip</code> 里任何非 visible 值，浏览器就会强制把 <code>visible</code> 那一轴降级成 <code>auto</code>。</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* ❌ 看起来对，实际 overflow-y 被计算成 auto：</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>元素变成滚动容器，浮层照样被关在里面裁掉 */</span></div></div><div><div><div>3</div></div><div><span>overflow-x</span><span>: hidden;</span></div></div><div><div><div>4</div></div><div><span>overflow-y</span><span>: visible;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>正确的组合是 <strong><code>overflow-x: clip</code> 配 <code>overflow-y: visible</code></strong>——<code>clip</code> 不触发上面那条降级规则（规范里 <code>visible</code> 和 <code>clip</code> 是允许共存的一对），只裁切、不生成滚动容器，也不影响列内 <code>sticky</code> 吸顶：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* ✅ 横向只裁不滚，纵向完全放开 */</span></div></div><div><div><div>2</div></div><div><span>overflow-x</span><span>: clip;</span></div></div><div><div><div>3</div></div><div><span>overflow-y</span><span>: visible;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>修复<a href="#修复"><span>#</span></a></h2><p>一共改两个文件：</p><p><strong>1. <code>src/components/comment/index.astro</code> — 评论卡片</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;div id="post-comments" class="card-base p-8 mb-6 relative overflow-hidden"&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;div id="post-comments" class="card-base p-8 mb-6 relative overflow-x-clip overflow-y-visible"&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意 <code>.card-base</code> 基类里还有 <code>overflow: hidden</code>，光删类名上的 <code>overflow-hidden</code> 没用，必须用 <code>overflow-x-clip</code> / <code>overflow-y-visible</code> 两个工具类去覆盖长轴属性（本主题用 Tailwind v4，utilities 层优先级高于 <code>@layer components</code> 里的 <code>.card-base</code>，能覆盖住）。</p><p><strong>2. <code>src/utils/responsive-utils.ts</code> — 主内容列</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>classes.push("min-w-0");</span></div></div><div><div><div>2</div></div><div><span>classes.push("overflow-hidden");</span></div></div><div><div><div>3</div></div><div><span>classes.push("overflow-x-clip");</span></div></div><div><div><div>4</div></div><div><span>classes.push("overflow-y-visible");</span></div></div><div><div><div>5</div></div><div><span>classes.push("z-10");</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>横向的 <code>clip</code> 要保留：卡片的圆角和装饰性 SVG 靠它裁剪，横向不能放。</p><p><strong>3. 为什么要加 <code>z-10</code></strong></p><p>浮层纵向放开后会溢出内容列、盖到页脚区域。本主题的页脚带着 <code>onload-animation</code>，动画结束后 <code>transform: translateY(0)</code> 会<strong>残留</strong>（<code>animation-fill-mode: forwards</code>），只要有 transform 就会生成层叠上下文——页脚在 DOM 里靠后，绘制顺序压在内容列上面。结果是：浮层显示出来了，但底部 tab 被页脚盖住，<strong>看得见点不着</strong>。</p><p>页脚和内容列都是 <code>#main-grid</code> 的 grid 子项，grid 子项直接写 <code>z-index</code> 就能生效（不需要额外加 <code>position</code>），给内容列加 <code>z-10</code> 把它整体抬到页脚之上即可。这一幕和羡鱼文章里描述的”旧架构”问题一模一样——他后来赶上上游重构、页脚那层 transform 没了才不用加；本站页脚还有这个 transform 残留，所以这个 <code>z-10</code> 得带上。</p></section><section><h2>换成你自己的主题/评论系统怎么修<a href="#换成你自己的主题评论系统怎么修"><span>#</span></a></h2><p>不管用 Twikoo、Waline 还是 Artalk，凡是”绝对定位浮层被容器切掉”，排查套路通用：</p><ol>
<li>DevTools 选中浮层，沿祖先链找 <code>overflow</code> 不是 visible 的那一层（通常不止一层，逐个记下来）；</li>
<li>每一层改成 <code>overflow-x: clip; overflow-y: visible</code>——<strong>不要</strong>用 <code>overflow-x: hidden</code> 配 <code>overflow-y: visible</code>，会被降级成 auto 白改；</li>
<li>横向想继续裁（保圆角、防横向滚动条）就靠 <code>clip</code>，别用 <code>visible</code>；</li>
<li>改完如果浮层能出来但<strong>被下方元素盖住或点不动</strong>，用元素选择器看盖住它的东西：若那个元素（或它的某个祖先）带 <code>transform</code>、<code>opacity &lt; 1</code>、<code>position + z-index</code> 之类生成层叠上下文的属性，给你的内容容器加个 <code>z-index</code>（grid/flex 子项可直接写，普通元素配合 <code>position: relative</code>）压回去；</li>
<li>验证三件事：回复框贴底时面板完整显示、分类 tab 能点、评论卡片的圆角和装饰没有溢出。</li>
</ol></section><section><h2>验证结果<a href="#验证结果"><span>#</span></a></h2><ul>
<li>主评论框表情面板：正常；</li>
<li>回复框（贴近卡片底部）表情面板：完整浮出卡片，分类 tab 可见可点；</li>
<li>卡片圆角、装饰 SVG：横向仍被裁剪，无溢出；</li>
<li><code>astro check</code> / <code>type-check</code> / Biome lint：无新增问题。</li>
</ul></section><section><h2>参考<a href="#参考"><span>#</span></a></h2><ul>
<li><a href="https://x1anyu.cn/posts/16/" target="_blank">临渊羡鱼：评论区表情面板被裁、切换还抖，原因在 overflow</a></li>
<li><a href="https://www.w3.org/TR/css-overflow-3/" target="_blank">CSS Overflow Module Level 3</a>（<code>visible</code> / <code>clip</code> 轴组合的降级规则）</li>
</ul></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-13/</id>
      <title type="text">Firefly 博客实战：给文章页加阅读进度条与圆环百分比</title>
      <published>2026-09-28T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-13/"/>
      <summary type="text">从设计思路到逐文件代码，手把手为 Firefly/Astro 博客实现纯客户端阅读进度功能：顶部细条、右下角与悬浮按钮同款的圆环百分比，零网络请求，兼容 Swup 切页</summary>
      <content type="html"><![CDATA[<section><h1>Firefly 博客实战：给文章页加阅读进度条与圆环百分比<a href="#firefly-博客实战给文章页加阅读进度条与圆环百分比"><span>#</span></a></h1><p>本文记录一次完整的功能实现：为基于 Astro + Swup 的 Firefly 博客添加<strong>阅读进度</strong>——文章页顶部一条随滚动生长的细条，右下角悬浮按钮栈最上方一个<strong>与现有按钮同规格的方框</strong>，内部 SVG 圆环实时填充百分比。</p><p>功能特点：</p><ul>
<li><strong>纯客户端计算</strong>：零网络请求、零第三方依赖，不拖慢加载</li>
<li><strong>精准语义</strong>：只统计正文（横幅、评论区、推荐文章不计入）</li>
<li><strong>UI 与现有按钮一致</strong>：3rem 方框、圆角、<code>--card-bg</code> 背景、毛玻璃、细边框，尺寸断点跟随 <code>FloatingButton</code></li>
<li><strong>Swup 兼容</strong>：切页不残留旧值、软导航后自动重新绑定</li>
<li><strong>可配置开关</strong>：<code>siteConfig.post.readingProgress</code> 一键启停</li>
</ul><p>改动规模：<strong>7 个文件修改 + 1 个新文件</strong>，净增约 100 行。本文给出每一处改动的精确位置和完整代码，可对照逐条复现。</p><hr /><section><h2>一、设计思路（先读懂再动手）<a href="#一设计思路先读懂再动手"><span>#</span></a></h2><section><h3>1. 进度怎么算<a href="#1-进度怎么算"><span>#</span></a></h3><p>进度语义定义为：<strong>正文进入视口底边记 0%，正文底边到达视口底边记 100%</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>p</span><span> </span></span><span>=</span><span> </span><span>clamp</span><span><span>((</span><span>viewportHeight</span><span> </span></span><span>-</span><span><span> </span><span>rect</span><span>.</span></span><span>top</span><span>) </span><span>/</span><span><span> </span><span>rect</span><span>.</span></span><span>height</span><span>, </span><span>0</span><span>, </span><span>1</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>其中 <code>rect</code> 是正文容器 <code>#post-container</code> 的 <code>getBoundingClientRect()</code>。这个公式的好处：</p><ul>
<li>横幅高度、评论区长度、推荐文章数量都不影响进度</li>
<li>短文章（高度小于视口）也能自然收敛到 0~1</li>
</ul><p>顶部细条用 <code>transform: scaleX(p)</code> 驱动；圆环用 SVG <code>stroke-dashoffset = 周长 × (1 - p)</code> 驱动——两者都不触发布局。</p></section><section><h3>2. 性能三板斧<a href="#2-性能三板斧"><span>#</span></a></h3><p>这个博客的滚动管线是 <code>scrollFunction</code>（<code>scroll</code> 事件 rAF 节流），阅读进度<strong>不是自己注册监听器</strong>，而是挂进现有管线，这是全部性能设计的前提：</p><ol>
<li><strong>调用点卡在布局新鲜期</strong>：<code>scrollFunction</code> 先读 <code>scrollTop</code>（布局脏时浏览器在此强制计算一次），紧接着读 <code>rect</code> 命中<strong>同一次</strong>新鲜布局——增量回流成本为 0</li>
<li><strong>文字只在整数变化时写</strong>：百分比从整数维度最多写 100 次/全程；徽章 CSS 带 <code>contain: content</code>，文字变更的布局失效被隔离在徽章子树内，不污染正文布局。圆环的 <code>dashoffset</code> 是 SVG 属性，只标记绘制不触发布局，可以逐帧写</li>
<li><strong>非文章页近乎零成本</strong>：靠 <code>isConnected</code> + <code>null === null</code> 短路，首页每帧只多一次 <code>getElementById</code> 哈希查找（与 <code>scrollFunction</code> 里已有的 navbar 查询同量级）</li>
</ol></section><section><h3>3. Swup 适配（这个博客的特殊点）<a href="#3-swup-适配这个博客的特殊点"><span>#</span></a></h3><p>本站用 Swup 做软导航，DOM 会局部替换，处理不好会出现”换页后进度条卡在旧值”：</p>

<table><thead><tr><th>元素</th><th>位置</th><th>跨页行为</th></tr></thead><tbody><tr><td><code>#reading-progress-bar</code></td><td><code>Layout.astro</code></td><td><strong>不在 Swup 容器内</strong>，元素持久，引用不失效</td></tr><tr><td><code>#reading-progress-badge</code></td><td><code>FloatingControls.astro</code></td><td>同上，持久</td></tr><tr><td><code>#post-container</code></td><td><code>#swup-container</code> 内</td><td><strong>每次换页被替换</strong>，靠 <code>isConnected</code> 自愈重查</td></tr></tbody></table><p>再加两条 CSS：切页期间（<code>html.is-page-transitioning</code>）同时隐藏细条和徽章，避免旧页百分比残值闪现；<code>scrollFunction</code> 在过渡期本来就提前 return，<code>visit:end</code> 后恢复调用并刷新。</p></section><section><h3>4. 三个补测时机<a href="#4-三个补测时机"><span>#</span></a></h3><p>有些场景<strong>没有滚动事件</strong>，需要主动补算：</p><ul>
<li><strong>图片加载 / 代码块展开 / 加密文章解密</strong> → 正文高度变了但用户没滚 → <code>ResizeObserver</code> 盯正文容器</li>
<li><strong>移动端地址栏收起</strong> → 视口高度变了但正文没变 → 单独听 <code>resize</code></li>
<li><strong>沉浸阅读切换</strong> → 正文位置变了但可能停在同一滚动位置 → 监听 <code>immersiveReadingChange</code> 事件，立即算一次 + 400ms 过渡结束后再算一次</li>
</ul><hr /></section></section><section><h2>二、改动总览<a href="#二改动总览"><span>#</span></a></h2>

<table><thead><tr><th>#</th><th>文件</th><th>改动</th></tr></thead><tbody><tr><td>1</td><td><code>src/types/siteConfig.ts</code></td><td>类型：加 <code>readingProgress: boolean</code></td></tr><tr><td>2</td><td><code>src/config/siteConfig.ts</code></td><td>配置：加 <code>readingProgress: true</code></td></tr><tr><td>3</td><td><code>src/utils/reading-progress.ts</code></td><td><strong>新文件</strong>，核心逻辑</td></tr><tr><td>4</td><td><code>src/utils/scroll-utils.ts</code></td><td>滚动管线接入（2 处）</td></tr><tr><td>5</td><td><code>src/utils/layout-init.ts</code></td><td>初始化调用（2 处）</td></tr><tr><td>6</td><td><code>src/layouts/Layout.astro</code></td><td>顶部条 DOM（1 行）</td></tr><tr><td>7</td><td><code>src/styles/transition.css</code></td><td>顶部条样式（文件末尾追加）</td></tr><tr><td>8</td><td><code>src/components/controls/FloatingControls.astro</code></td><td>圆环徽章 DOM + 样式</td></tr></tbody></table><hr /></section><section><h2>三、分步实现<a href="#三分步实现"><span>#</span></a></h2><section><h3>Step 1：配置开关（2 个文件）<a href="#step-1配置开关2-个文件"><span>#</span></a></h3><p><strong><code>src/types/siteConfig.ts</code></strong> —— 在 <code>post</code> 类型区块中，<code>generateOgImages: boolean;</code> 之后添加：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>    </span><span>// 是否显示阅读进度（顶部进度条 + 百分比 + 预计剩余时间）</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>readingProgress</span><span>: </span><span>boolean</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong><code>src/config/siteConfig.ts</code></strong> —— 在 <code>post</code> 配置区块中，<code>generateOgImages: true,</code> 之后添加：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>    </span><span>// 阅读进度：文章页顶部细条 + 悬浮百分比与预计剩余时间（纯客户端计算，无网络请求）</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>readingProgress</span><span>: </span><span>true</span><span>,</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><code>SiteConfig</code> 是严格类型，只改 config 不改类型会直接 type-check 报错，两处必须成对加。</p></blockquote></section><section><h3>Step 2：核心模块（新文件）<a href="#step-2核心模块新文件"><span>#</span></a></h3><p>新建 <strong><code>src/utils/reading-progress.ts</code></strong>，完整内容如下（可整段复制）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/**</span></div></div><div><div><div>2</div></div><div><span><span> </span></span><span>* 阅读进度（顶部细条 + 右下角圆环百分比）。</span></div></div><div><div><div>3</div></div><div><span><span> </span></span><span>*</span></div></div><div><div><div>4</div></div><div><span><span> </span></span><span>* 进度语义：正文进入视口底边为 0%，正文底边到达视口底边为 100%，</span></div></div><div><div><div>5</div></div><div><span><span> </span></span><span>* 即 p = clamp((viewportHeight - rect.top) / rect.height, 0, 1)——</span></div></div><div><div><div>6</div></div><div><span><span> </span></span><span>* 横幅、评论区、推荐文章不计入。</span></div></div><div><div><div>7</div></div><div><span><span> </span></span><span>*</span></div></div><div><div><div>8</div></div><div><span><span> </span></span><span>* 每帧成本：一次 getBoundingClientRect + 算术。scrollFunction 先读 scrollTop</span></div></div><div><div><div>9</div></div><div><span><span> </span></span><span>*（布局脏时已在此强制计算），随后本函数的 rect 读取命中同一次新鲜布局，不触发额外回流。</span></div></div><div><div><div>10</div></div><div><span><span> </span></span><span>* 百分比文字仅在整数变化时写入，且 badge 带 contain:content，文字变更的布局失效</span></div></div><div><div><div>11</div></div><div><span><span> </span></span><span>* 被限制在 badge 子树内，不污染正文布局；圆环 dashoffset 走 SVG 属性（不触发布局）。</span></div></div><div><div><div>12</div></div><div><span><span> </span></span><span>* 正文元素被 Swup 替换后靠 isConnected 自愈重新查询，非文章页每帧仅一次</span></div></div><div><div><div>13</div></div><div><span><span> </span></span><span>* getElementById 哈希查找（与 scrollFunction 中已有的 navbar 查询同量级）。</span></div></div><div><div><div>14</div></div><div><span><span> </span></span><span>*/</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>let</span><span><span> </span><span>initialized</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>17</div></div><div><span>let</span><span><span> </span><span>enabled</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>let</span><span><span> </span><span>bar</span></span><span>:</span><span> </span><span>HTMLElement</span><span> </span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>20</div></div><div><span>let</span><span><span> </span><span>badge</span></span><span>:</span><span> </span><span>HTMLElement</span><span> </span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>21</div></div><div><span>let</span><span><span> </span><span>pctEl</span></span><span>:</span><span> </span><span>HTMLElement</span><span> </span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>22</div></div><div><span>let</span><span><span> </span><span>ringEl</span></span><span>:</span><span> </span><span>SVGCircleElement</span><span> </span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>let</span><span><span> </span><span>article</span></span><span>:</span><span> </span><span>HTMLElement</span><span> </span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>25</div></div><div><span>let</span><span><span> </span><span>observer</span></span><span>:</span><span> </span><span>ResizeObserver</span><span> </span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>26</div></div><div><span>let</span><span><span> </span><span>active</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>27</div></div><div><span>let</span><span><span> </span><span>lastP</span><span> </span></span><span>=</span><span> </span><span>-</span><span>1</span><span>;</span></div></div><div><div><div>28</div></div><div><span>let</span><span><span> </span><span>lastPct</span><span> </span></span><span>=</span><span> </span><span>-</span><span>1</span><span>;</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>/** 圆环周长 2πr（r=15，与 SVG viewBox 一致） */</span></div></div><div><div><div>31</div></div><div><span>const</span><span> </span><span>RING_C</span><span> </span><span>=</span><span> </span><span>2</span><span> </span><span>*</span><span><span> </span><span>Math</span><span>.</span></span><span>PI</span><span> </span><span>*</span><span> </span><span>15</span><span>;</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>/** Swup 换页/解密后旧引用断开 → 重查并重绑 ResizeObserver；非文章页 null===null 直接短路 */</span></div></div><div><div><div>34</div></div><div><span>function</span><span> </span><span>resolveArticle</span><span>()</span><span>:</span><span> </span><span>void</span><span> {</span></div></div><div><div><div>35</div></div><div><span>  </span><span>if</span><span><span> (</span><span>article</span><span>?.</span></span><span>isConnected</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>36</div></div><div><span>  </span><span>const</span><span> </span><span>next</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"post-container"</span><span>);</span></div></div><div><div><div>37</div></div><div><span>  </span><span>if</span><span><span> (</span><span>next</span><span> </span></span><span>===</span><span><span> </span><span>article</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>38</div></div><div><span><span>  </span></span><span>article</span><span> </span><span>=</span><span><span> </span><span>next</span><span>;</span></span></div></div><div><div><div>39</div></div><div><span><span>  </span></span><span>lastPct</span><span> </span><span>=</span><span> </span><span>-</span><span>1</span><span>;</span></div></div><div><div><div>40</div></div><div><span>  </span><span>if</span><span><span> (</span><span>observer</span><span>) {</span></span></div></div><div><div><div>41</div></div><div><span><span>    </span></span><span>observer</span><span>.</span><span>disconnect</span><span>();</span></div></div><div><div><div>42</div></div><div><span>    </span><span>if</span><span><span> (</span><span>article</span><span>) </span><span>observer</span><span>.</span></span><span>observe</span><span><span>(</span><span>article</span><span>);</span></span></div></div><div><div><div>43</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>44</div></div><div><span>}</span></div></div><div><div><div>45</div></div><div>
</div></div><div><div><div>46</div></div><div><span>function</span><span> </span><span>setActive</span><span><span>(</span><span>next</span></span><span>:</span><span> </span><span>boolean</span><span>)</span><span>:</span><span> </span><span>void</span><span> {</span></div></div><div><div><div>47</div></div><div><span>  </span><span>if</span><span><span> (</span><span>active</span><span> </span></span><span>===</span><span><span> </span><span>next</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>48</div></div><div><span><span>  </span></span><span>active</span><span> </span><span>=</span><span><span> </span><span>next</span><span>;</span></span></div></div><div><div><div>49</div></div><div><span><span>  </span></span><span>lastPct</span><span> </span><span>=</span><span> </span><span>-</span><span>1</span><span>;</span></div></div><div><div><div>50</div></div><div><span><span>  </span></span><span>bar</span><span>?.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"visible"</span><span><span>, </span><span>next</span><span>);</span></span></div></div><div><div><div>51</div></div><div><span><span>  </span></span><span>badge</span><span>?.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"show"</span><span><span>, </span><span>next</span><span>);</span></span></div></div><div><div><div>52</div></div><div><span>}</span></div></div><div><div><div>53</div></div><div>
</div></div><div><div><div>54</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>initReadingProgress</span><span>()</span><span>:</span><span> </span><span>void</span><span> {</span></div></div><div><div><div>55</div></div><div><span>  </span><span>if</span><span><span> (</span><span>initialized</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>56</div></div><div><span><span>  </span></span><span>initialized</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>57</div></div><div>
</div></div><div><div><div>58</div></div><div><span>  </span><span>// 配置关闭时 DOM 不渲染；查询不到即永久空转</span></div></div><div><div><div>59</div></div><div><span><span>  </span></span><span>bar</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"reading-progress-bar"</span><span>);</span></div></div><div><div><div>60</div></div><div><span><span>  </span></span><span>badge</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"reading-progress-badge"</span><span>);</span></div></div><div><div><div>61</div></div><div><span><span>  </span></span><span>pctEl</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"reading-progress-pct"</span><span>);</span></div></div><div><div><div>62</div></div><div><span><span>  </span></span><span>ringEl</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span></div></div><div><div><div>63</div></div><div><span>    </span><span>"reading-progress-ring"</span><span>,</span></div></div><div><div><div>64</div></div><div><span><span>  </span></span><span>) </span><span>as</span><span> </span><span>SVGCircleElement</span><span> </span><span>|</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>65</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>bar</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>badge</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>pctEl</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>ringEl</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>66</div></div><div>
</div></div><div><div><div>67</div></div><div><span><span>  </span></span><span>enabled</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>68</div></div><div>
</div></div><div><div><div>69</div></div><div><span>  </span><span>if</span><span> (</span><span>typeof</span><span><span> </span><span>ResizeObserver</span><span> </span></span><span>!==</span><span> </span><span>"undefined"</span><span>) {</span></div></div><div><div><div>70</div></div><div><span>    </span><span>// 正文高度变化（图片加载、代码块展开、解密）后无滚动也能重算</span></div></div><div><div><div>71</div></div><div><span><span>    </span></span><span>observer</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>ResizeObserver</span><span>(() </span><span>=&gt;</span><span> </span><span>updateReadingProgress</span><span>());</span></div></div><div><div><div>72</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>73</div></div><div>
</div></div><div><div><div>74</div></div><div><span>  </span><span>// 移动端地址栏收起只改视口高度、不改正文尺寸 → 单独听 resize</span></div></div><div><div><div>75</div></div><div><span><span>  </span></span><span>window</span><span>.</span><span>addEventListener</span><span>(</span><span>"resize"</span><span><span>, </span><span>updateReadingProgress</span><span>, { </span></span><span>passive</span><span>:</span><span> </span><span>true</span><span> });</span></div></div><div><div><div>76</div></div><div><span>  </span><span>// 沉浸阅读切换改变正文位置且可能落在同一滚动位置（无 scroll 事件）→ 立即 + 过渡结束后各算一次</span></div></div><div><div><div>77</div></div><div><span><span>  </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>"immersiveReadingChange"</span><span>, () </span><span>=&gt;</span><span> {</span></div></div><div><div><div>78</div></div><div><span>    </span><span>updateReadingProgress</span><span>();</span></div></div><div><div><div>79</div></div><div><span><span>    </span></span><span>window</span><span>.</span><span>setTimeout</span><span><span>(</span><span>updateReadingProgress</span><span>, </span></span><span>400</span><span>);</span></div></div><div><div><div>80</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>81</div></div><div>
</div></div><div><div><div>82</div></div><div><span>  </span><span>updateReadingProgress</span><span>();</span></div></div><div><div><div>83</div></div><div><span>}</span></div></div><div><div><div>84</div></div><div>
</div></div><div><div><div>85</div></div><div><span>/** 由 scrollFunction 每帧调用；页面过渡期间 scrollFunction 提前返回，不会执行到这里 */</span></div></div><div><div><div>86</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>updateReadingProgress</span><span>()</span><span>:</span><span> </span><span>void</span><span> {</span></div></div><div><div><div>87</div></div><div><span>  </span><span>// enabled 为 true 时下列元素必然存在，显式判空仅为满足 TS 收窄</span></div></div><div><div><div>88</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>enabled</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>bar</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>badge</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>pctEl</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>ringEl</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>89</div></div><div>
</div></div><div><div><div>90</div></div><div><span>  </span><span>resolveArticle</span><span>();</span></div></div><div><div><div>91</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>article</span><span>) {</span></span></div></div><div><div><div>92</div></div><div><span>    </span><span>setActive</span><span>(</span><span>false</span><span>);</span></div></div><div><div><div>93</div></div><div><span>    </span><span>return</span><span>;</span></div></div><div><div><div>94</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>95</div></div><div>
</div></div><div><div><div>96</div></div><div><span>  </span><span>const</span><span> </span><span>rect</span><span> </span><span>=</span><span><span> </span><span>article</span><span>.</span></span><span>getBoundingClientRect</span><span>();</span></div></div><div><div><div>97</div></div><div><span>  </span><span>if</span><span><span> (</span><span>rect</span><span>.</span></span><span>height</span><span> </span><span>&lt;=</span><span> </span><span>0</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>98</div></div><div>
</div></div><div><div><div>99</div></div><div><span>  </span><span>const</span><span> </span><span>p</span><span> </span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>min</span><span>(</span></div></div><div><div><div>100</div></div><div><span>    </span><span>1</span><span>,</span></div></div><div><div><div>101</div></div><div><span><span>    </span></span><span>Math</span><span>.</span><span>max</span><span>(</span><span>0</span><span><span>, (</span><span>window</span><span>.</span></span><span>innerHeight</span><span> </span><span>-</span><span><span> </span><span>rect</span><span>.</span></span><span>top</span><span>) </span><span>/</span><span><span> </span><span>rect</span><span>.</span></span><span>height</span><span>),</span></div></div><div><div><div>102</div></div><div><span><span>  </span></span><span>);</span></div></div><div><div><div>103</div></div><div><span>  </span><span>setActive</span><span>(</span><span>true</span><span>);</span></div></div><div><div><div>104</div></div><div>
</div></div><div><div><div>105</div></div><div><span>  </span><span>// transform/dashoffset 只影响合成与绘制，不触发布局，逐帧写入开销可忽略</span></div></div><div><div><div>106</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span> </span></span><span>!==</span><span><span> </span><span>lastP</span><span>) {</span></span></div></div><div><div><div>107</div></div><div><span><span>    </span></span><span>lastP</span><span> </span><span>=</span><span><span> </span><span>p</span><span>;</span></span></div></div><div><div><div>108</div></div><div><span><span>    </span></span><span>bar</span><span>.</span><span>style</span><span>.</span><span>transform</span><span> </span><span>=</span><span> </span><span>`scaleX(</span><span>${</span><span>p</span><span>}</span><span>)`</span><span>;</span></div></div><div><div><div>109</div></div><div><span><span>    </span></span><span>ringEl</span><span>.</span><span>style</span><span>.</span><span>strokeDashoffset</span><span> </span><span>=</span><span> </span><span>String</span><span>(</span><span>RING_C</span><span> </span><span>*</span><span> (</span><span>1</span><span> </span><span>-</span><span><span> </span><span>p</span><span>));</span></span></div></div><div><div><div>110</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>111</div></div><div>
</div></div><div><div><div>112</div></div><div><span>  </span><span>// 百分比文字仅整数变化时写，配合 contain:content 把回流限制在徽章内</span></div></div><div><div><div>113</div></div><div><span>  </span><span>const</span><span> </span><span>pct</span><span> </span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>round</span><span><span>(</span><span>p</span><span> </span></span><span>*</span><span> </span><span>100</span><span>);</span></div></div><div><div><div>114</div></div><div><span>  </span><span>if</span><span><span> (</span><span>pct</span><span> </span></span><span>!==</span><span><span> </span><span>lastPct</span><span>) {</span></span></div></div><div><div><div>115</div></div><div><span><span>    </span></span><span>lastPct</span><span> </span><span>=</span><span><span> </span><span>pct</span><span>;</span></span></div></div><div><div><div>116</div></div><div><span><span>    </span></span><span>pctEl</span><span>.</span><span>textContent</span><span> </span><span>=</span><span> </span><span>`</span><span>${</span><span>pct</span><span>}</span><span>%`</span><span>;</span></div></div><div><div><div>117</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>118</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>模块导出两个函数：<code>initReadingProgress</code>（一次性初始化）和 <code>updateReadingProgress</code>（每帧调用）。模块级缓存了 DOM 引用、正文引用、<code>ResizeObserver</code> 实例和两个”上次写入值”（<code>lastP</code> / <code>lastPct</code>），全部写入都先比对再落 DOM。</p><p>两个细节：</p><ul>
<li><code>getElementById</code> 返回类型是 <code>HTMLElement</code>，赋给 <code>SVGCircleElement</code> 需要显式断言（<code>as SVGCircleElement | null</code>），否则 type-check 报 TS2322</li>
<li>圆环周长常量 <code>RING_C = 2π×15 ≈ 94.2478</code> 必须与 Step 6 中 SVG 的 <code>r="15"</code> 保持一致，改半径要同步改这里和 CSS 里的 <code>stroke-dasharray</code></li>
</ul></section><section><h3>Step 3：接入滚动管线（<code>src/utils/scroll-utils.ts</code>）<a href="#step-3接入滚动管线srcutilsscroll-utilsts"><span>#</span></a></h3><p>两处改动。</p><p><strong>第一处</strong>，文件顶部 import 区，加入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> { </span><span>updateReadingProgress</span><span> } </span><span>from</span><span> </span><span>"@/utils/reading-progress"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>第二处</strong>，<code>scrollFunction</code> 函数内，找到这段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>  </span><span>const</span><span> </span><span>scrollTop</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>documentElement</span><span>.</span><span>scrollTop</span><span>;</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>bannerHeight</span><span> </span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>innerHeight</span><span> </span><span>*</span><span> (</span><span>BANNER_HEIGHT</span><span> </span><span>/</span><span> </span><span>100</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>navbarElement</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"navbar"</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>// 根据滚动位置动态更新侧边栏 sticky 间距</span></div></div><div><div><div>6</div></div><div><span>  </span><span>updateSidebarStickySpacing</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在 <code>navbarElement</code> 之后、<code>updateSidebarStickySpacing()</code> <strong>之前</strong>插入调用：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>  </span><span>const</span><span> </span><span>scrollTop</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>documentElement</span><span>.</span><span>scrollTop</span><span>;</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>bannerHeight</span><span> </span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>innerHeight</span><span> </span><span>*</span><span> (</span><span>BANNER_HEIGHT</span><span> </span><span>/</span><span> </span><span>100</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>navbarElement</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"navbar"</span><span>);</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>// 阅读进度：紧跟 scrollTop 读取（布局已新鲜），后续 DOM 写入前完成 rect 测量</span></div></div><div><div><div>6</div></div><div><span>  </span><span>updateReadingProgress</span><span>();</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>  </span><span>// 根据滚动位置动态更新侧边栏 sticky 间距</span></div></div><div><div><div>9</div></div><div><span>  </span><span>updateSidebarStickySpacing</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><strong>位置很关键</strong>：必须在所有 DOM 写入之前完成 rect 测量。<code>scrollTop</code> 读取已经把布局算干净了，此时 <code>getBoundingClientRect</code> 不会触发新的回流；如果挪到函数末尾（一堆 class 写入之后），每帧会多付一次强制布局。</p></blockquote></section><section><h3>Step 4：初始化（<code>src/utils/layout-init.ts</code>）<a href="#step-4初始化srcutilslayout-initts"><span>#</span></a></h3><p>两处改动。</p><p><strong>import 区</strong>加：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> { </span><span>initReadingProgress</span><span> } </span><span>from</span><span> </span><span>"@/utils/reading-progress"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong><code>initLayout()</code> 函数内</strong>，<code>initScroll()</code> 之后紧跟调用：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>  </span><span>initScroll</span><span>();</span></div></div><div><div><div>2</div></div><div><span>  </span><span>initReadingProgress</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><code>initLayout</code> 有 <code>window.__fireflyLayoutInit</code> 守卫只会执行一次，<code>initReadingProgress</code> 内部也有 <code>initialized</code> 双保险。注意顺序：<code>initScroll()</code> 注册监听后会立即调一次 <code>scrollFunction</code>，此时阅读进度尚未初始化、<code>enabled</code> 为 false 会静默跳过——紧接着的 <code>initReadingProgress()</code> 自己会补一次首帧计算，所以顺序必须是 <code>initScroll</code> 在前。</p></blockquote></section><section><h3>Step 5：顶部细条 DOM + 样式（2 个文件）<a href="#step-5顶部细条-dom--样式2-个文件"><span>#</span></a></h3><p><strong><code>src/layouts/Layout.astro</code></strong> —— 找到 Swup 导航进度条，在其<strong>后面</strong>加一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"progress-bar"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>    </span><span>{</span><span>siteConfig</span><span>.</span><span>post</span><span>.</span><span>readingProgress</span><span> </span><span>&amp;&amp;</span><span> &lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"reading-progress-bar"</span><span> </span><span>aria-hidden</span><span>=</span><span>"true"</span><span> /&gt;</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>（<code>siteConfig</code> 该文件本来就有 import，无需新增。）</p><p><strong><code>src/styles/transition.css</code></strong> —— 文件末尾追加：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* --------------------------------------------------------------------------</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>4b. Reading Progress Bar (post pages)</span></div></div><div><div><div>3</div></div><div><span><span>   </span></span><span>-------------------------------------------------------------------------- */</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>#reading-progress-bar</span><span> {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>--reading-progress-height</span><span>: </span><span><span>3</span><span>px</span></span><span>;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>position: </span><span>fixed</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>top: </span><span>0</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>left: </span><span>0</span><span>;</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>width: </span><span><span>100</span><span>%</span></span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>height: </span><span>var</span><span>(</span><span>--reading-progress-height</span><span>);</span></div></div><div><div><div>13</div></div><div><span>    </span><span>/* 低于 #progress-bar(9999)：切页时由导航进度条覆盖，同色无缝衔接 */</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>z-index: </span><span>9998</span><span>;</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>pointer-events: </span><span>none</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>background: </span><span>var</span><span>(</span><span>--primary</span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>transform-origin: </span><span>left</span><span> </span><span>center</span><span>;</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>transform: </span><span>scaleX</span><span>(</span><span>0</span><span>);</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>transition: opacity </span><span><span>0.3</span><span>s</span></span><span><span> </span><span>ease</span><span>;</span></span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>will-change: transform;</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>#reading-progress-bar</span><span>.visible</span><span> {</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>opacity: </span><span>1</span><span>;</span></div></div><div><div><div>26</div></div><div><span>}</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>/* 切页期间隐藏阅读进度条，避免旧页残值与导航进度条混显；visit:end 移除类后淡入 */</span></div></div><div><div><div>29</div></div><div><span>html</span><span>.is-page-transitioning</span><span> </span><span>#reading-progress-bar</span><span> {</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>31</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>样式要点：</p><ul>
<li><strong>z-index 9998 vs Swup 条的 9999</strong>：切页时导航进度条永远压在阅读条上面，两者同为 <code>var(--primary)</code> 颜色，视觉无缝</li>
<li><strong><code>transition</code> 只写了 <code>opacity</code></strong>：transform 不能加过渡，否则进度条会”追不上”滚动</li>
<li><strong><code>html.is-page-transitioning</code> 规则优先级 (1,1,1) 高于 <code>.visible</code> 的 (1,1,0)</strong>：切页时无需 JS 介入即可强制隐藏</li>
</ul></section><section><h3>Step 6：圆环徽章（<code>src/components/controls/FloatingControls.astro</code>）<a href="#step-6圆环徽章srccomponentscontrolsfloatingcontrolsastro"><span>#</span></a></h3><p>徽章放在右下角悬浮按钮栈的<strong>第一项</strong>（最顶部），样式与 <code>FloatingButton</code>（<code>.floating-btn</code>）同规格：3rem 方框、1rem 圆角、<code>--card-bg</code> 背景、12px 毛玻璃、细边框——远看就是第五个按钮，只是内部是圆环而非图标。</p><p><strong>import 区无需改动</strong>（徽章不依赖 i18n），frontmatter 里 <code>immersiveReadingEnabled</code> 之后加一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>readingProgressEnabled</span><span> </span><span>=</span><span><span> </span><span>siteConfig</span><span>.</span></span><span>post</span><span>.</span><span>readingProgress</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>模板</strong>里，<code>.floating-controls-container</code> 内第一项插入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"floating-controls-container"</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>  </span><span>{</span><span><span>readingProgressEnabled</span><span> </span></span><span>&amp;&amp;</span><span> (</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"reading-progress-badge"</span><span> </span><span>class</span><span>=</span><span>"reading-progress-badge card-base"</span><span> </span><span>aria-hidden</span><span>=</span><span>"true"</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>      </span></span><span>&lt;!-- 类名用 rp- 前缀：.ring 会撞 Tailwind 的 .ring 焦点环工具类（box-shadow 1px currentColor） --&gt;</span></div></div><div><div><div>5</div></div><div><span><span>      </span></span><span>&lt;</span><span>svg</span><span> </span><span>class</span><span>=</span><span>"rp-ring"</span><span> </span><span>viewBox</span><span>=</span><span>"0 0 36 36"</span><span>&gt;</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>&lt;</span><span>circle</span><span> </span><span>class</span><span>=</span><span>"rp-ring-track"</span><span> </span><span>cx</span><span>=</span><span>"18"</span><span> </span><span>cy</span><span>=</span><span>"18"</span><span> </span><span>r</span><span>=</span><span>"15"</span><span> /&gt;</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>&lt;</span><span>circle</span><span> </span><span>id</span><span>=</span><span>"reading-progress-ring"</span><span> </span><span>class</span><span>=</span><span>"rp-ring-value"</span><span> </span><span>cx</span><span>=</span><span>"18"</span><span> </span><span>cy</span><span>=</span><span>"18"</span><span> </span><span>r</span><span>=</span><span>"15"</span><span> /&gt;</span></div></div><div><div><div>8</div></div><div><span><span>      </span></span><span>&lt;/</span><span>svg</span><span>&gt;</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>&lt;</span><span>span</span><span> </span><span>id</span><span>=</span><span>"reading-progress-pct"</span><span>&gt;0%&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>)</span><span>}</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>&lt;</span><span>FloatingTOC</span><span> </span><span>headings</span><span>=</span><span>{</span><span><span>headings</span><span> </span></span><span>??</span><span> []</span><span>}</span><span> </span><span>encrypted</span><span>=</span><span>{</span><span>encrypted</span><span>}</span><span> /&gt;</span></div></div><div><div><div>13</div></div><div><span>  </span><span>&lt;!-- ……其余原有内容不动…… --&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong><code>&lt;style&gt;</code> 块开头</strong>（<code>.floating-controls-container</code> 规则之前）插入徽章样式：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>  </span><span>/*</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>* 阅读进度圆环：与 .floating-btn 同规格的方框。背景/圆角/overflow 交给 card-base——</span></div></div><div><div><div>3</div></div><div><span><span>   </span></span><span>* 卡片透明度、卡片边框等全局设置都通过 .card-base 类挂钩，必须带上才能跟随这些设置。</span></div></div><div><div><div>4</div></div><div><span><span>   </span></span><span>* 本组件只补尺寸、毛玻璃、边框与内容布局。不可交互（pointer-events: none），无 hover。</span></div></div><div><div><div>5</div></div><div><span><span>   </span></span><span>*/</span></div></div><div><div><div>6</div></div><div><span>  </span><span>#reading-progress-badge</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>display: </span><span>none</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>place-items: </span><span>center</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>position: </span><span>relative</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>width: </span><span><span>3</span><span>rem</span></span><span>;</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>height: </span><span><span>3</span><span>rem</span></span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>flex-shrink: </span><span>0</span><span>;</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>border: </span><span><span>1</span><span>px</span></span><span><span> </span><span>solid</span><span> </span></span><span>rgba</span><span>(</span><span>0</span><span>, </span><span>0</span><span>, </span><span>0</span><span>, </span><span>0.1</span><span>);</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>backdrop-filter: </span><span>blur</span><span>(</span><span><span>12</span><span>px</span></span><span>);</span></div></div><div><div><div>15</div></div><div><span>    </span><span>-webkit-backdrop-filter</span><span>: </span><span>blur</span><span>(</span><span><span>12</span><span>px</span></span><span>);</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>color: </span><span>var</span><span>(</span><span>--primary</span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>pointer-events: </span><span>none</span><span>;</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>user-select: </span><span>none</span><span>;</span></div></div><div><div><div>19</div></div><div><span>    </span><span>/* 环与文字的布局失效限制在徽章子树内，不污染正文布局 */</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>contain: </span><span>content</span><span>;</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>22</div></div><div>
</div></div><div><div><div>23</div></div><div><span>  </span><span>#reading-progress-badge</span><span>.show</span><span> {</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>display: </span><span>grid</span><span>;</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>  </span><span>/* 切页期间隐藏，visit:end 移除类并刷新后重新显示，避免旧页百分比残值闪现 */</span></div></div><div><div><div>28</div></div><div><span>  </span><span>:global(</span><span>html</span><span>.is-page-transitioning</span><span>) </span><span>#reading-progress-badge</span><span> {</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>display: </span><span>none</span><span>;</span></div></div><div><div><div>30</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span>  </span><span>/* SVG 与数字叠放（同 FloatingButton 双图标的手法：grid 同格） */</span></div></div><div><div><div>33</div></div><div><span>  </span><span>#reading-progress-badge</span><span> </span><span>.rp-ring</span><span>,</span></div></div><div><div><div>34</div></div><div><span>  </span><span>#reading-progress-badge</span><span> </span><span>#reading-progress-pct</span><span> {</span></div></div><div><div><div>35</div></div><div><span><span>    </span></span><span>grid-area: </span><span>1</span><span> / </span><span>1</span><span>;</span></div></div><div><div><div>36</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div><span>  </span><span>.rp-ring</span><span> {</span></div></div><div><div><div>39</div></div><div><span><span>    </span></span><span>width: </span><span><span>83</span><span>%</span></span><span>;</span></div></div><div><div><div>40</div></div><div><span><span>    </span></span><span>height: </span><span><span>83</span><span>%</span></span><span>;</span></div></div><div><div><div>41</div></div><div><span>    </span><span>/* 从 12 点方向起笔 */</span></div></div><div><div><div>42</div></div><div><span><span>    </span></span><span>transform: </span><span>rotate</span><span>(</span><span><span>-90</span><span>deg</span></span><span>);</span></div></div><div><div><div>43</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>44</div></div><div>
</div></div><div><div><div>45</div></div><div><span>  </span><span>.rp-ring-track</span><span>,</span></div></div><div><div><div>46</div></div><div><span>  </span><span>.rp-ring-value</span><span> {</span></div></div><div><div><div>47</div></div><div><span><span>    </span></span><span>fill: </span><span>none</span><span>;</span></div></div><div><div><div>48</div></div><div><span><span>    </span></span><span>stroke-width: </span><span>3</span><span>;</span></div></div><div><div><div>49</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>50</div></div><div>
</div></div><div><div><div>51</div></div><div><span>  </span><span>.rp-ring-track</span><span> {</span></div></div><div><div><div>52</div></div><div><span><span>    </span></span><span>stroke: </span><span>rgba</span><span>(</span><span>0</span><span>, </span><span>0</span><span>, </span><span>0</span><span>, </span><span>0.1</span><span>);</span></div></div><div><div><div>53</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>54</div></div><div>
</div></div><div><div><div>55</div></div><div><span>  </span><span>.rp-ring-value</span><span> {</span></div></div><div><div><div>56</div></div><div><span><span>    </span></span><span>stroke: </span><span>var</span><span>(</span><span>--primary</span><span>);</span></div></div><div><div><div>57</div></div><div><span><span>    </span></span><span>stroke-linecap: </span><span>round</span><span>;</span></div></div><div><div><div>58</div></div><div><span>    </span><span>/* 周长 2πr（r=15）≈ 94.2478；初始 dashoffset = 周长 = 0% */</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>stroke-dasharray: </span><span>94.2478</span><span>;</span></div></div><div><div><div>60</div></div><div><span><span>    </span></span><span>stroke-dashoffset: </span><span>94.2478</span><span>;</span></div></div><div><div><div>61</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>62</div></div><div>
</div></div><div><div><div>63</div></div><div><span>  </span><span>#reading-progress-pct</span><span> {</span></div></div><div><div><div>64</div></div><div><span><span>    </span></span><span>font-size: </span><span><span>0.6875</span><span>rem</span></span><span>;</span></div></div><div><div><div>65</div></div><div><span><span>    </span></span><span>font-weight: </span><span>600</span><span>;</span></div></div><div><div><div>66</div></div><div><span><span>    </span></span><span>line-height: </span><span>1</span><span>;</span></div></div><div><div><div>67</div></div><div><span><span>    </span></span><span>font-variant-numeric: </span><span>tabular-nums</span><span>;</span></div></div><div><div><div>68</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>69</div></div><div>
</div></div><div><div><div>70</div></div><div><span>  </span><span>:global(</span><span>.dark</span><span>) </span><span>#reading-progress-badge</span><span> {</span></div></div><div><div><div>71</div></div><div><span><span>    </span></span><span>border-color: </span><span>rgba</span><span>(</span><span>255</span><span>, </span><span>255</span><span>, </span><span>255</span><span>, </span><span>0.15</span><span>);</span></div></div><div><div><div>72</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>73</div></div><div>
</div></div><div><div><div>74</div></div><div><span>  </span><span>:global(</span><span>.dark</span><span>) </span><span>.rp-ring-track</span><span> {</span></div></div><div><div><div>75</div></div><div><span><span>    </span></span><span>stroke: </span><span>rgba</span><span>(</span><span>255</span><span>, </span><span>255</span><span>, </span><span>255</span><span>, </span><span>0.15</span><span>);</span></div></div><div><div><div>76</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>77</div></div><div>
</div></div><div><div><div>78</div></div><div><span>  </span><span>/* 尺寸断点跟随 FloatingButton（480 → 2.5rem，360 → 2rem，320 → 1.75rem） */</span></div></div><div><div><div>79</div></div><div><span>  </span><span>@media</span><span> (max-width: </span><span><span>480</span><span>px</span></span><span>) {</span></div></div><div><div><div>80</div></div><div><span>    </span><span>#reading-progress-badge</span><span> {</span></div></div><div><div><div>81</div></div><div><span><span>      </span></span><span>width: </span><span><span>2.5</span><span>rem</span></span><span>;</span></div></div><div><div><div>82</div></div><div><span><span>      </span></span><span>height: </span><span><span>2.5</span><span>rem</span></span><span>;</span></div></div><div><div><div>83</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>84</div></div><div>
</div></div><div><div><div>85</div></div><div><span>    </span><span>#reading-progress-pct</span><span> {</span></div></div><div><div><div>86</div></div><div><span><span>      </span></span><span>font-size: </span><span><span>0.625</span><span>rem</span></span><span>;</span></div></div><div><div><div>87</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>88</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>89</div></div><div>
</div></div><div><div><div>90</div></div><div><span>  </span><span>@media</span><span> (max-width: </span><span><span>360</span><span>px</span></span><span>) {</span></div></div><div><div><div>91</div></div><div><span>    </span><span>#reading-progress-badge</span><span> {</span></div></div><div><div><div>92</div></div><div><span><span>      </span></span><span>width: </span><span><span>2</span><span>rem</span></span><span>;</span></div></div><div><div><div>93</div></div><div><span><span>      </span></span><span>height: </span><span><span>2</span><span>rem</span></span><span>;</span></div></div><div><div><div>94</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>95</div></div><div>
</div></div><div><div><div>96</div></div><div><span>    </span><span>#reading-progress-pct</span><span> {</span></div></div><div><div><div>97</div></div><div><span><span>      </span></span><span>font-size: </span><span><span>0.5625</span><span>rem</span></span><span>;</span></div></div><div><div><div>98</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>99</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>100</div></div><div>
</div></div><div><div><div>101</div></div><div><span>  </span><span>@media</span><span> (max-width: </span><span><span>320</span><span>px</span></span><span>) {</span></div></div><div><div><div>102</div></div><div><span>    </span><span>#reading-progress-badge</span><span> {</span></div></div><div><div><div>103</div></div><div><span><span>      </span></span><span>width: </span><span><span>1.75</span><span>rem</span></span><span>;</span></div></div><div><div><div>104</div></div><div><span><span>      </span></span><span>height: </span><span><span>1.75</span><span>rem</span></span><span>;</span></div></div><div><div><div>105</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>106</div></div><div>
</div></div><div><div><div>107</div></div><div><span>    </span><span>#reading-progress-pct</span><span> {</span></div></div><div><div><div>108</div></div><div><span><span>      </span></span><span>font-size: </span><span><span>0.5</span><span>rem</span></span><span>;</span></div></div><div><div><div>109</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>110</div></div><div><span><span>  </span></span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>设计要点：</p><ul>
<li><strong>必须带 <code>card-base</code> 类</strong>：本站”卡片透明度”滑块的生效链是 <code>--card-transparent-opacity</code> → <code>--card-bg-transparent</code> → <code>main.css</code> 中的 <code>.wallpaper-transparent .card-base { background-color: var(--card-bg-transparent) !important }</code>——透明背景、卡片边框等全局设置<strong>全部通过 <code>.card-base</code> 类挂钩</strong>。徽章的背景/圆角/<code>overflow</code> 因此直接交给 <code>card-base</code> 提供（<code>--radius-large</code> 也等于 1rem），组件自身只写尺寸、毛玻璃、边框和内容布局；漏掉这个类，徽章就会游离在透明度体系之外，和其余按钮表现不一致</li>
<li><strong>与 <code>.floating-btn</code> 同规格</strong>：3rem / 1rem 圆角 / <code>--card-bg</code> / blur(12px) / 1px 细边框 / 尺寸断点（480→2.5rem、360→2rem、320→1.75rem）全部对齐，放进按钮栈毫无违和</li>
<li><strong>SVG 双圆环</strong>：<code>rp-ring-track</code> 是底环（淡色轨道），<code>rp-ring-value</code> 是进度环（<code>--primary</code> 色、圆头线帽）。整只 SVG <code>rotate(-90deg)</code> 让进度从 12 点方向起笔</li>
<li><strong>类名必须避开 Tailwind 工具类</strong>：最初用的 <code>.ring</code> 正好撞上 Tailwind 的 <code>.ring</code>（<code>box-shadow: 0 0 0 1px currentColor</code>），导致 SVG 外围出现一圈橙色直角描边——详见坑 6</li>
<li><strong><code>stroke-dasharray</code> + <code>dashoffset</code></strong>：<code>dasharray = 周长</code> 把圆环变成一条整长虚线，<code>dashoffset</code> 从周长（完全看不见 = 0%）收到 0（完整闭合 = 100%）</li>
<li><strong>数字与环同格叠放</strong>：<code>grid-area: 1 / 1</code>——与 <code>FloatingButton</code> 双图标（default/toggled 两个 SVG 同格切换）同一手法</li>
<li><strong><code>font-variant-numeric: tabular-nums</code></strong>：等宽数字防止 9%→10% 时文字宽度抖动</li>
<li><strong><code>aria-hidden="true"</code></strong>：纯视觉进度，避免读屏被频繁变化的数字打扰</li>
<li><strong>默认 <code>display: none</code>，<code>.show</code> 才 <code>grid</code></strong>：非文章页徽章完全不占位，悬浮按钮栈不留空隙</li>
<li><strong>Astro 作用域样式</strong>：<code>.dark</code> 和 <code>html.is-page-transitioning</code> 是全局祖先选择器，必须用 <code>:global()</code> 包裹，否则匹配不到</li>
</ul><p>至此全部改动完成。</p><hr /></section></section><section><h2>四、验证<a href="#四验证"><span>#</span></a></h2><section><h3>1. 静态检查<a href="#1-静态检查"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>type-check</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>预期</strong>：与本功能相关的文件 0 错误。注意本仓库存在一批<strong>存量错误</strong>（与本功能无关，改动前就存在），复现时不要误判：</p><ul>
<li><code>en/ja/ko/ru/zh_TW.ts</code>：缺 <code>siteStatsTotalViews</code> / <code>siteStatsTotalVisitors</code> 两个旧键（只有 zh_CN 有）</li>
<li><code>anime.astro</code> / <code>vndb.astro</code>：引用了不存在的 <code>I18nKey.anime*</code>、<code>siteConfig.anime</code>、<code>vndb.nsfw</code></li>
</ul><p>如果在核心模块遇到 <code>TS2322: Type 'HTMLElement | null' is not assignable to type 'SVGCircleElement'</code>，说明漏了 Step 2 里的 <code>as SVGCircleElement | null</code> 断言。</p></section><section><h3>2. 手动功能验证<a href="#2-手动功能验证"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>dev</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>打开任意文章页（如 <code>/posts/markdown-extended/</code>），逐项核对：</p>

<table><thead><tr><th>#</th><th>操作</th><th>预期</th></tr></thead><tbody><tr><td>1</td><td>页面加载</td><td>顶部无进度条、右下角无圆环徽章</td></tr><tr><td>2</td><td>向下滚动</td><td>顶部 3px 细条从左往右生长；徽章圆环从 12 点方向顺时针填充，中心数字 <code>N%</code> 实时跳动</td></tr><tr><td>3</td><td>滚到正文结束（评论区上方）</td><td>细条满宽、圆环闭合成整圆、数字停在 <code>100%</code></td></tr><tr><td>4</td><td>滚回顶部</td><td>细条缩回、圆环清空、数字回 <code>0%</code></td></tr><tr><td>5</td><td>导航到首页</td><td>徽章和细条消失</td></tr><tr><td>6</td><td>从首页点回文章（Swup 软导航）</td><td>徽章重新出现且数值正确，<strong>无旧值闪现</strong></td></tr><tr><td>7</td><td>两篇文章之间切换</td><td>数值立即反映新文章，不残留上一篇的百分比</td></tr><tr><td>8</td><td>切换暗色模式</td><td>方框边框与底环切换为暗色配色（<code>rgba(255,255,255,0.15)</code>）</td></tr><tr><td>9</td><td>开启沉浸阅读再退出</td><td>进度立即重算（监听了 <code>immersiveReadingChange</code>）</td></tr><tr><td>10</td><td>窗口缩放 / 手机模拟器改视口</td><td>进度立即重算；480px 以下徽章缩为 2.5rem、360px 缩为 2rem</td></tr><tr><td>11</td><td>对比右下角其余按钮</td><td>同尺寸、同圆角、同背景毛玻璃、同边框，视觉上是”第五个按钮”</td></tr></tbody></table></section><section><h3>3. 关闭功能验证<a href="#3-关闭功能验证"><span>#</span></a></h3><p>把 <code>src/config/siteConfig.ts</code> 中 <code>readingProgress</code> 改为 <code>false</code> 后：细条和徽章的 DOM 都不再渲染，控制台无报错（<code>initReadingProgress</code> 查不到元素后静默空转）。</p><hr /></section></section><section><h2>五、避坑记录<a href="#五避坑记录"><span>#</span></a></h2><section><h3>坑 1：<code>pnpm lint</code> 会顺手改你的无关文件<a href="#坑-1pnpm-lint-会顺手改你的无关文件"><span>#</span></a></h3><p>本仓库的 lint 脚本是 <code>biome check --write ./src ./scripts</code>——<strong>带 <code>--write</code> 的全局自动修复</strong>。实际执行时它顺带格式化了 21 个与本功能无关的文件（music-player、各种 config 等），不得不逐一 <code>git checkout</code> 回滚。</p><p>复现时建议只对自己的文件做<strong>只读</strong>校验：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>biome</span><span> </span><span>check</span><span> </span><span>src/utils/reading-progress.ts</span><span> </span><span>src/utils/scroll-utils.ts</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>或者先 <code>git stash</code> 记下基线，跑完 lint 后用 <code>git diff --stat</code> 检查波及范围。</p></section><section><h3>坑 2：i18n 加键必须六个语言文件同步<a href="#坑-2i18n-加键必须六个语言文件同步"><span>#</span></a></h3><p>本功能最终没有引入任何 i18n 文案（圆环只显示数字），但仓库里 <code>pnpm type-check</code> 会报一批 <code>TS2739</code> 存量错误——成因是 <code>src/i18n/translation.ts</code> 的 <code>Translation</code> 类型是全键映射：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>type</span><span> </span><span>Translation</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>[</span><span>K</span><span> </span><span>in</span><span> </span><span>I18nKey</span><span>]</span><span>:</span><span> </span><span>string</span><span>;</span></div></div><div><div><div>3</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>每加一个枚举键，六个语言文件必须同时补全</strong>，漏一个就报错（存量 <code>siteStats*</code> 错误正是只加了枚举和 zh_CN）。复现本文时不需要动 i18n，但以后加文案要记住这条。</p></section><section><h3>坑 3：进度条的调用位置不能挪<a href="#坑-3进度条的调用位置不能挪"><span>#</span></a></h3><p><code>updateReadingProgress()</code> 必须放在 <code>scrollFunction</code> 中<strong>所有 class 写入之前</strong>。本站滚动管线的约定是”先批量读、再批量写”（写操作 deferred 到嵌套 rAF），如果把 rect 测量挪到写操作之后，每帧都会强制重排一次——正是 <code>swup-transitions.ts</code> 注释里警告过的”大型文章 DOM 同步重排”问题。</p></section><section><h3>坑 4：<code>transition</code> 属性别写成 <code>all</code><a href="#坑-4transition-属性别写成-all"><span>#</span></a></h3><p>顶部条 CSS 若写 <code>transition: all .3s</code>，<code>transform</code> 也会被过渡——进度条会滞后于滚动约 300ms。必须显式只写 <code>transition: opacity .3s</code>。</p></section><section><h3>坑 5：SVG 半径三处联动<a href="#坑-5svg-半径三处联动"><span>#</span></a></h3><p>圆环半径 <code>r=15</code> 出现在三个地方，改一个必须同步改全部：</p><ol>
<li>模板 <code>viewBox="0 0 36 36"</code> 与 <code>r="15"</code></li>
<li>核心模块 <code>const RING_C = 2 * Math.PI * 15</code></li>
<li>样式 <code>.rp-ring-value { stroke-dasharray: 94.2478 }</code>（= 2π×15）</li>
</ol><p>任何一处不一致，圆环都会填不满或超过 100%。</p></section><section><h3>坑 6：类名撞了 Tailwind 工具类（真实踩坑）<a href="#坑-6类名撞了-tailwind-工具类真实踩坑"><span>#</span></a></h3><p>初版给 SVG 用的类名是 <code>.ring</code>，结果徽章外围出现一圈<strong>橙色直角描边</strong>，与预期的圆角按钮完全不像。排查过程值得记录：</p><ol>
<li>先怀疑自己的 <code>border</code>/<code>border-radius</code> 没生效——抓取页面编译后的 scoped CSS，规则完整输出，排除</li>
<li>再全局搜索 <code>.ring</code> 的定义——<strong>实锤</strong>：页面 CSS（Tailwind v4）里有工具类</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.ring</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>--tw-ring-shadow</span><span>: ... </span><span>0</span><span> </span><span>0</span><span> </span><span>0</span><span> </span><span>calc</span><span>(</span><span><span>1</span><span>px</span></span><span><span> </span><span>+</span><span> </span></span><span>var</span><span>(</span><span>--tw-ring-offset-width</span><span>)) </span><span>var</span><span>(</span><span>--tw-ring-color</span><span>, </span><span>currentcolor</span><span>);</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>box-shadow: </span><span>var</span><span>(</span><span>--tw-ring-shadow</span><span>);</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Tailwind 的 <code>.ring</code> 是焦点环工具类：<code>box-shadow 0 0 0 1px currentColor</code>。三个要素全部命中截图症状：</p><ul>
<li><code>currentColor</code> = 元素的 <code>color</code> = <code>var(--primary)</code> = <strong>橙色</strong></li>
<li><code>box-shadow</code> 扩散出的轮廓是矩形（SVG 元素没有圆角）→ <strong>直角</strong></li>
<li>扩散只 1px 且紧贴 SVG 边界 → 比按钮<strong>小一圈</strong>的”奇怪方框”</li>
</ul><p>而且我 scoped 里的 <code>.ring</code> 规则只设了 <code>width/height/transform</code>，没设 <code>box-shadow</code>——两条规则各管各的属性，scoped 样式”生效了”也压不掉它。</p><p><strong>修复</strong>：类名换 <code>rp-</code> 前缀（<code>rp-ring</code> / <code>rp-ring-track</code> / <code>rp-ring-value</code>），彻底避开 Tailwind 命名空间。JS 用的是 <code>id="reading-progress-ring"</code>（id 不与类名冲突），核心模块无需改动。</p><p><strong>教训</strong>：这个项目全局启用 Tailwind，起 class 名前先搜一遍目标名字是否是 Tailwind utility（<code>ring</code>、<code>grid</code>、<code>container</code>、<code>hidden</code>、<code>group</code>……都是雷区）；组件内自定义类统一加项目前缀最稳。</p><hr /></section></section><section><h2>六、文件清单速查<a href="#六文件清单速查"><span>#</span></a></h2><p>复现顺序建议：<strong>配置类型 → 核心模块 → 滚动管线 → 初始化 → 顶部条 → 圆环徽章 → 验证</strong>。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>src/types/siteConfig.ts                        +2    类型开关</span></div></div><div><div><div>2</div></div><div><span>src/config/siteConfig.ts                       +2    配置开关</span></div></div><div><div><div>3</div></div><div><span>src/utils/reading-progress.ts                  新增   核心逻辑</span></div></div><div><div><div>4</div></div><div><span>src/utils/scroll-utils.ts                      +4    每帧调用点</span></div></div><div><div><div>5</div></div><div><span>src/utils/layout-init.ts                       +2    初始化</span></div></div><div><div><div>6</div></div><div><span>src/layouts/Layout.astro                       +1    顶部条 DOM</span></div></div><div><div><div>7</div></div><div><span>src/styles/transition.css                      +31   顶部条样式</span></div></div><div><div><div>8</div></div><div><span>src/components/controls/FloatingControls.astro +107  圆环徽章 DOM + 样式</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>（本文即为该功能的完整实现记录，欢迎对照复现。）</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-7/</id>
      <title type="text">更新公告20260928</title>
      <published>2026-09-28T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-7/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<section><h2>更新公告20260928<a href="#更新公告20260928"><span>#</span></a></h2><p>1.网站版本更新到6.16.7</p><p>2.新增<a href="https://azuma.mstzuomu.space/fcircle/" target="_blank"><strong>友圈</strong></a>功能，聚合各位友链的最新文章，数据来自友链们的RSS订阅，数据站部署在<a href="https://fc.mstzuomu.space/" target="_blank"><strong>fc.mstzuomu.space</strong></a></p><p>3.新增文章阅读进度功能：页面顶部显示细进度条，悬浮按钮实时显示已读百分比与预计剩余阅读时间，只统计正文内容，横幅、评论区和推荐文章不计入</p><p>4.新增沉浸阅读模式，阅读时可一键隐藏侧边栏等干扰元素，专注正文，配合独立的沉浸目录栏使用</p><p>5.新增文章系列功能，同一系列的文章可以通过系列导航连续阅读</p><p>6.导航栏支持static、fixed、dynamic三种模式，重构了移动端导航菜单，菜单抽屉加入毛玻璃效果</p><p>7.全屏壁纸支持classic、hero两种布局，可自由切换</p><p>8.更新了网站logo和默认字体</p><p>9.修复了移动端访客数据显示异常、友链评论区Markdown渲染、音乐播放器失效、亮暗色切换面板闪烁、MDX中mermaid图表报错等问题</p><p>10.完善了SEO与结构化数据，整理了文章分类，更新了友链</p><p>11.重构了三个Markdown嵌入组件（PDF阅读器、文章内音乐播放器、哔哩哔哩卡片）：样式统一收进框架的Markdown扩展样式体系，按页面按需加载；颜色全部改用主题变量，暗色模式与主题色现在能正确跟随；文件去重，清理了散落在public目录的手写样式和脚本引入；修复文章音乐播放器在站内切页时初始化失效、歌曲信息不加载、切页后音乐不停止的问题</p></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-12/</id>
      <title type="text">Firefly 博客接入友链朋友圈：从零开始的完整部署教程</title>
      <published>2026-09-26T00:00:00.000Z</published>
      <updated>2026-09-26T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-12/"/>
      <summary type="text">手把手教你为博客搭建自动更新的友链朋友圈，含全部代码改动与实战踩坑排查</summary>
      <content type="html"><![CDATA[<section><h1>Firefly 博客接入「友链朋友圈」完整部署教程<a href="#firefly-博客接入友链朋友圈完整部署教程"><span>#</span></a></h1><blockquote><p>适用对象：基于 <strong>Firefly（Astro）</strong> 主题的静态博客 + <strong>Friend-Circle-Lite</strong>（下称 FCL）友圈数据服务。
本文是实战复盘：每一步、每一处代码改动都来自真实部署过程，照做即可从零跑通。
预计耗时：1～2 小时。难度：会复制粘贴、会用命令行敲几条命令即可（文中有兜底的网页操作替代方案）。</p></blockquote><hr /><section><h2>0. 先搞懂原理：三个部分怎么协作<a href="#0-先搞懂原理三个部分怎么协作"><span>#</span></a></h2><p>友链朋友圈不是”装进博客的插件”，而是一条独立的数据流水线：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>┌────────────────────────────┐</span></div></div><div><div><div>2</div></div><div><span>│ ① 你的博客仓库              │  展示层</span></div></div><div><div><div>3</div></div><div><span>│ Firefly (Astro)            │  /fcircle/ 页面</span></div></div><div><div><div>4</div></div><div><span>│ 每次构建生成 friend.json ───┼──┐  （你的友链名单）</span></div></div><div><div><div>5</div></div><div><span>└────────────────────────────┘  │</span></div></div><div><div><div>6</div></div><div><span><span>                                </span></span><span>▼</span></div></div><div><div><div>7</div></div><div><span>┌────────────────────────────┐  每 4 小时爬一次</span></div></div><div><div><div>8</div></div><div><span>│ ② 你的 FCL fork 仓库        │◄─┘ 读取 friend.json</span></div></div><div><div><div>9</div></div><div><span>│ GitHub Actions 定时爬虫      │──► 抓取每个友链的 RSS</span></div></div><div><div><div>10</div></div><div><span>│ 产出 all.json / link.json   │──► 推送到 page 分支</span></div></div><div><div><div>11</div></div><div><span>└──────────────┬─────────────┘</span></div></div><div><div><div>12</div></div><div><span><span>               </span></span><span>│ git push (自动)</span></div></div><div><div><div>13</div></div><div><span><span>               </span></span><span>▼</span></div></div><div><div><div>14</div></div><div><span>┌────────────────────────────┐</span></div></div><div><div><div>15</div></div><div><span>│ ③ Vercel 数据站             │  托管层</span></div></div><div><div><div>16</div></div><div><span>│ 托管 page 分支的静态文件      │  https://fc.你的域名/</span></div></div><div><div><div>17</div></div><div><span>│ 提供 all.json (带 CORS 头)  │</span></div></div><div><div><div>18</div></div><div><span>└──────────────┬─────────────┘</span></div></div><div><div><div>19</div></div><div><span><span>               </span></span><span>│ fetch 跨域读取</span></div></div><div><div><div>20</div></div><div><span><span>               </span></span><span>▼</span></div></div><div><div><div>21</div></div><div><span><span>        </span></span><span>/fcircle/ 页面渲染友圈文章流</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><strong>职责划分：</strong></p>

<table><thead><tr><th>部分</th><th>负责什么</th><th>技术栈</th></tr></thead><tbody><tr><td>① 博客仓库</td><td>新增 <code>/fcircle/</code> 页面；构建时生成友链名单 <code>friend.json</code></td><td>Astro + Firefly 主题</td></tr><tr><td>② FCL fork 仓库</td><td>每 4 小时定时爬取友链 RSS，生成数据文件</td><td>Python + GitHub Actions（免费）</td></tr><tr><td>③ Vercel</td><td>把数据文件托管成 HTTPS 静态站（带 CORS 头）</td><td>Vercel 免费版</td></tr></tbody></table><p><strong>最终效果</strong>：博客上出现「友圈」菜单和 <code>/fcircle/</code> 页面，自动聚合所有友链的最新文章，每 4 小时更新一次，全程无需人工干预。</p><hr /></section><section><h2>1. 准备工作<a href="#1-准备工作"><span>#</span></a></h2><p>开始前确认你有：</p><ol>
<li><strong>GitHub 账号</strong> —— 托管博客仓库和 FCL fork</li>
<li><strong>Vercel 账号</strong> —— 免费版即可（vercel.com 注册，可用 GitHub 登录）</li>
<li><strong>Cloudflare 账号 + 你自己的域名</strong> —— 博客已解析在 Cloudflare 上（本文以 <code>mstzuomu.space</code> 为例，换成你自己的）
<blockquote><p>国内访问者无法解析 <code>*.vercel.app</code>（被 DNS 污染），<strong>必须绑自定义域名</strong>，否则访客拉不到数据。</p></blockquote>
</li>
<li><strong>本机环境</strong>：
<ul>
<li>Node.js ≥ 22、pnpm ≥ 11（你跑得动 <code>pnpm dev</code> 就说明够了）</li>
<li>Git 基本操作（add / commit / push）</li>
<li>浏览器</li>
</ul>
</li>
</ol><p><strong>命令行注意（Windows）</strong>：教程里的 <code>curl</code> 命令请写成 <strong><code>curl.exe</code></strong>。PowerShell 里裸写 <code>curl</code> 会别名到 <code>Invoke-WebRequest</code>，参数不兼容会报错。</p><hr /></section><section><h2>2. 第一部分：博客侧代码改造<a href="#2-第一部分博客侧代码改造"><span>#</span></a></h2><p>改动清单（共 10 处，先看全貌再逐个做）：</p>

<table><thead><tr><th>#</th><th>文件</th><th>操作</th><th>作用</th></tr></thead><tbody><tr><td>2.1</td><td><code>scripts/generate-friend-json.ts</code></td><td><strong>新增</strong></td><td>从友链配置生成 FCL 需要的 <code>friend.json</code></td></tr><tr><td>2.2</td><td><code>package.json</code></td><td>修改</td><td>把生成脚本挂进 <code>pnpm build</code></td></tr><tr><td>2.3</td><td><code>public/friend.json</code></td><td>生成</td><td>执行脚本产出（会被发布到站点根目录）</td></tr><tr><td>2.4</td><td><code>src/config/fcircleConfig.ts</code></td><td><strong>新增</strong></td><td>友圈页面配置（数据站地址等）</td></tr><tr><td>2.5</td><td><code>src/config/index.ts</code></td><td>修改</td><td>导出新配置</td></tr><tr><td>2.6</td><td><code>src/pages/fcircle.astro</code></td><td><strong>新增</strong></td><td>友圈页面本体</td></tr><tr><td>2.7</td><td><code>src/types/siteConfig.ts</code> + <code>src/config/siteConfig.ts</code></td><td>修改</td><td>页面开关 <code>pages.fcircle</code></td></tr><tr><td>2.8</td><td><code>src/config/navBarConfig.ts</code></td><td>修改</td><td>导航栏「社交 → 友圈」菜单</td></tr><tr><td>2.9</td><td><code>public/fclite/fclite.js</code>、<code>fclite.css</code></td><td><strong>新增</strong></td><td>FCL 前端渲染脚本（自托管 + 按 2.9 做主题化改造）</td></tr><tr><td>2.10</td><td>—</td><td>验证</td><td>本地跑通 → 提交推送 → 线上验证</td></tr></tbody></table><section><h3>2.1 新增 <code>scripts/generate-friend-json.ts</code><a href="#21-新增-scriptsgenerate-friend-jsonts"><span>#</span></a></h3><p>FCL 的爬虫只认一种友链格式：<code>{"friends": [["站点名", "网址", "头像"], ...]}</code>。Firefly 的友链写在 <code>src/config/friendsConfig.ts</code> 里，所以需要一个脚本做转换。</p><p>新建文件，完整内容：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> </span><span>fs</span><span> </span><span>from</span><span> </span><span>"node:fs/promises"</span><span>;</span></div></div><div><div><div>2</div></div><div><span>import</span><span> </span><span>path</span><span> </span><span>from</span><span> </span><span>"node:path"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>import</span><span> { </span><span>getEnabledFriends</span><span> } </span><span>from</span><span> </span><span>"../src/config/friendsConfig"</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>// 生成 Friend-Circle-Lite 所需的友链数据文件</span></div></div><div><div><div>6</div></div><div><span>// 格式: { "friends": [["站点名", "站点地址", "头像地址"], ...] }</span></div></div><div><div><div>7</div></div><div><span>// 产物发布到站点根目录 (public/friend.json)，供 FCL 的 Action 定时拉取</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>const</span><span> </span><span>OUTPUT_FILE</span><span> </span><span>=</span><span><span> </span><span>path</span><span>.</span></span><span>join</span><span>(</span><span>"public"</span><span>, </span><span>"friend.json"</span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>main</span><span>() {</span></div></div><div><div><div>12</div></div><div><span>  </span><span>const</span><span> </span><span>friends</span><span> </span><span>=</span><span> </span><span>getEnabledFriends</span><span>();</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>  </span><span>const</span><span> </span><span>data</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>15</div></div><div><span>    </span><span>friends</span><span>:</span><span><span> </span><span>friends</span><span>.</span></span><span>map</span><span><span>((</span><span>item</span><span>) </span></span><span>=&gt;</span><span><span> [</span><span>item</span><span>.</span></span><span>title</span><span><span>, </span><span>item</span><span>.</span></span><span>siteurl</span><span><span>, </span><span>item</span><span>.</span></span><span>imgurl</span><span>]),</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>};</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>  </span><span>await</span><span><span> </span><span>fs</span><span>.</span></span><span>mkdir</span><span><span>(</span><span>path</span><span>.</span></span><span>dirname</span><span>(</span><span>OUTPUT_FILE</span><span>), { </span><span>recursive</span><span>:</span><span> </span><span>true</span><span> });</span></div></div><div><div><div>19</div></div><div><span>  </span><span>await</span><span><span> </span><span>fs</span><span>.</span></span><span>writeFile</span><span>(</span><span>OUTPUT_FILE</span><span>, </span><span>`</span><span>${</span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>data</span><span>,</span></span><span> </span><span>null</span><span>,</span><span> </span><span>2</span><span>)</span><span>}</span><span>\n</span><span>`</span><span>, </span><span>"utf-8"</span><span>);</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>console</span><span>.</span><span>log</span><span>(</span><span>`friend.json 已生成: </span><span>${</span><span>OUTPUT_FILE</span><span>}</span><span> (</span><span>${</span><span>friends</span><span>.</span><span>length</span><span>}</span><span> 个友链)`</span><span>);</span></div></div><div><div><div>22</div></div><div><span>}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>main</span><span>().</span><span>catch</span><span><span>((</span><span>error</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>console</span><span>.</span><span>error</span><span>(</span><span>"生成 friend.json 失败:"</span><span><span>, </span><span>error</span><span>);</span></span></div></div><div><div><div>26</div></div><div><span><span>  </span></span><span>process</span><span>.</span><span>exit</span><span>(</span><span>1</span><span>);</span></div></div><div><div><div>27</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p>它只导出 <code>enabled: true</code> 的友链，与 <code>/friends/</code> 页面看到的列表保持一致。以后你增删友链，只要正常改 <code>friendsConfig.ts</code>，其余全自动。</p></blockquote></section><section><h3>2.2 修改 <code>package.json</code><a href="#22-修改-packagejson"><span>#</span></a></h3><p>打开 <code>package.json</code> 的 <code>scripts</code>，做两处修改：</p><p><strong>改动 1</strong>：<code>build</code> 命令<strong>最前面</strong>插入生成步骤：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"build": "npx tsx scripts/generate-github-card-data.ts &amp;&amp; ..."</span></div></div><div><div><div>2</div></div><div><span>"build": "npx tsx scripts/generate-friend-json.ts &amp;&amp; npx tsx scripts/generate-github-card-data.ts &amp;&amp; ..."</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>改动 2</strong>：新增一个手动执行的命令（放在 <code>lqips</code> 旁边）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"lqips": "npx tsx scripts/generate-lqips.ts",</span></div></div><div><div><div>2</div></div><div><span>"friend-json": "npx tsx scripts/generate-friend-json.ts",</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>2.3 执行脚本生成 <code>public/friend.json</code><a href="#23-执行脚本生成-publicfriendjson"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>pnpm friend</span><span>-</span><span>json</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>预期输出：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>friend.json 已生成: public\friend.json (N 个友链)   ← N 为你的友链数量</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>打开 <code>public/friend.json</code> 确认内容长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"friends"</span><span>: [</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>[</span></div></div><div><div><div>4</div></div><div><span>      </span><span>"MIFENG BLOG"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"https://blog.imbee.top/"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>      </span><span>"https://blog.imbee.top/images/logo/logo.webp"</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>]</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>]</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>2.4 新增 <code>src/config/fcircleConfig.ts</code><a href="#24-新增-srcconfigfcircleconfigts"><span>#</span></a></h3><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 友链朋友圈 (Friend-Circle-Lite) 页面配置</span></div></div><div><div><div>2</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>fcircleConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// 页面标题</span></div></div><div><div><div>4</div></div><div><span>  </span><span>title</span><span>:</span><span> </span><span>"友链朋友圈"</span><span>,</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>  </span><span>// 页面描述</span></div></div><div><div><div>7</div></div><div><span>  </span><span>description</span><span>:</span><span> </span><span>"聚合朋友们的最新文章，数据来自各位友链的 RSS 订阅"</span><span>,</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>// FCL 数据站地址（fork 仓库 page 分支部署后的站点根地址，末尾必须带 /）</span></div></div><div><div><div>10</div></div><div><span>  </span><span>// 部署完成后把这里改成你自己的地址，例如 https://xxx.vercel.app/</span></div></div><div><div><div>11</div></div><div><span>  </span><span>apiUrl</span><span>:</span><span> </span><span>"https://YOUR-FCL-DOMAIN.vercel.app/"</span><span>,</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>  </span><span>// 每次加载文章数量</span></div></div><div><div><div>14</div></div><div><span>  </span><span>pageSize</span><span>:</span><span> </span><span>24</span><span>,</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>  </span><span>// 头像加载失败时的默认图片</span></div></div><div><div><div>17</div></div><div><span>  </span><span>errorImg</span><span>:</span><span> </span><span>"/favicon/sakura2.png"</span><span>,</span></div></div><div><div><div>18</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p>⚠️ <strong>先保持占位符不动</strong>，<code>apiUrl</code> 在第 6 章拿到数据站地址后再回填（本文示例最终填的是 <code>https://fc.mstzuomu.space/</code>）。</p></blockquote></section><section><h3>2.5 修改 <code>src/config/index.ts</code><a href="#25-修改-srcconfigindexts"><span>#</span></a></h3><p>加一行导出（保持字母序，放在 <code>dynamicConfig</code> 之后）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export { dynamicConfig } from "./dynamicConfig"; // 动态页面配置</span></div></div><div><div><div>2</div></div><div><span>export { fcircleConfig } from "./fcircleConfig"; // 友链朋友圈页面配置</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>2.6 新增 <code>src/pages/fcircle.astro</code>（页面本体）<a href="#26-新增-srcpagesfcircleastro页面本体"><span>#</span></a></h3><p>完整内容：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>import</span><span> { </span><span>Icon</span><span> } </span><span>from</span><span> </span><span>"astro-icon/components"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>import</span><span> { </span><span>fcircleConfig</span><span>, </span><span>siteConfig</span><span> } </span><span>from</span><span> </span><span>"@/config"</span><span>;</span></div></div><div><div><div>4</div></div><div><span>import</span><span> </span><span>MainGridLayout</span><span> </span><span>from</span><span> </span><span>"@/layouts/MainGridLayout.astro"</span><span>;</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>// 检查页面是否启用</span></div></div><div><div><div>7</div></div><div><span>if</span><span> (</span><span>!</span><span><span>siteConfig</span><span>.</span></span><span>pages</span><span>.</span><span>fcircle</span><span>) {</span></div></div><div><div><div>8</div></div><div><span>  </span><span>return</span><span><span> </span><span>Astro</span><span>.</span></span><span>redirect</span><span>(</span><span>"/404/"</span><span>);</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>const</span><span> </span><span>title</span><span> </span><span>=</span><span><span> </span><span>fcircleConfig</span><span>.</span></span><span>title</span><span>;</span></div></div><div><div><div>12</div></div><div><span>const</span><span> </span><span>description</span><span> </span><span>=</span><span><span> </span><span>fcircleConfig</span><span>.</span></span><span>description</span><span>;</span></div></div><div><div><div>13</div></div><div><span>---</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>&lt;</span><span>MainGridLayout</span><span> </span><span>title</span><span>=</span><span>{</span><span>title</span><span>}</span><span> </span><span>description</span><span>=</span><span>{</span><span>description</span><span>}</span><span>&gt;</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span></div></div><div><div><div>17</div></div><div><span>    </span><span>class</span><span>=</span><span>"flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32"</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>&gt;</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"card-base z-10 px-9 py-6 relative w-full"</span><span>&gt;</span></div></div><div><div><div>20</div></div><div><span>      </span><span>&lt;!-- 页面标题和描述 --&gt;</span></div></div><div><div><div>21</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"mb-4"</span><span>&gt;</span></div></div><div><div><div>22</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"flex items-center gap-3 mb-3"</span><span>&gt;</span></div></div><div><div><div>23</div></div><div><span><span>          </span></span><span>&lt;</span><span>div</span></div></div><div><div><div>24</div></div><div><span>            </span><span>class</span><span>=</span><span>"h-8 w-8 rounded-lg bg-(--primary) flex items-center justify-center text-white dark:text-black/70"</span></div></div><div><div><div>25</div></div><div><span><span>          </span></span><span>&gt;</span></div></div><div><div><div>26</div></div><div><span><span>            </span></span><span>&lt;</span><span>Icon</span><span> </span><span>name</span><span>=</span><span>"material-symbols:public"</span><span> </span><span>class</span><span>=</span><span>"text-[1.5rem]"</span><span> /&gt;</span></div></div><div><div><div>27</div></div><div><span><span>          </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>28</div></div><div><span><span>          </span></span><span>&lt;</span><span>div</span></div></div><div><div><div>29</div></div><div><span>            </span><span>class</span><span>=</span><span>"text-3xl font-bold text-neutral-900 dark:text-neutral-100"</span></div></div><div><div><div>30</div></div><div><span><span>          </span></span><span>&gt;</span></div></div><div><div><div>31</div></div><div><span>            </span><span>{</span><span>title</span><span>}</span></div></div><div><div><div>32</div></div><div><span><span>          </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>33</div></div><div><span><span>        </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>34</div></div><div><span>        </span><span>{</span></div></div><div><div><div>35</div></div><div><span><span>          </span></span><span>description</span><span> </span><span>&amp;&amp;</span><span> (</span></div></div><div><div><div>36</div></div><div><span><span>            </span></span><span>&lt;</span><span>p</span><span> </span><span>class</span><span>=</span><span>"text-base text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4"</span><span>&gt;</span></div></div><div><div><div>37</div></div><div><span><span>              </span></span><span>{</span><span>description</span><span>}</span></div></div><div><div><div>38</div></div><div><span><span>            </span></span><span>&lt;/</span><span>p</span><span>&gt;</span></div></div><div><div><div>39</div></div><div><span><span>          </span></span><span>)</span></div></div><div><div><div>40</div></div><div><span>        </span><span>}</span></div></div><div><div><div>41</div></div><div><span><span>      </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>      </span><span>&lt;!-- Friend-Circle-Lite 友圈文章流 --&gt;</span></div></div><div><div><div>44</div></div><div><span><span>      </span></span><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"stylesheet"</span><span> </span><span>href</span><span>=</span><span>"/fclite/fclite.css"</span><span> /&gt;</span></div></div><div><div><div>45</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"friend-circle-lite-root"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>46</div></div><div><span><span>      </span></span><span>&lt;</span><span>script</span></div></div><div><div><div>47</div></div><div><span>        </span><span>is:inline</span></div></div><div><div><div>48</div></div><div><span>        </span><span>define:vars</span><span>=</span><span>{</span><span>{</span></div></div><div><div><div>49</div></div><div><span><span>          </span></span><span>apiUrl</span><span>:</span><span> </span><span>fcircleConfig</span><span>.</span><span>apiUrl</span><span>,</span></div></div><div><div><div>50</div></div><div><span><span>          </span></span><span>pageSize</span><span>:</span><span> </span><span>fcircleConfig</span><span>.</span><span>pageSize</span><span>,</span></div></div><div><div><div>51</div></div><div><span><span>          </span></span><span>errorImg</span><span>:</span><span> </span><span>fcircleConfig</span><span>.</span><span>errorImg</span><span>,</span></div></div><div><div><div>52</div></div><div><span><span>        </span></span><span>}</span><span>}</span></div></div><div><div><div>53</div></div><div><span><span>      </span></span><span>&gt;</span></div></div><div><div><div>54</div></div><div><span><span>        </span></span><span>window</span><span>.</span><span>UserConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>55</div></div><div><span>          </span><span>private_api_url</span><span>:</span><span><span> </span><span>apiUrl</span><span>,</span></span></div></div><div><div><div>56</div></div><div><span>          </span><span>page_turning_number</span><span>:</span><span><span> </span><span>pageSize</span><span>,</span></span></div></div><div><div><div>57</div></div><div><span>          </span><span>error_img</span><span>:</span><span><span> </span><span>errorImg</span><span>,</span></span></div></div><div><div><div>58</div></div><div><span><span>        </span></span><span>};</span></div></div><div><div><div>59</div></div><div><span><span>      </span></span><span>&lt;/</span><span>script</span><span>&gt;</span></div></div><div><div><div>60</div></div><div><span><span>      </span></span><span>&lt;</span><span>script</span><span> </span><span>is:inline</span><span> </span><span>src</span><span>=</span><span>"/fclite/fclite.js"</span><span>&gt;&lt;/</span><span>script</span><span>&gt;</span></div></div><div><div><div>61</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>62</div></div><div><span><span>  </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>63</div></div><div>
</div></div><div><div><div>64</div></div><div><span>  </span><span>&lt;!--</span></div></div><div><div><div>65</div></div><div><span><span>    </span></span><span>fclite.css 依赖 [data-theme=light]/[data-theme=dark] 切换亮暗色，</span></div></div><div><div><div>66</div></div><div><span><span>    </span></span><span>而本站的 data-theme 是代码高亮主题名（one-light/one-dark-pro），</span></div></div><div><div><div>67</div></div><div><span><span>    </span></span><span>亮暗切换实际由 html.dark 控制，这里做一层变量桥接。</span></div></div><div><div><div>68</div></div><div><span><span>  </span></span><span>--&gt;</span></div></div><div><div><div>69</div></div><div><span><span>  </span></span><span>&lt;</span><span>style</span><span> </span><span>is:global</span><span>&gt;</span></div></div><div><div><div>70</div></div><div><span>    </span><span>#friend-circle-lite-root</span><span> {</span></div></div><div><div><div>71</div></div><div><span>      </span><span>--text-color</span><span>: </span><span>var</span><span>(</span><span>--text-color-light</span><span>);</span></div></div><div><div><div>72</div></div><div><span>      </span><span>--background-color</span><span>: </span><span>var</span><span>(</span><span>--background-color-light</span><span>);</span></div></div><div><div><div>73</div></div><div><span>      </span><span>--tag-bg-color</span><span>: </span><span>#</span><span>bfbfbf</span><span>;</span></div></div><div><div><div>74</div></div><div><span>      </span><span>--container-bg-color</span><span>: </span><span>var</span><span>(</span><span>--container-bg-color-light</span><span>);</span></div></div><div><div><div>75</div></div><div><span>      </span><span>/* 副文本（日期、统计标签、页脚）统一到主题元信息色 */</span></div></div><div><div><div>76</div></div><div><span>      </span><span>--author-color</span><span>: </span><span>var</span><span>(</span><span>--content-meta</span><span>);</span></div></div><div><div><div>77</div></div><div><span>      </span><span>--shadow-color</span><span>: </span><span>var</span><span>(</span><span>--shadow-color-light</span><span>);</span></div></div><div><div><div>78</div></div><div><span>      </span><span>--border-color</span><span>: </span><span>var</span><span>(</span><span>--border-color-light</span><span>);</span></div></div><div><div><div>79</div></div><div><span>      </span><span>--modal-bg-color</span><span>: </span><span>rgba</span><span>(</span><span>255</span><span>, </span><span>255</span><span>, </span><span>255</span><span>, </span><span>0.5</span><span>);</span></div></div><div><div><div>80</div></div><div><span>      </span><span>--modal-content-bg-color</span><span>: </span><span>rgba</span><span>(</span><span>239</span><span>, </span><span>250</span><span>, </span><span>255</span><span>, </span><span>0.5</span><span>);</span></div></div><div><div><div>81</div></div><div><span>      </span><span>--load-more-btn-bg-color</span><span>: </span><span>var</span><span>(</span><span>--container-bg-color</span><span>);</span></div></div><div><div><div>82</div></div><div><span>      </span><span>/* 跟随站点主题色 */</span></div></div><div><div><div>83</div></div><div><span>      </span><span>--hover-color</span><span>: </span><span>var</span><span>(</span><span>--primary</span><span>);</span></div></div><div><div><div>84</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>85</div></div><div>
</div></div><div><div><div>86</div></div><div><span>    </span><span><span>:root</span><span>.dark</span></span><span> </span><span>#friend-circle-lite-root</span><span> {</span></div></div><div><div><div>87</div></div><div><span>      </span><span>--text-color</span><span>: </span><span>var</span><span>(</span><span>--text-color-dark</span><span>);</span></div></div><div><div><div>88</div></div><div><span>      </span><span>--background-color</span><span>: </span><span>var</span><span>(</span><span>--background-color-dark</span><span>);</span></div></div><div><div><div>89</div></div><div><span>      </span><span>--tag-bg-color</span><span>: </span><span>#</span><span>474747</span><span>;</span></div></div><div><div><div>90</div></div><div><span>      </span><span>--container-bg-color</span><span>: </span><span>var</span><span>(</span><span>--container-bg-color-dark</span><span>);</span></div></div><div><div><div>91</div></div><div><span>      </span><span>--author-color</span><span>: </span><span>var</span><span>(</span><span>--content-meta</span><span>);</span></div></div><div><div><div>92</div></div><div><span>      </span><span>--shadow-color</span><span>: </span><span>var</span><span>(</span><span>--shadow-color-dark</span><span>);</span></div></div><div><div><div>93</div></div><div><span>      </span><span>--border-color</span><span>: </span><span>var</span><span>(</span><span>--border-color-dark</span><span>);</span></div></div><div><div><div>94</div></div><div><span>      </span><span>--modal-bg-color</span><span>: </span><span>rgba</span><span>(</span><span>0</span><span>, </span><span>0</span><span>, </span><span>0</span><span>, </span><span>0.3</span><span>);</span></div></div><div><div><div>95</div></div><div><span>      </span><span>--modal-content-bg-color</span><span>: </span><span>rgba</span><span>(</span><span>20</span><span>, </span><span>20</span><span>, </span><span>20</span><span>, </span><span>0.5</span><span>);</span></div></div><div><div><div>96</div></div><div><span>      </span><span>--load-more-btn-bg-color</span><span>: </span><span>var</span><span>(</span><span>--container-bg-color</span><span>);</span></div></div><div><div><div>97</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>98</div></div><div><span><span>  </span></span><span>&lt;/</span><span>style</span><span>&gt;</span></div></div><div><div><div>99</div></div><div><span>&lt;/</span><span>MainGridLayout</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><strong>几个关键点解释（看懂可以更好 DIY）：</strong></p><ul>
<li><code>define:vars</code> 会把 <code>fcircleConfig</code> 里的值序列化进内联脚本，所以改配置文件就能改页面行为，不用动这个文件。</li>
<li><strong>顺序很重要</strong>：<code>window.UserConfig</code> 的 <code>&lt;script&gt;</code> 必须写在 <code>fclite.js</code> 之前——<code>fclite.js</code> 加载时会立即读取 <code>UserConfig</code>，读不到会直接报错。Firefly 的 Swup 页面切换会在每次进入页面时重新执行容器内脚本，所以从别的页面切过来也能正常渲染。</li>
<li>底部 <code>&lt;style is:global&gt;</code> 是<strong>亮暗色桥接</strong>：FCL 原版样式用 <code>[data-theme=light/dark]</code> 切色，而 Firefly 的 <code>data-theme</code> 装的是代码高亮主题名（<code>one-light</code>/<code>one-dark-pro</code>），真正控制暗色的是 <code>html.dark</code> 类。不加这段，暗色模式下友圈样式会错乱。</li>
</ul></section><section><h3>2.7 页面开关：<code>pages.fcircle</code><a href="#27-页面开关pagesfcircle"><span>#</span></a></h3><p><strong>文件 1</strong> <code>src/types/siteConfig.ts</code>，在 <code>pages</code> 类型里加一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>pages: {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>booknav: boolean; // 书签导航页面开关</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>friends: boolean; // 友链页面开关</span></div></div><div><div><div>4</div></div><div><span><span><span>  </span></span><span>fcircle: boolean; // 友链朋友圈页面开关</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>文件 2</strong> <code>src/config/siteConfig.ts</code>，在 <code>resolvePageToggles</code> 里加开关：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 友链页面开关</span></div></div><div><div><div>2</div></div><div><span>friends: true,</span></div></div><div><div><div>3</div></div><div><span>// 友链朋友圈页面开关（Friend-Circle-Lite）</span></div></div><div><div><div>4</div></div><div><span>fcircle: true,</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p>开关为 <code>false</code> 时：页面自动 404、导航菜单自动隐藏（前提是 2.8 的菜单项配了 <code>pageKey</code>）。还支持环境变量覆盖 <code>PUBLIC_PAGES_FCIRCLE=false</code>。</p></blockquote></section><section><h3>2.8 导航栏菜单：<code>src/config/navBarConfig.ts</code><a href="#28-导航栏菜单srcconfignavbarconfigts"><span>#</span></a></h3><p><strong>改动 1</strong>，在「社交」子菜单里、友链之后插入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 友链</span></div></div><div><div><div>2</div></div><div><span>LinkPresets.Friends,</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>// 友链朋友圈</span></div></div><div><div><div>5</div></div><div><span>LinkPresets.Fcircle,</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>// 留言</span></div></div><div><div><div>8</div></div><div><span>LinkPresets.Guestbook,</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>改动 2</strong>，在 <code>LinkPresets</code> 表里、<code>Friends</code> 之后新增条目：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Friends: {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>name: "友链",</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>url: "/friends/",</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>icon: "material-symbols:link-2-rounded",</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>pageKey: "friends",</span></div></div><div><div><div>6</div></div><div><span>},</span></div></div><div><div><div>7</div></div><div><span>Fcircle: {</span></div></div><div><div><div>8</div></div><div><span><span><span>  </span></span><span>name: "友圈",</span></span></div></div><div><div><div>9</div></div><div><span><span><span>  </span></span><span>url: "/fcircle/",</span></span></div></div><div><div><div>10</div></div><div><span><span><span>  </span></span><span>icon: "material-symbols:public",</span></span></div></div><div><div><div>11</div></div><div><span><span><span>  </span></span><span>pageKey: "fcircle",</span></span></div></div><div><div><div>12</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>2.9 自托管 FCL 前端文件 <code>public/fclite/</code>（含主题化改造）<a href="#29-自托管-fcl-前端文件-publicfclite含主题化改造"><span>#</span></a></h3><p>FCL 官方文档让你用 jsdelivr CDN 引脚本——<strong>国内不稳定，不要用</strong>。改为自托管：</p><ol>
<li>打开你 fork 的仓库（第 3 章会讲怎么 fork），进入 <code>main/</code> 目录</li>
<li>下载其中的 <strong><code>fclite.js</code></strong> 和 <strong><code>fclite.css</code></strong> 两个文件</li>
<li>在博客仓库新建目录 <code>public/fclite/</code>，把两个文件放进去</li>
</ol><p>（也可以从上游仓库拿：<code>https://github.com/willow-god/Friend-Circle-Lite</code> 的 <code>main/</code> 目录。）</p><p>拿到的<strong>原版</strong>文件是 FCL 自带画风（8px 圆角、灰底卡片、方角按钮），和 Firefly 主题不搭，而且<strong>不接入主题的卡片设置</strong>——拖透明度滑块、开关卡片边框对它完全无效。所以还要做两轮本地化改造（本文实战时改的，改处都留了「本地修改」注释方便日后比对）：</p><section><h4>改造 ①：卡片接入主题卡片系统（<code>card-base</code>）<a href="#改造-卡片接入主题卡片系统card-base"><span>#</span></a></h4><p><strong>原理</strong>：Firefly 的卡片透明度、卡片边框开关、主题色相底色全部通过 <code>.card-base</code> 类挂钩（规则在 <code>src/styles/main.css</code>：<code>.wallpaper-transparent .card-base</code> 用 <code>!important</code> 吃掉一切自写背景）。FCL 原版自己写死了背景/圆角/边框，把主题系统的值全压掉了。改法 = <strong>给卡片加上 <code>card-base</code> 类 + 删掉它自写的那三样属性</strong>。</p><p><code>fclite.js</code> 两处：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const randomArticleContainer = document.createElement('div');</span></div></div><div><div><div>2</div></div><div><span>randomArticleContainer.id = 'random-article';</span></div></div><div><div><div>3</div></div><div><span>randomArticleContainer.classList.add('card-base');</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>card.className = 'card';</span></div></div><div><div><div>2</div></div><div><span>card.className = 'card card-base';</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>fclite.css</code> 两处（只删下面标 <code>-</code> 的属性行，其余保留）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#random-article {</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>display: flex;</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>...</span></div></div><div><div><div>4</div></div><div><span><span><span>    </span></span><span>background-color: var(--container-bg-color);</span></span></div></div><div><div><div>5</div></div><div><span><span><span>    </span></span><span>border-radius: 8px;</span></span></div></div><div><div><div>6</div></div><div><span><span><span>    </span></span><span>border: 1px solid var(--border-color);</span></span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>padding: 20px;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.card {</span></div></div><div><div><div>2</div></div><div><span><span><span>    </span></span><span>background-color: var(--container-bg-color);</span></span></div></div><div><div><div>3</div></div><div><span><span><span>    </span></span><span>border-radius: 8px;</span></span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>padding: 12px;</span></div></div><div><div><div>5</div></div><div><span><span><span>    </span></span><span>border: 1px solid var(--border-color);</span></span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>position: relative;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>改完后的效果：底色 = <code>var(--card-bg)</code>（透明壁纸模式下自动改用滑块控制的 <code>--card-bg-transparent</code>）、圆角 = <code>--radius-large</code>（1rem，与全站卡片一致）、边框跟随「卡片边框」开关，开启时 hover 仍会变主题色高亮（原 fclite 的 hover 规则保留）。</p></section><section><h4>改造 ②：内层元素统一到主题设计语言<a href="#改造-内层元素统一到主题设计语言"><span>#</span></a></h4><p>以下都在 <code>fclite.css</code> 里改。<strong>颜色一律用主题变量</strong>（用之前先在 <code>src/styles/variables.styl</code> 里核对存在，防止幽灵变量），对照表：</p>

<table><thead><tr><th>选择器</th><th>对应页面元素</th><th>关键改动</th></tr></thead><tbody><tr><td><code>.stat-item</code></td><td>订阅/活跃/文章/失败 四个统计块</td><td>底色 → <code>var(--btn-regular-bg)</code>；边框 → <code>1px solid var(--line-divider)</code>；圆角 → <code>var(--radius-xl)</code>；hover 边框 → <code>color-mix(in srgb, var(--primary) 30%, transparent)</code></td></tr><tr><td><code>.card-author</code>、<code>.random-author</code>、<code>.random-date</code></td><td>名字/日期胶囊</td><td>改成 <code>category-pill</code> 同款：底 <code>color-mix(in srgb, var(--btn-content) 6%, transparent)</code>、边框 <code>color-mix(in srgb, var(--btn-content) 12%, transparent)</code>、<code>border-radius: var(--radius-full)</code>、文字 <code>var(--btn-content)</code>；hover 边框/文字 → <code>var(--primary)</code></td></tr><tr><td><code>.random-button-container a</code></td><td>「换一篇」按钮</td><td>底 <code>var(--btn-regular-bg)</code>、边框 <code>var(--line-divider)</code>、圆角 <code>var(--radius-full)</code>、文字 <code>var(--btn-content)</code>；hover 底 <code>var(--btn-regular-bg-hover)</code></td></tr><tr><td><code>.random-link-button</code></td><td>「阅读文章」按钮</td><td><code>border-radius: var(--radius-full)</code>（底色本来就是 <code>var(--primary)</code>，不动）</td></tr><tr><td><code>#load-more-btn</code></td><td>「再来亿点」加载按钮</td><td>套「换一篇」同款 btn-regular 胶囊方案</td></tr></tbody></table><p>最后回到 2.6 的 <code>fcircle.astro</code>：桥接块里 <code>--author-color</code> 两条映射改成 <code>var(--content-meta)</code>（代码已同步），日期文字、统计标签、页脚这些副文本就统一到主题元信息色，明暗自动适配。</p><blockquote><p>⚠️ 这些改造都在<strong>你博客仓库</strong>的 <code>public/fclite/</code> 里，不影响数据站。若日后从上游重新下载这两个文件，改造需要重新做一遍。</p></blockquote></section></section><section><h3>2.10 本地验证 → 推送上线<a href="#210-本地验证--推送上线"><span>#</span></a></h3><p><strong>本地验证（3 条命令）：</strong></p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm check          </span><span># Astro 诊断（既有的 i18n/anime 报错与本改动无关，可忽略）</span></div></div><div><div><div>2</div></div><div><span>pnpm lint           </span><span># Biome 检查</span></div></div><div><div><div>3</div></div><div><span>pnpm dev            </span><span># 启动后访问 http://localhost:4321/fcircle/</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>浏览器打开 <code>http://localhost:4321/fcircle/</code>，应看到：标题「友链朋友圈」、友圈数据加载中（此时 <code>apiUrl</code> 还是占位符，报加载失败属<strong>正常</strong>，第 6 章回填后就好）。
同时检查导航栏「社交」下多了「友圈」菜单。</p><p><strong>提交推送：</strong></p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>git add .</span></div></div><div><div><div>2</div></div><div><span><span>git commit </span><span>-</span><span>m </span></span><span>"feat: 新增友链朋友圈页面"</span></div></div><div><div><div>3</div></div><div><span>git push</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>推送后等 CI 构建完成（几分钟），然后用 <code>curl.exe</code> 线上验证两个关键地址：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 应返回 200，且内容是你的友链名单</span></div></div><div><div><div>2</div></div><div><span>curl.exe</span><span><span> </span><span>-</span><span>sL https:</span><span>//</span><span>你的域名</span><span>/</span><span>friend.json</span></span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 应返回 200</span></div></div><div><div><div>5</div></div><div><span>curl.exe</span><span><span> </span><span>-</span><span>o NUL </span><span>-</span><span>w </span></span><span>"%{http_code}"</span><span><span> https:</span><span>//</span><span>你的域名</span><span>/</span><span>fcircle</span><span>/</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><code>friend.json</code> 是 FCL 爬虫的<strong>输入</strong>，必须先上线，第 3 章的爬虫才能读到你的友链。<strong>顺序不能反。</strong></p></blockquote><hr /></section></section><section><h2>3. 第二部分：Fork Friend-Circle-Lite 并配置（4 处改动）<a href="#3-第二部分fork-friend-circle-lite-并配置4-处改动"><span>#</span></a></h2><section><h3>3.1 Fork 仓库<a href="#31-fork-仓库"><span>#</span></a></h3><ol>
<li>打开上游仓库：<code>https://github.com/willow-god/Friend-Circle-Lite</code></li>
<li>点右上角 <strong>Fork</strong> → Create fork（只 fork <code>main</code> 分支即可）</li>
<li>克隆到本地（换成你的用户名）：</li>
</ol><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>git clone https:</span><span>//</span></span><span>github.com</span><span><span>/</span><span>你的用户名</span><span>/</span><span>Friend</span><span>-</span><span>Circle</span><span>-</span><span>Lite.git</span></span></div></div><div><div><div>2</div></div><div><span><span>cd Friend</span><span>-</span><span>Circle</span><span>-</span><span>Lite</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3.2 改动 1：<code>conf.yaml</code>（4 处，最重要）<a href="#32-改动-1confyaml4-处最重要"><span>#</span></a></h3><p>这个文件是爬虫的总配置。<strong>只改值，不动结构</strong>：</p>

<table><thead><tr><th>配置项</th><th>原值（上游）</th><th>改成</th><th>说明</th></tr></thead><tbody><tr><td><code>spider_settings.json_url</code></td><td><code>"https://blog.liushen.fun/friend.json"</code></td><td><code>"https://你的域名/friend.json"</code></td><td><strong>你的</strong>友链名单地址</td></tr><tr><td><code>link_check.author_url</code></td><td><code>"blog.liushen.fun"</code></td><td><code>"你的域名"</code></td><td>反链检测用（只填域名不带 https）</td></tr><tr><td><code>rss_subscribe.enable</code></td><td><code>true</code></td><td><code>false</code></td><td>邮件订阅功能，用不到就关</td></tr><tr><td><code>rss_subscribe.github_username</code></td><td><code>willow-god</code></td><td><code>你的GitHub用户名</code></td><td>顺手改掉</td></tr><tr><td><code>rss_subscribe.your_blog_url</code></td><td><code>https://blog.liushen.fun/</code></td><td><code>https://你的域名/</code></td><td>顺手改掉</td></tr><tr><td><code>rss_subscribe.website_info.title</code></td><td><code>"清羽飞扬"</code></td><td><code>"你的博客标题"</code></td><td>顺手改掉</td></tr></tbody></table><p>对照 diff：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>spider_settings:</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>enable: true</span></div></div><div><div><div>3</div></div><div><span><span><span>  </span></span><span>json_url: "https://blog.liushen.fun/friend.json"</span></span></div></div><div><div><div>4</div></div><div><span><span><span>  </span></span><span>json_url: "https://你的域名/friend.json"</span></span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>article_count: 5</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>link_check:</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>...</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>enable_backlink_check: true</span></div></div><div><div><div>10</div></div><div><span><span><span>  </span></span><span>author_url: "blog.liushen.fun"</span></span></div></div><div><div><div>11</div></div><div><span><span><span>  </span></span><span>author_url: "你的域名"</span></span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>rss_subscribe:</span></div></div><div><div><div>14</div></div><div><span><span><span>  </span></span><span>enable: true</span></span></div></div><div><div><div>15</div></div><div><span><span><span>  </span></span><span>github_username: willow-god</span></span></div></div><div><div><div>16</div></div><div><span><span><span>  </span></span><span>enable: false</span></span></div></div><div><div><div>17</div></div><div><span><span><span>  </span></span><span>github_username: 你的GitHub用户名</span></span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>github_repo: Friend-Circle-Lite</span></div></div><div><div><div>19</div></div><div><span><span><span>  </span></span><span>your_blog_url: https://blog.liushen.fun/</span></span></div></div><div><div><div>20</div></div><div><span><span><span>  </span></span><span>your_blog_url: https://你的域名/</span></span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>...</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>website_info:</span></div></div><div><div><div>23</div></div><div><span><span><span>    </span></span><span>title: "清羽飞扬"</span></span></div></div><div><div><div>24</div></div><div><span><span><span>    </span></span><span>title: "你的博客标题"</span></span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p><strong>这是整个部署里最容易漏、漏了后果最严重的一步。</strong> 如果忘了改 <code>json_url</code>，爬虫会去爬上游作者的 200 多个友链，你的友圈页面会显示一堆陌生人（本文实战就踩过这个坑，第 9 章有排查方法）。</p></blockquote></section><section><h3>3.3 改动 2：新增 <code>static/vercel.json</code>（CORS 跨域头）<a href="#33-改动-2新增-staticverceljsoncors-跨域头"><span>#</span></a></h3><p>浏览器从你的博客域名跨域请求 Vercel 数据站，对方必须返回 <code>Access-Control-Allow-Origin: *</code>，否则数据拉不下来。</p><p>新建文件 <code>static/vercel.json</code>，完整内容：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"headers"</span><span>: [</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>      </span><span>"source"</span><span>: </span><span>"/(.*)"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"headers"</span><span>: [</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>7</div></div><div><span>          </span><span>"key"</span><span>: </span><span>"Access-Control-Allow-Origin"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>          </span><span>"value"</span><span>: </span><span>"*"</span></div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>},</span></div></div><div><div><div>10</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>11</div></div><div><span>          </span><span>"key"</span><span>: </span><span>"Access-Control-Allow-Methods"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>          </span><span>"value"</span><span>: </span><span>"GET, POST, OPTIONS, PUT, DELETE"</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>},</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>          </span><span>"key"</span><span>: </span><span>"Access-Control-Allow-Headers"</span><span>,</span></div></div><div><div><div>16</div></div><div><span>          </span><span>"value"</span><span>: </span><span>"*"</span></div></div><div><div><div>17</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span><span>      </span></span><span>]</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>]</span></div></div><div><div><div>21</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p>为什么放 <code>static/</code> 而不是仓库根目录？因为 Actions 工作流会把 <code>static/</code> 里的指定文件<strong>拷贝进 <code>page</code> 分支</strong>，而 Vercel 部署 <code>page</code> 分支时只认分支根目录的 <code>vercel.json</code>。下一步就是把这个文件加进拷贝清单。</p></blockquote></section><section><h3>3.4 改动 3：<code>.github/workflows/friend_circle_lite.yml</code><a href="#34-改动-3githubworkflowsfriend_circle_liteyml"><span>#</span></a></h3><p>找到 <code>Build static publish directory</code> 步骤里的 <code>cp</code> 命令，在清单里加上 <code>./static/vercel.json</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>- name: Build static publish directory</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>run: |</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>mkdir pages</span></div></div><div><div><div>4</div></div><div><span><span><span>    </span></span><span>cp -r main ./static/edgeone.json ./static/_headers ./static/index.html ./static/readme.md ./static/favicon.ico ./static/bg-light.webp ./static/bg-dark.webp all.json link.json errors.json pages/</span></span></div></div><div><div><div>5</div></div><div><span><span><span>    </span></span><span>cp -r main ./static/edgeone.json ./static/_headers ./static/vercel.json ./static/index.html ./static/readme.md ./static/favicon.ico ./static/bg-light.webp ./static/bg-dark.webp all.json link.json errors.json pages/</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3.5 改动 4：<code>.gitignore</code><a href="#35-改动-4gitignore"><span>#</span></a></h3><p>仓库的 <code>.gitignore</code> 忽略了所有 <code>*.json</code>（防止数据文件进主分支），需要给 <code>vercel.json</code> 开例外，否则它永远推不上去：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># 忽略数据文件</span></div></div><div><div><div>2</div></div><div><span>*.json</span></div></div><div><div><div>3</div></div><div><span>!edgeone.json</span></div></div><div><div><div>4</div></div><div><span>!vercel.json</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3.6 提交并推送<a href="#36-提交并推送"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>git add conf.yaml .gitignore .github</span><span>/</span><span>workflows</span><span>/</span><span>friend_circle_lite.yml </span></span><span>static</span><span><span>/</span><span>vercel.json</span></span></div></div><div><div><div>2</div></div><div><span><span>git commit </span><span>-</span><span>m </span></span><span>"fix: 友链数据源指向本站并为 page 分支添加 Vercel CORS 配置"</span></div></div><div><div><div>3</div></div><div><span>git push</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p>⚠️ <strong>核对清单</strong>：<code>git status</code> 确认这 4 个文件都进了提交。实战中曾出现”以为推了实际没推”的情况，导致爬虫用了上游的友链列表。推完可以去 GitHub 网页上点开 <code>conf.yaml</code> 确认 <code>json_url</code> 已经是你的域名。</p></blockquote><hr /></section></section><section><h2>4. 第三部分：启用 GitHub Actions，生成 <code>page</code> 分支<a href="#4-第三部分启用-github-actions生成-page-分支"><span>#</span></a></h2><p>FCL 的数据完全由 GitHub Actions 定时生成，部署在<strong>免费额度内</strong>（公开仓库无限，私有仓库每月 2000 分钟，这个爬虫每次不到 2 分钟，绰绰有余）。</p><section><h3>4.1 启用工作流（fork 有两道开关，都要点！）<a href="#41-启用工作流fork-有两道开关都要点"><span>#</span></a></h3><p><strong>开关 ①</strong>：打开 <code>https://github.com/你的用户名/Friend-Circle-Lite/actions</code>
顶部会有条黄色提示 <em>“Workflows aren’t being run on this forked repository…”</em> → 点 <strong>“I understand my workflows, go ahead and enable them”</strong>。</p><p><strong>开关 ②</strong>（很多人卡在这）：左侧工作流列表里，<code>Friend Circle Lite</code> 可能仍显示 <strong>「已禁用」</strong>（fork 特有的 <code>disabled_fork</code> 状态）——
→ 点击左侧的 <strong>Friend Circle Lite</strong> → 工作流页面点绿色 <strong>“Enable workflow”</strong> 按钮。</p><blockquote><p>只启用开关 ① 不启用开关 ②，手动触发会报 <code>422 Cannot trigger a 'workflow_dispatch' on a disabled workflow</code>。</p></blockquote></section><section><h3>4.2 手动运行一次<a href="#42-手动运行一次"><span>#</span></a></h3><ol>
<li>左侧点 <strong>Friend Circle Lite</strong></li>
<li>右上角 <strong>Run workflow</strong> → 下拉选 <code>main</code> → 点绿色 <strong>Run workflow</strong></li>
<li>等 1～2 分钟刷新，看到绿色 ✅ <strong>success</strong> 即可（首次运行要下载依赖，慢一点正常）</li>
</ol><p>这次运行会：</p><ul>
<li>读取你博客上线的 <code>friend.json</code>（你的友链名单）</li>
<li>检测每个友链可达性 + 抓取 RSS 文章</li>
<li>生成 <code>all.json</code> / <code>link.json</code> / <code>errors.json</code></li>
<li><strong>强制推送 <code>page</code> 分支</strong>（首次创建）</li>
</ul></section><section><h3>4.3 验证 <code>page</code> 分支<a href="#43-验证-page-分支"><span>#</span></a></h3><p>打开 <code>https://github.com/你的用户名/Friend-Circle-Lite/tree/page</code>，确认存在：</p><ul>
<li><code>all.json</code>（点开能看到 <code>"friends_num": 你的友链数</code>）</li>
<li><code>link.json</code></li>
<li><code>vercel.json</code>（<strong>重点</strong>：没有它 CORS 会挂）</li>
<li><code>main/</code>、<code>index.html</code> 等静态文件</li>
</ul><hr /></section></section><section><h2>5. 第四部分：Vercel 部署数据站<a href="#5-第四部分vercel-部署数据站"><span>#</span></a></h2><section><h3>5.1 导入仓库<a href="#51-导入仓库"><span>#</span></a></h3><ol>
<li>登录 <a href="https://vercel.com" target="_blank">vercel.com</a> → <strong>Add New… → Project</strong></li>
<li>在仓库列表里找到 <code>Friend-Circle-Lite</code> 点 <strong>Import</strong>（首次会让你安装/授权 Vercel 的 GitHub App，按提示授权）</li>
<li>构建配置全部保持默认即可（<code>main</code> 分支没有构建步骤，仓库根目录的 <code>vercel.json</code> 里 <code>deploymentEnabled.main: false</code> 会让它跳过 main 分支的部署——这是上游作者设计好的）</li>
<li>等导入完成</li>
</ol></section><section><h3>5.2 把生产分支改成 <code>page</code>（关键步骤）<a href="#52-把生产分支改成-page关键步骤"><span>#</span></a></h3><p>进入项目 → <strong>Settings → Environments → Production</strong>，找到 <strong>Branch Tracking</strong>：</p><ul>
<li>把分支从 <code>main</code> 改成 <strong><code>page</code></strong> → 点 <strong>Save</strong></li>
</ul><p><strong>两个坑：</strong></p><ol>
<li><strong>报错 <code>Branch "page" not found in the connected Git repository</code></strong> —— 说明第 4 章还没跑成功、<code>page</code> 分支不存在。回去先完成第 4 章再来。</li>
<li><strong>新版 Vercel 界面里没有单独的 “Production Branch” 选项</strong>（旧教程会误导你去 Git 页找）——它就在上面说的 Environments → Production → Branch Tracking 里。找不到就用 Settings 页左侧顶部的 <strong>Find</strong> 搜索框搜 <code>production</code>。</li>
</ol></section><section><h3>5.3 首次生产部署<a href="#53-首次生产部署"><span>#</span></a></h3><p>保存成功后 Vercel 通常会弹出 <strong>Redeploy</strong> 对话框：</p><ul>
<li>环境选 <strong>Production</strong></li>
<li>源部署选带 <code>page</code> 分支标记的那条（列表里最新的）</li>
<li>点 <strong>Redeploy</strong></li>
</ul><p>如果没弹窗，去 <strong>Deployments</strong> 页找到 <code>page</code> 分支的那条部署 → 右侧 <code>…</code> → <strong>Promote to Production</strong>。</p><p>完成后，项目的生产地址（形如 <code>friend-circle-lite-xxx.vercel.app</code>）就由 <code>page</code> 分支驱动了——<strong>以后 Actions 每次推送，Vercel 自动部署，无需任何手动操作。</strong></p></section><section><h3>5.4 绑定自定义子域名（国内必做）<a href="#54-绑定自定义子域名国内必做"><span>#</span></a></h3><blockquote><p>为什么必须：<code>*.vercel.app</code> 域名在国内被 DNS 污染（解析出假 IP），你的访客会全部加载失败。实测必须绑自有域名。</p></blockquote><p><strong>第 1 步 — Vercel 侧添加域名：</strong></p><p>项目 → <strong>Settings → Domains</strong> → 输入你想用的子域名（例：<code>fc.你的域名</code>）→ <strong>Add</strong>。
添加后 Vercel 会提示需要一条 CNAME 记录（目标是 <code>cname.vercel-dns.com</code>）。</p><p><strong>第 2 步 — Cloudflare 侧加 DNS 记录：</strong></p><p>Cloudflare 控制台 → 你的域名 → <strong>DNS → Records → Add record</strong>：</p>

<table><thead><tr><th>字段</th><th>填写</th></tr></thead><tbody><tr><td>Type</td><td><code>CNAME</code></td></tr><tr><td>Name</td><td><code>fc</code>（对应 <code>fc.你的域名</code> 的前缀）</td></tr><tr><td>Target</td><td><code>cname.vercel-dns.com</code></td></tr><tr><td>Proxy</td><td><strong>DNS only（一定选灰云，不要开橙色云代理）</strong></td></tr><tr><td>TTL</td><td>Auto</td></tr></tbody></table><p><strong>第 3 步 — 回 Vercel 等证书签发：</strong></p><p>DNS 生效通常 1～5 分钟。如果域名状态显示证书错误，点 <strong>Refresh</strong> / 重新保存一次域名即可触发重新签发。</p><p><strong>验证：</strong></p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 应返回 200，并且能看到 Access-Control-Allow-Origin: *</span></div></div><div><div><div>2</div></div><div><span>curl.exe</span><span><span> </span><span>-</span><span>sI https:</span><span>//</span><span>fc.你的域名</span><span>/</span><span>all.json</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应头里必须有这一行，缺了就回头检查 <code>page</code> 分支上有没有 <code>vercel.json</code>（第 3.3/3.4 步）。</p><hr /></section></section><section><h2>6. 第五部分：回填数据站地址，全线贯通<a href="#6-第五部分回填数据站地址全线贯通"><span>#</span></a></h2><p>打开 <code>src/config/fcircleConfig.ts</code>，把占位符换成你的真实数据站地址（<strong>末尾的 <code>/</code> 不能少</strong>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// FCL 数据站地址（fork 仓库 page 分支部署后的站点根地址，末尾必须带 /）</span></div></div><div><div><div>2</div></div><div><span>apiUrl: "https://YOUR-FCL-DOMAIN.vercel.app/",</span></div></div><div><div><div>3</div></div><div><span>apiUrl: "https://fc.你的域名/",</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>提交推送：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>git add src</span><span>/</span><span>config</span><span>/</span><span>fcircleConfig.ts</span></span></div></div><div><div><div>2</div></div><div><span><span>git commit </span><span>-</span><span>m </span></span><span>"feat: 回填友圈数据站地址"</span></div></div><div><div><div>3</div></div><div><span>git push</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>等博客 CI 部署完成（几分钟），最终验证：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 1. 页面 HTML 里已包含新地址</span></div></div><div><div><div>2</div></div><div><span>curl.exe</span><span><span> </span><span>-</span><span>sL https:</span><span>//</span><span>你的域名</span><span>/</span><span>fcircle</span><span>/</span><span> | findstr </span></span><span>"fc.你的域名"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 2. 数据接口 200 + CORS + 你的友链数</span></div></div><div><div><div>5</div></div><div><span>curl.exe</span><span><span> </span><span>-</span><span>sI https:</span><span>//</span><span>fc.你的域名</span><span>/</span><span>all.json</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>浏览器打开 <code>https://你的域名/fcircle/</code> —— 看到自己朋友们的最新文章，<strong>部署完成</strong> 🎉</p><blockquote><p>页面如果还显示旧数据/加载失败：FCL 前端有 <strong>10 分钟 localStorage 缓存</strong>，等 10 分钟再刷新，或开无痕窗口立即查看。</p></blockquote><hr /></section><section><h2>7. 成品验收清单<a href="#7-成品验收清单"><span>#</span></a></h2><p>逐项打勾，全过即部署成功：</p>

<table><thead><tr><th>#</th><th>检查项</th><th>验证方法</th><th>预期</th></tr></thead><tbody><tr><td>1</td><td>友链名单已上线</td><td><code>curl.exe -sL https://你的域名/friend.json</code></td><td>200，<code>friends</code> 数量 = 你的友链数</td></tr><tr><td>2</td><td>页面已上线</td><td>浏览器打开 <code>/fcircle/</code></td><td>200，标题「友链朋友圈」</td></tr><tr><td>3</td><td>导航菜单</td><td>导航栏「社交」下拉</td><td>有「友链」和「友圈」两项</td></tr><tr><td>4</td><td>Actions 正常</td><td>fork 的 Actions 页</td><td>最近一次运行是绿色 success</td></tr><tr><td>5</td><td><code>page</code> 分支</td><td><code>github.com/你/Friend-Circle-Lite/tree/page</code></td><td>有 all.json / link.json / vercel.json</td></tr><tr><td>6</td><td>数据站可达</td><td><code>curl.exe -sI https://fc.你的域名/all.json</code></td><td>200 + <code>Access-Control-Allow-Origin: *</code></td></tr><tr><td>7</td><td>数据是你的友链</td><td>浏览器打开 <code>https://fc.你的域名/all.json</code></td><td><code>friends_num</code> = 你的友链数，作者都是你朋友</td></tr><tr><td>8</td><td>文章渲染</td><td>无痕窗口打开 <code>/fcircle/</code></td><td>文章卡片正常显示，点「换一篇」「阅读文章」可用</td></tr><tr><td>9</td><td>自动更新</td><td>等一个 cron 周期（≤4 小时）后看 <code>last_updated_time</code></td><td>时间刷新</td></tr><tr><td>10</td><td>亮暗色</td><td>切换博客暗色模式</td><td>友圈样式跟随切换、无错乱</td></tr><tr><td>11</td><td>卡片跟随主题设置</td><td>拖「卡片透明度」滑块、开关「卡片边框」</td><td>统计面板与文章卡片底色/边框同步变化（未做 2.9 改造则此项不过）</td></tr></tbody></table><hr /></section><section><h2>8. 自动化之后：日常运行说明<a href="#8-自动化之后日常运行说明"><span>#</span></a></h2><p>部署完成后你<strong>什么都不用做</strong>，全自动链路：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>每 4 小时 (cron: 22 */4 * * * UTC，即北京时间 00:22 / 04:22 / 08:22 / 12:22 / 16:22 / 20:22)</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>└─ GitHub Actions 爬虫运行</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>├─ 检测友链可达性（结果缓存 24 小时，不会频繁骚扰友链站点）</span></div></div><div><div><div>4</div></div><div><span><span>        </span></span><span>├─ 抓取各站 RSS 最新文章</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>├─ 生成 all.json / link.json</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>└─ 推送 page 分支</span></div></div><div><div><div>7</div></div><div><span><span>             </span></span><span>└─ Vercel 自动生产部署 → fc.你的域名 更新</span></div></div><div><div><div>8</div></div><div><span><span>                  </span></span><span>└─ 博客 /fcircle/ 下次访问即是新数据</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>常见日常操作：</strong></p><ul>
<li><strong>改了友链配置</strong>：改 <code>friendsConfig.ts</code> → 推送博客（<code>friend.json</code> 自动更新）→ 下一个爬虫周期生效。想立即生效就去 fork 的 Actions 手动 Run workflow 一次。</li>
<li><strong>想手动立刻刷新数据</strong>：fork → Actions → Friend Circle Lite → Run workflow，约 90 秒完成并自动上线。</li>
<li><strong>看哪些友链挂了</strong>：打开 <code>https://fc.你的域名/</code>（FCL 自带的展示首页有可达性列表），或看博客友圈统计里的「失败」数。</li>
</ul><p><strong>数据字段含义</strong>（友圈顶部统计条）：</p>

<table><thead><tr><th>字段</th><th>含义</th></tr></thead><tbody><tr><td>订阅</td><td>友链总数</td></tr><tr><td>活跃</td><td>可达且能抓到 RSS 的站点数</td></tr><tr><td>文章</td><td>聚合的文章总数</td></tr><tr><td>失败</td><td>不可达 / 没有 RSS / 抓取失败的站点数（<strong>有几台挂了属正常现象</strong>，不是你的部署问题）</td></tr></tbody></table><hr /></section><section><h2>9. 踩坑排查表（本文实战遇到的全部问题）<a href="#9-踩坑排查表本文实战遇到的全部问题"><span>#</span></a></h2>

<table><thead><tr><th>现象</th><th>原因</th><th>解决</th></tr></thead><tbody><tr><td>Actions 页显示「无工作流程运行」，列表里工作流带「已禁用」</td><td>fork 仓库有<strong>两道开关</strong>：仓库级启用 + 单个工作流启用</td><td>第 4.1 章：先点 “I understand my workflows…”，再进工作流页点 “Enable workflow”</td></tr><tr><td>手动触发报 <code>422 Cannot trigger a 'workflow_dispatch' on a disabled workflow</code></td><td>同上，第二道开关没开</td><td>同上</td></tr><tr><td>Vercel 报 <code>Branch "page" not found</code></td><td><code>page</code> 分支还不存在</td><td>先成功运行一次 Actions（第 4 章），再回来保存</td></tr><tr><td>找不到 “Production Branch” 设置</td><td>Vercel 新版 UI 已移动位置</td><td>Settings → <strong>Environments → Production → Branch Tracking</strong>；或左侧 Find 搜 <code>production</code></td></tr><tr><td><strong>友圈显示的全是陌生人</strong>（200+ 订阅，作者都不认识）</td><td><code>conf.yaml</code> 的 <code>json_url</code> 还指向上游作者的友链列表（改动没推送成功）</td><td>改 <code>conf.yaml</code> → <code>git add</code> 确认 → 推送 → 手动 Run workflow → 核对 <code>page</code> 分支 <code>all.json</code> 的 <code>friends_num</code></td></tr><tr><td>友圈页面一直加载中/加载失败</td><td><code>apiUrl</code> 占位符没回填、末尾漏 <code>/</code>、或数据站 CORS 头缺失</td><td>第 6 章回填；<code>curl.exe -sI</code> 检查 <code>Access-Control-Allow-Origin: *</code>；确认 <code>page</code> 分支有 <code>vercel.json</code></td></tr><tr><td>数据站地址国内打不开</td><td><code>*.vercel.app</code> 被 DNS 污染</td><td>第 5.4 章绑定自定义子域名（CNAME 到 <code>cname.vercel-dns.com</code>，<strong>灰云</strong>）</td></tr><tr><td>页面数据是旧的</td><td>fclite.js 有 10 分钟 localStorage 缓存 + 浏览器缓存</td><td>等 10 分钟或无痕窗口刷新</td></tr><tr><td>友圈数据突然变回旧数据（或又出现陌生人文章）</td><td>Vercel 的 <strong>Redeploy 弹窗里选中了历史旧部署</strong>并发布，生产被回滚</td><td>Deployments 列表核对最新一条 <code>page</code> 部署的时间 → 对最新的那条执行 <code>… → Promote to Production</code>；别选日期是几天前的部署</td></tr><tr><td>证书签发失败 / 域名一直 Pending</td><td>DNS 还没生效</td><td>等 1～5 分钟，在 Vercel Domains 页 Refresh 重试；确认 Cloudflare 里是<strong>灰云</strong></td></tr><tr><td>PowerShell 里 <code>curl -sI</code> 报”参数无效”</td><td>PS5.1 把 <code>curl</code> 别名成 <code>Invoke-WebRequest</code></td><td>一律写 <strong><code>curl.exe</code></strong></td></tr><tr><td><code>pnpm check</code> / <code>type-check</code> 一堆报错</td><td>项目既有问题（i18n 缺键、anime/vndb 页）</td><td>与本教程无关，只看你新增文件有没有报错</td></tr><tr><td><code>pnpm lint</code> 把一堆无关文件改了格式</td><td><code>biome check --write</code> 会顺手格式化</td><td><code>git checkout -- &lt;无关文件&gt;</code> 还原，只保留自己的改动</td></tr><tr><td>本机构建报 <code>unable to verify the first certificate</code>（OG 图片生成）</td><td>本机网络环境的 TLS 证书链问题</td><td><code>set NODE_OPTIONS=--use-system-ca</code> 后重跑，或忽略（CI 上不会出现）</td></tr><tr><td>本地跑 FCL 爬虫报 <code>ZoneInfoNotFoundError: Asia/Shanghai</code></td><td>Windows 的 Python 缺 <code>tzdata</code> 包</td><td><code>pip install tzdata</code>（见附录 B）</td></tr></tbody></table><hr /></section><section><h2>10. 安全收尾（推荐）<a href="#10-安全收尾推荐"><span>#</span></a></h2><p>如果你在部署过程中用过 API Token（比如为了用 CLI 部署临时生成的 Vercel Token）：</p><ol>
<li>vercel.com → <strong>Settings → Tokens</strong> → 删除用完的 token</li>
<li>清理本机临时凭据文件（如果有）</li>
<li>检查 fork 仓库：<strong>Settings → Actions → General → Workflow permissions</strong> 确认是 <code>Read repository contents and permissions</code>（本教程的工作流文件里已显式声明 <code>contents: write</code>，够用，无需给默认权限开写）</li>
</ol><hr /></section><section><h2>附录 A：文件改动总清单<a href="#附录-a文件改动总清单"><span>#</span></a></h2><p><strong>博客仓库（FireflyPriWeb）：</strong></p>

<table><thead><tr><th>文件</th><th>操作</th></tr></thead><tbody><tr><td><code>scripts/generate-friend-json.ts</code></td><td>新增</td></tr><tr><td><code>package.json</code></td><td>修改（build 链 + friend-json 命令）</td></tr><tr><td><code>public/friend.json</code></td><td>脚本生成（勿手改）</td></tr><tr><td><code>src/config/fcircleConfig.ts</code></td><td>新增</td></tr><tr><td><code>src/config/index.ts</code></td><td>修改（+1 行导出）</td></tr><tr><td><code>src/pages/fcircle.astro</code></td><td>新增</td></tr><tr><td><code>src/types/siteConfig.ts</code></td><td>修改（pages.fcircle 类型）</td></tr><tr><td><code>src/config/siteConfig.ts</code></td><td>修改（pages.fcircle 开关）</td></tr><tr><td><code>src/config/navBarConfig.ts</code></td><td>修改（Fcircle 菜单项 ×2 处）</td></tr><tr><td><code>public/fclite/fclite.js</code>、<code>fclite.css</code></td><td>新增（从 fork 的 <code>main/</code> 目录复制，<strong>并按 2.9 完成两轮主题化改造</strong>）</td></tr></tbody></table><p><strong>FCL fork 仓库：</strong></p>

<table><thead><tr><th>文件</th><th>操作</th></tr></thead><tbody><tr><td><code>conf.yaml</code></td><td>修改（json_url / author_url / rss_subscribe 共 6 处值）</td></tr><tr><td><code>static/vercel.json</code></td><td>新增</td></tr><tr><td><code>.github/workflows/friend_circle_lite.yml</code></td><td>修改（cp 清单 +1 文件）</td></tr><tr><td><code>.gitignore</code></td><td>修改（<code>!vercel.json</code>）</td></tr></tbody></table><p><strong>平台侧配置（非代码）：</strong></p>

<table><thead><tr><th>位置</th><th>操作</th></tr></thead><tbody><tr><td>GitHub fork → Actions</td><td>启用仓库 workflows + 启用 Friend Circle Lite 工作流</td></tr><tr><td>Vercel 项目 → Environments</td><td>Production 分支 = <code>page</code></td></tr><tr><td>Vercel 项目 → Domains</td><td>添加 <code>fc.你的域名</code></td></tr><tr><td>Cloudflare DNS</td><td>CNAME <code>fc</code> → <code>cname.vercel-dns.com</code>（灰云）</td></tr><tr><td>博客 <code>fcircleConfig.apiUrl</code></td><td>回填数据站地址</td></tr></tbody></table><hr /></section><section><h2>附录 B（可选方案）：不用 GitHub Actions，本地 Python 爬虫手动部署<a href="#附录-b可选方案不用-github-actions本地-python-爬虫手动部署"><span>#</span></a></h2><p>适用于：想本地先跑通看看效果，或不想用 Actions 的场景。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>cd Friend</span><span>-</span><span>Circle</span><span>-</span><span>Lite</span></span></div></div><div><div><div>2</div></div><div><span><span>python </span><span>-</span><span>m venv .venv</span></span></div></div><div><div><div>3</div></div><div><span>.venv\Scripts\</span><span>python.exe</span><span><span> </span><span>-</span><span>m pip install </span><span>-</span><span>i https:</span><span>//</span><span>pypi.tuna.tsinghua.edu.cn</span><span>/</span><span>simple requests feedparser PyYAML jinja2 python</span><span>-</span><span>dateutil tzdata</span></span></div></div><div><div><div>4</div></div><div><span>.venv\Scripts\</span><span>python.exe</span><span> run.py        </span><span># 生成 all.json / link.json / errors.json</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意两点：</p><ul>
<li><strong>国内直连 PyPI 会超时</strong>，务必加清华镜像 <code>-i https://pypi.tuna.tsinghua.edu.cn/simple</code>；Windows 必须装 <code>tzdata</code>。</li>
<li>生成后把 <code>main/</code>、<code>static/</code> 下的指定文件、<code>vercel.json</code> 和三个 json 拷到一个目录，用 Vercel CLI 部署：</li>
</ul><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>npm install vercel </span><span>--</span><span>registry</span><span>=</span><span>https:</span><span>//</span></span><span>registry.npmmirror.com</span><span>   </span><span># npmjs 被墙时用 npmmirror</span></div></div><div><div><div>2</div></div><div><span><span>vercel deploy </span><span>--</span><span>prod </span><span>--</span><span>token</span><span>=</span><span>你的token</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>但此方式<strong>数据不会自动更新</strong>（每次都要手动跑），正式使用推荐本文正文的 GitHub Actions 方案。</p><hr /></section><section><h2>附录 C：把本文发布到你的博客<a href="#附录-c把本文发布到你的博客"><span>#</span></a></h2><p>想把这篇教程作为博客文章发布：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm </span><span>new-post</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>把本文内容粘进生成的 md 文件，补上 frontmatter（标题、日期、分类、标签），放 <code>src/content/posts/</code> 下推送即可。若你的博客启用了友圈失败站点的<strong>评论系统</strong>接入，按 Firefly 惯例在 frontmatter 加 <code>comment: true</code>。</p><hr /><p><em>教程基于实战部署整理：Firefly 6.16.7 + Friend-Circle-Lite（v2.x）+ Vercel Hobby + Cloudflare DNS。文中示例域名 <code>mstzuomu.space</code> / <code>fc.mstzuomu.space</code> 请替换为你自己的。</em></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-11/</id>
      <title type="text">MiMo-V2.6：扩展强化学习规模，迈向自我提升</title>
      <published>2026-09-22T00:00:00.000Z</published>
      <updated>2026-09-22T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-11/"/>
      <summary type="text">全新MIMO模型发布</summary>
      <content type="html"><![CDATA[<section><h1>MiMo-V2.6：扩展强化学习规模，迈向自我提升<a href="#mimo-v26扩展强化学习规模迈向自我提升"><span>#</span></a></h1><blockquote><p>来源：<a href="https://mimo.mi.com/docs/zh-CN/news/latest/v2-6" target="_blank">Xiaomi MiMo 文档中心</a> ｜ 更新时间：2026 年 09 月 22 日</p></blockquote><p>今天，我们正式发布并开源 Xiaomi MiMo-V2.6 系列。这是我们探索 RSI（递归自我改进）路径的关键一步：以可验证的复杂任务为基础，规模化扩展强化学习（RL）算力，让模型在持续的探索与反馈中不断拓展智能边界。</p><p><strong>夫夷以近，则游者众；险以远，则至者少</strong>。在一个智能容易被复制的时代，我们选择把算力投进真实环境，让模型在反馈中一次次试错、自己学会。这条路更慢，也更少被看见。MiMo-V2.6 这 6 天的 Live RL 训练，是我们在这条路上的一次公开跋涉；而这 6 天的背后，是长达半年的基础研究积累和工程试错。</p><p>MiMo-V2.6 系列包含 Pro 和 Flash 两个原生全模态模型。得益于 RL 算力的扩展，<strong>MiMo-V2.6-Pro 在 Artificial Analysis Intelligence Index（AA 综合智能指数）中取得 46 分，超过 Kimi K3 和 Qwen3.8 Max，成为当前最强的开源模型</strong>；但与最强的闭源模型 Claude Fable 5.1 和 GPT-6 Astra 相比，仍有差距。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/KYAEbDMMVoVHsexkfyOcv8HQnch.png" alt="综合智能指数对比" /><figcaption>综合智能指数对比</figcaption></figure><p></p><p>MiMo-V2.6 系列沿用 V2.5 系列的 API 定价。智能提升，价格不变，“智能-成本”的帕累托前沿由此再度向外推进。MiMo-V2.6-Pro 刷新了国产模型的性价比纪录：在同等智能水平下，价格仅为海外模型的 <strong>1/20 至 1/60</strong>。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/OaNDbA6znoG7HOxlWJJcWvsknHe.png" alt="智能与成本对比" /><figcaption>智能与成本对比</figcaption></figure><p></p><section><h2>大规模扩展 RL 并全面开源<a href="#大规模扩展-rl-并全面开源"><span>#</span></a></h2><p><strong>在 RL 训练阶段，MiMo-V2.6 可能是目前国产开源模型中投入算力最多的模型之一</strong>。经过大规模、多任务强化学习训练，MiMo-V2.6-Pro 在多数 Agent Benchmark 上取得了比肩 Claude Opus5 和 GPT-5.6 Sol 的效果，MiMo-V2.6-Flash 则全面超越 MiMo-V2.5-Pro。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/MhdfbCm3AokF6Hxakc6c0d5lnYb.png" alt="Agent Benchmark 对比" /><figcaption>Agent Benchmark 对比</figcaption></figure><p></p><p>整个过程中，我们攻克了 RL 训练中的基础研究与工程挑战，并通过 Live 分享正式实验历程。历时不到 6 天，MiMo-V2.6-Flash 与 MiMo-V2.6-Pro 训练成本分别约 85 万与 262 万美元，各完成 30 步，累计约 75 万条轨迹；训练任务平均通过率分别相对提升 25% 和 12%，样本外的长程软件工程评测基准 <strong>DeepSWE v1.1 分别提升约 17 分（48.8 → 65.7）和约 14 分（58.4 → 72.6）</strong>，体现出 RL 较高的样本效率、持续改进能力和样本外的泛化能力。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/PRlRbNId3oGwCwxi0wycqYjUnlc.png" alt="DeepSWE v1.1 提升" /><figcaption>DeepSWE v1.1 提升</figcaption></figure><p></p><p>本次训练主要从三个维度扩展 RL 算力：</p><ul>
<li><strong>更大的 Batch 与更高吞吐</strong>：结合大 Batch 和全异步架构，单次更新使用 <strong>1,568 个样本</strong>，支持 <strong>1M 上下文长度</strong>训练，单步训练 Token 达 <strong>3.5～3.7B</strong>。</li>
<li><strong>更多任务与复杂环境</strong>：构建覆盖 Code、General、Visual、Cyber 等方向的多任务训练体系，并混合多个 Harness，促进<strong>不同能力维度协同提升</strong>。</li>
<li><strong>更大的 Grader 算力</strong>：通过 Group 内相对比较，为 Long-Horizon RL 任务提供更精准、多样的奖励信号，形成模型自我改进闭环，并<strong>引导模型以更短路径、更少 Token 完成任务</strong>。</li>
</ul><p>随着训练规模扩大，我们冻结 MoE Router 以抑制专家负载漂移，并建立覆盖奖励设计、对抗性评测、异常检测和验证器交叉校验的 Reward Hacking 防线，提升训练稳定性与奖励可靠性。</p><p>为支撑大规模混合任务的智能体强化学习，我们设计统一的轨迹表示与惩罚机制来细化学习信号，支撑多种智能体框架的高并发交互，解耦控制面与数据面以支持海量轨迹的迁移，在混合批次中稳定各任务的样本配比，并优化训练与推理引擎的效率及它们的一致性。</p><p>我们已开源上述技术成果及配套资源，包括完整技术报告、训练环境与 RL 代码，帮助更多研究者复现和验证相关结果，共同探索规模化 RL 与模型自我改进的更多可能。</p></section><section><h2>从 Vibe Coding 到 Vibe World<a href="#从-vibe-coding-到-vibe-world"><span>#</span></a></h2><p>MiMo-V2.6 融合了 <strong>3D 空间推理、多模态感知与计算机操作（CUA）能力</strong>，进一步拓展了编程所能触及的边界，它可以将自然语言驱动的编程任务拓展为面向交互世界构建的”Vibe World”。</p><section><h3>3D 开放世界游戏<a href="#3d-开放世界游戏"><span>#</span></a></h3><p>在游戏开发中，用户输入图片、视频或文字后，MiMo-V2.6 会将需求拆解为多个任务，由多智能体协作完成游戏 3D 场景搭建、交互逻辑编写与视觉验证，并根据渲染结果不断修正，最终生成符合用户意图的可运行交互世界。</p><p></p></section><section><h3>Blender 3D 建模<a href="#blender-3d-建模"><span>#</span></a></h3><p>MiMo-V2.6 能够根据用户的文字描述或参考图片，在 Blender 中完成物体与场景的三维建模，生成可用于动画制作、3D 打印与游戏开发的 3D 资产。</p><p></p></section><section><h3>具身智能<a href="#具身智能"><span>#</span></a></h3><p>在具身仿真环境中，MiMo-V2.6 可以直接以多视角相机画面为输入，持续进行推理与决策，并通过视觉反馈闭环控制 Franka Panda 机械臂，完成物体抓取、颜色匹配与精准放置。</p><p></p></section><section><h3>Computer Use Agent<a href="#computer-use-agent"><span>#</span></a></h3><p>MiMo-V2.6 进一步拓展了 Computer Use 能力，将多模态感知与原生训练的行动能力相结合。它可以理解复杂的图形界面，使用常见办公与生产力工具，完成信息检索、编辑和数据处理等任务，并根据视觉反馈检查结果、排查问题、调整后续行动。</p><p></p></section></section><section><h2>推动前沿科学研究<a href="#推动前沿科学研究"><span>#</span></a></h2><p>未经针对科研任务的专项强化学习训练，MiMo-V2.6 已在多个研究领域展现出应用潜力。从材料设计到数学形式化，以下案例展示了模型如何将推理、编程与工具使用能力应用于具体科研任务。</p><section><h3>材料科研的 Co-Scientist<a href="#材料科研的-co-scientist"><span>#</span></a></h3><p>MiMo-V2.6-Pro 协助研究人员完成材料设计与计算筛选。在小米前沿材料研究团队的多轮提示与交互下，它提出了数种金属有机框架（MOF）材料的设计方案，目标是吸附被称为”永久性污染物”的全氟和多氟烷基物质（PFAS）。在这一过程中，模型检索并梳理相关文献与专利，提出研究假设，并评估设计方案的新颖性。</p><p>随后，它进一步开展了”干实验”：即调用开源计算工具，自动搭建模拟环境，计算设计出的 MOF 材料与 PFAS 之间的结合强度，从中筛选出最有潜力的候选材料，以供后续”湿实验”验证。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/Xw54brNqwoguv1xZ9e1c6LbVnAh.gif" alt="MOF 材料设计干实验演示" /><figcaption>MOF 材料设计干实验演示</figcaption></figure><p></p></section><section><h3>形式化数学证明<a href="#形式化数学证明"><span>#</span></a></h3><p>MiMo-V2.6-Pro 协助研究员，在 Lean 4 中完成了 Li–Yorke 经典论文《周期三蕴含混沌》原始主定理的完整形式化。该定理揭示：对于连续的区间自映射，一个三周期轨道的存在，就足以推出所有正整数周期的轨道，以及一个不可数的混沌集。</p><p>在研究员设计的探索策略引导下，MiMo-V2.6-Pro 通过 Sub-Agent 协作推进了定理叙述与证明的形式化。经后续修订与整合，项目最终形成了 6000 余行 Lean 源码，完整证明通过 Lean 内核核验，无未完成证明占位。模型未接受过针对 Lean 的专项后训练，这一案例展示了其参与复杂形式化证明任务的能力。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/IggpbYLGCoiAHXxFZxIcKkDxnph.gif" alt="Lean 4 形式化证明过程" /><figcaption>Lean 4 形式化证明过程</figcaption></figure><p></p></section></section><section><h2>代码驱动的内容创作与审美表现<a href="#代码驱动的内容创作与审美表现"><span>#</span></a></h2><p>MiMo-V2.6 系列大幅提升了模型创建精美数字产品的能力，涵盖包括前端网页、Figma 设计稿、幻灯片、SVG、视频、音乐等在内的方方面面。在设计评测榜单 Design Arena 上，MiMo-V2.6-Pro 取得了与 Claude Opus 5、GPT-5.6 Sol 相近的水平。</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/NMLvb9QzEoyWtLxkhsccSPBFngf.png" alt="Design Arena 设计评测" /><figcaption>Design Arena 设计评测</figcaption></figure><p></p><section><h3>前端 &amp; PPT 的审美表现<a href="#前端--ppt-的审美表现"><span>#</span></a></h3><p>MiMo-V2.6 可以将简单的指令转化为完整的前端界面和 PPT，生成结构化的布局并精心设计美观的组件、交互元素和丰富的动画效果。还熟练运用 Figma 和图像/视频生成工具，制作符合整体风格的视觉素材，在字体、配色和图文编排上保持协调，兼顾审美表现与阅读、交互体验。</p><p></p></section><section><h3>视频创作<a href="#视频创作"><span>#</span></a></h3><p>MiMo-V2.6 能够端到端完成高质量视频创作。在创意与产品宣传视频中，MiMo-V2.6 可根据用户需求完成视觉设计、镜头与动效编排、配乐合成及节奏卡点；在科普视频中，它能将傅里叶分解（Fourier Decomposition）、凸包（Convex Hull）等抽象概念转化为通俗解说与连贯动画，并调用 MiMo-V2.5-TTS 合成旁白，与画面精准对齐，从而将复杂知识转化为观众可轻松理解的生动内容，实现从概念拆解到成片输出的全流程自动化。</p><p></p></section><section><h3>音乐创作<a href="#音乐创作"><span>#</span></a></h3><p>在 MiMo-V2.6 中，我们进一步强化了模型的音乐理解、审美判断与知识运用能力，探索模型在音乐创作中的应用。它已展现出创作 Demo 级音乐作品的能力，以及辅助专业作曲与编曲人员开展创作的潜力。</p><p>在这一案例中，MiMo-V2.6-Pro 根据要求创作了一首<strong>包含约十种乐器的管弦乐作品</strong>，完成乐谱生成后，自主将其转换为 MIDI。作品体现了模型对不同乐器分工与配器关系的理解，也展示了它将音乐知识用于旋律创作和整体编排的能力。</p><p></p></section></section><section><h2>开始使用<a href="#开始使用"><span>#</span></a></h2><section><h3>使用 Xiaomi MiMo Desktop 桌面客户端<a href="#使用-xiaomi-mimo-desktop-桌面客户端"><span>#</span></a></h3><p>随着新模型发布，MiMo Desktop 桌面客户端及会员订阅方案同步上线，欢迎通过下方链接下载体验。订阅会员即可使用 MiMo-V2.6-Pro 和 Flash 模型，也可配置自己的 API Key 使用客户端。</p><p>🔗: <a href="https://mimo.xiaomimimo.com/desktop/" target="_blank">https://mimo.xiaomimimo.com/desktop/</a></p><p>MiMo Desktop 同步上线 MiMo-V2.6-Pro 的 UltraSpeed 超高速模式，提供最高 20 倍的推理速度，满足强实时交互与对响应速度敏感的场景。</p><p>原邀测活动将在 1 周后结束，已获得邀测资格的用户切换模型名称后方可继续使用。</p></section><section><h3>接入 Xiaomi MiMo API<a href="#接入-xiaomi-mimo-api"><span>#</span></a></h3><p>同时，MiMo-V2.6 系列已上线 Xiaomi MiMo 开放平台，API 价格维持不变。</p><p>MiMo-V2.6-Pro 同样于开放平台提供 UltraSpeed 超高速模式，提供最高 20 倍的推理速度。</p><p>模型定价如下：</p><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/LadHbthLYo3UZxx0GMbc67drnYe.png" alt="模型定价" /><figcaption>模型定价</figcaption></figure><p></p></section></section><section><h2>全面开源<a href="#全面开源"><span>#</span></a></h2><p>我们全面开源 MiMo-V2.6-Pro、Flash 模型权重与技术报告，同步开放 MiMo-V2.6-Distill-Qwen-9B 及配套 RL 研究资源，分享经过验证的训练实践，以下是详细开源内容：</p><ul>
<li><strong>7k+ 高质量 RL 任务环境</strong>：覆盖软件工程、漏洞复现、知识型工作、网页设计开发四类智能体任务。以 MiMo-V2.6-Distill-Qwen-9B 为起点展开 RL 训练，在 11 项评测中均较 SFT 基线取得提升：SWE-bench Verified 从 61.1 提升至 66.2，MiMo Cyber Bench 从 31.3 提升至 47.0，Terminal Bench 2.1 从 37.1 提升至 52.8，MiMo Visual Coding 从 64.0 提升至 72.4；</li>
<li><strong>端到端 RL 训练框架</strong>：基于 verl、uni-agent 和 mini-swe-agent，覆盖环境交互、轨迹采集、奖励评估与策略优化的完整训练流程。结合开放的模型与任务环境，支持社区围绕训练算法、奖励机制和 agent harness 开展研究与迭代；</li>
<li><strong>轻量可组合的 Harness</strong>：开源极简 mini-harnesses，将系统提示、工具与上下文管理解耦，构建多样且可控的训练配置。通过 Multi-Harness Training，能够将多样性和整洁性纳入 RL 训练，提升模型在不同框架、包括未见框架上的泛化能力，支持社区自由组合框架组件、拓展训练配置，持续探索新的研究方向。</li>
</ul><p></p><figure><img src="https://aistudio-cdn.xiaomimimo.com/xiaomimimo-static/mimo-docs-figures/display/CFceb5NaioiB8Cxx6eEcxUzZntf.png" alt="开源内容概览" /><figcaption>开源内容概览</figcaption></figure><p></p><p>希望这次分享能为社区持续探索强化学习算法与智能体机制提供共同基础，推动 Agentic RL 研究不断向前。</p><p>开源链接：<a href="https://huggingface.co/collections/XiaomiMiMo/mimo-v26" target="_blank">https://huggingface.co/collections/XiaomiMiMo/mimo-v26</a></p><blockquote><p><strong>注意</strong>：调用 API 时请使用全小写模型名 mimo-v2.6-pro、mimo-v2.6-flash、mimo-v2.6-pro-ultraspeed。</p></blockquote></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-10/</id>
      <title type="text">零基础部署Codex接入第三方大模型API</title>
      <published>2026-09-13T00:00:00.000Z</published>
      <updated>2026-09-13T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-10/"/>
      <summary type="text">让你的Codex更换大脑</summary>
      <content type="html"><![CDATA[<blockquote><p>OpenAI Codex 作为强大的代码生成、解释、调试智能工具，默认依赖官方接口，存在访问限制、调用成本高、国内适配差等问题。通过接入<strong>第三方大模型API</strong>（DeepSeek、MiniMax、通义千问、聚合API等），可完美替代官方接口，实现无限制调用、低成本落地、适配国内网络环境，同时完整保留 Codex 的代码补全、项目分析、智能问答、工程优化等核心功能。</p></blockquote>
<p>本文为<strong>零基础保姆级教程</strong>，主要方便零基础小白进行部署，实际在国内网络能<strong>主动</strong>访问到本博客的人也不需要本教程 <strong>哈哈</strong>！</p>
<section><h1>前置准备<a href="#前置准备"><span>#</span></a></h1><blockquote><p>点击相应文本可跳转下载</p></blockquote><p><a href="https://www.python.org/" target="_blank"><strong>python</strong></a> : 编写脚本、处理数据、自动化任务、开发后端、运行测试，也能生成 Excel、PDF、图表等文件。</p><p><a href="https://git-scm.com/book/zh/v2/" target="_blank"><strong>Git</strong></a> : 查看代码改动和历史、对比版本、创建提交、处理分支，帮助安全地管理项目。</p><p><a href="https://nodejs.org/zh-cn" target="_blank"><strong>node.js</strong></a> : 开发和运行网页、前端及 JavaScript/TypeScript 项目；安装依赖、执行构建、测试和代码检查。</p><p><a href="https://code.visualstudio.com/" target="_blank"><strong>vscode</strong></a> : 方便查看和继续编辑codex修改的代码、运行程序、调试问题。</p><section><h2>python部署教程<a href="#python部署教程"><span>#</span></a></h2><ol>
<li>
<p>访问<a href="https://www.python.org/" target="_blank"><strong>python</strong></a>，点击<strong>downloads</strong>，出现新界面后点击python 3.14.6(默认下载推荐版本即可，注意不要点击Python install manager)</p>
</li>
<li>
<p>下载完成后双击安装包</p>
</li>
</ol><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789272337417_codex%E6%95%99%E7%A8%8B1.png" alt="" /></p><p>推荐通过<em><strong>Customize installation</strong></em>进行安装，可以自定义安装位置。Install Now是默认安装到C盘。
同时勾选下方的两个选项</p><ul>
<li> Use admin privileges when installing py.exe</li>
<li> Add python.exe to PATH</li>
</ul><ol>
<li>直接点击Next</li>
</ol><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789283806727_codex%E6%95%99%E7%A8%8B2.png" alt="" /></p><ol>
<li>自定义安装路径</li>
</ol><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789284662883_codex%E6%95%99%E7%A8%8B3.png" alt="" />
5. 安装完成后关闭窗口即可</p></section><section><h2>Git部署教程<a href="#git部署教程"><span>#</span></a></h2><ol>
<li>访问<a href="https://git-scm.com/book/zh/v2/" target="_blank"><strong>Git</strong></a> 点击<strong>install</strong>，选择对应的系统下载，点击<strong>Click here to download</strong></li>
</ol><blockquote><p>如果你在国内的网络下载Git过慢，并且没有加速器，可以选择<a href="https://mirrors.tuna.tsinghua.edu.cn/" target="_blank"><strong>清华大学开源软件镜像站 | Tsinghua Open Source Mirror</strong></a>找到Git for windows下载安装即可。</p></blockquote><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789286714953_codex%E6%95%99%E7%A8%8B4.png" alt="" /></p><ol>
<li>Git安装相对简单，下载完成安装包后一直点击<strong>Next</strong>即可，无需修改设置</li>
</ol></section><section><h2>node.js部署教程<a href="#nodejs部署教程"><span>#</span></a></h2><ol>
<li>访问<a href="https://nodejs.org/zh-cn" target="_blank">Node.js — 在任何地方运行 JavaScript</a>,点击<strong>获取Node.js</strong></li>
</ol><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789287217259_codex%E6%95%99%E7%A8%8B5.png" alt="" /></p><ol>
<li>
<p>点击Windows安装程序即可获取下载。</p>
</li>
<li>
<p>下载完成安装包后选择安装路径，无需修改设置直接默认安装即可。</p>
</li>
</ol></section><section><h2>VS Code部署教程<a href="#vs-code部署教程"><span>#</span></a></h2><ol>
<li>访问<a href="https://code.visualstudio.com/" target="_blank">Visual Studio Code - 开源的 AI 代码编辑器 |你多智能体开发的家园</a>，点击<strong>Windows版下载</strong></li>
<li>下载完成后打开安装包，选择安装路径后安装即可。</li>
<li>VScode默认为英文，切换中文可以按如下操作</li>
</ol><ul>
<li>点击左侧工具栏的扩展，在弹出的搜索界面搜索chinese</li>
<li>点击安装，安装完成后右下角会弹出引导提示，跟随提示重启VScode即可</li>
</ul><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789287940816_codex%E6%95%99%E7%A8%8B6.png" alt="" /></p></section></section>
<section><h1>Codex部署<a href="#codex部署"><span>#</span></a></h1><ol>
<li>
<p>访问<a href="https://chatgpt.com/zh-Hans-CN/codex/" target="_blank"><strong>ChatGPT 中的 Codex | 专为软件工程打造的 AI 编程智能体</strong></a>点击下载Windows版。注意！国内网络不支持访问Codex官网，请自行解决该问题。</p>
</li>
<li>
<p>如果您无法解决网络问题，可以通过微软商店下载。本文提供微软镜像下载方法，您可以通过本站“我的—书签导航—工具”找到<a href="https://store.rg-adguard.net/" target="_blank">Microsoft商店 - 世代项目（v1.2.3）[由@rgadguard提供]</a>在该网站内键入</p>
</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://apps.microsoft.com/detail/9plm9xgg6vks?hl=zh-CN&amp;gl=CN</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>选择适合你的安装包</p><p><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1789288756996_codex%E6%95%99%E7%A8%8B7.png" alt="" /></p><p>如无特殊设备，选择x64版本的msix文件下载即可</p><div><div><div></div><div>警告：如果选择此方式安装，请勿将系统的默认安装位置C盘修改成其他盘，微软商店不支持修改安装位置，否则会无法安装或无法正常使用</div></div><div></div></div></section>
<section><h1>第三方大模型 API Key获取<a href="#第三方大模型-api-key获取"><span>#</span></a></h1><p>目前市面上有许多主流大模型，如豆包太蠢了哈哈！千问、文心一言、智谱GLM、DeepSeek、Kimi。本文以<strong>小米MiMo</strong>模型为例演示如何让获取API KEY，推荐读者使用智谱，GPT等模型，不考虑价格因素，目前是市面上相对较强的模型。</p><ol>
<li>访问<a href="https://mimo.mi.com/" target="_blank"><strong>Xiaomi MiMo Home</strong></a>，点击API接入</li>
<li>点击API KEYS—新建API KEY—输入KEY的名称，随后即可生成API KEY</li>
</ol><div><div><div></div><div>警告：API KEY只会出现一次，请及时复制之后保存。</div></div><div></div></div><ol>
<li>将API KEY填入CC Switch，如果您不想使用CC S作为API中转，可以根据不同Agent的官方文档教程来进行部署，本文不作介绍。</li>
</ol></section>
<section><h1>CC Switch部署<a href="#cc-switch部署"><span>#</span></a></h1><p>访问<a href="https://ccswitch.io/zh/" target="_blank">CC Switch 官方网站 - AI 编程工具统一管理平台</a>，下载安装即可</p><section><h2>CC Switch快速上手<a href="#cc-switch快速上手"><span>#</span></a></h2><section><h3>第一步：添加供应商<a href="#第一步添加供应商"><span>#</span></a></h3><ol>
<li>点击主界面右上角的 + 按钮</li>
<li>在「预设」下拉框中选择你的供应商
• 常用预设：智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode
• 或选择「自定义」手动配置</li>
<li>填写 API Key</li>
<li>点击「添加」
<img src="https://ccswitch.io/docs/assets/image-20260108002807657.png" alt="" /></li>
</ol></section><section><h3>第二步：切换供应商<a href="#第二步切换供应商"><span>#</span></a></h3><p>添加完成后，供应商会出现在列表中。</p><p><strong>方式一：主界面切换</strong></p><ul>
<li>点击供应商卡片的「启用」按钮</li>
</ul><p><strong>方式二：托盘快速切换</strong></p><ul>
<li>右键系统托盘图标</li>
<li>直接点击供应商名称</li>
</ul><p>完成以上步骤后，您可以自由的使用Codex接入的第三方API，请注意每次启动Codex之前都需要开启CC Switch</p></section></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-9/</id>
      <title type="text">MIMO CODE客户端发布</title>
      <published>2026-09-09T00:00:00.000Z</published>
      <updated>2026-09-09T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-9/"/>
      <summary type="text">属于MIMO的客户端即将发布</summary>
      <content type="html"><![CDATA[<section><h1>前言<a href="#前言"><span>#</span></a></h1><p>就在今天下午，小米官方通过邮件发文：面向个人开发者的桌面客户端 <strong>Xiaomi MiMo Desktop</strong> 正式开放邀测。</p><blockquote><p>MiMo Desktop 是一款面向真实工作场景的桌面 AI 应用：接收多格式素材输入，理解目标，拆解任务，调用工具，交付高可用度成果，并支持实时预览与修改。我们希望通过真实任务，验证它在长链路执行、工具调用、交付质量和使用体验上的边界，并据此持续迭代。</p></blockquote></section>
<section><h1>核心能力<a href="#核心能力"><span>#</span></a></h1><ul>
<li><strong>从回答问题到交付成果</strong>：表格、图片、视频、PDF、录音甚至压缩包都可直接作为输入；MiMo Desktop 理解素材、拆解任务、调用工具，交付可继续编辑的文档、表格、幻灯片、网页、图片、音频、视频、3D 模型、App 或软件工程项目。</li>
<li><strong>可直接交互的预览界面</strong>：成果预览不是静态截图，而是可点击、操作和继续修改的完整页面，支持在会话中实时运行和迭代。</li>
<li><strong>在成果上继续工作</strong>：选中任意元素即可用自然语言提出修改要求，系统只处理选中部分并保留版本记录，可随时回退到此前版本。</li>
<li><strong>Smart 调度</strong>：自动评估任务类型、复杂度与成本，在标准与旗舰模型之间动态路由，并按需匹配 Harness、Agent 与 Skill；复杂任务可拆解给多个 Agent 并行推进。</li>
<li><strong>浏览器操控</strong>：自主控制浏览器检索信息、提取素材、填写表单，信息直接进入当前任务；生成网页类成果后可自动检查关键交互。</li>
<li><strong>让长任务成本可控</strong>：模型路由、局部编辑与上下文缓存减少不必要消耗，同会话缓存命中率最高可达 99%，跨会话最高可达 95%。</li>
</ul></section>
<section><h1>新模型抢先体验<a href="#新模型抢先体验"><span>#</span></a></h1><p>邀测期间，通过申请的用户可限时、限量、免费体验两款 Xiaomi MiMo 新一代模型（代号：MiMo-X-Pro-Preview、MiMo-X-Flash-Preview）。MiMo-X 系列面向真实专业工作场景优化，重点覆盖复杂任务推理、多 Agent 协作、大型编程项目、Office、网页设计、前端与交互设计、视频剪辑、3D 生成、音乐生成、电脑操控等。目前仍处于预览测试阶段，能力表现可能随版本迭代变化。</p><section><h2>如何加入邀测<a href="#如何加入邀测"><span>#</span></a></h2><p>访问<a href="https://mimo.xiaomimimo.com/desktop/invite/" target="_blank"><strong>申请链接</strong></a>并提交申请。审核通过后，即可下载和使用 MiMo Desktop。本次邀测将优先面向 Xiaomi MiMo 开放平台已有用户开放。</p><p>Xiaomi MiMo Desktop 暂不在欧盟、英国及韩国提供服务，来自相关区域的用户登录时会收到“该地区暂不支持”的提示。</p><p>我们相信，AGI 的意义不止于理解世界，更在于帮助每个人创造世界。MiMo Desktop 是我们迈入真实工作流、帮助用户将想法转化为高质量成果的第一步。欢迎申请参与邀测，并用真实任务告诉我们：它已经能够完成什么，还有哪些地方需要改进。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-8/</id>
      <title type="text">龙之剑自由相机模组</title>
      <published>2026-08-28T00:00:00.000Z</published>
      <updated>2026-08-28T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-8/"/>
      <summary type="text">在龙之剑中调整相机</summary>
      <content type="html"><![CDATA[<section><h1>🗡龙之剑自由相机模组<a href="#龙之剑自由相机模组"><span>#</span></a></h1><p>N网上有个模组我挺喜欢用，可以调整相机镜头，并且反虚化，但是没有汉化并且窗口特别小，在大屏上看眼睛都要花了，于是我简单进行了汉化，并且增添了调整窗口大小的功能。</p><p>原版模组<a href="https://www.nexusmods.com/dragonswordawakening/mods/120" target="_blank"><em><strong>下载地址</strong></em></a></p><p>我的修改版<a href="https://pan.baidu.com/s/16PkOWTgklbnmFSWf3PzAKw?pwd=9bd8" target="_blank"><em><strong>下载地址</strong></em></a></p><blockquote><p>原版模组描述：移除放大角色时的透明效果，这样你可以欣赏素材或其他内容。还允许配置一些摄像机设置（距离、角色偏移等），默认还能解锁摄像机的俯仰角，这样你就能看天空，嗯嗯。</p></blockquote></section>
<section><h1>📕使用教程<a href="#使用教程"><span>#</span></a></h1><ol>
<li>需要安装<a href="https://github.com/UE4SS-RE/RE-UE4SS/releases" target="_blank">发行版 ·UE4SS-RE/RE-UE4SS</a>，并且一定要安装最新的版本，如果你已经安装可以忽略此步骤。</li>
<li>下载后解压到<code>DragonSword  Awakening\DS\Binaries\Win64\</code>路径下。</li>
<li>下载模组，并解压到<code>DragonSword  Awakening\DS\Binaries\Win64\ue4ss\Mods</code>目录下</li>
<li>启动游戏，当完全进入游戏后按<strong>F5</strong>即可唤出模组界面。</li>
</ol><p></p><figure><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1787886946708_%E9%BE%99%E4%B9%8B%E5%89%91.png" alt="龙之剑.png" title="模组界面" /><figcaption>龙之剑.png</figcaption></figure><p></p></section>
<section><h1>📷 CameraTweaker 设置面板功能详解<a href="#-cameratweaker-设置面板功能详解"><span>#</span></a></h1><section><h2>🔘 开关类设置<a href="#-开关类设置"><span>#</span></a></h2>

<table><thead><tr><th>设置</th><th>说明</th></tr></thead><tbody><tr><td><strong>启用相机调整</strong></td><td><strong>总开关</strong>。关闭后所有相机参数恢复游戏默认值。</td></tr><tr><td><strong>禁用近距离透明</strong></td><td>开启后，角色靠近镜头时不会变透明（原版游戏会变透明）。需要切换角色后生效。</td></tr><tr><td><strong>应用到载具</strong></td><td>开启后，相机参数也会应用到载具上。默认关闭。</td></tr></tbody></table><hr /></section><section><h2>📐 实时更新选项（拖动滑块立即生效）<a href="#-实时更新选项拖动滑块立即生效"><span>#</span></a></h2>

<table><thead><tr><th>设置</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>最小俯仰角</strong></td><td>-90°</td><td>视角能往下看的最低角度。原版是 -45°，改成 -90° 可以直接看地面。</td></tr><tr><td><strong>最大俯仰角</strong></td><td>90°</td><td>视角能往上看的最高角度。原版是 45°，改成 90° 可以直接看天空。</td></tr><tr><td><strong>相机跟随速度</strong></td><td>10</td><td>相机跟随角色移动的速度。值越大，相机跟得越紧，不容易”飘”。设为 0 则禁用跟随延迟。</td></tr><tr><td><strong>相机相对位置 X/Y/Z</strong></td><td>0</td><td>相机相对于角色的直接偏移量。X=前后，Y=左右，Z=上下。</td></tr></tbody></table><hr /></section><section><h2>⏳ 非实时更新选项（切换角色后生效）<a href="#-非实时更新选项切换角色后生效"><span>#</span></a></h2>

<table><thead><tr><th>设置</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>相机最近距离</strong></td><td>0</td><td>拉近镜头的最小距离。值越小，镜头能离角色越近。</td></tr><tr><td><strong>相机最远距离</strong></td><td>600</td><td>拉远镜头的最大距离。值越大，镜头能离角色越远。</td></tr><tr><td><strong>近景插槽偏移 X</strong></td><td>-100</td><td>镜头拉近时，相机的前后位置。负值=在角色前方（防止镜头穿进身体）。</td></tr><tr><td><strong>近景插槽偏移 Y</strong></td><td>30</td><td>镜头拉近时，相机的左右位置。正值=偏右，负值=偏左。</td></tr><tr><td><strong>近景插槽偏移 Z</strong></td><td>75</td><td>镜头拉近时，相机的高度。75 大约是头部高度。</td></tr><tr><td><strong>远景插槽偏移 X</strong></td><td>0</td><td>镜头拉远时，相机的前后位置。</td></tr><tr><td><strong>远景插槽偏移 Y</strong></td><td>60</td><td>镜头拉远时，相机的左右位置。</td></tr><tr><td><strong>远景插槽偏移 Z</strong></td><td>125</td><td>镜头拉远时，相机的高度。</td></tr></tbody></table><hr /></section><section><h2>🖥️ 面板设置<a href="#️-面板设置"><span>#</span></a></h2>

<table><thead><tr><th>设置</th><th>说明</th></tr></thead><tbody><tr><td><strong>面板宽度</strong></td><td>调整设置面板的宽度（300-1200）。</td></tr><tr><td><strong>面板高度</strong></td><td>调整设置面板的高度（400-1000）。</td></tr><tr><td><strong>应用面板大小</strong></td><td>点击后保存并重建面板，使尺寸更改生效。</td></tr></tbody></table><hr /></section><section><h2>🔘 底部按钮<a href="#-底部按钮"><span>#</span></a></h2>

<table><thead><tr><th>按钮</th><th>说明</th></tr></thead><tbody><tr><td><strong>应用并保存</strong></td><td>应用所有相机参数并保存到配置文件。</td></tr><tr><td><strong>重置</strong></td><td>恢复所有设置为默认值。</td></tr></tbody></table><hr /></section><section><h2>💡 使用建议<a href="#-使用建议"><span>#</span></a></h2><ol>
<li><strong>想要自由视角</strong>：把俯仰角范围调到 -90° ~ 90°</li>
<li><strong>想要更近的特写镜头</strong>：把相机最近距离调小</li>
<li><strong>想要更稳定的镜头</strong>：把相机跟随速度调高</li>
<li><strong>想要肩越视角</strong>：调整近景插槽偏移 Y（左右）和 Z（高度）</li>
</ol><p><strong>按 F5 打开面板，试试调整这些参数！</strong> 🎮</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-7/</id>
      <title type="text">从零开始搭建个人图床</title>
      <published>2026-08-24T00:00:00.000Z</published>
      <updated>2026-08-24T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-7/"/>
      <summary type="text">简单搭建属于你的个人图床</summary>
      <content type="html"><![CDATA[<section><h1>利用CF和TG搭建个人图床<a href="#利用cf和tg搭建个人图床"><span>#</span></a></h1><blockquote><p>本文介绍方法基于<strong>Cloudflare Workers</strong>和<strong>Telegram</strong>，开始部署前请先确保有CF和TG账号，如果您想自行选取方法，请参考官方文档。
<code>项目地址：</code><a href="https://github.com/MarSeventh/CloudFlare-ImgBed" target="_blank"><code>CloudFlare-ImgBed</code></a>
<code>官方文档：</code><a href="https://cfbed.sanyue.de/" target="_blank"><code>CloudFlare-ImgBed</code></a></p></blockquote><section><h2>前期准备<a href="#前期准备"><span>#</span></a></h2><ol>
<li>在 Telegram 中搜索 <a href="https://t.me/BotFather" target="_blank">@BotFather</a>并添加到列表</li>
<li>发送 <code>/newbot</code> 命令</li>
<li>按提示输入 Bot 名称和用户名等信息</li>
<li>成功创建后将会获得 Bot Token（格式：<code>123456789:ABCdefGHIjklMNOpqrsTUVwxyz</code>）</li>
<li>创建一个全新的频道，选择频道管理，将刚才创建的Bot设为管理员。</li>
<li>设备Bot权限，除邀请新成员外其他权限全部开启</li>
<li>在频道中发送一条测试消息</li>
<li>向 <a href="https://t.me/VersaToolsBot" target="_blank">@VersaToolsBot</a> 转发这条消息</li>
<li>获得频道 ID（示例：<code>-1001234567890</code>）</li>
</ol></section><section><h2>Cloudflare Workers部署<a href="#cloudflare-workers部署"><span>#</span></a></h2><section><h3>第一步：Fork 项目<a href="#第一步fork-项目"><span>#</span></a></h3><ol>
<li>访问 <a href="https://github.com/MarSeventh/CloudFlare-ImgBed" target="_blank">CloudFlare ImgBed</a></li>
<li>点击右上角的 “Fork” 按钮，如果您喜欢该项目，欢迎加入Star以支持</li>
<li>选择您的 GitHub 账户</li>
<li>确认 Fork 完成</li>
</ol></section><section><h3>第二步：准备 Cloudflare 资源<a href="#第二步准备-cloudflare-资源"><span>#</span></a></h3><section><h4>2.1 获取 API 令牌 和 Account ID<a href="#21-获取-api-令牌-和-account-id"><span>#</span></a></h4><ol>
<li>登录 <a href="https://dash.cloudflare.com/" target="_blank">Cloudflare Dashboard</a></li>
<li>点击右上角头像 → “配置文件” → “API 令牌”</li>
<li>点击 “创建令牌”</li>
<li>选择 “编辑 Cloudflare Workers” 模板</li>
<li>账户资源选择自己的账户，区域资源选择所有区域</li>
<li>确认权限并创建，<strong>记录生成的API令牌</strong></li>
<li>返回账户主页，右侧三个点展开复制账户ID，如果找不到可以直接搜索”复制账户”，<strong>记录复制的账户ID</strong></li>
</ol></section><section><h4>2.2 创建数据库<a href="#22-创建数据库"><span>#</span></a></h4><p>数据库用于存储文件元数据，本文选择KV</p>

<table><thead><tr><th>特点</th><th>KV 数据库</th></tr></thead><tbody><tr><td>读写性能</td><td>高</td></tr><tr><td>免费额度</td><td>少</td></tr></tbody></table></section><section><h4>KV 数据库<a href="#kv-数据库"><span>#</span></a></h4><ol>
<li>在 Dashboard 中选择 “存储和数据库” → “Workers KV”</li>
<li>点击 “创建实例”，名称填 <code>img_url</code>，请勿修改此名称</li>
<li>创建完成后返回，<strong>记录img_url ID</strong></li>
</ol></section></section><section><h3>⚙️ 第三步：配置 GitHub Secrets<a href="#️-第三步配置-github-secrets"><span>#</span></a></h3><p>在 Fork 的仓库中，进入 <strong>Settings → Secrets and variables → Actions → Secrets</strong>，添加以下 Secrets：</p>

<table><thead><tr><th>Secret 名称</th><th>说明</th><th>是否必填</th></tr></thead><tbody><tr><td><code>CLOUDFLARE_API_TOKEN</code></td><td>Cloudflare API Token</td><td>✅ 必填</td></tr><tr><td><code>CLOUDFLARE_ACCOUNT_ID</code></td><td>Cloudflare Account ID</td><td>✅ 必填</td></tr><tr><td><code>KV_NAMESPACE_ID</code></td><td>KV 命名空间 ID</td><td></td></tr></tbody></table><p>创建完成后进入 <strong>Actions → I understand… →Deploy to Cloudflare Workers →Run workflow →Run workflow</strong>（注意此处默认的work name不要修改）</p><p>运行之后等待一段时间部署deploy</p></section></section><section><h2>存储渠道配置<a href="#存储渠道配置"><span>#</span></a></h2><p>部署完成后访问您的域名，进入管理后台配置存储渠道。</p><section><h3>访问管理后台<a href="#访问管理后台"><span>#</span></a></h3><p>访问 <code>https://你的域名/dashboard</code></p><div><div><div></div><div>管理后台默认无需密码，登录后请及时设置管理员用户名和密码。</div></div><div></div></div></section><section><h3>配置 Telegram 渠道<a href="#配置-telegram-渠道"><span>#</span></a></h3><ol>
<li>左上角菜单栏进入 “系统设置” → “上传设置”</li>
<li>找到 “Telegram 渠道配置”</li>
<li>点击 “添加渠道”</li>
<li>填入准备好的 Token 和 Chat ID：
<ul>
<li><strong>渠道名称</strong>：自定义名称（如：主渠道）</li>
<li><strong>Bot Token</strong>：从 @BotFather 获得的 Token</li>
<li><strong>Chat ID</strong>：频道 ID（有<code>-</code>号时需要保留）</li>
<li><strong>代理 URL</strong>：（可选）自定义代理地址，用于代理 Telegram API 请求</li>
<li><strong>启用状态</strong>：开启</li>
</ul>
</li>
<li>点击 “保存设置”</li>
</ol></section></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-6/</id>
      <title type="text">更新公告20260823</title>
      <published>2026-08-23T00:00:00.000Z</published>
      <updated>2026-08-23T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-6/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20260823<a href="#更新公告20260823"><span>#</span></a></h2><p>1.网站版本更新到6.16.2</p><p>2.修复了<a href="https://azuma.mstzuomu.space/posts/azuma.zuomu-blog-6/" target="_blank"><strong>文章中添加音乐播放器</strong></a>功能失效的问题，修复了音频丢失的问题</p><p>3.增添全新的首页样式</p><p>4.网站默认字体更换为汉仪文黑65w,修改路径src\config\fontConfig.ts</p></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-6/</id>
      <title type="text">文章中添加音乐播放器</title>
      <published>2026-08-18T00:00:00.000Z</published>
      <updated>2026-08-18T00:00:00.000Z</updated>
      <author><name>陌殊途左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-6/"/>
      <summary type="text">简单实现文章嵌入播放器</summary>
      <content type="html"><![CDATA[<section><h2>利用现有组件添加音乐播放器<a href="#利用现有组件添加音乐播放器"><span>#</span></a></h2><blockquote><p>依旧可以直接利用 MD 语法在文章中嵌入组件，并且实现默认自动播放。</p></blockquote></section>
<section><h2>效果展示（音频源网易云示例）<a href="#效果展示音频源网易云示例"><span>#</span></a></h2><div><div><div></div><div><div>未知歌曲</div><div>未知艺术家</div><div><span>网易云音乐 · 单曲</span></div></div></div><div><span></span><span></span><div><div><div></div></div><div><span>0:00</span><span>0:00</span></div></div><div><span></span><span></span></div></div></div></section>
<section><h2>使用说明<a href="#使用说明"><span>#</span></a></h2><section><h3>直接URL方式<a href="#直接url方式"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>::music{url="https://example.com/song.mp3" title="歌曲标题" artist="艺术家" cover="封面URL"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>网易云音乐方式<a href="#网易云音乐方式"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>::music{server="netease" type="song" id="1901371647" title="歌曲标题" artist="艺术家"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>参数说明（基本和主页播放器组件相同）<a href="#参数说明基本和主页播放器组件相同"><span>#</span></a></h2>

<table><thead><tr><th>参数</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><code>url</code></td><td>音乐文件URL</td><td><code>url="/assets/music/song.mp3"</code></td></tr><tr><td><code>server</code></td><td>音乐平台</td><td><code>server="netease"</code></td></tr><tr><td><code>id</code></td><td>歌曲ID</td><td><code>id="1901371647"</code></td></tr><tr><td><code>type</code></td><td>ID类型(默认song)</td><td><code>type="song"</code></td></tr><tr><td><code>title</code></td><td>歌曲标题</td><td><code>title="我的歌曲"</code></td></tr><tr><td><code>artist</code></td><td>艺术家</td><td><code>artist="艺术家"</code></td></tr><tr><td><code>cover</code></td><td>封面图片URL</td><td><code>cover="https://example.com/cover.jpg"</code></td></tr><tr><td><code>autoplay</code></td><td>自动播放(默认true)</td><td><code>autoplay="true"</code></td></tr><tr><td><code>loop</code></td><td>循环播放(默认false)</td><td><code>loop="true"</code></td></tr><tr><td><code>volume</code></td><td>音量0-1(默认0.7)</td><td><code>volume="0.5"</code></td></tr></tbody></table><section><h3>代码实现<a href="#代码实现"><span>#</span></a></h3><ol>
<li>
<p>rehype-component-music-player.mjs（组件，<code>src/plugins/</code>）
解析 ::music{…} 语法
支持参数：url、server、id、type、title、artist、cover、autoplay、loop、volume</p>
</li>
<li>
<p>music-player-script.js（脚本，<code>src/plugins/</code>）
音频播放控制（播放/暂停/进度/音量）
Meting API 集成（网易云、QQ音乐等，三级回退）
多播放器互斥暂停、自动播放处理
切页时自动暂停并重新初始化（见下方事件说明）</p>
</li>
</ol><blockquote><p>切页事件的坑：站点用的是 Swup 4 + <code>@swup/astro</code>，它有个兼容层会把 Swup 钩子转成
astro 风格事件派发——<code>content:replace</code> 前触发 <code>astro:before-swap</code>，<code>page:view</code>
触发 <code>astro:page-load</code>。<strong>这两个才是实际会触发的事件</strong>；Swup v3 时代的
<code>swup:contentReplaced</code> / <code>swup:willReplaceContent</code> 在 4.x 里根本不派发，
写了也白写（会表现为：swup 切进文章时播放器不初始化、切页后音乐不停）。
脚本里监听 <code>astro:before-swap</code> 暂停、<code>astro:page-load</code> 清理并重新初始化。</p></blockquote><ol>
<li>
<p>music-player.css（样式，<code>src/styles/</code>）
跟随主题色的扁平卡片风，全部用 Tailwind <code>@apply</code> + 主题变量
暗色模式自动适配（不再手写 .dark 覆盖）
加载中/错误/播放中三个状态类</p>
</li>
<li>
<p>astro.config.mjs（配置）</p>
</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import { MusicPlayerComponent } from "./src/plugins/rehype-component-music-player.mjs";</span></div></div><div><div><div>2</div></div><div><span>// ...</span></div></div><div><div><div>3</div></div><div><span>components: {</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>music: MusicPlayerComponent,</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>MusicPlayerManager.astro（脚本接入，<code>src/components/features/</code>）</li>
</ol><p>对齐框架里 GithubCardManager 的模式，一行副作用导入脚本，由 Layout 统一渲染：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>// 文章内嵌音乐播放器客户端管理器（::music 指令）</span></div></div><div><div><div>3</div></div><div><span>---</span></div></div><div><div><div>4</div></div><div><span>&lt;script&gt;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>import "@/plugins/music-player-script.js";</span></div></div><div><div><div>6</div></div><div><span>&lt;/script&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>Layout.astro</code> 里引入并渲染这个组件即可，不再需要手写 <code>&lt;link&gt;</code> 和 <code>&lt;script src&gt;</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import MusicPlayerManager from "@components/features/MusicPlayerManager.astro";</span></div></div><div><div><div>2</div></div><div><span>// ...</span></div></div><div><div><div>3</div></div><div><span>&lt;MusicPlayerManager /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>样式加载（<code>Markdown.astro</code> / <code>DynamicItemTemplate.astro</code>）</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import "@/styles/music-player.css";</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>和 markdown.css、markdown-extend.styl 放在一起，只在有正文的页面（文章、动态）加载，首页等页面不背这份 CSS。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-5/</id>
      <title type="text">2026新版Kindle解密｜告别旧版PC客户端，一键导出EPUB电子书</title>
      <published>2026-08-17T00:00:00.000Z</published>
      <updated>2026-08-17T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-5/"/>
      <summary type="text">一键去除Kindle的D加密？已经这么方便啦！</summary>
      <content type="html"><![CDATA[<section><h1>解除Kindle电子书DRM加密并转换为EPUB格式教程<a href="#解除kindle电子书drm加密并转换为epub格式教程"><span>#</span></a></h1><blockquote><p>本教程适用于<strong>微软商店版Kindle应用</strong>。原exe安装包版本（Kindle for PC）已于2026‑06‑30终止服务，2.8版本已停止维护，2.9版本虽暂时可用，但后续也将不再提供支持。</p></blockquote><section><h2>所需工具<a href="#所需工具"><span>#</span></a></h2><ol>
<li>微软商店版 <a href="https://apps.microsoft.com/detail/9p8jq0jjstll?hl=zh-CN&amp;gl=CN" target="_blank">Kindle</a>（如果下载过慢可从<a href="https://store.rg-adguard.net/" target="_blank">离线站</a>下载）</li>
<li><a href="https://calibre-ebook.com/" target="_blank">Calibre</a>：开源电子书管理软件，支持各类电子书格式转换与插件扩展</li>
<li><a href="https://github.com/Satsuoni/DeDRM_tools/releases" target="_blank">DeDRM‑Tools</a> 解密工具包：版本需要与Kindle版本匹配，版本号建议大于等于对应Kindle版本</li>
</ol></section><section><h2>操作步骤<a href="#操作步骤"><span>#</span></a></h2><section><h3>1. 在Kindle客户端下载电子书<a href="#1-在kindle客户端下载电子书"><span>#</span></a></h3><p>打开微软商店版Kindle，将需要解密的电子书完整下载到本地。</p><figure><img src="https://tu.mstzuomu.space/file/%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE/1786968985952_%E6%96%87%E7%AB%A0%E6%8F%92%E5%9B%BE.png" alt="新版Kindle界面" /><figcaption>新版Kindle界面</figcaption></figure><p></p></section><section><h3>2. 使用DeDRM‑Tools提取解密后的KFX‑ZIP文件<a href="#2-使用dedrmtools提取解密后的kfxzip文件"><span>#</span></a></h3><ol>
<li>在DeDRM工具包目录中，双击运行 <code>MSIXKFXArchiverMobi1_18632.exe</code>；</li>
<li>程序运行成功后，工具包文件夹内会生成 <code>archived_kfx</code> 文件夹；</li>
<li>该文件夹内的 <strong>kfx‑zip</strong> 文件即为去除DRM加密后的电子书源文件；</li>
<li>提示：工具会自动在C盘根目录生成 <code>Date</code> 文件夹，任务完成后可直接删除该文件夹。</li>
</ol></section><section><h3>3. Calibre安装KFX Input插件<a href="#3-calibre安装kfx-input插件"><span>#</span></a></h3><blockquote><p>Calibre需要安装KFX Input插件才能识别kfx‑zip格式文件</p></blockquote><ol>
<li>打开Calibre，点击顶部菜单栏「首选项」；</li>
<li>在窗口下方选择「插件」；</li>
<li>在弹窗底部点击「获取新的插件」；</li>
<li>搜索 <code>KFX Input</code>，选中后点击安装，安装过程可能短暂卡顿；</li>
<li>按照提示<strong>重启Calibre</strong>使插件生效。</li>
</ol></section><section><h3>4. 导入文件并转换为EPUB格式<a href="#4-导入文件并转换为epub格式"><span>#</span></a></h3><ol>
<li>将解密得到的 <code>kfx‑zip</code> 文件直接拖拽到Calibre书库中；</li>
<li>文件导入成功后，右键选中该书，选择「批量转换」；</li>
<li>在转换设置右上角，输出格式选择 <code>EPUB</code>，按需调整其他参数；</li>
<li>确认执行转换，完成后即可得到无DRM加密的EPUB电子书。</li>
</ol></section></section><section><h2>补充说明<a href="#补充说明"><span>#</span></a></h2><p>相比旧版Kindle for PC的解密流程，当前DeDRM‑Tools更新后的版本操作流程更加简洁。本教程流程基于该工具作者更新的新版工具实现。</p><blockquote><p>⚠️ 法律提示：本教程仅用于个人已购买电子书的备份，请勿将解密后的文件用于传播、分享或商用，遵守著作权相关法律法规。</p></blockquote><hr /></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-5/</id>
      <title type="text">更新公告20260817</title>
      <published>2026-08-17T00:00:00.000Z</published>
      <updated>2026-08-17T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-5/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20260817<a href="#更新公告20260817"><span>#</span></a></h2><p>1.修复了网站异常跳转后崩溃的问题</p><p>2.添加了文章中音乐播放器功能，详情可见<a href="https://azuma.mstzuomu.space/posts/azuma.zuomu-blog-6/" target="_blank"><strong>文章中添加音乐播放器</strong></a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-4/</id>
      <title type="text">基于Firefly主题的列表封面位置改造</title>
      <published>2026-08-16T00:00:00.000Z</published>
      <updated>2026-08-16T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-4/"/>
      <summary type="text">不想让封面挤在侧边？Firefly封面置顶记录</summary>
      <content type="html"><![CDATA[<section><h1>Firefly 主题自定义文章封面位置：实现列表模式封面在上方<a href="#firefly-主题自定义文章封面位置实现列表模式封面在上方"><span>#</span></a></h1><blockquote><p>Firefly 主题默认的列表模式只支持封面在左侧或右侧，但我想让封面显示在标题上方。于是开始了一番折腾，记录一下完整的修改过程。</p></blockquote><section><h2>最终效果<a href="#最终效果"><span>#</span></a></h2><p>修改前（封面在右侧）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>┌─────────────────────────────────┐</span></div></div><div><div><div>2</div></div><div><span>│ 标题                    [封面图] │</span></div></div><div><div><div>3</div></div><div><span>│ 标签                            │</span></div></div><div><div><div>4</div></div><div><span>└─────────────────────────────────┘</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>修改后（封面在上方）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>┌─────────────────────────────────┐</span></div></div><div><div><div>2</div></div><div><span>│ [封面图]                        │</span></div></div><div><div><div>3</div></div><div><span>│ 标题                            │</span></div></div><div><div><div>4</div></div><div><span>│ 标签                            │</span></div></div><div><div><div>5</div></div><div><span>└─────────────────────────────────┘</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>需要修改的文件<a href="#需要修改的文件"><span>#</span></a></h2><p>一共涉及 4 个文件：</p><ol>
<li><code>src/types/siteConfig.ts</code> — 类型定义</li>
<li><code>src/config/siteConfig.ts</code> — 配置文件</li>
<li><code>src/components/layout/PostCard.astro</code> — 文章卡片组件</li>
<li><code>src/components/layout/PostPage.astro</code> — 文章列表页面</li>
</ol><hr /></section><section><h2>第一步：修改类型定义<a href="#第一步修改类型定义"><span>#</span></a></h2><p><strong>文件：</strong> <code>src/types/siteConfig.ts</code></p><p>找到 <code>coverPosition</code> 的类型定义：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>coverPosition</span><span>?:</span><span> </span><span>"left"</span><span> </span><span>|</span><span> </span><span>"right"</span><span> </span><span>|</span><span> </span><span>undefined</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>添加 <code>"top"</code> 选项，修改为：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>coverPosition</span><span>?:</span><span> </span><span>"left"</span><span> </span><span>|</span><span> </span><span>"right"</span><span> </span><span>|</span><span> </span><span>"top"</span><span> </span><span>|</span><span> </span><span>undefined</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section><section><h2>第二步：修改配置文件<a href="#第二步修改配置文件"><span>#</span></a></h2><p><strong>文件：</strong> <code>src/config/siteConfig.ts</code></p><p>找到 <code>postListLayout</code> 配置中的 <code>coverPosition</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>postListLayout</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>    </span><span>// 列表模式下封面图显示在哪一侧："right" 右侧，"left" 左侧</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>coverPosition</span><span>: </span><span>"right"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>// ...</span></div></div><div><div><div>5</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>修改为：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>postListLayout</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>    </span><span>// 列表模式下封面图显示在哪一侧："right" 右侧，"left" 左侧，"top" 上方</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>coverPosition</span><span>: </span><span>"top"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>// ...</span></div></div><div><div><div>5</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section><section><h2>第三步：修改文章卡片组件<a href="#第三步修改文章卡片组件"><span>#</span></a></h2><p><strong>文件：</strong> <code>src/components/layout/PostCard.astro</code></p><section><h3>3.1 添加变量判断<a href="#31-添加变量判断"><span>#</span></a></h3><p>找到这行代码（约第 53 行）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>coverWidth</span><span> </span><span>=</span><span> </span><span>"30%"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在它下面添加：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>isCoverTop</span><span> </span><span>=</span><span><span> </span><span>style</span><span>.</span></span><span>includes</span><span>(</span><span>"cover-top"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3.2 修改容器 class<a href="#32-修改容器-class"><span>#</span></a></h3><p>找到容器 div 的 class 定义（约第 107-114 行）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class:list=</span><span>{</span><span>[</span></div></div><div><div><div>2</div></div><div><span>    </span><span>"post-card-wrapper"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>hasCover</span><span> </span><span>?</span><span> </span><span>"has-cover"</span><span> </span><span>:</span><span> </span><span>"no-cover"</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>pinned</span><span> </span><span>?</span><span> </span><span>"pinned"</span><span> </span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>gridCoverFullWidth</span><span> </span><span>?</span><span> </span><span>""</span><span> </span><span>:</span><span> </span><span>"grid-cover-inset"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"card-base flex flex-col-reverse w-full rounded-(--radius-large) overflow-hidden relative"</span><span>,</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>className</span><span>,</span></div></div><div><div><div>8</div></div><div><span>]</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>添加 <code>cover-top</code> 类，修改为：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class:list=</span><span>{</span><span>[</span></div></div><div><div><div>2</div></div><div><span>    </span><span>"post-card-wrapper"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>hasCover</span><span> </span><span>?</span><span> </span><span>"has-cover"</span><span> </span><span>:</span><span> </span><span>"no-cover"</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>pinned</span><span> </span><span>?</span><span> </span><span>"pinned"</span><span> </span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>gridCoverFullWidth</span><span> </span><span>?</span><span> </span><span>""</span><span> </span><span>:</span><span> </span><span>"grid-cover-inset"</span><span>,</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>isCoverTop</span><span> </span><span>?</span><span> </span><span>"cover-top"</span><span> </span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>"card-base flex flex-col-reverse w-full rounded-(--radius-large) overflow-hidden relative"</span><span>,</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>className</span><span>,</span></div></div><div><div><div>9</div></div><div><span>]</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3.3 添加 CSS 样式<a href="#33-添加-css-样式"><span>#</span></a></h3><p>在文件末尾的 <code>&lt;style&gt;</code> 标签内添加以下 CSS：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 列表模式封面在上方 - 移动端 */</span></div></div><div><div><div>2</div></div><div><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.post-card-wrapper</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>flex-direction: </span><span>column-reverse</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.has-cover</span><span> </span><span>.post-card-image</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>position: </span><span>relative</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>top: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>bottom: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>right: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>left: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>height: </span><span><span>200</span><span>px</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>margin: </span><span>0</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>border-radius: </span><span>var</span><span>(</span><span>--radius-large</span><span>) </span><span>var</span><span>(</span><span>--radius-large</span><span>) </span><span>0</span><span> </span><span>0</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.has-cover</span><span> </span><span>.post-card-content</span><span> {</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>20</div></div><div><span>}</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>/* 列表模式封面在上方 - 桌面端 */</span></div></div><div><div><div>23</div></div><div><span>@media</span><span> (min-width: </span><span><span>768</span><span>px</span></span><span>) {</span></div></div><div><div><div>24</div></div><div><span>    </span><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.post-card-wrapper</span><span> {</span></div></div><div><div><div>25</div></div><div><span><span>        </span></span><span>flex-direction: </span><span>column-reverse</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span>    </span><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.has-cover</span><span> </span><span>.post-card-image</span><span> {</span></div></div><div><div><div>29</div></div><div><span><span>        </span></span><span>position: </span><span>relative</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>30</div></div><div><span><span>        </span></span><span>top: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>31</div></div><div><span><span>        </span></span><span>bottom: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>32</div></div><div><span><span>        </span></span><span>right: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>33</div></div><div><span><span>        </span></span><span>left: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>34</div></div><div><span><span>        </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>35</div></div><div><span><span>        </span></span><span>height: </span><span><span>300</span><span>px</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>36</div></div><div><span><span>        </span></span><span>margin: </span><span>0</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>37</div></div><div><span><span>        </span></span><span>border-radius: </span><span>var</span><span>(</span><span>--radius-large</span><span>) </span><span>var</span><span>(</span><span>--radius-large</span><span>) </span><span>0</span><span> </span><span>0</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>38</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>39</div></div><div>
</div></div><div><div><div>40</div></div><div><span>    </span><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.has-cover</span><span> </span><span>.post-card-content</span><span> {</span></div></div><div><div><div>41</div></div><div><span><span>        </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>42</div></div><div><span><span>        </span></span><span>padding: </span><span><span>1.75</span><span>rem</span></span><span> </span><span><span>2.25</span><span>rem</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>43</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>44</div></div><div>
</div></div><div><div><div>45</div></div><div><span>    </span><span>:global(</span><span>.list-mode.cover-top</span><span>) </span><span>.has-cover</span><span> </span><span><span>.post-card-title</span><span>::before</span></span><span> {</span></div></div><div><div><div>46</div></div><div><span><span>        </span></span><span>display: </span><span>none</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>47</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>48</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><hr /></section></section><section><h2>第四步：修改文章列表页面<a href="#第四步修改文章列表页面"><span>#</span></a></h2><p><strong>文件：</strong> <code>src/components/layout/PostPage.astro</code></p><p>找到 <code>coverPositionClass</code> 的定义（约第 28-29 行）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>coverPositionClass</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>siteConfig</span><span>.</span><span>postListLayout</span><span>.</span><span>coverPosition</span><span> </span><span>===</span><span> </span><span>"left"</span><span> </span><span>?</span><span> </span><span>" cover-left"</span><span> </span><span>:</span><span> </span><span>""</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>添加对 <code>"top"</code> 的支持，修改为：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>coverPositionClass</span><span> </span><span>=</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>siteConfig</span><span>.</span><span>postListLayout</span><span>.</span><span>coverPosition</span><span> </span><span>===</span><span> </span><span>"left"</span><span> </span><span>?</span><span> </span><span>" cover-left"</span><span> </span><span>:</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>siteConfig</span><span>.</span><span>postListLayout</span><span>.</span><span>coverPosition</span><span> </span><span>===</span><span> </span><span>"top"</span><span> </span><span>?</span><span> </span><span>" cover-top"</span><span> </span><span>:</span><span> </span><span>""</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section><section><h2>完整配置参考<a href="#完整配置参考"><span>#</span></a></h2><p>修改完成后，<code>siteConfig.ts</code> 中的 <code>postListLayout</code> 配置示例：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>postListLayout</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span>    </span><span>// 默认布局模式："list" 列表模式，"grid" 网格模式</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>defaultMode</span><span>: </span><span>"list"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>// 移动端默认布局模式</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>mobileDefaultMode</span><span>: </span><span>"grid"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>// 列表模式下封面图位置："right" 右侧，"left" 左侧，"top" 上方</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>coverPosition</span><span>: </span><span>"top"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>// 文章简介显示行数</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>descriptionLines</span><span>: </span><span>2</span><span>,</span></div></div><div><div><div>10</div></div><div><span>    </span><span>// 其他配置...</span></div></div><div><div><div>11</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section><section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>问题 1：TypeScript 类型错误<a href="#问题-1typescript-类型错误"><span>#</span></a></h3><p>一开始直接在配置里写 <code>coverPosition: "top"</code>，结果报错：</p><blockquote><p>不能将类型”top”分配给类型”left” | “right” | undefined”</p></blockquote><p><strong>原因：</strong> 类型定义文件中没有 <code>"top"</code> 选项。</p><p><strong>解决：</strong> 先修改 <code>types/siteConfig.ts</code>，添加 <code>"top"</code> 类型。</p></section><section><h3>问题 2：修改后没有效果<a href="#问题-2修改后没有效果"><span>#</span></a></h3><p>改了配置和样式，但页面没有任何变化。</p><p><strong>原因：</strong> <code>PostPage.astro</code> 中的 <code>coverPositionClass</code> 只处理了 <code>"left"</code>，没有处理 <code>"top"</code>，导致 <code>cover-top</code> 这个 class 根本没被加到容器上。</p><p><strong>解决：</strong> 修改 <code>PostPage.astro</code>，添加对 <code>"top"</code> 的判断逻辑。</p><hr /></section></section><section><h2>小结<a href="#小结"><span>#</span></a></h2><p>整体修改涉及 4 个文件，核心逻辑就是四步：</p><ol>
<li>在类型定义中添加新选项</li>
<li>在配置中启用新选项</li>
<li>在组件中添加对应的 CSS 样式</li>
<li>在页面中添加对应的 class 处理</li>
</ol><p>Firefly 主题的组件化设计很清晰，修改起来逻辑明确，唯一需要注意的就是别漏了 <code>PostPage.astro</code> 里的 class 映射——我一开始就栽在这了，样式写了半天不生效，最后发现是 class 根本没挂上去。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-4/</id>
      <title type="text">更新公告20260816</title>
      <published>2026-08-16T00:00:00.000Z</published>
      <updated>2026-08-16T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-4/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20260816<a href="#更新公告20260816"><span>#</span></a></h2><p>1.为网站添加了MD语法下的PDF控件，现在可以直接在文章里阅读PDF</p><p>2.修复了外链PDF无法正常读取的问题</p><p>3.更换网站图床，感谢开源项目<a href="https://cfbed.sanyue.de/" target="_blank"><strong>CloudFlare ImgBed</strong></a></p><p>4.将文章封面显示位置新增top选项</p><p>PDF控件示例：</p><div><div><div>我的PDF文档</div><a href="https://tu.mstzuomu.space/file/PDF文件/1786844764371_A51.pdf" target="_blank">下载PDF</a></div><div></div></div></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-3/</id>
      <title type="text">基于rehype的博客PDF嵌入组件实现</title>
      <published>2026-08-16T00:00:00.000Z</published>
      <updated>2026-08-16T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-3/"/>
      <summary type="text">基于现有组件体系的博客 PDF 预览扩展实践</summary>
      <content type="html"><![CDATA[<blockquote><p>博客里已经有了 GitHub 卡片、B站卡片这类 Markdown 扩展组件，一直想着能不能把 PDF 也直接嵌进文章里，不用再丢个链接让读者跳出去看。今天折腾了一下，用 AI 辅助搞定了，由于制作B站卡片时忘记记录了，于是决定记录一下制作PDF组件全过程。</p></blockquote>
<section><h2>思路<a href="#思路"><span>#</span></a></h2><p>项目里已经有一套基于 rehype 的自定义指令组件体系，比如 <code>::github{...}</code> 和 <code>::bilibili{...}</code>。我要做的就是照着这个模式，新增一个 <code>::pdf{...}</code> 指令，让它在 Markdown 里渲染成一个带标题栏和下载按钮的 PDF 查看器。</p><p>整个过程分三步：<strong>写插件 → 注册插件 → 写样式</strong>。样式直接写在框架统一存放 Markdown 扩展样式的 <code>markdown-extend.styl</code> 里，它由 <code>Markdown.astro</code> 自动加载，不需要额外引入。</p><hr /></section>
<section><h2>第一步：创建 PDF 查看器插件<a href="#第一步创建-pdf-查看器插件"><span>#</span></a></h2><p>在 <code>src/plugins/</code> 路径下新建 <code>rehype-component-pdf-viewer.mjs</code>，核心逻辑是接收 <code>url</code>、<code>title</code>、<code>height</code> 三个参数，用 <code>hastscript</code> 拼出一个带 header 和 iframe 的 DOM 结构。</p><p>代码里做了两个校验：如果指令不是叶子类型（带了子内容），或者没传 <code>url</code>，就返回一个隐藏的错误提示 div，避免页面崩掉。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/// </span><span>&lt;</span><span>reference</span><span> </span><span>types</span><span>=</span><span>"mdast"</span><span> /&gt;</span></div></div><div><div><div>2</div></div><div><span>import</span><span> { </span><span>h</span><span> } </span><span>from</span><span> </span><span>"hastscript"</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>/**</span></div></div><div><div><div>5</div></div><div><span><span> </span></span><span>* Creates a PDF Viewer component.</span></div></div><div><div><div>6</div></div><div><span><span> </span></span><span>*</span></div></div><div><div><div>7</div></div><div><span><span> </span></span><span>* </span><span>@</span><span>param</span><span> </span><span>{Object}</span><span> </span><span>properties</span><span> - The properties of the component.</span></div></div><div><div><div>8</div></div><div><span><span> </span></span><span>* </span><span>@</span><span>param</span><span> </span><span>{string}</span><span> </span><span>properties.url</span><span> - The URL of the PDF file.</span></div></div><div><div><div>9</div></div><div><span><span> </span></span><span>* </span><span>@</span><span>param</span><span> </span><span>{string}</span><span> </span><span>[properties.title]</span><span> - Optional title for the PDF viewer.</span></div></div><div><div><div>10</div></div><div><span><span> </span></span><span>* </span><span>@</span><span>param</span><span> </span><span>{number}</span><span> </span><span>[properties.height]</span><span> - Optional height in pixels (default: 600).</span></div></div><div><div><div>11</div></div><div><span><span> </span></span><span>* </span><span>@</span><span>param</span><span> </span><span>{import('mdast').RootContent[]}</span><span> </span><span>children</span><span> - The children elements.</span></div></div><div><div><div>12</div></div><div><span><span> </span></span><span>* </span><span>@</span><span>returns</span><span> </span><span>{import('mdast').Parent}</span><span> The created PDF Viewer component.</span></div></div><div><div><div>13</div></div><div><span><span> </span></span><span>*/</span></div></div><div><div><div>14</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>PdfViewerComponent</span><span><span>(</span><span>properties</span><span>, </span><span>children</span><span>) {</span></span></div></div><div><div><div>15</div></div><div><span>  </span><span>if</span><span><span> (</span><span>Array</span><span>.</span></span><span>isArray</span><span><span>(</span><span>children</span><span>) </span></span><span>&amp;&amp;</span><span><span> </span><span>children</span><span>.</span></span><span>length</span><span> </span><span>!==</span><span> </span><span>0</span><span>)</span></div></div><div><div><div>16</div></div><div><span>    </span><span>return</span><span> </span><span>h</span><span>(</span><span>"div"</span><span>, { </span><span>class</span><span>:</span><span> </span><span>"hidden"</span><span> }, [</span></div></div><div><div><div>17</div></div><div><span>      </span><span>'Invalid directive. ("pdf" directive must be leaf type "::pdf{url="https://example.com/file.pdf"}")'</span><span>,</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>]);</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>properties</span><span>.</span></span><span>url</span><span>)</span></div></div><div><div><div>21</div></div><div><span>    </span><span>return</span><span> </span><span>h</span><span>(</span></div></div><div><div><div>22</div></div><div><span>      </span><span>"div"</span><span>,</span></div></div><div><div><div>23</div></div><div><span><span>      </span></span><span>{ </span><span>class</span><span>:</span><span> </span><span>"hidden"</span><span> },</span></div></div><div><div><div>24</div></div><div><span>      </span><span>'Invalid URL. ("url" attribute is required)'</span><span>,</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>);</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>  </span><span>const</span><span> </span><span>url</span><span> </span><span>=</span><span><span> </span><span>properties</span><span>.</span></span><span>url</span><span>;</span></div></div><div><div><div>28</div></div><div><span>  </span><span>const</span><span> </span><span>title</span><span> </span><span>=</span><span><span> </span><span>properties</span><span>.</span></span><span>title</span><span> </span><span>||</span><span> </span><span>"PDF Viewer"</span><span>;</span></div></div><div><div><div>29</div></div><div><span>  </span><span>const</span><span> </span><span>height</span><span> </span><span>=</span><span><span> </span><span>properties</span><span>.</span></span><span>height</span><span> </span><span>||</span><span> </span><span>600</span><span>;</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>  </span><span>// 浏览器原生 PDF 预览（iframe 直接加载），不引入 pdf.js 重依赖</span></div></div><div><div><div>32</div></div><div><span>  </span><span>return</span><span> </span><span>h</span><span>(</span><span>"div"</span><span>, { </span><span>class</span><span>:</span><span> </span><span>"pdf-container"</span><span> }, [</span></div></div><div><div><div>33</div></div><div><span>    </span><span>h</span><span>(</span><span>"div"</span><span>, { </span><span>class</span><span>:</span><span> </span><span>"pdf-header"</span><span> }, [</span></div></div><div><div><div>34</div></div><div><span>      </span><span>h</span><span>(</span><span>"div"</span><span>, { </span><span>class</span><span>:</span><span> </span><span>"pdf-title"</span><span><span> }, </span><span>title</span><span>),</span></span></div></div><div><div><div>35</div></div><div><span>      </span><span>h</span><span>(</span></div></div><div><div><div>36</div></div><div><span>        </span><span>"a"</span><span>,</span></div></div><div><div><div>37</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>38</div></div><div><span>          </span><span>class</span><span>:</span><span> </span><span>"pdf-download"</span><span>,</span></div></div><div><div><div>39</div></div><div><span>          </span><span>href</span><span>:</span><span><span> </span><span>url</span><span>,</span></span></div></div><div><div><div>40</div></div><div><span>          </span><span>target</span><span>:</span><span> </span><span>"_blank"</span><span>,</span></div></div><div><div><div>41</div></div><div><span>          </span><span>download</span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>42</div></div><div><span><span>        </span></span><span>},</span></div></div><div><div><div>43</div></div><div><span>        </span><span>"下载PDF"</span><span>,</span></div></div><div><div><div>44</div></div><div><span><span>      </span></span><span>),</span></div></div><div><div><div>45</div></div><div><span><span>    </span></span><span>]),</span></div></div><div><div><div>46</div></div><div><span>    </span><span>h</span><span>(</span><span>"div"</span><span>, { </span><span>class</span><span>:</span><span> </span><span>"pdf-viewer-wrapper"</span><span> }, [</span></div></div><div><div><div>47</div></div><div><span>      </span><span>h</span><span>(</span><span>"iframe"</span><span>, {</span></div></div><div><div><div>48</div></div><div><span>        </span><span>class</span><span>:</span><span> </span><span>"pdf-viewer"</span><span>,</span></div></div><div><div><div>49</div></div><div><span>        </span><span>src</span><span>:</span><span><span> </span><span>url</span><span>,</span></span></div></div><div><div><div>50</div></div><div><span>        </span><span>style</span><span>:</span><span> </span><span>`height: </span><span>${</span><span>height</span><span>}</span><span>px;`</span><span>,</span></div></div><div><div><div>51</div></div><div><span>        </span><span>title</span><span>:</span><span><span> </span><span>title</span><span>,</span></span></div></div><div><div><div>52</div></div><div><span><span>      </span></span><span>}),</span></div></div><div><div><div>53</div></div><div><span><span>    </span></span><span>]),</span></div></div><div><div><div>54</div></div><div><span><span>  </span></span><span>]);</span></div></div><div><div><div>55</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><hr /></section>
<section><h2>第二步：在 astro.config.mjs 中注册插件<a href="#第二步在-astroconfigmjs-中注册插件"><span>#</span></a></h2><p>插件写好了还得注册才能用。打开 <code>astro.config.mjs</code>，分两处修改。</p><p><strong>第一处：顶部 import（和其他组件插件放一起，约 41–44 行附近）</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> { </span><span>BilibiliCardComponent</span><span> } </span><span>from</span><span> </span><span>"./src/plugins/rehype-component-bilibili-card.mjs"</span><span>;</span></div></div><div><div><div>2</div></div><div><span>import</span><span> { </span><span>GithubCardComponent</span><span> } </span><span>from</span><span> </span><span>"./src/plugins/rehype-component-github-card.mjs"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>import</span><span> { </span><span>MusicPlayerComponent</span><span> } </span><span>from</span><span> </span><span>"./src/plugins/rehype-component-music-player.mjs"</span><span>;</span></div></div><div><div><div>4</div></div><div><span>import</span><span> { </span><span>PdfViewerComponent</span><span> } </span><span>from</span><span> </span><span>"./src/plugins/rehype-component-pdf-viewer.mjs"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>第二处：components 中注册（约第 329 行附近）</strong></p><p>找到 components 配置，在里面加一行 <code>pdf</code> 字段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>components</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>github</span><span>: </span><span>GithubCardComponent</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>bilibili</span><span>: </span><span>BilibiliCardComponent</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>pdf</span><span>: </span><span>PdfViewerComponent</span><span>,</span></div></div><div><div><div>5</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section>
<section><h2>第三步：添加 CSS 样式<a href="#第三步添加-css-样式"><span>#</span></a></h2><p>光有 DOM 还不够，得给它穿件衣服。打开框架统一的 Markdown 扩展样式文件 <code>src/styles/markdown-extend.styl</code>（GitHub 卡片、图表控件的样式也都在这里），按 Stylus 语法追加一段。</p><p>有两点值得注意：颜色全部用主题变量而不是写死的色值——<code>var(--license-block-bg)</code>、<code>var(--line-divider)</code> 这类变量在暗色主题下会自动切换，不用再手写一套 <code>.dark</code> 覆盖；另外要显式覆盖 <code>.custom-md iframe</code> 的全局 margin 和圆角，否则 iframe 会被文章里通用的 iframe 规则影响。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// ─── PDF 查看器（::pdf 指令，rehype-component-pdf-viewer） ──────────────────</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>.pdf-container</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>margin: </span><span><span>1.5</span><span>rem</span></span><span> </span><span>0</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>border-radius: </span><span><span>0.75</span><span>rem</span></span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>overflow: </span><span>hidden</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>background: </span><span>var</span><span>(</span><span>--license-block-bg</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>border: </span><span><span>1</span><span>px</span></span><span><span> </span><span>solid</span><span> </span></span><span>var</span><span>(</span><span>--line-divider</span><span>)</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>.pdf-header</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>display: </span><span>flex</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>align-items: </span><span>center</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>justify-content: </span><span>space-between</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>padding: </span><span><span>0.75</span><span>rem</span></span><span> </span><span><span>1</span><span>rem</span></span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>border-bottom: </span><span><span>1</span><span>px</span></span><span><span> </span><span>solid</span><span> </span></span><span>var</span><span>(</span><span>--line-divider</span><span>)</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>.pdf-title</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>font-weight: </span><span>600</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>font-size: </span><span><span>0.95</span><span>rem</span></span></div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>color: </span><span>var</span><span>(</span><span>--tw-prose-headings</span><span>)</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>.pdf-download</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>font-size: </span><span><span>0.85</span><span>rem</span></span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>color: </span><span>var</span><span>(</span><span>--primary</span><span>)</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>text-decoration: </span><span>none</span></div></div><div><div><div>26</div></div><div><span><span>  </span></span><span>padding: </span><span><span>0.25</span><span>rem</span></span><span> </span><span><span>0.75</span><span>rem</span></span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>border-radius: </span><span><span>0.375</span><span>rem</span></span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>background: </span><span>var</span><span>(</span><span>--btn-regular-bg</span><span>)</span></div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>transition: background-color </span><span><span>0.2</span><span>s</span></span><span><span> </span><span>ease</span></span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span><span>  </span></span><span>&amp;</span><span>:hover</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>background: </span><span>var</span><span>(</span><span>--btn-regular-bg-hover</span><span>)</span></div></div><div><div><div>33</div></div><div>
</div></div><div><div><div>34</div></div><div><span>.pdf-viewer-wrapper</span></div></div><div><div><div>35</div></div><div><span><span>  </span></span><span>position: </span><span>relative</span></div></div><div><div><div>36</div></div><div><span><span>  </span></span><span>width: </span><span><span>100</span><span>%</span></span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div><span>.pdf-viewer</span></div></div><div><div><div>39</div></div><div><span><span>  </span></span><span>display: </span><span>block</span></div></div><div><div><div>40</div></div><div><span><span>  </span></span><span>width: </span><span><span>100</span><span>%</span></span></div></div><div><div><div>41</div></div><div><span><span>  </span></span><span>border: </span><span>none</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>// 覆盖上方 .custom-md iframe 的全局 margin/border-radius，由容器自己控制留白</span></div></div><div><div><div>44</div></div><div><span>.custom-md</span><span> </span><span>.pdf-viewer</span></div></div><div><div><div>45</div></div><div><span><span>  </span></span><span>margin: </span><span>0</span></div></div><div><div><div>46</div></div><div><span><span>  </span></span><span>border-radius: </span><span>0</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>由于 PDF 阅读器并未进行本地配置而是选择了 iframe 直接嵌入，因此实际阅读控件和读者的浏览器有关。</p><p>样式写完就完事了——<code>markdown-extend.styl</code> 已经由 <code>Markdown.astro</code> 引入，不需要在 <code>Layout.astro</code> 里再手动 import 一份。</p><hr /></section>
<section><h2>使用方法<a href="#使用方法"><span>#</span></a></h2><p>全部搞定之后，在任何 Markdown 文章里直接写一行指令就行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>::pdf{url="https://example.com/your-file.pdf" title="我的PDF文档" height="600"}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个参数说明：</p>

<table><thead><tr><th>参数</th><th>说明</th><th>是否必填</th><th>默认值</th></tr></thead><tbody><tr><td><code>url</code></td><td>PDF 文件的 URL 地址</td><td>必填</td><td>—</td></tr><tr><td><code>title</code></td><td>查看器顶部显示的标题</td><td>可选</td><td><code>"PDF Viewer"</code></td></tr><tr><td><code>height</code></td><td>查看器高度（像素）</td><td>可选</td><td><code>600</code></td></tr></tbody></table><hr /><p>PDF控件示例：</p><div><div><div>我的PDF文档</div><a href="https://tu.mstzuomu.space/file/PDF文件/1786844764371_A51.pdf" target="_blank">下载PDF</a></div><div></div></div></section>
<section><h2>小结<a href="#小结"><span>#</span></a></h2><p>整体下来没什么坑，核心就是复用项目已有的 rehype 组件模式，新增一个指令处理器而已。iframe 直接加载 PDF URL，浏览器原生支持预览，省得引入 pdf.js 那种重依赖。header 上放个下载按钮，读者想保存也方便。</p><p>以后写技术文章放论文、说明书之类的 PDF 就舒服多了，不用再让读者跳出博客去看。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-3/</id>
      <title type="text">更新公告20260815</title>
      <published>2026-08-15T00:00:00.000Z</published>
      <updated>2026-08-15T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-3/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20260815<a href="#更新公告20260815"><span>#</span></a></h2><p>1.新增MD语法下的哔哩哔哩控件</p><p>2.修复了哔哩哔哩控件无法正常显示的问题</p><p>3.修复了移动端哔哩哔哩控件文字溢出的问题</p><p>4.为网站添加Live2D控件</p><hr /></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-2/</id>
      <title type="text">Twikoo评论系统部署踩坑记录：Vercel域名无法访问的解决之路</title>
      <published>2026-08-14T00:00:00.000Z</published>
      <updated>2026-08-14T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-2/"/>
      <summary type="text">解决评论系统无法部署的经历</summary>
      <content type="html"><![CDATA[<section><h1>Twikoo 评论系统部署踩坑记录：Vercel 域名无法访问的解决之路<a href="#twikoo-评论系统部署踩坑记录vercel-域名无法访问的解决之路"><span>#</span></a></h1><blockquote><p>给自己留个备忘，也给遇到同样问题的朋友指条路。</p></blockquote><section><h2>起因<a href="#起因"><span>#</span></a></h2><p>博客的主要内容基本完成更换，于是决定完善评论系统。</p><p>在本网站项目里其实已经内置好配置文件了，只需修改个人的 envId 即可，最后定了 <a href="https://twikoo.js.org/intro.html" target="_blank"><strong>Twikoo</strong></a>，（最大的原因是免费🤣）并且支持 Vercel 一键部署。</p><p>部署过程遇到一些问题，首先浏览器插件建议关闭，或直接更换浏览器，否则在部署的时候相关控件被隐藏导致部署失败。（建议使用 Chrome ）另外官方文档相关内容略微落后实际网页版本，请以实际网页内容为主，并且在保存自己的 envId 信息的时候，建议下载 .env 文件保存，后续可以一键导入。部署操作完成后 Vercel 控制台显示 <strong>Ready</strong>，以为已经部署成功了。</p><p>然后问题来了。</p></section><section><h2>问题一：Vercel 默认域名无法访问<a href="#问题一vercel-默认域名无法访问"><span>#</span></a></h2><p>点了 Visit 按钮，浏览器转了半天，给我甩了一句：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>无法访问此网站</span></div></div><div><div><div>2</div></div><div><span>xxxxxxxxx-xxxx-xxxx（你部署后的域名） 的响应时间过长。</span></div></div><div><div><div>3</div></div><div><span>ERR_CONNECTION_TIMED_OUT</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>连接超时。</p><p>第一反应是我哪里配错了。回去检查：</p><ul>
<li>部署状态：Ready ✅</li>
<li>环境变量（MONGODB_URI / USERNAME / PASSWORD）：都配了 ✅</li>
<li>域名配置：Valid Configuration ✅</li>
</ul><p>啥都没问题，但就是打不开。</p><p>后来才反应过来——<strong><code>*.vercel.app</code> 这个默认子域名在大陆本身就属于超时状态</strong>，而官方文档和相关教程并没有提到这点，最新的教程也只停留在23年。</p></section><section><h2>解决方案：绑定自定义域名<a href="#解决方案绑定自定义域名"><span>#</span></a></h2><p>既然默认域名用不了，那就绑定本网站的子域名。</p><section><h3>第一步：Verc 添加域名<a href="#第一步verc-添加域名"><span>#</span></a></h3><p>在 Vercel 项目的 <strong>Settings → Domains</strong> 里点 <strong>Add Existing</strong>，输入 <code>twikoo.xxxx（你的域名）</code>。</p><p>添加完之后，Vercel 会显示 <strong>Invalid Configuration</strong>，并告诉你需要配什么 DNS 记录。它自动识别出我的 DNS 是 Cloudflare 管理的，给出了精确的配置：</p>

<table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td>类型</td><td>CNAME</td></tr><tr><td>名称</td><td>twikoo</td></tr><tr><td>目标</td><td><code>xxxxxx</code></td></tr><tr><td>代理状态</td><td>Disabled</td></tr></tbody></table><p>注意类型一定要填写正确</p></section><section><h3>第二步：Cloudflare 配置 DNS（重点！）<a href="#第二步cloudflare-配置-dns重点"><span>#</span></a></h3><p>去 Cloudflare 的 DNS 记录页面，点 <strong>添加记录</strong>：</p><ul>
<li>类型选 <code>CNAME</code></li>
<li>名称填 <code>twikoo</code></li>
<li>目标填 Vercel 给的那串 <code>xxxxx.com</code> 地址</li>
<li>TTL 保持自动</li>
<li><strong>代理状态必须选「仅 DNS」（灰色云朵）</strong></li>
</ul><p>这里是最容易踩坑的地方。我博客的主域名和其他子域名都是开着 Cloudflare 代理的（橙色云朵），但 Twikoo 这条<strong>必须关代理</strong>。</p><p>原因是：Vercel 需要通过 DNS 验证域名所有权并签发 SSL 证书，如果开着 Cloudflare 代理，请求会先走到 Cloudflare，Vercel 验证不过去，证书也签不下来。</p></section><section><h3>第三步：等待验证和证书签发<a href="#第三步等待验证和证书签发"><span>#</span></a></h3><p>保存 DNS 记录后，回到 Vercel 点 <strong>Refresh</strong>。</p><p>先是 DNS 验证通过（蓝色对勾），然后状态变成 <strong>Generating SSL Certificate</strong>。等了大概一两分钟，再访问 <code>https://xxxxx.xxxxx.xxxxx</code>，返回：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"code"</span><span>:</span><span>100</span><span>,</span><span>"message"</span><span>:</span><span>"Twikoo 云函数运行正常，请参考 https://twikoo.js.org/frontend.html 完成前端的配置"</span><span>,</span><span>"version"</span><span>:</span><span>"1.7.19"</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>到这一步就算是成了。</p></section><section><h3>第四步：博客前端配置<a href="#第四步博客前端配置"><span>#</span></a></h3><p>最后在博客的 Twikoo 配置里，把 <code>envId</code> 改成自定义域名：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://xxxxxxx.xxxxx.xxxx</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>原来的 <code>*.vercel.app</code> 地址就可以换掉了。</p><p>评论系统首次上线一定要点设置，保存密码，并在配置文件里隐藏设置控件。</p></section></section><section><h2>总结<a href="#总结"><span>#</span></a></h2><p>如果你也在部署 Twikoo 或者类似的 Vercel 项目，并遇到相关问题，希望这篇能帮你少走点弯路。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-2/</id>
      <title type="text">更新公告20260814</title>
      <published>2026-08-14T00:00:00.000Z</published>
      <updated>2026-08-14T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-2/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20260814<a href="#更新公告20260814"><span>#</span></a></h2><p>1.留言功能正式上线</p><p>2.更换公告图片</p><p>3.修复了网站文章丢失的问题</p><p>4.为网站添加Umami后台控件，网站数据后台可视化</p><p>5.添加不蒜子访客量统计</p><p>6.修复访客量不显示的问题</p><p>7.删除了Markdown语法中的哔哩哔哩控件，项目手动回档</p><p>8.修复了站点统计异常显示的BUG</p><hr /></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/azuma.zuomu-blog-1/</id>
      <title type="text">这是我的第一篇BLOG</title>
      <published>2026-08-13T00:00:00.000Z</published>
      <updated>2026-08-13T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/azuma.zuomu-blog-1/"/>
      <summary type="text">首次个人博客搭建记录</summary>
      <content type="html"><![CDATA[<section><h2>来自我的第一条博客<a href="#来自我的第一条博客"><span>#</span></a></h2><p>🎉 从零开始，翻阅众多教程，在 <strong><em>MIMO</em></strong> 的帮助和自己不懈的调试摸索下，我终于搭建完成了属于自己的个人博客！</p><hr /><ul>
<li>📦 感谢开源项目：<strong><a href="https://github.com/CuteLeaf/Firefly" target="_blank">Firefly</a></strong></li>
<li>📚 感谢 B站UP主：<strong><a href="https://space.bilibili.com/2017273493?spm_id_from=333.788.upinfo.head.click" target="_blank">番茄煮理人</a></strong>，借助她的视频与博客教程，顺利完成整套部署。</li>
<li>☁️ 感谢服务平台：<a href="https://github.com/" target="_blank">Github</a>、<a href="https://dash.cloudflare.com/" target="_blank">Cloudflare</a>、<a href="https://www.aliyun.com/?utm_content=se_1023334680" target="_blank">阿里云</a></li>
</ul><hr /><p>接下来我会继续摸索，持续更新与完善本站。</p><p>END</p></section>]]></content>
    </entry>
    <entry>
      <id>https://mstzuomu.space/posts/web-update-1/</id>
      <title type="text">更新公告20260813</title>
      <published>2026-08-13T00:00:00.000Z</published>
      <updated>2026-08-13T00:00:00.000Z</updated>
      <author><name>左沐</name></author>
      <link rel="alternate" href="https://mstzuomu.space/posts/web-update-1/"/>
      <summary type="text">网站更新公告</summary>
      <content type="html"><![CDATA[<hr />
<section><h2>更新公告20260813<a href="#更新公告20260813"><span>#</span></a></h2><p>1.移除了站点信息组件</p><p>2.新增<strong>访客信息</strong>组件，并添加<strong>地址</strong>显示以及<strong>天气</strong>显示</p><p>3.更换天气API地址</p><p>4.新增移动端访客信息组件显示</p><p>5.修复了移动端访客信息无法成功加载的问题</p><p>6.新增<strong>留言</strong>控件(留言功能暂未开放)</p><hr /></section>]]></content>
    </entry>
</feed>
