<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://929255.xyz/</id>
  <title type="text">早安喵栈</title>
  <subtitle type="text">早安的赛博猫爬架</subtitle>
  <updated>2026-11-01T10:38:15.478Z</updated>
  <author><name>ZaoCat</name></author>
  <link rel="alternate" href="https://929255.xyz/"/>
  <link rel="self" href="https://929255.xyz/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://929255.xyz/posts/add-ai-to-astro/</id>
      <title type="text">在 Astro Firefly 主题中集成专属 AI 智能助手：从 API 配置到 UI 落地全记录</title>
      <published>2026-10-31T00:00:00.000Z</published>
      <updated>2026-10-31T16:45:39.120Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/add-ai-to-astro/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>在搭建独立博客的过程中，为访客提供一个轻量、实时且懂你网站的 AI 助手一直是许多独立开发者的心愿。今天这篇文章，就带大家把一个非intrusive（不唐突）、支持上下文问答与文章摘要的 AI 聊天小组件，完美集成到基于 Astro 的 <strong>Firefly</strong> 主题中。</p>
<section><h2>技术栈选型<a href="#技术栈选型"><span>#</span></a></h2><ul>
<li><strong>前端框架</strong>：Astro (SSR 动态渲染模式)</li>
<li><strong>部署托管</strong>：Vercel Serverless Functions</li>
<li><strong>AI 引擎</strong>：Groq API (选用高性价比且高效的 <code>openai/gpt-oss-20b</code> 模型)</li>
<li><strong>UI 交互</strong>：原生 Tailwind CSS + 浮动卡片交互，完美兼容 Swup 无刷新跳转</li>
</ul></section>
<section><h2>后端 API 接口实现 (<code>src/pages/api/ai.ts</code>)<a href="#后端-api-接口实现-srcpagesapiaits"><span>#</span></a></h2><p>为了保证安全性，我们不能把 API Key 暴露在前端。因此，我们需要在 Astro 中创建一个后端的 Serverless 接口。</p><p>这里我们不仅实现了基础的聊天问答，还加入了针对长文章的 <strong>AI 摘要功能</strong>，以及一套<strong>全能文本清洗逻辑</strong>——用来彻底过滤掉大模型偶尔“抽风”吐出的 Markdown 表格管道符（<code>|</code>）和多余符号，确保在纯文本聊天框中排版清爽。</p><p>在项目的 <code>src/pages/api/</code> 目录下创建 <code>ai.ts</code> 文件：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> </span><span>type</span><span> </span><span>{</span><span><span> </span><span>APIRoute</span><span> </span></span><span>}</span><span> </span><span>from</span><span> </span><span>'</span><span>astro</span><span>'</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>const</span><span><span> </span><span>prerender</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>// 🧹 全能文本清洗函数：确保在纯文本气泡中格式清爽、多余 Markdown 符号彻底被清除</span></div></div><div><div><div>6</div></div><div><span>function</span><span> </span><span>cleanAiResponse</span><span>(</span><span>text</span><span>:</span><span> </span><span>string</span><span>):</span><span> </span><span>string</span><span> </span><span>{</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> </span><span>(!</span><span>text</span><span>)</span><span> </span><span>return</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>let</span><span><span> </span><span>cleaned</span><span> </span></span><span>=</span><span><span> </span><span>text</span></span><span>;</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>// 1. 转换表格管道符为竖向列表</span></div></div><div><div><div>12</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>cleaned</span><span>.</span><span>includes</span><span>(</span><span>'</span><span>|</span><span>'</span><span>))</span><span> </span><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>const</span><span><span> </span><span>lines</span><span> </span></span><span>=</span><span><span> </span><span>cleaned</span></span><span>.</span><span>split</span><span>(</span><span>'</span><span>\n</span><span>'</span><span>);</span></div></div><div><div><div>14</div></div><div><span>    </span><span>const</span><span><span> </span><span>formattedLines</span></span><span>:</span><span> </span><span>string</span><span>[]</span><span> </span><span>=</span><span> </span><span>[];</span></div></div><div><div><div>15</div></div><div><span>    </span><span>for</span><span> </span><span>(</span><span>const</span><span><span> </span><span>line</span><span> </span></span><span>of</span><span><span> </span><span>lines</span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>16</div></div><div><span>      </span><span>if</span><span> </span><span>(</span><span>line</span><span>.</span><span>replace</span><span>(</span><span><span>/[</span><span>\s</span><span>|:</span></span><span>\-</span><span>]+/</span><span>g</span><span>,</span><span> </span><span>''</span><span>)</span><span> </span><span>===</span><span> </span><span>''</span><span>)</span><span> </span><span>continue</span><span>;</span></div></div><div><div><div>17</div></div><div><span>      </span><span>if</span><span> </span><span>(</span><span>line</span><span>.</span><span>includes</span><span>(</span><span>'</span><span>|</span><span>'</span><span>))</span><span> </span><span>{</span></div></div><div><div><div>18</div></div><div><span>        </span><span>const</span><span><span> </span><span>parts</span><span> </span></span><span>=</span><span><span> </span><span>line</span></span><span>.</span><span>split</span><span>(</span><span>'</span><span>|</span><span>'</span><span>).</span><span>map</span><span>(</span><span><span>p</span><span> </span></span><span>=&gt;</span><span><span> </span><span>p</span></span><span>.</span><span>trim</span><span>()).</span><span>filter</span><span>(</span><span>Boolean</span><span>);</span></div></div><div><div><div>19</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span>parts</span><span>.</span><span><span>length</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>20</div></div><div><span><span>          </span></span><span>formattedLines</span><span>.</span><span>push</span><span>(</span><span>`</span><span>• </span><span>${</span><span>parts</span><span>.</span><span>join</span><span>(</span><span>'</span><span>：</span><span>'</span><span>)</span><span><span>}</span><span>`</span></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><span><span>      </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>23</div></div><div><span><span>        </span></span><span>formattedLines</span><span>.</span><span>push</span><span>(</span><span>line</span><span>);</span></div></div><div><div><div>24</div></div><div><span><span>      </span></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><span><span>    </span></span><span>cleaned</span><span> </span><span>=</span><span><span> </span><span>formattedLines</span></span><span>.</span><span>join</span><span>(</span><span>'</span><span>\n</span><span>'</span><span>);</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>// 2. 清除裸露的反引号和粗体符号</span></div></div><div><div><div>30</div></div><div><span><span>  </span></span><span>cleaned</span><span> </span><span>=</span><span><span> </span><span>cleaned</span></span><span>.</span><span>replace</span><span>(</span><span>/</span><span>`</span><span>/</span><span>g</span><span>,</span><span> </span><span>''</span><span>);</span></div></div><div><div><div>31</div></div><div><span><span>  </span></span><span>cleaned</span><span> </span><span>=</span><span><span> </span><span>cleaned</span></span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\*\*</span><span>/</span><span>g</span><span>,</span><span> </span><span>''</span><span>);</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span><span>  </span></span><span>// 3. 确保列表项前自带换行，防止粘连</span></div></div><div><div><div>34</div></div><div><span><span>  </span></span><span>cleaned</span><span> </span><span>=</span><span><span> </span><span>cleaned</span></span><span>.</span><span>replace</span><span>(</span><span><span>/</span><span>(</span><span>[</span><span>^</span><span>\n</span><span>]</span><span>)</span><span>\s</span><span>*[-•]</span><span>\s</span><span>*/</span></span><span>g</span><span>,</span><span> </span><span>'</span><span>$1</span><span>\n</span><span>• </span><span>'</span><span>);</span></div></div><div><div><div>35</div></div><div><span><span>  </span></span><span>cleaned</span><span> </span><span>=</span><span><span> </span><span>cleaned</span></span><span>.</span><span>replace</span><span>(</span><span><span>/</span><span>(</span><span>[</span><span>^</span><span>\n</span><span>]</span><span>)</span><span>\s</span><span>*</span><span>(</span><span>\d</span><span>+</span></span><span>\.</span><span><span>)</span><span>\s</span><span>*/</span></span><span>g</span><span>,</span><span> </span><span>'</span><span>$1</span><span>\n</span><span>$2 </span><span>'</span><span>);</span></div></div><div><div><div>36</div></div><div>
</div></div><div><div><div>37</div></div><div><span>  </span><span>return</span><span><span> </span><span>cleaned</span></span><span>.</span><span>trim</span><span>();</span></div></div><div><div><div>38</div></div><div><span>}</span></div></div><div><div><div>39</div></div><div>
</div></div><div><div><div>40</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>POST</span><span>:</span><span> </span><span>APIRoute</span><span> </span><span>=</span><span> </span><span>async</span><span> </span><span>({</span><span><span> </span><span>request</span><span> </span></span><span>})</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>41</div></div><div><span>  </span><span>try</span><span> </span><span>{</span></div></div><div><div><div>42</div></div><div><span>    </span><span>const</span><span><span> </span><span>apiKey</span><span> </span></span><span>=</span><span> </span><span>import</span><span>.</span><span>meta</span><span>.</span><span>env</span><span>.</span><span><span>GROQ_API_KEY</span><span> </span></span><span>||</span><span><span> </span><span>process</span></span><span>.</span><span>env</span><span>.</span><span>GROQ_API_KEY</span><span>;</span></div></div><div><div><div>43</div></div><div>
</div></div><div><div><div>44</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>apiKey</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>45</div></div><div><span>      </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span>(</span></div></div><div><div><div>46</div></div><div><span><span>        </span></span><span>JSON</span><span>.</span><span>stringify</span><span>({</span><span> </span><span>result</span><span>:</span><span> </span><span>"</span><span>服务器未配置 API Key，请检查 Vercel 环境变量设置。</span><span>"</span><span> </span><span>}),</span></div></div><div><div><div>47</div></div><div><span><span>        </span></span><span>{</span><span> </span><span>status</span><span>:</span><span> </span><span>200</span><span>,</span><span> </span><span>headers</span><span>:</span><span> </span><span>{</span><span> </span><span>'</span><span>Content-Type</span><span>'</span><span>:</span><span> </span><span>'</span><span>application/json</span><span>'</span><span> </span><span>}</span><span> </span><span>}</span></div></div><div><div><div>48</div></div><div><span><span>      </span></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>const</span><span><span> </span><span>body</span><span> </span></span><span>=</span><span> </span><span>await</span><span><span> </span><span>request</span></span><span>.</span><span>json</span><span>();</span></div></div><div><div><div>52</div></div><div><span>    </span><span>const</span><span> </span><span>{</span><span><span> </span><span>action</span></span><span>,</span><span><span> </span><span>prompt</span></span><span>,</span><span><span> </span><span>text</span><span> </span></span><span>}</span><span> </span><span>=</span><span><span> </span><span>body</span></span><span>;</span></div></div><div><div><div>53</div></div><div>
</div></div><div><div><div>54</div></div><div><span>    </span><span>let</span><span><span> </span><span>systemPrompt</span><span> </span></span><span>=</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>55</div></div><div><span>    </span><span>let</span><span><span> </span><span>userContent</span><span> </span></span><span>=</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>56</div></div><div>
</div></div><div><div><div>57</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span><span>action</span><span> </span></span><span>===</span><span> </span><span>'</span><span>summary</span><span>'</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>58</div></div><div><span><span>      </span></span><span>systemPrompt</span><span> </span><span>=</span><span> </span><span>"</span><span>你是一个专业的博客文章摘要助手。请为以下文章生成一段结构清晰、内容充实的摘要，字数控制在 150~200 字左右，包含文章的核心观点和主要干货，语言亲切自然，不要使用表格。</span><span>"</span><span>;</span></div></div><div><div><div>59</div></div><div><span><span>      </span></span><span>userContent</span><span> </span><span>=</span><span> </span><span>(</span><span><span>text</span><span> </span></span><span>||</span><span> </span><span>""</span><span>).</span><span>slice</span><span>(</span><span>0</span><span>,</span><span> </span><span>3000</span><span>);</span></div></div><div><div><div>60</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>61</div></div><div><span><span>      </span></span><span>systemPrompt</span><span> </span><span>=</span><span> </span><span>`</span><span>你是「喵栈 AI」小助手，专为独立博客「早安喵栈 (929255.xyz)」服务。</span></div></div><div><div><div>62</div></div><div><span>【核心规则】：</span></div></div><div><div><div>63</div></div><div><span>1. 语言亲切可爱，控制在 100 字以内。</span></div></div><div><div><div>64</div></div><div><span>2. 遇到列表或多项说明时，每条内容必须换行输出（使用 - 或 • 开头）。</span></div></div><div><div><div>65</div></div><div><span>3. 严禁使用表格，严禁输出反引号或粗体符号。</span></div></div><div><div><div>66</div></div><div>
</div></div><div><div><div>67</div></div><div><span>【本站背景与精准指引】：</span></div></div><div><div><div>68</div></div><div><span>- 站点核心技术：Astro 静态博客框架、Tailwind CSS 自定义样式、Vercel 自动化部署、GitHub 托管、Cloudflare CDN 加速、Markdown/MDX 写作。</span></div></div><div><div><div>69</div></div><div><span>- 若询问“有哪些文章推荐”：请推荐本站关于 Astro 博客配置、Vercel 部署实践、Docker/Gatus 自建服务和独立博客搭建的探索记录。</span></div></div><div><div><div>70</div></div><div><span>- 若询问“怎么搭建类似博客”：简要列出使用 Astro、Tailwind CSS、Vercel 与 GitHub 部署的步骤。</span><span>`</span><span>;</span></div></div><div><div><div>71</div></div><div>
</div></div><div><div><div>72</div></div><div><span><span>      </span></span><span>userContent</span><span> </span><span>=</span><span><span> </span><span>prompt</span><span> </span></span><span>||</span><span> </span><span>"</span><span>你好</span><span>"</span><span>;</span></div></div><div><div><div>73</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>74</div></div><div>
</div></div><div><div><div>75</div></div><div><span><span>    </span></span><span>// 封装带自动重试的 Groq 请求函数（应对 429 或 500 抖动）</span></div></div><div><div><div>76</div></div><div><span>    </span><span>async</span><span> </span><span>function</span><span> </span><span>fetchWithRetry</span><span>(</span><span><span>retries</span><span> </span></span><span>=</span><span> </span><span>2</span><span>,</span><span><span> </span><span>delay</span><span> </span></span><span>=</span><span> </span><span>1000</span><span>):</span><span><span> </span><span>Promise</span></span><span>&lt;</span><span>any</span><span>&gt;</span><span> </span><span>{</span></div></div><div><div><div>77</div></div><div><span>      </span><span>try</span><span> </span><span>{</span></div></div><div><div><div>78</div></div><div><span>        </span><span>const</span><span><span> </span><span>response</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span><span>'</span><span>https://api.groq.com/openai/v1/chat/completions</span><span>'</span><span>,</span><span> </span><span>{</span></div></div><div><div><div>79</div></div><div><span>          </span><span>method</span><span>:</span><span> </span><span>'</span><span>POST</span><span>'</span><span>,</span></div></div><div><div><div>80</div></div><div><span>          </span><span>headers</span><span>:</span><span> </span><span>{</span></div></div><div><div><div>81</div></div><div><span>            </span><span>'</span><span>Authorization</span><span>'</span><span>:</span><span> </span><span>`</span><span>Bearer </span><span>${</span><span>apiKey</span><span><span>}</span><span>`</span></span><span>,</span></div></div><div><div><div>82</div></div><div><span>            </span><span>'</span><span>Content-Type</span><span>'</span><span>:</span><span> </span><span>'</span><span>application/json</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><span>          </span><span>body</span><span>:</span><span><span> </span><span>JSON</span></span><span>.</span><span>stringify</span><span>({</span></div></div><div><div><div>85</div></div><div><span>            </span><span>model</span><span>:</span><span> </span><span>'</span><span>openai/gpt-oss-20b</span><span>'</span><span>,</span></div></div><div><div><div>86</div></div><div><span>            </span><span>messages</span><span>:</span><span> </span><span>[</span></div></div><div><div><div>87</div></div><div><span><span>              </span></span><span>{</span><span> </span><span>role</span><span>:</span><span> </span><span>'</span><span>system</span><span>'</span><span>,</span><span> </span><span>content</span><span>:</span><span><span> </span><span>systemPrompt</span><span> </span></span><span>},</span></div></div><div><div><div>88</div></div><div><span><span>              </span></span><span>{</span><span> </span><span>role</span><span>:</span><span> </span><span>'</span><span>user</span><span>'</span><span>,</span><span> </span><span>content</span><span>:</span><span><span> </span><span>userContent</span><span> </span></span><span>}</span></div></div><div><div><div>89</div></div><div><span><span>            </span></span><span>],</span></div></div><div><div><div>90</div></div><div><span>            </span><span>temperature</span><span>:</span><span> </span><span>0.2</span><span>,</span></div></div><div><div><div>91</div></div><div><span>            </span><span>max_tokens</span><span>:</span><span> </span><span>800</span></div></div><div><div><div>92</div></div><div><span><span>          </span></span><span>})</span></div></div><div><div><div>93</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>94</div></div><div>
</div></div><div><div><div>95</div></div><div><span>        </span><span>if</span><span> </span><span>(!</span><span>response</span><span>.</span><span>ok</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>96</div></div><div><span>          </span><span>const</span><span><span> </span><span>errText</span><span> </span></span><span>=</span><span> </span><span>await</span><span><span> </span><span>response</span></span><span>.</span><span>text</span><span>();</span></div></div><div><div><div>97</div></div><div><span>          </span><span>if</span><span> </span><span>((</span><span>response</span><span>.</span><span><span>status</span><span> </span></span><span>===</span><span> </span><span>429</span><span> </span><span>||</span><span><span> </span><span>response</span></span><span>.</span><span><span>status</span><span> </span></span><span>&gt;=</span><span> </span><span>500</span><span>)</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>retries</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>98</div></div><div><span>            </span><span>await</span><span> </span><span>new</span><span> </span><span>Promise</span><span>(</span><span><span>resolve</span><span> </span></span><span>=&gt;</span><span> </span><span>setTimeout</span><span>(</span><span>resolve</span><span>,</span><span><span> </span><span>delay</span></span><span>));</span></div></div><div><div><div>99</div></div><div><span>            </span><span>return</span><span> </span><span>fetchWithRetry</span><span>(</span><span><span>retries</span><span> </span></span><span>-</span><span> </span><span>1</span><span>,</span><span><span> </span><span>delay</span><span> </span></span><span>*</span><span> </span><span>2</span><span>);</span></div></div><div><div><div>100</div></div><div><span><span>          </span></span><span>}</span></div></div><div><div><div>101</div></div><div><span>          </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>`</span><span>Groq 状态码 </span><span>${</span><span>response</span><span>.</span><span>status</span><span>}</span><span>: </span><span>${</span><span>errText</span><span><span>}</span><span>`</span></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>
</div></div><div><div><div>104</div></div><div><span>        </span><span>return</span><span> </span><span>await</span><span><span> </span><span>response</span></span><span>.</span><span>json</span><span>();</span></div></div><div><div><div>105</div></div><div><span><span>      </span></span><span>}</span><span> </span><span>catch</span><span> </span><span>(</span><span>error</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>106</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span><span>retries</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>107</div></div><div><span>          </span><span>await</span><span> </span><span>new</span><span> </span><span>Promise</span><span>(</span><span><span>resolve</span><span> </span></span><span>=&gt;</span><span> </span><span>setTimeout</span><span>(</span><span>resolve</span><span>,</span><span><span> </span><span>delay</span></span><span>));</span></div></div><div><div><div>108</div></div><div><span>          </span><span>return</span><span> </span><span>fetchWithRetry</span><span>(</span><span><span>retries</span><span> </span></span><span>-</span><span> </span><span>1</span><span>,</span><span><span> </span><span>delay</span><span> </span></span><span>*</span><span> </span><span>2</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>throw</span><span><span> </span><span>error</span></span><span>;</span></div></div><div><div><div>111</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>112</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>113</div></div><div>
</div></div><div><div><div>114</div></div><div><span>    </span><span>const</span><span><span> </span><span>data</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>fetchWithRetry</span><span>();</span></div></div><div><div><div>115</div></div><div><span>    </span><span>const</span><span><span> </span><span>rawResult</span><span> </span></span><span>=</span><span><span> </span><span>data</span></span><span>.</span><span>choices</span><span>?.[</span><span>0</span><span>]?.</span><span>message</span><span>?.</span><span><span>content</span><span> </span></span><span>||</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>116</div></div><div>
</div></div><div><div><div>117</div></div><div><span>    </span><span>const</span><span><span> </span><span>aiResult</span><span> </span></span><span>=</span><span> </span><span>cleanAiResponse</span><span>(</span><span>rawResult</span><span>);</span></div></div><div><div><div>118</div></div><div>
</div></div><div><div><div>119</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>aiResult</span><span>.</span><span>trim</span><span>())</span><span> </span><span>{</span></div></div><div><div><div>120</div></div><div><span>      </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span>(</span></div></div><div><div><div>121</div></div><div><span><span>        </span></span><span>JSON</span><span>.</span><span>stringify</span><span>({</span><span> </span><span>result</span><span>:</span><span> </span><span>"</span><span>喵~ AI 刚才走神了，请再点一次发送试试！</span><span>"</span><span> </span><span>}),</span></div></div><div><div><div>122</div></div><div><span><span>        </span></span><span>{</span><span> </span><span>status</span><span>:</span><span> </span><span>200</span><span>,</span><span> </span><span>headers</span><span>:</span><span> </span><span>{</span><span> </span><span>'</span><span>Content-Type</span><span>'</span><span>:</span><span> </span><span>'</span><span>application/json</span><span>'</span><span> </span><span>}</span><span> </span><span>}</span></div></div><div><div><div>123</div></div><div><span><span>      </span></span><span>);</span></div></div><div><div><div>124</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>125</div></div><div>
</div></div><div><div><div>126</div></div><div><span>    </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span>(</span></div></div><div><div><div>127</div></div><div><span><span>      </span></span><span>JSON</span><span>.</span><span>stringify</span><span>({</span><span> </span><span>result</span><span>:</span><span><span> </span><span>aiResult</span><span> </span></span><span>}),</span></div></div><div><div><div>128</div></div><div><span><span>      </span></span><span>{</span><span> </span><span>status</span><span>:</span><span> </span><span>200</span><span>,</span><span> </span><span>headers</span><span>:</span><span> </span><span>{</span><span> </span><span>'</span><span>Content-Type</span><span>'</span><span>:</span><span> </span><span>'</span><span>application/json</span><span>'</span><span> </span><span>}</span><span> </span><span>}</span></div></div><div><div><div>129</div></div><div><span><span>    </span></span><span>);</span></div></div><div><div><div>130</div></div><div>
</div></div><div><div><div>131</div></div><div><span><span>  </span></span><span>}</span><span> </span><span>catch</span><span> </span><span>(</span><span>error</span><span>:</span><span> </span><span>any</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>132</div></div><div><span><span>    </span></span><span>console</span><span>.</span><span>error</span><span>(</span><span>"</span><span>❌ AI API 内部错误:</span><span>"</span><span>,</span><span><span> </span><span>error</span></span><span>);</span></div></div><div><div><div>133</div></div><div><span>    </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span>(</span></div></div><div><div><div>134</div></div><div><span><span>      </span></span><span>JSON</span><span>.</span><span>stringify</span><span>({</span><span> </span><span>result</span><span>:</span><span> </span><span>"</span><span>网络开小差啦，请稍后再提问~</span><span>"</span><span> </span><span>}),</span></div></div><div><div><div>135</div></div><div><span><span>      </span></span><span>{</span><span> </span><span>status</span><span>:</span><span> </span><span>200</span><span>,</span><span> </span><span>headers</span><span>:</span><span> </span><span>{</span><span> </span><span>'</span><span>Content-Type</span><span>'</span><span>:</span><span> </span><span>'</span><span>application/json</span><span>'</span><span> </span><span>}</span><span> </span><span>}</span></div></div><div><div><div>136</div></div><div><span><span>    </span></span><span>);</span></div></div><div><div><div>137</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>138</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h2>前端悬浮挂件与 UI 落地 (<code>Layout.astro</code> 或根布局)<a href="#前端悬浮挂件与-ui-落地-layoutastro-或根布局"><span>#</span></a></h2><p>为了让所有页面都能在左下角展示这个可爱的 AI 悬浮球，我们在全局布局文件（例如 Firefly 主题的根 Layout 中）加入了挂件的 HTML 结构与交互脚本。</p><section><h3>1. HTML 与样式组件<a href="#1-html-与样式组件"><span>#</span></a></h3><p>在布局文件的底部（<code>&lt;/body&gt;</code> 之前）加入悬浮窗 DOM：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!-- 🤖 全站固定悬浮“喵栈 AI”小助手（固定左下角） --&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-widget</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>fixed bottom-6 left-6 z-50</span><span>"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>&lt;!-- 聊天对话框（默认隐藏） --&gt;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-box</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>hidden w-[360px] h-[460px] mb-4 rounded-2xl border border-black/10 dark:border-white/10 card-base shadow-2xl flex flex-col overflow-hidden text-neutral-800 dark:text-neutral-200</span><span>"</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span>    </span><span>&lt;!-- 头部 --&gt;</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>px-4 py-3 border-b border-black/10 dark:border-white/10 flex justify-between items-center bg-black/5 dark:bg-white/5</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> </span><span>class</span><span>=</span><span>"</span><span>flex items-center gap-2.5</span><span>"</span><span>&gt;</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>w-7 h-7 rounded-lg bg-teal-500/20 border border-teal-500/30 flex items-center justify-center text-teal-600 dark:text-teal-400 text-sm</span><span>"</span><span>&gt;</span><span>🐱</span><span>&lt;/</span><span>div</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>class</span><span>=</span><span>"</span><span>font-semibold text-sm text-neutral-900 dark:text-white</span><span>"</span><span>&gt;</span><span>喵栈 AI</span><span>&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>span</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 font-medium</span><span>"</span><span>&gt;</span><span>在线</span><span>&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>flex items-center gap-3 text-neutral-500 dark:text-neutral-400 text-xs</span><span>"</span><span>&gt;</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>&lt;</span><span>button</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-clear</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>hover:text-black dark:hover:text-white transition-colors</span><span>"</span><span> </span><span>title</span><span>=</span><span>"</span><span>清空对话</span><span>"</span><span>&gt;</span><span>🗑️</span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>&lt;</span><span>button</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-close</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>hover:text-black dark:hover:text-white text-base leading-none transition-colors</span><span>"</span><span>&gt;</span><span>✕</span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>15</div></div><div><span><span>      </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>17</div></div><div>
</div></div><div><div><div>18</div></div><div><span>    </span><span>&lt;!-- 消息展示区（关键点：加入 whitespace-pre-wrap 确保换行生效） --&gt;</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-messages</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>flex-1 p-4 overflow-y-auto space-y-3.5 text-xs leading-relaxed</span><span>"</span><span>&gt;</span></div></div><div><div><div>20</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>flex gap-2</span><span>"</span><span>&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>"</span><span>w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0</span><span>"</span><span>&gt;</span><span>🐱</span><span>&lt;/</span><span>div</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>"</span><span>space-y-3</span><span>"</span><span>&gt;</span></div></div><div><div><div>23</div></div><div><span><span>          </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-3 rounded-2xl rounded-tl-none max-w-[90%] text-neutral-800 dark:text-neutral-200 whitespace-pre-wrap</span><span>"</span><span>&gt;</span></div></div><div><div><div>24</div></div><div><span><span>            </span></span><span>喵~ 我是「喵栈 AI」小助手，专门为早安喵栈 (929255.xyz) 服务。有什么想问的吗？</span></div></div><div><div><div>25</div></div><div><span><span>          </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>26</div></div><div><span>          </span><span>&lt;!-- 快捷提问按钮 --&gt;</span></div></div><div><div><div>27</div></div><div><span><span>          </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>flex flex-wrap gap-2 pt-1</span><span>"</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-quick-prompts</span><span>"</span><span>&gt;</span></div></div><div><div><div>28</div></div><div><span><span>            </span></span><span>&lt;</span><span>button</span><span> </span><span>class</span><span>=</span><span>"</span><span>quick-btn px-3 py-1.5 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 text-neutral-700 dark:text-neutral-300 text-[11px] transition-all hover:scale-[1.02] active:scale-95</span><span>"</span><span>&gt;</span></div></div><div><div><div>29</div></div><div><span><span>              </span></span><span>这个博客用了什么技术？</span></div></div><div><div><div>30</div></div><div><span><span>            </span></span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>31</div></div><div><span><span>            </span></span><span>&lt;</span><span>button</span><span> </span><span>class</span><span>=</span><span>"</span><span>quick-btn px-3 py-1.5 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 text-neutral-700 dark:text-neutral-300 text-[11px] transition-all hover:scale-[1.02] active:scale-95</span><span>"</span><span>&gt;</span></div></div><div><div><div>32</div></div><div><span><span>              </span></span><span>有哪些文章推荐？</span></div></div><div><div><div>33</div></div><div><span><span>            </span></span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>34</div></div><div><span><span>            </span></span><span>&lt;</span><span>button</span><span> </span><span>class</span><span>=</span><span>"</span><span>quick-btn px-3 py-1.5 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 text-neutral-700 dark:text-neutral-300 text-[11px] transition-all hover:scale-[1.02] active:scale-95</span><span>"</span><span>&gt;</span></div></div><div><div><div>35</div></div><div><span><span>              </span></span><span>怎么搭建类似的博客？</span></div></div><div><div><div>36</div></div><div><span><span>            </span></span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>37</div></div><div><span><span>          </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>39</div></div><div><span><span>      </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>40</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>41</div></div><div>
</div></div><div><div><div>42</div></div><div><span>    </span><span>&lt;!-- 底部输入框 --&gt;</span></div></div><div><div><div>43</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>p-3 border-t border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex gap-2 items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>44</div></div><div><span><span>      </span></span><span>&lt;</span><span>input</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-input</span><span>"</span><span> </span><span>type</span><span>=</span><span>"</span><span>text</span><span>"</span><span> </span><span>placeholder</span><span>=</span><span>"</span><span>向喵栈 AI 提问...</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>flex-1 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl px-3.5 py-2 text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 outline-none focus:border-black/30 dark:focus:border-white/30 text-xs transition-colors</span><span>"</span><span> /&gt;</span></div></div><div><div><div>45</div></div><div><span><span>      </span></span><span>&lt;</span><span>button</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-send</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>bg-black/10 hover:bg-black/20 dark:bg-white/15 dark:hover:bg-white/25 text-neutral-900 dark:text-white px-3.5 py-2 rounded-xl text-xs font-medium transition-all active:scale-95</span><span>"</span><span>&gt;</span><span>发送</span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>46</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>47</div></div><div><span><span>  </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>48</div></div><div>
</div></div><div><div><div>49</div></div><div><span>  </span><span>&lt;!-- 悬浮触发圆球 --&gt;</span></div></div><div><div><div>50</div></div><div><span><span>  </span></span><span>&lt;</span><span>button</span><span> </span><span>id</span><span>=</span><span>"</span><span>ai-toggle</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>w-12 h-12 rounded-full card-base border border-black/10 dark:border-white/10 shadow-2xl flex items-center justify-center text-xl hover:scale-110 active:scale-95 transition-transform</span><span>"</span><span>&gt;</span></div></div><div><div><div>51</div></div><div><span><span>    </span></span><span>🐱</span></div></div><div><div><div>52</div></div><div><span><span>  </span></span><span>&lt;/</span><span>button</span><span>&gt;</span></div></div><div><div><div>53</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section><section><h3>2. 交互与无刷新挂载脚本 (<code>&lt;script&gt;</code>)<a href="#2-交互与无刷新挂载脚本-script"><span>#</span></a></h3><p>由于 Astro 主题通常支持 Swup 无刷新路由切换，我们需要确保每次页面跳转后脚本都能正确初始化：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>script</span><span> </span><span>is:inline</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>  </span><span>function</span><span> </span><span>initGlobalAIFeatures</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>const</span><span><span> </span><span>toggleBtn</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-toggle</span><span>'</span><span>);</span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span><span> </span><span>closeBtn</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-close</span><span>'</span><span>);</span></div></div><div><div><div>5</div></div><div><span>    </span><span>const</span><span><span> </span><span>clearBtn</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-clear</span><span>'</span><span>);</span></div></div><div><div><div>6</div></div><div><span>    </span><span>const</span><span><span> </span><span>box</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-box</span><span>'</span><span>);</span></div></div><div><div><div>7</div></div><div><span>    </span><span>const</span><span><span> </span><span>sendBtn</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-send</span><span>'</span><span>);</span></div></div><div><div><div>8</div></div><div><span>    </span><span>const</span><span><span> </span><span>input</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-input</span><span>'</span><span>);</span></div></div><div><div><div>9</div></div><div><span>    </span><span>const</span><span><span> </span><span>msgContainer</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>'</span><span>ai-messages</span><span>'</span><span>);</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span><span>toggleBtn</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span>toggleBtn</span><span>.</span><span>dataset</span><span>.</span><span>inited</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>toggleBtn</span><span>.</span><span>dataset</span><span>.</span><span><span>inited</span><span> </span></span><span>=</span><span> </span><span>'</span><span>true</span><span>'</span><span>;</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span><span>      </span></span><span>toggleBtn</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>click</span><span>'</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>15</div></div><div><span><span>        </span></span><span>box</span><span>.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>'</span><span>hidden</span><span>'</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></span><span>closeBtn</span><span>?.</span><span>addEventListener</span><span>(</span><span>'</span><span>click</span><span>'</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span><span> </span><span>box</span></span><span>.</span><span>classList</span><span>.</span><span>add</span><span>(</span><span>'</span><span>hidden</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></span><span>clearBtn</span><span>?.</span><span>addEventListener</span><span>(</span><span>'</span><span>click</span><span>'</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>21</div></div><div><span><span>        </span></span><span>msgContainer</span><span>.</span><span><span>innerHTML</span><span> </span></span><span>=</span><span> </span><span>`</span></div></div><div><div><div>22</div></div><div><span><span>          </span></span><span>&lt;div class="flex gap-2"&gt;</span></div></div><div><div><div>23</div></div><div><span><span>            </span></span><span>&lt;div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0"&gt;🐱&lt;/div&gt;</span></div></div><div><div><div>24</div></div><div><span><span>            </span></span><span>&lt;div class="space-y-3"&gt;</span></div></div><div><div><div>25</div></div><div><span><span>              </span></span><span>&lt;div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-3 rounded-2xl rounded-tl-none max-w-[90%] text-neutral-800 dark:text-neutral-200 whitespace-pre-wrap"&gt;</span></div></div><div><div><div>26</div></div><div><span><span>                </span></span><span>对话已清空，我是「喵栈 AI」，随时准备为你解答！</span></div></div><div><div><div>27</div></div><div><span><span>              </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>28</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>29</div></div><div><span><span>          </span></span><span>&lt;/div&gt;</span><span>`</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>async</span><span> </span><span>function</span><span> </span><span>handleSend</span><span>(</span><span>textToSend</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>33</div></div><div><span>        </span><span>const</span><span><span> </span><span>text</span><span> </span></span><span>=</span><span><span> </span><span>textToSend</span><span> </span></span><span>||</span><span><span> </span><span>input</span></span><span>.</span><span>value</span><span>.</span><span>trim</span><span>();</span></div></div><div><div><div>34</div></div><div><span>        </span><span>if</span><span> </span><span>(!</span><span>text</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div><span><span>        </span></span><span>msgContainer</span><span>.</span><span><span>innerHTML</span><span> </span></span><span>+=</span><span> </span><span>`</span></div></div><div><div><div>37</div></div><div><span><span>          </span></span><span>&lt;div class="flex justify-end"&gt;</span></div></div><div><div><div>38</div></div><div><span><span>            </span></span><span>&lt;div class="bg-teal-600/20 border border-teal-500/30 text-teal-900 dark:text-teal-100 p-2.5 px-3.5 rounded-2xl rounded-tr-none max-w-[85%] text-xs"&gt;</span></div></div><div><div><div>39</div></div><div><span>              </span><span>${</span><span>text</span><span>}</span></div></div><div><div><div>40</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>41</div></div><div><span><span>          </span></span><span>&lt;/div&gt;</span><span>`</span><span>;</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>        </span><span>if</span><span> </span><span>(!</span><span>textToSend</span><span>)</span><span><span> </span><span>input</span></span><span>.</span><span><span>value</span><span> </span></span><span>=</span><span> </span><span>''</span><span>;</span></div></div><div><div><div>44</div></div><div><span><span>        </span></span><span>msgContainer</span><span>.</span><span><span>scrollTop</span><span> </span></span><span>=</span><span><span> </span><span>msgContainer</span></span><span>.</span><span>scrollHeight</span><span>;</span></div></div><div><div><div>45</div></div><div>
</div></div><div><div><div>46</div></div><div><span>        </span><span>const</span><span><span> </span><span>loadingId</span><span> </span></span><span>=</span><span> </span><span>'</span><span>loading-</span><span>'</span><span> </span><span>+</span><span><span> </span><span>Date</span></span><span>.</span><span>now</span><span>();</span></div></div><div><div><div>47</div></div><div><span><span>        </span></span><span>msgContainer</span><span>.</span><span><span>innerHTML</span><span> </span></span><span>+=</span><span> </span><span>`</span></div></div><div><div><div>48</div></div><div><span><span>          </span></span><span>&lt;div id="</span><span>${</span><span>loadingId</span><span>}</span><span>" class="flex gap-2"&gt;</span></div></div><div><div><div>49</div></div><div><span><span>            </span></span><span>&lt;div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0"&gt;🐱&lt;/div&gt;</span></div></div><div><div><div>50</div></div><div><span><span>            </span></span><span>&lt;div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-2.5 px-3.5 rounded-2xl rounded-tl-none max-w-[85%] text-neutral-500 dark:text-neutral-400 animate-pulse text-xs"&gt;</span></div></div><div><div><div>51</div></div><div><span><span>              </span></span><span>正在思考...</span></div></div><div><div><div>52</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>53</div></div><div><span><span>          </span></span><span>&lt;/div&gt;</span><span>`</span><span>;</span></div></div><div><div><div>54</div></div><div><span><span>        </span></span><span>msgContainer</span><span>.</span><span><span>scrollTop</span><span> </span></span><span>=</span><span><span> </span><span>msgContainer</span></span><span>.</span><span>scrollHeight</span><span>;</span></div></div><div><div><div>55</div></div><div>
</div></div><div><div><div>56</div></div><div><span>        </span><span>try</span><span> </span><span>{</span></div></div><div><div><div>57</div></div><div><span>          </span><span>const</span><span><span> </span><span>res</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span><span>'</span><span>/api/ai/</span><span>'</span><span>,</span><span> </span><span>{</span></div></div><div><div><div>58</div></div><div><span>            </span><span>method</span><span>:</span><span> </span><span>'</span><span>POST</span><span>'</span><span>,</span></div></div><div><div><div>59</div></div><div><span>            </span><span>headers</span><span>:</span><span> </span><span>{</span><span> </span><span>'</span><span>Content-Type</span><span>'</span><span>:</span><span> </span><span>'</span><span>application/json</span><span>'</span><span> </span><span>},</span></div></div><div><div><div>60</div></div><div><span>            </span><span>body</span><span>:</span><span><span> </span><span>JSON</span></span><span>.</span><span>stringify</span><span>({</span><span> </span><span>action</span><span>:</span><span> </span><span>'</span><span>chat</span><span>'</span><span>,</span><span> </span><span>prompt</span><span>:</span><span><span> </span><span>text</span><span> </span></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><span>          </span><span>const</span><span><span> </span><span>data</span><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>63</div></div><div><span>          </span><span>const</span><span><span> </span><span>targetEl</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>loadingId</span><span>);</span></div></div><div><div><div>64</div></div><div><span>          </span><span>if</span><span> </span><span>(</span><span>targetEl</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>65</div></div><div><span><span>            </span></span><span>// 💡 注意：必须加上 whitespace-pre-wrap 类名，否则换行符会被浏览器忽略</span></div></div><div><div><div>66</div></div><div><span><span>            </span></span><span>targetEl</span><span>.</span><span><span>outerHTML</span><span> </span></span><span>=</span><span> </span><span>`</span></div></div><div><div><div>67</div></div><div><span><span>              </span></span><span>&lt;div class="flex gap-2"&gt;</span></div></div><div><div><div>68</div></div><div><span><span>                </span></span><span>&lt;div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0"&gt;🐱&lt;/div&gt;</span></div></div><div><div><div>69</div></div><div><span><span>                </span></span><span>&lt;div class="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-3 rounded-2xl rounded-tl-none max-w-[85%] text-neutral-800 dark:text-neutral-200 text-xs whitespace-pre-wrap"&gt;</span></div></div><div><div><div>70</div></div><div><span>                  </span><span>${</span><span>data</span><span>.</span><span>result</span><span> </span><span>||</span><span> </span><span>data</span><span>.</span><span>error</span><span> </span><span>||</span><span> </span><span>'</span><span>出错了，请稍后重试。</span><span><span>'</span><span>}</span></span></div></div><div><div><div>71</div></div><div><span><span>                </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>72</div></div><div><span><span>              </span></span><span>&lt;/div&gt;</span><span>`</span><span>;</span></div></div><div><div><div>73</div></div><div><span><span>          </span></span><span>}</span></div></div><div><div><div>74</div></div><div><span><span>        </span></span><span>}</span><span> </span><span>catch</span><span> </span><span>(</span><span>err</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>75</div></div><div><span>          </span><span>const</span><span><span> </span><span>targetEl</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>loadingId</span><span>);</span></div></div><div><div><div>76</div></div><div><span>          </span><span>if</span><span> </span><span>(</span><span>targetEl</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>77</div></div><div><span><span>            </span></span><span>targetEl</span><span>.</span><span><span>outerHTML</span><span> </span></span><span>=</span><span> </span><span>`</span></div></div><div><div><div>78</div></div><div><span><span>              </span></span><span>&lt;div class="flex gap-2"&gt;</span></div></div><div><div><div>79</div></div><div><span><span>                </span></span><span>&lt;div class="w-6 h-6 rounded-md bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center text-xs shrink-0"&gt;🐱&lt;/div&gt;</span></div></div><div><div><div>80</div></div><div><span><span>                </span></span><span>&lt;div class="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-300 p-2.5 px-3.5 rounded-2xl rounded-tl-none text-xs"&gt;</span></div></div><div><div><div>81</div></div><div><span><span>                  </span></span><span>网络连接失败，请稍后重试。</span></div></div><div><div><div>82</div></div><div><span><span>                </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>83</div></div><div><span><span>              </span></span><span>&lt;/div&gt;</span><span>`</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><span><span>        </span></span><span>}</span></div></div><div><div><div>86</div></div><div><span><span>        </span></span><span>msgContainer</span><span>.</span><span><span>scrollTop</span><span> </span></span><span>=</span><span><span> </span><span>msgContainer</span></span><span>.</span><span>scrollHeight</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>
</div></div><div><div><div>89</div></div><div><span><span>      </span></span><span>sendBtn</span><span>?.</span><span>addEventListener</span><span>(</span><span>'</span><span>click</span><span>'</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span> </span><span>handleSend</span><span>());</span></div></div><div><div><div>90</div></div><div><span><span>      </span></span><span>input</span><span>?.</span><span>addEventListener</span><span>(</span><span>'</span><span>keydown</span><span>'</span><span>,</span><span> </span><span>(</span><span>e</span><span>)</span><span> </span><span>=&gt;</span><span><span> </span><span>e</span></span><span>.</span><span><span>key</span><span> </span></span><span>===</span><span> </span><span>'</span><span>Enter</span><span>'</span><span> </span><span>&amp;&amp;</span><span> </span><span>handleSend</span><span>());</span></div></div><div><div><div>91</div></div><div>
</div></div><div><div><div>92</div></div><div><span><span>      </span></span><span>msgContainer</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>click</span><span>'</span><span>,</span><span> </span><span>(</span><span>e</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>93</div></div><div><span>        </span><span>const</span><span><span> </span><span>target</span><span> </span></span><span>=</span><span><span> </span><span>e</span></span><span>.</span><span>target</span><span>;</span></div></div><div><div><div>94</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span><span>target</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>target</span></span><span>.</span><span>classList</span><span>.</span><span>contains</span><span>(</span><span>'</span><span>quick-btn</span><span>'</span><span>))</span><span> </span><span>{</span></div></div><div><div><div>95</div></div><div><span>          </span><span>handleSend</span><span>(</span><span>target</span><span>.</span><span>innerText</span><span>.</span><span>trim</span><span>());</span></div></div><div><div><div>96</div></div><div><span><span>        </span></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>}</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></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>DOMContentLoaded</span><span>'</span><span>,</span><span><span> </span><span>initGlobalAIFeatures</span></span><span>);</span></div></div><div><div><div>102</div></div><div><span><span>  </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>swup:pageView</span><span>'</span><span>,</span><span><span> </span><span>initGlobalAIFeatures</span></span><span>);</span></div></div><div><div><div>103</div></div><div><span><span>  </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>astro:page-load</span><span>'</span><span>,</span><span><span> </span><span>initGlobalAIFeatures</span></span><span>);</span></div></div><div><div><div>104</div></div><div><span>&lt;/</span><span>script</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section></section>
<section><h2>避坑指南与核心优化总结<a href="#避坑指南与核心优化总结"><span>#</span></a></h2><p>在开发和调试这个小组件时，我们踩过并解决了以下几个非常关键的痛点：</p><ol>
<li><strong>解决纯文本聊天框“排版乱七八糟”的问题</strong>： 大模型经常喜欢输出 Markdown 表格（带有 <code>|</code> 管道符）或长段落。我们在后端编写了 <code>cleanAiResponse</code> 清洗函数，强行将表格转换为漂亮的带点列表（<code>•</code>），并移除了所有反引号和粗体符号。同时在前端容器上设置了 CSS <code>whitespace-pre-wrap</code>，完美解决了换行符失效、文本挤成一团的痛点。</li>
<li><strong>防止回答“答非所问”或泛泛而谈</strong>： 通过在后端 System Prompt 中加入<strong>强力的站点背景锚点</strong>（明确告知其专属早安喵栈 <code>929255.xyz</code> 的技术栈），AI 现在能够精准、可爱地回答关于本博客的架构、搭建指南和文章推荐。</li>
<li><strong>环境变量与 Vercel 部署</strong>： 确保在 Vercel 项目后台正确配置了 <code>GROQ_API_KEY</code> 环境变量，并将 Astro 接口设置为 <code>export const prerender = false;</code>（SSR 动态路由模式），接口即可稳定运行。</li>
</ol><p>希望这篇分享能给同样使用 Astro 搭建独立博客的小伙伴们带来一些灵感！快去给你的小站也装上一个专属 AI 助手吧~ 🐱✨</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/astro-category-bar-conditional-render/</id>
      <title type="text">给 Astro 博客的 CategoryBar 组件添加“特定页面显示”及 Swup 无刷新跳转适配指南</title>
      <published>2026-10-21T00:00:00.000Z</published>
      <updated>2026-10-21T18:02:47.147Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/astro-category-bar-conditional-render/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>在基于 Astro（配合 Swup 客户端无刷新跳转）的博客主题中，我们经常会遇到需要对某些特定组件（如分类栏 <code>CategoryBar</code>）进行路由控制的需求。</p>
<p>在实际开发中，我们往往<strong>不希望</strong>分类栏在全站所有页面无条件显示（例如在关于页、友链页等独立页面中显得多余），而是希望它<strong>只在特定页面显示</strong>。例如：</p>
<ul>
<li><strong>首页</strong>（<code>/</code>）</li>
<li><strong>归档页</strong>（<code>/archive/</code>）</li>
<li><strong>分类汇总页</strong>（<code>/categories/</code>）</li>
<li><strong>标签页与系列页</strong>（<code>/tags/</code>、<code>/series/</code>）</li>
<li><strong>所有文章详情页</strong>（例如以 <code>/posts/</code> 开头的所有路径：<code>/posts/hello-world/</code>、<code>/posts/astro-tutorial/</code> 等）</li>
</ul>
<p>本文将记录如何实现这一需求，并解决在无刷新跳转（Swup）环境下的状态同步与显隐控制问题。</p>
<section><h2>核心痛点分析<a href="#核心痛点分析"><span>#</span></a></h2><p>在实现“特定页面显示”时，会遇到两个典型问题：</p><ol>
<li><strong>直接服务端 <code>return null</code> 导致 Swup 状态失效</strong>：如果用传统的服务端判断，当处于非白名单页面时直接让组件不渲染，那么当用户通过 Swup 局部无刷新跳转到白名单页面时，由于 DOM 树中根本不存在该组件，导致分类栏无法显示，必须手动刷新网页。</li>
<li><strong>动态路由匹配（如文章详情页 <code>/posts/*</code>）</strong>：静态白名单无法枚举所有的文章路径，因此需要引入前缀匹配逻辑（<code>startsWith</code>）。</li>
</ol></section>
<section><h2>最终解决方案：CSS 显隐控制 + Swup 动态响应<a href="#最终解决方案css-显隐控制--swup-动态响应"><span>#</span></a></h2><p>为了兼顾 <strong>SSR（服务端首屏渲染性能）</strong> 与 <strong>Swup 客户端无刷新跳转</strong>，最佳策略是：</p><ul>
<li>让组件正常渲染到 DOM 中，但在最外层套一个包装容器。</li>
<li>初始根据白名单（支持固定路径与动态前缀匹配）决定是否直接设置 <code>display: none</code>。</li>
<li>配合客户端脚本，在 Swup 页面切换（<code>swup:contentReplaced</code> / <code>astro:page-load</code>）时，实时检查当前新 URL 并动态控制容器显隐。</li>
</ul></section>
<section><h2>完整组件代码 (<code>CategoryBar.astro</code>)<a href="#完整组件代码-categorybarastro"><span>#</span></a></h2><p>以下是完整实现，已内置具体的白名单路径（如 <code>/tags</code>、<code>/series</code>）以及动态文章详情页（<code>/posts/*</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>import</span><span><span> </span><span>I18nKey</span><span> </span></span><span>from</span><span> </span><span>"</span><span>@i18n/i18nKey</span><span>"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>import</span><span> </span><span>{</span><span><span> </span><span>i18n</span><span> </span></span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>@i18n/translation</span><span>"</span><span>;</span></div></div><div><div><div>4</div></div><div><span>import</span><span> </span><span>{</span><span><span> </span><span>Icon</span><span> </span></span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>astro-icon/components</span><span>"</span><span>;</span></div></div><div><div><div>5</div></div><div><span>import</span><span> </span><span>{</span><span><span> </span><span>getCategoryList</span></span><span>,</span><span><span> </span><span>getSortedPostsList</span><span> </span></span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>@/utils/content-utils</span><span>"</span><span>;</span></div></div><div><div><div>6</div></div><div><span>import</span><span> </span><span>{</span><span><span> </span><span>url</span><span> </span></span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>@/utils/url-utils</span><span>"</span><span>;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>interface</span><span><span> </span><span>Props</span><span> </span></span><span>{</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>currentPostCategory</span><span><span>?</span><span>:</span></span><span> </span><span>string</span><span>;</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>const</span><span> </span><span>{</span><span><span> </span><span>currentPostCategory</span><span> </span></span><span>}</span><span> </span><span>=</span><span><span> </span><span>Astro</span></span><span>.</span><span><span>props</span><span> </span></span><span>as</span><span><span> </span><span>Props</span></span><span>;</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>const</span><span><span> </span><span>categories</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>getCategoryList</span><span>();</span></div></div><div><div><div>15</div></div><div><span>const</span><span><span> </span><span>totalPosts</span><span> </span></span><span>=</span><span> </span><span>(</span><span>await</span><span> </span><span>getSortedPostsList</span><span>()).</span><span>length</span><span>;</span></div></div><div><div><div>16</div></div><div><span>const</span><span><span> </span><span>homeUrl</span><span> </span></span><span>=</span><span> </span><span>url</span><span>(</span><span>"</span><span>/</span><span>"</span><span>);</span></div></div><div><div><div>17</div></div><div><span>const</span><span><span> </span><span>archiveUrl</span><span> </span></span><span>=</span><span> </span><span>url</span><span>(</span><span>"</span><span>/archive/</span><span>"</span><span>);</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>const</span><span><span> </span><span>pathname</span><span> </span></span><span>=</span><span><span> </span><span>Astro</span></span><span>.</span><span>url</span><span>.</span><span>pathname</span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>20</div></div><div><span>const</span><span><span> </span><span>homePath</span><span> </span></span><span>=</span><span><span> </span><span>homeUrl</span></span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>21</div></div><div><span>const</span><span><span> </span><span>archivePath</span><span> </span></span><span>=</span><span><span> </span><span>archiveUrl</span></span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>22</div></div><div><span>const</span><span><span> </span><span>isHome</span><span> </span></span><span>=</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span><span> </span><span>homePath</span><span> </span></span><span>||</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span> </span><span>""</span><span> </span><span>||</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span> </span><span>"</span><span>/</span><span>"</span><span>;</span></div></div><div><div><div>23</div></div><div><span>const</span><span><span> </span><span>isCategories</span><span> </span></span><span>=</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span> </span><span>"</span><span>/categories</span><span>"</span><span>;</span></div></div><div><div><div>24</div></div><div><span>const</span><span><span> </span><span>isArchive</span><span> </span></span><span>=</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span><span> </span><span>archivePath</span></span><span>;</span></div></div><div><div><div>25</div></div><div><span>const</span><span><span> </span><span>postCategory</span><span> </span></span><span>=</span><span> </span><span>(</span><span><span>currentPostCategory</span><span> </span></span><span>||</span><span> </span><span>""</span><span>).</span><span>trim</span><span>();</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>function</span><span> </span><span>initialActive</span><span>(</span><span>categoryName</span><span>:</span><span> </span><span>string</span><span>):</span><span> </span><span>{</span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>active</span><span><span>?</span><span>:</span></span><span> </span><span>boolean</span><span>;</span></div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>soft</span><span><span>?</span><span>:</span></span><span> </span><span>boolean</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><span>  </span><span>if</span><span> </span><span>(</span><span>isHome</span><span>)</span><span> </span><span>return</span><span><span> </span><span>categoryName</span><span> </span></span><span>===</span><span> </span><span>""</span><span> </span><span>?</span><span> </span><span>{</span><span> </span><span>active</span><span>:</span><span> </span><span>true</span><span> </span><span>}</span><span> </span><span>:</span><span> </span><span>{};</span></div></div><div><div><div>32</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>isCategories</span><span>)</span></div></div><div><div><div>33</div></div><div><span>    </span><span>return</span><span><span> </span><span>categoryName</span><span> </span></span><span>===</span><span> </span><span>"</span><span>__categories__</span><span>"</span><span> </span><span>?</span><span> </span><span>{</span><span> </span><span>active</span><span>:</span><span> </span><span>true</span><span> </span><span>}</span><span> </span><span>:</span><span> </span><span>{};</span></div></div><div><div><div>34</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>isArchive</span><span>)</span><span> </span><span>return</span><span><span> </span><span>categoryName</span><span> </span></span><span>===</span><span> </span><span>"</span><span>__archive__</span><span>"</span><span> </span><span>?</span><span> </span><span>{</span><span> </span><span>active</span><span>:</span><span> </span><span>true</span><span> </span><span>}</span><span> </span><span>:</span><span> </span><span>{};</span></div></div><div><div><div>35</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span><span>postCategory</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>categoryName</span><span> </span></span><span>===</span><span><span> </span><span>postCategory</span></span><span>)</span><span> </span><span>return</span><span> </span><span>{</span><span> </span><span>soft</span><span>:</span><span> </span><span>true</span><span> </span><span>};</span></div></div><div><div><div>36</div></div><div><span>  </span><span>return</span><span> </span><span>{};</span></div></div><div><div><div>37</div></div><div><span>}</span></div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div><span>// ==================== 具体白名单与路由匹配配置 ====================</span></div></div><div><div><div>40</div></div><div><span>// 在这里列出你需要显示分类栏的具体路径</span></div></div><div><div><div>41</div></div><div><span>const</span><span><span> </span><span>allowedPaths</span><span> </span></span><span>=</span><span> </span><span>[</span></div></div><div><div><div>42</div></div><div><span><span>  </span></span><span>homePath</span><span>,</span><span>      </span><span>// 首页</span></div></div><div><div><div>43</div></div><div><span><span>  </span></span><span>archivePath</span><span>,</span><span>   </span><span>// 归档页</span></div></div><div><div><div>44</div></div><div><span>  </span><span>"</span><span>/categories</span><span>"</span><span>,</span><span> </span><span>// 分类汇总页</span></div></div><div><div><div>45</div></div><div><span>  </span><span>"</span><span>/tags</span><span>"</span><span>,</span><span>       </span><span>// 标签页</span></div></div><div><div><div>46</div></div><div><span>  </span><span>"</span><span>/series</span><span>"</span><span>,</span><span>     </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><span>"</span><span>/</span><span>"</span></div></div><div><div><div>49</div></div><div><span>];</span></div></div><div><div><div>50</div></div><div>
</div></div><div><div><div>51</div></div><div><span>// 判断当前路径是否在白名单中，或者是否以 /posts/ 开头（匹配所有文章详情页）</span></div></div><div><div><div>52</div></div><div><span>const</span><span><span> </span><span>isAllowed</span><span> </span></span><span>=</span><span><span> </span><span>allowedPaths</span></span><span>.</span><span>includes</span><span>(</span><span>pathname</span><span>)</span><span> </span><span>||</span><span><span> </span><span>pathname</span></span><span>.</span><span>startsWith</span><span>(</span><span>"</span><span>/posts/</span><span>"</span><span>);</span></div></div><div><div><div>53</div></div><div><span>---</span></div></div><div><div><div>54</div></div><div>
</div></div><div><div><div>55</div></div><div><span>&lt;!-- 外层包裹容器：用动态 style 初始控制显隐，避免 SSR 闪烁 --&gt;</span></div></div><div><div><div>56</div></div><div><span>&lt;</span><span>div</span></div></div><div><div><div>57</div></div><div><span>  </span><span>id</span><span>=</span><span>"</span><span>category-bar-wrapper</span><span>"</span></div></div><div><div><div>58</div></div><div><span>  </span><span>style</span><span><span>=</span><span>{</span></span><span>isAllowed</span><span><span> </span><span>?</span><span> </span></span><span>""</span><span><span> </span><span>:</span><span> </span></span><span>"</span><span>display: none;</span><span>"</span><span>}</span></div></div><div><div><div>59</div></div><div><span>  </span><span>data-home-path</span><span><span>=</span><span>{</span></span><span>homeUrl</span><span>}</span></div></div><div><div><div>60</div></div><div><span>  </span><span>data-archive-path</span><span><span>=</span><span>{</span></span><span>archiveUrl</span><span>}</span></div></div><div><div><div>61</div></div><div><span>&gt;</span></div></div><div><div><div>62</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span></div></div><div><div><div>63</div></div><div><span>    </span><span>class</span><span>=</span><span>"</span><span>card-base category-bar p-3 onload-animation</span><span>"</span></div></div><div><div><div>64</div></div><div><span>    </span><span>id</span><span>=</span><span>"</span><span>category-bar</span><span>"</span></div></div><div><div><div>65</div></div><div><span>    </span><span>data-home-path</span><span><span>=</span><span>{</span></span><span>homeUrl</span><span>}</span></div></div><div><div><div>66</div></div><div><span>    </span><span>data-archive-path</span><span><span>=</span><span>{</span></span><span>archiveUrl</span><span>}</span></div></div><div><div><div>67</div></div><div><span>    </span><span>data-current-post-category</span><span><span>=</span><span>{</span></span><span>currentPostCategory</span><span> || </span><span>""</span><span>}</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>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>category-bar-inner flex gap-2</span><span>"</span><span>&gt;</span></div></div><div><div><div>70</div></div><div><span><span>      </span></span><span>&lt;</span><span>a</span></div></div><div><div><div>71</div></div><div><span>        </span><span>href</span><span><span>=</span><span>{</span></span><span>homeUrl</span><span>}</span></div></div><div><div><div>72</div></div><div><span>        </span><span>class</span><span>=</span><span>"</span><span>category-pill focus-ring-inset text-sm px-3 py-1.5 shrink-0</span></div></div><div><div><div>73</div></div><div><span><span>               </span></span><span>transition-colors duration-150 ease-out flex items-center justify-center</span><span>"</span></div></div><div><div><div>74</div></div><div><span>        </span><span>data-category-name</span><span>=</span><span>""</span></div></div><div><div><div>75</div></div><div><span>        </span><span>data-active</span><span><span>=</span><span>{</span></span><span>initialActive</span><span>(</span><span>""</span><span>).</span><span>active</span><span><span> </span><span>?</span><span> </span></span><span>""</span><span><span> </span><span>:</span><span> </span></span><span>undefined</span><span>}</span></div></div><div><div><div>76</div></div><div><span>        </span><span>aria-label</span><span><span>=</span><span>{</span></span><span>i18n</span><span>(</span><span>I18nKey</span><span>.</span><span>home</span><span><span>)</span><span>}</span></span></div></div><div><div><div>77</div></div><div><span><span>      </span></span><span>&gt;</span></div></div><div><div><div>78</div></div><div><span><span>        </span></span><span>&lt;</span><span>Icon</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-lg</span><span>"</span><span> </span><span>name</span><span>=</span><span>"</span><span>material-symbols:home</span><span>"</span><span>/&gt;</span></div></div><div><div><div>79</div></div><div><span><span>      </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>80</div></div><div><span><span>      </span></span><span>&lt;</span><span>a</span></div></div><div><div><div>81</div></div><div><span>        </span><span>href</span><span><span>=</span><span>{</span></span><span>archiveUrl</span><span>}</span></div></div><div><div><div>82</div></div><div><span>        </span><span>class</span><span>=</span><span>"</span><span>category-pill focus-ring-inset text-sm px-4 py-1.5 whitespace-nowrap shrink-0</span></div></div><div><div><div>83</div></div><div><span><span>               </span></span><span>transition-colors duration-150 ease-out flex items-center justify-center</span><span>"</span></div></div><div><div><div>84</div></div><div><span>        </span><span>data-category-name</span><span>=</span><span>"</span><span>__archive__</span><span>"</span></div></div><div><div><div>85</div></div><div><span>        </span><span>data-active</span><span><span>=</span><span>{</span></span><span>initialActive</span><span>(</span><span>"</span><span>__archive__</span><span>"</span><span>).</span><span>active</span><span><span> </span><span>?</span><span> </span></span><span>""</span><span><span> </span><span>:</span><span> </span></span><span>undefined</span><span>}</span></div></div><div><div><div>86</div></div><div><span><span>      </span></span><span>&gt;</span></div></div><div><div><div>87</div></div><div><span>        </span><span>{</span><span>i18n</span><span>(</span><span>I18nKey</span><span>.</span><span>archive</span><span><span>)</span><span>}</span></span></div></div><div><div><div>88</div></div><div><span><span>        </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"</span><span>pill-count</span><span>"</span><span><span>&gt;</span><span>{</span></span><span>totalPosts</span><span><span>}</span><span>&lt;/</span></span><span>span</span><span>&gt;</span></div></div><div><div><div>89</div></div><div><span><span>      </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>90</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>category-divider shrink-0</span><span>"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>91</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>scroll-area relative</span><span>"</span><span>&gt;</span></div></div><div><div><div>92</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>scroll-fade scroll-fade-left</span><span>"</span><span> </span><span>aria-hidden</span><span>=</span><span>"</span><span>true</span><span>"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>93</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>category-scroll flex gap-2 overflow-x-auto</span><span>"</span><span>&gt;</span></div></div><div><div><div>94</div></div><div><span>          </span><span>{</span></div></div><div><div><div>95</div></div><div><span><span>            </span></span><span>categories</span><span>.</span><span>map</span><span>((</span><span>cat</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>96</div></div><div><span>              </span><span>const</span><span><span> </span><span>active</span><span> </span></span><span>=</span><span> </span><span>initialActive</span><span>(</span><span>cat</span><span>.</span><span>name</span><span>);</span></div></div><div><div><div>97</div></div><div><span>              </span><span>return</span><span> </span><span>(</span></div></div><div><div><div>98</div></div><div><span><span>              </span></span><span>&lt;</span><span>a</span></div></div><div><div><div>99</div></div><div><span>                </span><span>href</span><span><span>=</span><span>{</span></span><span>cat</span><span>.</span><span>url</span><span>}</span></div></div><div><div><div>100</div></div><div><span>                </span><span>class</span><span>=</span><span>"</span><span>category-pill focus-ring-inset text-sm px-4 py-1.5 whitespace-nowrap</span></div></div><div><div><div>101</div></div><div><span><span>                       </span></span><span>transition-colors duration-150 ease-out flex items-center justify-center</span><span>"</span></div></div><div><div><div>102</div></div><div><span>                </span><span>data-category-name</span><span><span>=</span><span>{</span></span><span>cat</span><span>.</span><span>name</span><span>}</span></div></div><div><div><div>103</div></div><div><span>                </span><span>data-active</span><span><span>=</span><span>{</span></span><span>active</span><span>.</span><span>active</span><span><span> </span><span>?</span><span> </span></span><span>""</span><span><span> </span><span>:</span><span> </span></span><span>undefined</span><span>}</span></div></div><div><div><div>104</div></div><div><span>                </span><span>data-soft-active</span><span><span>=</span><span>{</span></span><span>active</span><span>.</span><span>soft</span><span><span> </span><span>?</span><span> </span></span><span>""</span><span><span> </span><span>:</span><span> </span></span><span>undefined</span><span>}</span></div></div><div><div><div>105</div></div><div><span><span>              </span></span><span>&gt;</span></div></div><div><div><div>106</div></div><div><span>                </span><span>{</span><span>cat</span><span>.</span><span>name</span><span>}</span></div></div><div><div><div>107</div></div><div><span><span>                </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"</span><span>pill-count</span><span>"</span><span><span>&gt;</span><span>{</span></span><span>cat</span><span>.</span><span>count</span><span><span>}</span><span>&lt;/</span></span><span>span</span><span>&gt;</span></div></div><div><div><div>108</div></div><div><span><span>              </span></span><span>&lt;/</span><span>a</span><span>&gt;</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><div><div><div>111</div></div><div><span>          </span><span>}</span></div></div><div><div><div>112</div></div><div><span><span>        </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>113</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>scroll-fade scroll-fade-right</span><span>"</span><span> </span><span>aria-hidden</span><span>=</span><span>"</span><span>true</span><span>"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>114</div></div><div><span><span>      </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>115</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>category-divider shrink-0 more-divider</span><span>"</span><span> </span><span>aria-hidden</span><span>=</span><span>"</span><span>true</span><span>"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>116</div></div><div><span><span>      </span></span><span>&lt;</span><span>a</span></div></div><div><div><div>117</div></div><div><span>        </span><span>href</span><span><span>=</span><span>{</span></span><span>url</span><span>(</span><span>"</span><span>/categories/</span><span>"</span><span><span>)</span><span>}</span></span></div></div><div><div><div>118</div></div><div><span>        </span><span>class</span><span>=</span><span>"</span><span>category-pill focus-ring-inset text-sm px-3 py-1.5 shrink-0</span></div></div><div><div><div>119</div></div><div><span><span>               </span></span><span>transition-colors duration-150 ease-out flex items-center justify-center gap-1</span><span>"</span></div></div><div><div><div>120</div></div><div><span>        </span><span>data-category-name</span><span>=</span><span>"</span><span>__categories__</span><span>"</span></div></div><div><div><div>121</div></div><div><span>        </span><span>data-active</span><span><span>=</span><span>{</span></span><span>initialActive</span><span>(</span><span>"</span><span>__categories__</span><span>"</span><span>).</span><span>active</span><span><span> </span><span>?</span><span> </span></span><span>""</span><span><span> </span><span>:</span><span> </span></span><span>undefined</span><span>}</span></div></div><div><div><div>122</div></div><div><span>        </span><span>aria-label</span><span><span>=</span><span>{</span></span><span>i18n</span><span>(</span><span>I18nKey</span><span>.</span><span>more</span><span><span>)</span><span>}</span></span></div></div><div><div><div>123</div></div><div><span><span>      </span></span><span>&gt;</span></div></div><div><div><div>124</div></div><div><span><span>        </span></span><span>&lt;</span><span>span</span><span><span>&gt;</span><span>{</span></span><span>i18n</span><span>(</span><span>I18nKey</span><span>.</span><span>more</span><span><span>)</span><span>}</span><span>&lt;/</span></span><span>span</span><span>&gt;</span></div></div><div><div><div>125</div></div><div><span><span>        </span></span><span>&lt;</span><span>Icon</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-sm</span><span>"</span><span> </span><span>name</span><span>=</span><span>"</span><span>fa7-solid:chevron-right</span><span>"</span><span>/&gt;</span></div></div><div><div><div>126</div></div><div><span><span>      </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>127</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>128</div></div><div><span><span>  </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>129</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>130</div></div><div>
</div></div><div><div><div>131</div></div><div><span>&lt;</span><span>style</span><span>&gt;</span></div></div><div><div><div>132</div></div><div><span>  </span><span>.</span><span><span>category-bar</span><span> </span></span><span>.</span><span>category-pill</span><span> {</span></div></div><div><div><div>133</div></div><div><span><span>    </span></span><span>border</span><span>: </span><span>none</span><span>;</span></div></div><div><div><div>134</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>135</div></div><div><span>  </span><span>.</span><span>category-bar</span><span> {</span></div></div><div><div><div>136</div></div><div><span><span>    </span></span><span>margin-bottom</span><span>: </span><span>1</span><span>rem</span><span>;</span></div></div><div><div><div>137</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>138</div></div><div><span>  </span><span>.</span><span>category-divider</span><span> {</span></div></div><div><div><div>139</div></div><div><span><span>    </span></span><span>width</span><span>: </span><span>1</span><span>px</span><span>;</span></div></div><div><div><div>140</div></div><div><span><span>    </span></span><span>align-self</span><span>: </span><span>stretch</span><span>;</span></div></div><div><div><div>141</div></div><div><span><span>    </span></span><span>background</span><span>: </span><span>var</span><span>(</span><span>--line-divider</span><span>);</span></div></div><div><div><div>142</div></div><div><span><span>    </span></span><span>opacity</span><span>: </span><span>0.5</span><span>;</span></div></div><div><div><div>143</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>144</div></div><div><span>  </span><span>.</span><span>more-divider</span><span> {</span></div></div><div><div><div>145</div></div><div><span><span>    </span></span><span>opacity</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>146</div></div><div><span><span>    </span></span><span>transition</span><span>: </span><span>opacity </span><span>300</span><span>ms</span><span><span> </span><span>ease-in-out</span></span><span>;</span></div></div><div><div><div>147</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>148</div></div><div><span>  </span><span>.</span><span>more-divider</span><span>[</span><span>data-visible</span><span>]</span><span> {</span></div></div><div><div><div>149</div></div><div><span><span>    </span></span><span>opacity</span><span>: </span><span>0.5</span><span>;</span></div></div><div><div><div>150</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>151</div></div><div><span>  </span><span>.</span><span>scroll-area</span><span> {</span></div></div><div><div><div>152</div></div><div><span><span>    </span></span><span>flex</span><span>: </span><span>1</span><span>;</span></div></div><div><div><div>153</div></div><div><span><span>    </span></span><span>min-width</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>154</div></div><div><span><span>    </span></span><span>position</span><span>: </span><span>relative</span><span>;</span></div></div><div><div><div>155</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>156</div></div><div><span>  </span><span>.</span><span>scroll-fade</span><span> {</span></div></div><div><div><div>157</div></div><div><span><span>    </span></span><span>position</span><span>: </span><span>absolute</span><span>;</span></div></div><div><div><div>158</div></div><div><span><span>    </span></span><span>top</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>159</div></div><div><span><span>    </span></span><span>bottom</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>160</div></div><div><span><span>    </span></span><span>width</span><span>: </span><span>2.5</span><span>rem</span><span>;</span></div></div><div><div><div>161</div></div><div><span><span>    </span></span><span>pointer-events</span><span>: </span><span>none</span><span>;</span></div></div><div><div><div>162</div></div><div><span><span>    </span></span><span>opacity</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>163</div></div><div><span><span>    </span></span><span>transition</span><span>: </span><span>opacity </span><span>300</span><span>ms</span><span><span> </span><span>ease-in-out</span></span><span>;</span></div></div><div><div><div>164</div></div><div><span><span>    </span></span><span>z-index</span><span>: </span><span>1</span><span>;</span></div></div><div><div><div>165</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>166</div></div><div><span>  </span><span>.</span><span>scroll-fade</span><span>[</span><span>data-visible</span><span>]</span><span> {</span></div></div><div><div><div>167</div></div><div><span><span>    </span></span><span>opacity</span><span>: </span><span>1</span><span>;</span></div></div><div><div><div>168</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>169</div></div><div><span>  </span><span>.</span><span>scroll-fade-left</span><span> {</span></div></div><div><div><div>170</div></div><div><span><span>    </span></span><span>left</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>171</div></div><div><span><span>    </span></span><span>background</span><span>: </span><span>linear-gradient</span><span>(to</span><span><span> </span><span>left</span></span><span>,</span><span><span> </span><span>transparent</span></span><span>,</span><span> </span><span>var</span><span>(</span><span>--card-bg</span><span>));</span></div></div><div><div><div>172</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>173</div></div><div><span>  </span><span>.</span><span>scroll-fade-right</span><span> {</span></div></div><div><div><div>174</div></div><div><span><span>    </span></span><span>right</span><span>: </span><span>0</span><span>;</span></div></div><div><div><div>175</div></div><div><span><span>    </span></span><span>background</span><span>: </span><span>linear-gradient</span><span>(to</span><span><span> </span><span>right</span></span><span>,</span><span><span> </span><span>transparent</span></span><span>,</span><span> </span><span>var</span><span>(</span><span>--card-bg</span><span>));</span></div></div><div><div><div>176</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>177</div></div><div><span>  </span><span>:global(body</span><span>.</span><span>wallpaper-transparent</span><span>) </span><span>.</span><span>scroll-fade</span><span> {</span></div></div><div><div><div>178</div></div><div><span><span>    </span></span><span>display</span><span>: </span><span>none</span><span>;</span></div></div><div><div><div>179</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>180</div></div><div><span>  </span><span>.</span><span>category-scroll</span><span> {</span></div></div><div><div><div>181</div></div><div><span><span>    </span></span><span>scrollbar-width</span><span>: </span><span>none</span><span>;</span></div></div><div><div><div>182</div></div><div><span><span>    </span></span><span>-ms-overflow-style</span><span>: </span><span>none</span><span>;</span></div></div><div><div><div>183</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>184</div></div><div><span>  </span><span>.</span><span>category-scroll</span><span>::</span><span>-webkit-scrollbar</span><span> {</span></div></div><div><div><div>185</div></div><div><span><span>    </span></span><span>display</span><span>: </span><span>none</span><span>;</span></div></div><div><div><div>186</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>187</div></div><div><span>&lt;/</span><span>style</span><span>&gt;</span></div></div><div><div><div>188</div></div><div>
</div></div><div><div><div>189</div></div><div><span>&lt;</span><span>script</span><span>&gt;</span></div></div><div><div><div>190</div></div><div><span>  </span><span>let</span><span><span> </span><span>delayedCategoryBarScrollTimer</span></span><span>:</span><span> </span><span>number</span><span> </span><span>|</span><span> </span><span>undefined</span><span>;</span></div></div><div><div><div>191</div></div><div>
</div></div><div><div><div>192</div></div><div><span><span>  </span></span><span>// 检查当前 URL 是否在白名单中，并动态控制显隐</span></div></div><div><div><div>193</div></div><div><span>  </span><span>function</span><span> </span><span>checkRouteVisibility</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>194</div></div><div><span>    </span><span>const</span><span><span> </span><span>wrapper</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>category-bar-wrapper</span><span>"</span><span>);</span></div></div><div><div><div>195</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>wrapper</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>196</div></div><div>
</div></div><div><div><div>197</div></div><div><span>    </span><span>const</span><span><span> </span><span>pathname</span><span> </span></span><span>=</span><span><span> </span><span>window</span></span><span>.</span><span>location</span><span>.</span><span>pathname</span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>198</div></div><div><span>    </span><span>const</span><span><span> </span><span>homePath</span><span> </span></span><span>=</span><span> </span><span>(</span><span>wrapper</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-home-path</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>"</span><span>/</span><span>"</span><span>).</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>199</div></div><div><span>    </span><span>const</span><span><span> </span><span>archivePath</span><span> </span></span><span>=</span><span> </span><span>(</span><span>wrapper</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-archive-path</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>"</span><span>/archive</span><span>"</span><span>).</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>200</div></div><div>
</div></div><div><div><div>201</div></div><div><span><span>    </span></span><span>// 客户端同步白名单路径</span></div></div><div><div><div>202</div></div><div><span>    </span><span>const</span><span><span> </span><span>allowedPaths</span><span> </span></span><span>=</span><span> </span><span>[</span><span>homePath</span><span>,</span><span><span> </span><span>archivePath</span></span><span>,</span><span> </span><span>"</span><span>/categories</span><span>"</span><span>,</span><span> </span><span>"</span><span>/tags</span><span>"</span><span>,</span><span> </span><span>"</span><span>/series</span><span>"</span><span>,</span><span> </span><span>""</span><span>,</span><span> </span><span>"</span><span>/</span><span>"</span><span>];</span></div></div><div><div><div>203</div></div><div><span>    </span><span>const</span><span><span> </span><span>isAllowed</span><span> </span></span><span>=</span><span><span> </span><span>allowedPaths</span></span><span>.</span><span>includes</span><span>(</span><span>pathname</span><span>)</span><span> </span><span>||</span><span><span> </span><span>pathname</span></span><span>.</span><span>startsWith</span><span>(</span><span>"</span><span>/posts/</span><span>"</span><span>);</span></div></div><div><div><div>204</div></div><div>
</div></div><div><div><div>205</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>isAllowed</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>206</div></div><div><span><span>      </span></span><span>wrapper</span><span>.</span><span>style</span><span>.</span><span><span>display</span><span> </span></span><span>=</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>207</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>208</div></div><div><span><span>      </span></span><span>wrapper</span><span>.</span><span>style</span><span>.</span><span><span>display</span><span> </span></span><span>=</span><span> </span><span>"</span><span>none</span><span>"</span><span>;</span></div></div><div><div><div>209</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>210</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>211</div></div><div>
</div></div><div><div><div>212</div></div><div><span>  </span><span>function</span><span> </span><span>getCategoryBarState</span><span>(</span><span>bar</span><span>:</span><span> </span><span>HTMLElement</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>:</span><span> </span><span>URL</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>213</div></div><div><span>    </span><span>const</span><span><span> </span><span>pathname</span><span> </span></span><span>=</span><span><span> </span><span>currentUrl</span></span><span>.</span><span>pathname</span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>214</div></div><div><span>    </span><span>const</span><span><span> </span><span>homePath</span><span> </span></span><span>=</span><span> </span><span>(</span><span>bar</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-home-path</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>"</span><span>/</span><span>"</span><span>).</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>215</div></div><div><span>    </span><span>const</span><span><span> </span><span>archivePath</span><span> </span></span><span>=</span><span> </span><span>(</span><span>bar</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-archive-path</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>"</span><span>/archive</span><span>"</span><span>).</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>216</div></div><div><span>    </span><span>const</span><span><span> </span><span>isHome</span><span> </span></span><span>=</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span><span> </span><span>homePath</span><span> </span></span><span>||</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span> </span><span>""</span><span> </span><span>||</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span> </span><span>"</span><span>/</span><span>"</span><span>;</span></div></div><div><div><div>217</div></div><div><span>    </span><span>const</span><span><span> </span><span>isArchive</span><span> </span></span><span>=</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span><span> </span><span>archivePath</span></span><span>;</span></div></div><div><div><div>218</div></div><div><span>    </span><span>const</span><span><span> </span><span>isCategories</span><span> </span></span><span>=</span><span><span> </span><span>pathname</span><span> </span></span><span>===</span><span> </span><span>"</span><span>/categories</span><span>"</span><span>;</span></div></div><div><div><div>219</div></div><div>
</div></div><div><div><div>220</div></div><div><span>    </span><span>return</span><span> </span><span>{</span></div></div><div><div><div>221</div></div><div><span><span>      </span></span><span>isHome</span><span>,</span></div></div><div><div><div>222</div></div><div><span><span>      </span></span><span>isArchive</span><span>,</span></div></div><div><div><div>223</div></div><div><span><span>      </span></span><span>isCategories</span><span>,</span></div></div><div><div><div>224</div></div><div><span>      </span><span>activeCategory</span><span>:</span><span><span> </span><span>currentUrl</span></span><span>.</span><span>searchParams</span><span>.</span><span>get</span><span>(</span><span>"</span><span>category</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>225</div></div><div><span>      </span><span>hasTag</span><span>:</span><span><span> </span><span>currentUrl</span></span><span>.</span><span>searchParams</span><span>.</span><span>has</span><span>(</span><span>"</span><span>tag</span><span>"</span><span>),</span></div></div><div><div><div>226</div></div><div><span>      </span><span>hasUncategorized</span><span>:</span><span><span> </span><span>currentUrl</span></span><span>.</span><span>searchParams</span><span>.</span><span>has</span><span>(</span><span>"</span><span>uncategorized</span><span>"</span><span>),</span></div></div><div><div><div>227</div></div><div><span><span>    </span></span><span>};</span></div></div><div><div><div>228</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>229</div></div><div>
</div></div><div><div><div>230</div></div><div><span>  </span><span>function</span><span> </span><span>setActiveCategory</span><span>(</span><span>bar</span><span>:</span><span> </span><span>HTMLElement</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>:</span><span> </span><span>URL</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>231</div></div><div><span>    </span><span>const</span><span> </span><span>{</span><span><span> </span><span>isHome</span></span><span>,</span><span><span> </span><span>isArchive</span></span><span>,</span><span><span> </span><span>isCategories</span></span><span>,</span><span><span> </span><span>activeCategory</span></span><span>,</span><span><span> </span><span>hasTag</span></span><span>,</span><span><span> </span><span>hasUncategorized</span><span> </span></span><span>}</span><span> </span><span>=</span></div></div><div><div><div>232</div></div><div><span>      </span><span>getCategoryBarState</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>);</span></div></div><div><div><div>233</div></div><div><span>    </span><span>const</span><span><span> </span><span>postCategory</span><span> </span></span><span>=</span><span> </span><span>(</span><span>bar</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-current-post-category</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>""</span><span>).</span><span>trim</span><span>();</span></div></div><div><div><div>234</div></div><div>
</div></div><div><div><div>235</div></div><div><span>    </span><span>const</span><span><span> </span><span>pills</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelectorAll</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>"</span><span>.category-pill</span><span>"</span><span>);</span></div></div><div><div><div>236</div></div><div><span><span>    </span></span><span>pills</span><span>.</span><span>forEach</span><span>((</span><span>pill</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>237</div></div><div><span><span>      </span></span><span>pill</span><span>.</span><span>removeAttribute</span><span>(</span><span>"</span><span>data-active</span><span>"</span><span>);</span></div></div><div><div><div>238</div></div><div><span><span>      </span></span><span>pill</span><span>.</span><span>removeAttribute</span><span>(</span><span>"</span><span>data-soft-active</span><span>"</span><span>);</span></div></div><div><div><div>239</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>240</div></div><div>
</div></div><div><div><div>241</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>isHome</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>242</div></div><div><span><span>      </span></span><span>bar</span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>'</span><span>.category-pill[data-category-name=""]</span><span>'</span><span>)</span></div></div><div><div><div>243</div></div><div><span><span>        </span></span><span>?.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-active</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>244</div></div><div><span>      </span><span>return</span><span>;</span></div></div><div><div><div>245</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>246</div></div><div>
</div></div><div><div><div>247</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>isCategories</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>248</div></div><div><span><span>      </span></span><span>bar</span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>'</span><span>.category-pill[data-category-name="__categories__"]</span><span>'</span><span>)</span></div></div><div><div><div>249</div></div><div><span><span>        </span></span><span>?.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-active</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>250</div></div><div><span>      </span><span>return</span><span>;</span></div></div><div><div><div>251</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>252</div></div><div>
</div></div><div><div><div>253</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>activeCategory</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>254</div></div><div><span><span>      </span></span><span>pills</span><span>.</span><span>forEach</span><span>((</span><span>pill</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>255</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span>pill</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-category-name</span><span>"</span><span>)</span><span> </span><span>===</span><span><span> </span><span>activeCategory</span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>256</div></div><div><span><span>          </span></span><span>pill</span><span>.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-active</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>257</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>258</div></div><div><span><span>      </span></span><span>});</span></div></div><div><div><div>259</div></div><div><span>      </span><span>return</span><span>;</span></div></div><div><div><div>260</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>261</div></div><div>
</div></div><div><div><div>262</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span><span>isArchive</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>hasTag</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span>hasUncategorized</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>263</div></div><div><span><span>      </span></span><span>bar</span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>'</span><span>.category-pill[data-category-name="__archive__"]</span><span>'</span><span>)</span></div></div><div><div><div>264</div></div><div><span><span>        </span></span><span>?.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-active</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>265</div></div><div><span>      </span><span>return</span><span>;</span></div></div><div><div><div>266</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>267</div></div><div>
</div></div><div><div><div>268</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>postCategory</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>269</div></div><div><span><span>      </span></span><span>pills</span><span>.</span><span>forEach</span><span>((</span><span>pill</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>270</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span>pill</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-category-name</span><span>"</span><span>)</span><span> </span><span>===</span><span><span> </span><span>postCategory</span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>271</div></div><div><span><span>          </span></span><span>pill</span><span>.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-soft-active</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>272</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>273</div></div><div><span><span>      </span></span><span>});</span></div></div><div><div><div>274</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>275</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>276</div></div><div>
</div></div><div><div><div>277</div></div><div><span>  </span><span>function</span><span> </span><span>scrollActiveCategoryIntoView</span><span>(</span><span>bar</span><span>:</span><span> </span><span>HTMLElement</span><span>,</span><span><span> </span><span>behavior</span></span><span>:</span><span> </span><span>ScrollBehavior</span><span> </span><span>=</span><span> </span><span>"</span><span>smooth</span><span>"</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>278</div></div><div><span>    </span><span>const</span><span><span> </span><span>activePill</span><span> </span></span><span>=</span></div></div><div><div><div>279</div></div><div><span><span>      </span></span><span>bar</span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>'</span><span>.category-pill[data-active]</span><span>'</span><span>)</span><span> </span><span>||</span></div></div><div><div><div>280</div></div><div><span><span>      </span></span><span>bar</span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>'</span><span>.category-pill[data-soft-active]</span><span>'</span><span>);</span></div></div><div><div><div>281</div></div><div><span>    </span><span>const</span><span><span> </span><span>scroll</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLElement</span><span>&gt;(</span><span>"</span><span>.category-scroll</span><span>"</span><span>);</span></div></div><div><div><div>282</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span><span>activePill</span><span> </span></span><span>||</span><span> </span><span>!</span><span>scroll</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>283</div></div><div>
</div></div><div><div><div>284</div></div><div><span>    </span><span>const</span><span><span> </span><span>scrollLeft</span><span> </span></span><span>=</span><span><span> </span><span>activePill</span></span><span>.</span><span><span>offsetLeft</span><span> </span></span><span>-</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>offsetLeft</span><span> </span></span><span>-</span><span> </span><span>(</span><span>scroll</span><span>.</span><span><span>clientWidth</span><span> </span></span><span>-</span><span><span> </span><span>activePill</span></span><span>.</span><span>offsetWidth</span><span>)</span><span> </span><span>/</span><span> </span><span>2</span><span>;</span></div></div><div><div><div>285</div></div><div><span><span>    </span></span><span>scroll</span><span>.</span><span>scrollTo</span><span>({</span><span> </span><span>left</span><span>:</span><span><span> </span><span>Math</span></span><span>.</span><span>max</span><span>(</span><span>0</span><span>,</span><span><span> </span><span>scrollLeft</span></span><span>),</span><span><span> </span><span>behavior</span><span> </span></span><span>});</span></div></div><div><div><div>286</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>287</div></div><div>
</div></div><div><div><div>288</div></div><div><span>  </span><span>function</span><span> </span><span>shouldDeferCategoryScroll</span><span>(</span><span>bar</span><span>:</span><span> </span><span>HTMLElement</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>:</span><span> </span><span>URL</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>289</div></div><div><span>    </span><span>const</span><span> </span><span>{</span><span><span> </span><span>isHome</span><span> </span></span><span>}</span><span> </span><span>=</span><span> </span><span>getCategoryBarState</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>);</span></div></div><div><div><div>290</div></div><div><span>    </span><span>const</span><span><span> </span><span>isMobile</span><span> </span></span><span>=</span><span><span> </span><span>window</span></span><span>.</span><span><span>innerWidth</span><span> </span></span><span>&lt;</span><span> </span><span>1024</span><span>;</span></div></div><div><div><div>291</div></div><div><span>    </span><span>const</span><span><span> </span><span>isBannerMode</span><span> </span></span><span>=</span></div></div><div><div><div>292</div></div><div><span><span>      </span></span><span>document</span><span>.</span><span>documentElement</span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-wallpaper-mode</span><span>"</span><span>)</span><span> </span><span>===</span><span> </span><span>"</span><span>banner</span><span>"</span><span>;</span></div></div><div><div><div>293</div></div><div><span>    </span><span>return</span><span><span> </span><span>isMobile</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>isBannerMode</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span>isHome</span><span>;</span></div></div><div><div><div>294</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>295</div></div><div>
</div></div><div><div><div>296</div></div><div><span>  </span><span>function</span><span> </span><span>updateCategoryBar</span><span>(</span></div></div><div><div><div>297</div></div><div><span><span>    </span></span><span>currentUrl</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span>(</span><span>window</span><span>.</span><span>location</span><span>.</span><span>href</span><span>),</span></div></div><div><div><div>298</div></div><div><span><span>    </span></span><span>options</span><span>:</span><span> </span><span>{</span><span><span> </span><span>scrollBehavior</span></span><span><span>?</span><span>:</span></span><span> </span><span>ScrollBehavior</span><span>;</span><span><span> </span><span>deferScroll</span></span><span><span>?</span><span>:</span></span><span> </span><span>boolean</span><span> </span><span>}</span><span> </span><span>=</span><span> </span><span>{},</span></div></div><div><div><div>299</div></div><div><span><span>  </span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>300</div></div><div><span>    </span><span>checkRouteVisibility</span><span>();</span><span> </span><span>// 每次更新时同步显隐状态</span></div></div><div><div><div>301</div></div><div>
</div></div><div><div><div>302</div></div><div><span>    </span><span>const</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>category-bar</span><span>"</span><span>);</span></div></div><div><div><div>303</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>bar</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>304</div></div><div>
</div></div><div><div><div>305</div></div><div><span>    </span><span>const</span><span><span> </span><span>swupContainer</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>swup-container</span><span>"</span><span>);</span></div></div><div><div><div>306</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>swupContainer</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>307</div></div><div><span>      </span><span>const</span><span><span> </span><span>currentPostCategory</span><span> </span></span><span>=</span><span><span> </span><span>swupContainer</span></span><span>.</span><span>getAttribute</span><span>(</span><span>"</span><span>data-current-post-category</span><span>"</span><span>)</span><span> </span><span>||</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>308</div></div><div><span><span>      </span></span><span>bar</span><span>.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-current-post-category</span><span>"</span><span>,</span><span><span> </span><span>currentPostCategory</span></span><span>);</span></div></div><div><div><div>309</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>310</div></div><div>
</div></div><div><div><div>311</div></div><div><span>    </span><span>setActiveCategory</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>);</span></div></div><div><div><div>312</div></div><div>
</div></div><div><div><div>313</div></div><div><span>    </span><span>const</span><span><span> </span><span>deferScroll</span><span> </span></span><span>=</span><span><span> </span><span>options</span></span><span>.</span><span><span>deferScroll</span><span> </span></span><span>??</span><span> </span><span>shouldDeferCategoryScroll</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>currentUrl</span></span><span>);</span></div></div><div><div><div>314</div></div><div><span>    </span><span>const</span><span><span> </span><span>scrollBehavior</span><span> </span></span><span>=</span><span><span> </span><span>options</span></span><span>.</span><span><span>scrollBehavior</span><span> </span></span><span>??</span><span> </span><span>(</span><span><span>deferScroll</span><span> </span></span><span>?</span><span> </span><span>"</span><span>auto</span><span>"</span><span> </span><span>:</span><span> </span><span>"</span><span>smooth</span><span>"</span><span>);</span></div></div><div><div><div>315</div></div><div>
</div></div><div><div><div>316</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>delayedCategoryBarScrollTimer</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>317</div></div><div><span><span>      </span></span><span>window</span><span>.</span><span>clearTimeout</span><span>(</span><span>delayedCategoryBarScrollTimer</span><span>);</span></div></div><div><div><div>318</div></div><div><span><span>      </span></span><span>delayedCategoryBarScrollTimer</span><span> </span><span>=</span><span> </span><span>undefined</span><span>;</span></div></div><div><div><div>319</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>320</div></div><div>
</div></div><div><div><div>321</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>deferScroll</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>322</div></div><div><span><span>      </span></span><span>delayedCategoryBarScrollTimer</span><span> </span><span>=</span><span><span> </span><span>window</span></span><span>.</span><span>setTimeout</span><span>(()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>323</div></div><div><span>        </span><span>requestAnimationFrame</span><span>(()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>324</div></div><div><span>          </span><span>requestAnimationFrame</span><span>(()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>325</div></div><div><span>            </span><span>scrollActiveCategoryIntoView</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>scrollBehavior</span></span><span>);</span></div></div><div><div><div>326</div></div><div><span>            </span><span>updateScrollHint</span><span>();</span></div></div><div><div><div>327</div></div><div><span><span>          </span></span><span>});</span></div></div><div><div><div>328</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>329</div></div><div><span><span>      </span></span><span>},</span><span> </span><span>220</span><span>);</span></div></div><div><div><div>330</div></div><div><span>      </span><span>return</span><span>;</span></div></div><div><div><div>331</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>332</div></div><div>
</div></div><div><div><div>333</div></div><div><span>    </span><span>scrollActiveCategoryIntoView</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>scrollBehavior</span></span><span>);</span></div></div><div><div><div>334</div></div><div><span>    </span><span>updateScrollHint</span><span>();</span></div></div><div><div><div>335</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>336</div></div><div>
</div></div><div><div><div>337</div></div><div><span>  </span><span>function</span><span> </span><span>initCategoryBarInteractions</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>338</div></div><div><span>    </span><span>const</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>category-bar</span><span>"</span><span>);</span></div></div><div><div><div>339</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>bar</span></span><span>.</span><span>dataset</span><span>.</span><span><span>clickBound</span><span> </span></span><span>===</span><span> </span><span>"</span><span>true</span><span>"</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>340</div></div><div>
</div></div><div><div><div>341</div></div><div><span><span>    </span></span><span>bar</span><span>.</span><span>dataset</span><span>.</span><span><span>clickBound</span><span> </span></span><span>=</span><span> </span><span>"</span><span>true</span><span>"</span><span>;</span></div></div><div><div><div>342</div></div><div><span><span>    </span></span><span>bar</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>click</span><span>"</span><span>,</span><span> </span><span>(</span><span>event</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>343</div></div><div><span>      </span><span>if</span><span> </span><span>(!(</span><span>event</span><span>.</span><span><span>target</span><span> </span></span><span>instanceof</span><span><span> </span><span>Element</span></span><span>))</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>344</div></div><div>
</div></div><div><div><div>345</div></div><div><span>      </span><span>const</span><span><span> </span><span>pill</span><span> </span></span><span>=</span><span><span> </span><span>event</span></span><span>.</span><span>target</span><span>.</span><span>closest</span><span>&lt;</span><span>HTMLAnchorElement</span><span>&gt;(</span><span>"</span><span>.category-pill</span><span>"</span><span>);</span></div></div><div><div><div>346</div></div><div><span>      </span><span>if</span><span> </span><span>(!</span><span>pill</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>347</div></div><div>
</div></div><div><div><div>348</div></div><div><span>      </span><span>if</span><span> </span><span>(</span></div></div><div><div><div>349</div></div><div><span><span>        </span></span><span>event</span><span>.</span><span><span>defaultPrevented</span><span> </span></span><span>||</span></div></div><div><div><div>350</div></div><div><span><span>        </span></span><span>event</span><span>.</span><span><span>button</span><span> </span></span><span>!==</span><span> </span><span>0</span><span> </span><span>||</span></div></div><div><div><div>351</div></div><div><span><span>        </span></span><span>event</span><span>.</span><span><span>metaKey</span><span> </span></span><span>||</span></div></div><div><div><div>352</div></div><div><span><span>        </span></span><span>event</span><span>.</span><span><span>ctrlKey</span><span> </span></span><span>||</span></div></div><div><div><div>353</div></div><div><span><span>        </span></span><span>event</span><span>.</span><span><span>shiftKey</span><span> </span></span><span>||</span></div></div><div><div><div>354</div></div><div><span><span>        </span></span><span>event</span><span>.</span><span>altKey</span></div></div><div><div><div>355</div></div><div><span><span>      </span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>356</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>357</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>358</div></div><div>
</div></div><div><div><div>359</div></div><div><span>      </span><span>if</span><span> </span><span>(</span><span>pill</span><span>.</span><span><span>target</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>pill</span></span><span>.</span><span><span>target</span><span> </span></span><span>!==</span><span> </span><span>"</span><span>_self</span><span>"</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>360</div></div><div>
</div></div><div><div><div>361</div></div><div><span>      </span><span>const</span><span><span> </span><span>targetUrl</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span>(</span><span>pill</span><span>.</span><span>href</span><span>,</span><span><span> </span><span>window</span></span><span>.</span><span>location</span><span>.</span><span>href</span><span>);</span></div></div><div><div><div>362</div></div><div><span>      </span><span>if</span><span> </span><span>(</span><span>targetUrl</span><span>.</span><span><span>origin</span><span> </span></span><span>!==</span><span><span> </span><span>window</span></span><span>.</span><span>location</span><span>.</span><span>origin</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>363</div></div><div>
</div></div><div><div><div>364</div></div><div><span>      </span><span>setActiveCategory</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>targetUrl</span></span><span>);</span></div></div><div><div><div>365</div></div><div><span>      </span><span>scrollActiveCategoryIntoView</span><span>(</span><span>bar</span><span>,</span><span> </span><span>"</span><span>auto</span><span>"</span><span>);</span></div></div><div><div><div>366</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>367</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>368</div></div><div>
</div></div><div><div><div>369</div></div><div><span>  </span><span>function</span><span> </span><span>initScrollFeatures</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>370</div></div><div><span>    </span><span>const</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>category-bar</span><span>"</span><span>);</span></div></div><div><div><div>371</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>bar</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>372</div></div><div>
</div></div><div><div><div>373</div></div><div><span>    </span><span>const</span><span><span> </span><span>scroll</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLElement</span><span>&gt;(</span><span>"</span><span>.category-scroll</span><span>"</span><span>);</span></div></div><div><div><div>374</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>scroll</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>375</div></div><div>
</div></div><div><div><div>376</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>scroll</span><span>.</span><span>dataset</span><span>.</span><span><span>featuresBound</span><span> </span></span><span>===</span><span> </span><span>"</span><span>true</span><span>"</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>377</div></div><div><span>      </span><span>updateScrollHint</span><span>();</span></div></div><div><div><div>378</div></div><div><span>      </span><span>return</span><span>;</span></div></div><div><div><div>379</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>380</div></div><div><span><span>    </span></span><span>scroll</span><span>.</span><span>dataset</span><span>.</span><span><span>featuresBound</span><span> </span></span><span>=</span><span> </span><span>"</span><span>true</span><span>"</span><span>;</span></div></div><div><div><div>381</div></div><div>
</div></div><div><div><div>382</div></div><div><span><span>    </span></span><span>scroll</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>wheel</span><span>"</span><span>,</span><span> </span><span>(</span><span>e</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>383</div></div><div><span>      </span><span>if</span><span> </span><span>(</span></div></div><div><div><div>384</div></div><div><span><span>        </span></span><span>scroll</span><span>.</span><span><span>scrollWidth</span><span> </span></span><span>&lt;=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>clientWidth</span><span> </span></span><span>||</span></div></div><div><div><div>385</div></div><div><span><span>        </span></span><span>e</span><span>.</span><span><span>ctrlKey</span><span> </span></span><span>||</span></div></div><div><div><div>386</div></div><div><span><span>        </span></span><span>Math</span><span>.</span><span>abs</span><span>(</span><span>e</span><span>.</span><span>deltaX</span><span>)</span><span> </span><span>&gt;=</span><span><span> </span><span>Math</span></span><span>.</span><span>abs</span><span>(</span><span>e</span><span>.</span><span>deltaY</span><span>)</span></div></div><div><div><div>387</div></div><div><span><span>      </span></span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>388</div></div><div>
</div></div><div><div><div>389</div></div><div><span>      </span><span>const</span><span><span> </span><span>delta</span><span> </span></span><span>=</span><span><span> </span><span>e</span></span><span>.</span><span><span>deltaMode</span><span> </span></span><span>===</span><span><span> </span><span>WheelEvent</span></span><span>.</span><span>DOM_DELTA_LINE</span></div></div><div><div><div>390</div></div><div><span>        </span><span>?</span><span><span> </span><span>e</span></span><span>.</span><span><span>deltaY</span><span> </span></span><span>*</span><span> </span><span>16</span></div></div><div><div><div>391</div></div><div><span>        </span><span>:</span><span><span> </span><span>e</span></span><span>.</span><span><span>deltaMode</span><span> </span></span><span>===</span><span><span> </span><span>WheelEvent</span></span><span>.</span><span>DOM_DELTA_PAGE</span></div></div><div><div><div>392</div></div><div><span>          </span><span>?</span><span><span> </span><span>e</span></span><span>.</span><span><span>deltaY</span><span> </span></span><span>*</span><span><span> </span><span>scroll</span></span><span>.</span><span>clientWidth</span></div></div><div><div><div>393</div></div><div><span>          </span><span>:</span><span><span> </span><span>e</span></span><span>.</span><span>deltaY</span><span>;</span></div></div><div><div><div>394</div></div><div><span>      </span><span>const</span><span><span> </span><span>maxScrollLeft</span><span> </span></span><span>=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollWidth</span><span> </span></span><span>-</span><span><span> </span><span>scroll</span></span><span>.</span><span>clientWidth</span><span>;</span></div></div><div><div><div>395</div></div><div><span>      </span><span>const</span><span><span> </span><span>atStart</span><span> </span></span><span>=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollLeft</span><span> </span></span><span>&lt;=</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>396</div></div><div><span>      </span><span>const</span><span><span> </span><span>atEnd</span><span> </span></span><span>=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollLeft</span><span> </span></span><span>&gt;=</span><span><span> </span><span>maxScrollLeft</span><span> </span></span><span>-</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>397</div></div><div>
</div></div><div><div><div>398</div></div><div><span>      </span><span>if</span><span> </span><span>((</span><span><span>delta</span><span> </span></span><span>&lt;</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>atStart</span></span><span>)</span><span> </span><span>||</span><span> </span><span>(</span><span><span>delta</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>atEnd</span></span><span>))</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>399</div></div><div>
</div></div><div><div><div>400</div></div><div><span>      </span><span>const</span><span><span> </span><span>nextScrollLeft</span><span> </span></span><span>=</span><span><span> </span><span>Math</span></span><span>.</span><span>min</span><span>(</span></div></div><div><div><div>401</div></div><div><span><span>        </span></span><span>maxScrollLeft</span><span>,</span></div></div><div><div><div>402</div></div><div><span><span>        </span></span><span>Math</span><span>.</span><span>max</span><span>(</span><span>0</span><span>,</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollLeft</span><span> </span></span><span>+</span><span><span> </span><span>delta</span></span><span>),</span></div></div><div><div><div>403</div></div><div><span><span>      </span></span><span>);</span></div></div><div><div><div>404</div></div><div>
</div></div><div><div><div>405</div></div><div><span>      </span><span>if</span><span> </span><span>(</span><span><span>nextScrollLeft</span><span> </span></span><span>===</span><span><span> </span><span>scroll</span></span><span>.</span><span>scrollLeft</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>406</div></div><div><span><span>      </span></span><span>e</span><span>.</span><span>preventDefault</span><span>();</span></div></div><div><div><div>407</div></div><div><span><span>      </span></span><span>scroll</span><span>.</span><span><span>scrollLeft</span><span> </span></span><span>=</span><span><span> </span><span>nextScrollLeft</span></span><span>;</span></div></div><div><div><div>408</div></div><div><span><span>    </span></span><span>},</span><span> </span><span>{</span><span> </span><span>passive</span><span>:</span><span> </span><span>false</span><span> </span><span>});</span></div></div><div><div><div>409</div></div><div>
</div></div><div><div><div>410</div></div><div><span><span>    </span></span><span>scroll</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>scroll</span><span>"</span><span>,</span><span><span> </span><span>updateScrollHint</span></span><span>);</span></div></div><div><div><div>411</div></div><div><span><span>    </span></span><span>window</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>resize</span><span>"</span><span>,</span><span><span> </span><span>updateScrollHint</span></span><span>);</span></div></div><div><div><div>412</div></div><div><span>    </span><span>updateScrollHint</span><span>();</span></div></div><div><div><div>413</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>414</div></div><div>
</div></div><div><div><div>415</div></div><div><span>  </span><span>function</span><span> </span><span>updateScrollHint</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>416</div></div><div><span>    </span><span>const</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>category-bar</span><span>"</span><span>);</span></div></div><div><div><div>417</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>bar</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>418</div></div><div><span>    </span><span>const</span><span><span> </span><span>scroll</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLElement</span><span>&gt;(</span><span>"</span><span>.category-scroll</span><span>"</span><span>);</span></div></div><div><div><div>419</div></div><div><span>    </span><span>const</span><span><span> </span><span>fadeLeft</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLElement</span><span>&gt;(</span><span>"</span><span>.scroll-fade-left</span><span>"</span><span>);</span></div></div><div><div><div>420</div></div><div><span>    </span><span>const</span><span><span> </span><span>fadeRight</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLElement</span><span>&gt;(</span><span>"</span><span>.scroll-fade-right</span><span>"</span><span>);</span></div></div><div><div><div>421</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span><span>scroll</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>fadeLeft</span><span> </span></span><span>||</span><span> </span><span>!</span><span>fadeRight</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>422</div></div><div>
</div></div><div><div><div>423</div></div><div><span>    </span><span>const</span><span><span> </span><span>hasOverflow</span><span> </span></span><span>=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollWidth</span><span> </span></span><span>&gt;</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>clientWidth</span><span> </span></span><span>+</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>424</div></div><div><span>    </span><span>const</span><span><span> </span><span>atStart</span><span> </span></span><span>=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollLeft</span><span> </span></span><span>&lt;=</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>425</div></div><div><span>    </span><span>const</span><span><span> </span><span>atEnd</span><span> </span></span><span>=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollLeft</span><span> </span></span><span>+</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>clientWidth</span><span> </span></span><span>&gt;=</span><span><span> </span><span>scroll</span></span><span>.</span><span><span>scrollWidth</span><span> </span></span><span>-</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>426</div></div><div>
</div></div><div><div><div>427</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span><span>hasOverflow</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span>atStart</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>428</div></div><div><span><span>      </span></span><span>fadeLeft</span><span>.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-visible</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>429</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>430</div></div><div><span><span>      </span></span><span>fadeLeft</span><span>.</span><span>removeAttribute</span><span>(</span><span>"</span><span>data-visible</span><span>"</span><span>);</span></div></div><div><div><div>431</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>432</div></div><div>
</div></div><div><div><div>433</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span><span>hasOverflow</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span>atEnd</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>434</div></div><div><span><span>      </span></span><span>fadeRight</span><span>.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-visible</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>435</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>436</div></div><div><span><span>      </span></span><span>fadeRight</span><span>.</span><span>removeAttribute</span><span>(</span><span>"</span><span>data-visible</span><span>"</span><span>);</span></div></div><div><div><div>437</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>438</div></div><div>
</div></div><div><div><div>439</div></div><div><span>    </span><span>const</span><span><span> </span><span>moreDivider</span><span> </span></span><span>=</span><span><span> </span><span>bar</span></span><span>.</span><span>querySelector</span><span>&lt;</span><span>HTMLElement</span><span>&gt;(</span><span>"</span><span>.more-divider</span><span>"</span><span>);</span></div></div><div><div><div>440</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>moreDivider</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>441</div></div><div><span>      </span><span>if</span><span> </span><span>(</span><span>hasOverflow</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>442</div></div><div><span><span>        </span></span><span>moreDivider</span><span>.</span><span>setAttribute</span><span>(</span><span>"</span><span>data-visible</span><span>"</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>443</div></div><div><span><span>      </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>444</div></div><div><span><span>        </span></span><span>moreDivider</span><span>.</span><span>removeAttribute</span><span>(</span><span>"</span><span>data-visible</span><span>"</span><span>);</span></div></div><div><div><div>445</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>446</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>447</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>448</div></div><div>
</div></div><div><div><div>449</div></div><div><span>  </span><span>updateCategoryBar</span><span>();</span></div></div><div><div><div>450</div></div><div><span>  </span><span>initCategoryBarInteractions</span><span>();</span></div></div><div><div><div>451</div></div><div><span>  </span><span>initScrollFeatures</span><span>();</span></div></div><div><div><div>452</div></div><div>
</div></div><div><div><div>453</div></div><div><span>  </span><span>if</span><span> </span><span>(!</span><span>window</span><span>.</span><span>__categoryBarInit</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>454</div></div><div><span><span>    </span></span><span>window</span><span>.</span><span><span>__categoryBarInit</span><span> </span></span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>455</div></div><div><span><span>    </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>astro:page-load</span><span>"</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>456</div></div><div><span>      </span><span>updateCategoryBar</span><span>();</span></div></div><div><div><div>457</div></div><div><span>      </span><span>initScrollFeatures</span><span>();</span></div></div><div><div><div>458</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>459</div></div><div><span><span>    </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>swup:contentReplaced</span><span>"</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>460</div></div><div><span>      </span><span>updateCategoryBar</span><span>();</span></div></div><div><div><div>461</div></div><div><span>      </span><span>requestAnimationFrame</span><span>(()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>462</div></div><div><span>        </span><span>updateCategoryBar</span><span>();</span></div></div><div><div><div>463</div></div><div><span><span>      </span></span><span>});</span></div></div><div><div><div>464</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>465</div></div><div>
</div></div><div><div><div>466</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>window</span><span>.</span><span>swup</span><span>?.</span><span>hooks</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>467</div></div><div><span>      </span><span>setupSwupEarlyUpdate</span><span>();</span></div></div><div><div><div>468</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>469</div></div><div><span><span>      </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>"</span><span>swup:enable</span><span>"</span><span>,</span><span><span> </span><span>setupSwupEarlyUpdate</span></span><span>);</span></div></div><div><div><div>470</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>471</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>472</div></div><div>
</div></div><div><div><div>473</div></div><div><span>  </span><span>function</span><span> </span><span>setupSwupEarlyUpdate</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>474</div></div><div><span>    </span><span>if</span><span> </span><span>(!</span><span>window</span><span>.</span><span>swup</span><span>?.</span><span>hooks</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>475</div></div><div><span><span>    </span></span><span>window</span><span>.</span><span>swup</span><span>.</span><span>hooks</span><span>.</span><span>on</span><span>(</span><span>"</span><span>visit:start</span><span>"</span><span>,</span><span> </span><span>(</span><span>visit</span><span>:</span><span> </span><span>any</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>476</div></div><div><span>      </span><span>const</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span></span><span>.</span><span>getElementById</span><span>(</span><span>"</span><span>category-bar</span><span>"</span><span>);</span></div></div><div><div><div>477</div></div><div><span>      </span><span>if</span><span> </span><span>(!</span><span>bar</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>478</div></div><div><span>      </span><span>const</span><span><span> </span><span>targetUrl</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span>(</span><span>visit</span><span>.</span><span>to</span><span>.</span><span>url</span><span>,</span><span><span> </span><span>window</span></span><span>.</span><span>location</span><span>.</span><span>href</span><span>);</span></div></div><div><div><div>479</div></div><div><span>      </span><span>setActiveCategory</span><span>(</span><span>bar</span><span>,</span><span><span> </span><span>targetUrl</span></span><span>);</span></div></div><div><div><div>480</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>481</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>482</div></div><div><span>&lt;/</span><span>script</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><ol>
<li><strong>精准控制</strong>：通过 <code>allowedPaths</code> 数组自由增减固定路由（如 <code>/tags</code>、<code>/series</code>），配合 <code>pathname.startsWith("/posts/")</code> 优雅覆盖所有文章详情页。</li>
<li><strong>丝滑体验</strong>：结合 Swup 的生命周期钩子，实现无刷新跳转时的即时显隐切换与状态对齐，省去用户手动刷新的烦恼。</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/astro-iconify-build-fix/</id>
      <title type="text">Astro Iconify 格式图标踩坑指南</title>
      <published>2026-10-21T00:00:00.000Z</published>
      <updated>2026-10-21T18:04:51.506Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/astro-iconify-build-fix/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>如果你也在用 <strong>Astro</strong> 搭博客或者企业官网，那大概率会用 <code>astro-icon</code> 配合 <strong>Iconify</strong> 去搞定各种小图标。不得不说，只要配置好了，在构建阶段直接把 SVG 内联进去，页面加载快到飞起，确实爽得不行。</p>
<p>但是——<strong>凡事就怕个但是。</strong></p>
<p>你是不是也遇到过这种诡异现象：本地开发（<code>npm run dev</code>）跑得好好的，甚至预览也完全正常，结果一推送代码，CI/CD 打包部署直接报错，或者上线的页面图标莫名其妙全白了？</p>
<p>别慌，我也掉进这个坑里了。最后发现问题根本不在 Astro，而是 Iconify 和 Font Awesome 的<strong>图标命名与分类机制</strong>。今天就简单聊聊这两个让我抓耳挠腮的“小细节”，大家也赶紧自查一下！</p>
<section><h2>坑一：Material Symbols 的命名，真不是直接复制粘贴的！<a href="#坑一material-symbols-的命名真不是直接复制粘贴的"><span>#</span></a></h2><p>我们在找图标的时候，很多人习惯直接去 Google Fonts 的 <strong>Material Symbols</strong> 页面找。看中一个 <code>add_reaction</code>，点一下复制名字，转头就在 Astro 里面写：</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>import { Icon } from 'astro-icon/components';</span></div></div><div><div><div>3</div></div><div><span>---</span></div></div><div><div><div>4</div></div><div><span>&lt;!-- 错误示范：直接复制了 Google 官网的下划线名字 --&gt;</span></div></div><div><div><div>5</div></div><div><span>&lt;Icon name="material-symbols:add_reaction" /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>写完觉得没啥毛病，结果一构建打包，终端直接给你报一堆找不到图标的 error。</p><section><h3>怎么破？<a href="#怎么破"><span>#</span></a></h3><p>原来 <strong>Iconify 内部强制统一了规范：所有图标名必须是连字符（kebab-case）</strong>。</p><p>也就是说，不管是 Material Symbols 还是其他什么库，凡是在 Google 官方叫 <code>add_reaction</code> 的，到了 Iconify 这里，<strong>所有下划线 <code>_</code> 都必须手动改成中划线 <code>-</code></strong>。</p><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>import { Icon } from 'astro-icon/components';</span></div></div><div><div><div>3</div></div><div><span>---</span></div></div><div><div><div>4</div></div><div><span>&lt;!-- 正确示范：改成连字符 --&gt;</span></div></div><div><div><div>5</div></div><div><span>&lt;Icon name="material-symbols:add-reaction" /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一个小细节，不注意就能卡你半小时。</p></section></section>
<section><h2>坑二：Telegram 到底算什么分类？别啥都往 <code>fa-solid</code> 塞！<a href="#坑二telegram-到底算什么分类别啥都往-fa-solid-塞"><span>#</span></a></h2><p>搞个人网站或者页脚的时候，少不了要放 Telegram、GitHub 这种社交媒体图标。因为平时用 Font Awesome 用习惯了，脑子一热可能顺手就写成了：</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>import { Icon } from 'astro-icon/components';</span></div></div><div><div><div>3</div></div><div><span>---</span></div></div><div><div><div>4</div></div><div><span>&lt;!-- 错误示范：以为所有 FA 图标都能用 fa-solid --&gt;</span></div></div><div><div><div>5</div></div><div><span>&lt;Icon name="fa-solid:telegram" /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>结果部署之后上线一看：咦？我那么大一个 Telegram 图标去哪了？</p><section><h3>怎么破？<a href="#怎么破-1"><span>#</span></a></h3><p>这也是个常见误区。在 Font Awesome 的分类逻辑里，<strong>Telegram、Twitter/X、Discord 这种商业品牌 Logo，统统属于 Brands 分类，根本不属于 Solid（实心图标）</strong>！</p><p>如果你用的图标集是 FA6，前缀必须用 <code>fa6-brands</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>import { Icon } from 'astro-icon/components';</span></div></div><div><div><div>3</div></div><div><span>---</span></div></div><div><div><div>4</div></div><div><span>&lt;!-- 正确示范：认准 Brands 分类 --&gt;</span></div></div><div><div><div>5</div></div><div><span>&lt;Icon name="fa6-brands:telegram" /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><strong>踩坑小贴士</strong>：遇到不确定的图标，强烈建议先去 <a href="https://icones.js.org/">Icônes</a> 这个网站搜索一下，看看它究竟属于哪个包，别靠直觉盲猜。</p></blockquote></section></section>
<section><h2>顺便附赠：如何写出让部署更稳的 <code>astro.config.mjs</code><a href="#顺便附赠如何写出让部署更稳的-astroconfigmjs"><span>#</span></a></h2><p>如果你不想在打包的时候发生预料之外的错误，最好在配置文件里给用到的图标做个“显式声明”，同时把依赖装全。</p><p><strong>1. 显式声明：</strong></p><div><figure><figcaption><span>astro.config.mjs</span></figcaption><pre><code><div><div><div>1</div></div><div><span>import { defineConfig } from 'astro/config';</span></div></div><div><div><div>2</div></div><div><span>import icon from 'astro-icon';</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>export default defineConfig({</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>integrations: [</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>icon({</span></div></div><div><div><div>7</div></div><div><span><span>      </span></span><span>include: {</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>'material-symbols': ['add-reaction', 'search', 'menu'],</span></div></div><div><div><div>10</div></div><div><span><span>        </span></span><span>'fa6-brands': ['telegram', 'github', 'twitter'],</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}),</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></code></pre><div><div></div><div></div></div></figure></div><p><strong>2. 依赖别漏掉：</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>npm i -D @iconify-json/material-symbols @iconify-json/fa6-brands</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>总结起来，前端玩图标虽然简单，但也容易遇到这种“看似微不足道，实际上极度影响部署”的小绊脚石。</p><ul>
<li><strong>看下划线不爽就改中划线</strong>：Material Symbols 等图标名，统统换成 <code>kebab-case</code>。</li>
<li><strong>分清品牌与通用</strong>：Telegram、GitHub 认准 <code>brands</code> 分类，别硬塞给 <code>solid</code>。</li>
</ul><p>你最近在用 Astro 或者 Iconify 的时候还遇到过什么奇葩报错？欢迎在评论区吐槽交流！</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/astro-shields-footer/</id>
      <title type="text">极简页脚改版：为博客全量引入 Shields.io 徽章图标</title>
      <published>2026-10-21T00:00:00.000Z</published>
      <updated>2026-10-21T18:04:00.807Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/astro-shields-footer/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>在追求极简与美感的中途，我一直觉得原本纯文字渲染的页脚显得有些过于沉闷。原本的样式是由普通的文本链接拼接而成：</p>
<blockquote><p>Powered by Astro &amp; Firefly | Deployed on Vercel</p></blockquote>
<p>虽然清爽，但在视觉层级上缺乏一点精炼感。正好借着最近优化组件的机会，决定将页脚的版权声明和技术栈信息，全部重构为 <strong>Shields.io</strong> 的徽章风格。</p>
<section><h2>🎨 改版效果与思路<a href="#-改版效果与思路"><span>#</span></a></h2><p>本次改版主要分为两个核心区域：</p><ol>
<li><strong>版权声明行</strong>：将原本的 <code>© 2026 ZaoCat All Rights Reserved.</code> 转化为统一样式的 Shields.io 徽章，并保留年份与站长配置的动态渲染能力。</li>
<li><strong>技术栈与部署行</strong>：将 Astro、Firefly 主题以及 Vercel 部署标识转化为带有官方 Logo 和专属品牌色调的徽章组合。</li>
</ol></section>
<section><h2>🛠️ 具体实现步骤<a href="#️-具体实现步骤"><span>#</span></a></h2><p>直接编辑 <code>src/components/Footer.astro</code> 组件，将原本的文字结构替换为带有 Shields.io 生成接口的 <code>&lt;img&gt;</code> 标签组合。</p><section><h3>1. 动态版权徽章<a href="#1-动态版权徽章"><span>#</span></a></h3><p>为了保持代码的灵活性，版权信息借由 Astro 的模板语法直接拼接 <code>currentYear</code> 与 <code>profileConfig.name</code>，免去每年手动更新代码的烦恼：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!-- 动态年份与站名的版权徽章 --&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>mx-0.5 my-2 flex items-center justify-center gap-2 flex-wrap</span><span>"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>&lt;</span><span>a</span><span> </span><span>href</span><span>=</span><span>"</span><span>/</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>&lt;</span><span>img</span></div></div><div><div><div>5</div></div><div><span>      </span><span>src</span><span><span>=</span><span>{</span></span><span>`</span><span>[https://img.shields.io/badge/%C2%A9%20$](https://img.shields.io/badge/%C2%A9%20$){currentYear}-</span><span>${</span><span>profileConfig</span><span>.</span><span>name</span><span>}</span><span>%20All%20Rights%20Reserved.-4B5563?style=flat</span><span>`</span><span>}</span></div></div><div><div><div>6</div></div><div><span>      </span><span>alt</span><span><span>=</span><span>{</span></span><span>`</span><span>Copyright </span><span>${</span><span>currentYear</span><span>}</span><span> </span><span>${</span><span>profileConfig</span><span>.</span><span>name</span><span><span>}</span><span>`</span></span><span>}</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>/&gt;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>9</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure></div></section><section><h3>2. 技术栈与部署徽章<a href="#2-技术栈与部署徽章"><span>#</span></a></h3><p>在驱动框架与部署服务的图标中，引入了对应品牌的颜色与矢量 Logo，提升辨识度：</p><p><strong>代码段</strong></p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!-- 技术栈与部署徽章行 --&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>mx-0.5 my-2 flex items-center justify-center gap-2 flex-wrap</span><span>"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>&lt;!-- Astro 徽章 --&gt;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>href</span><span>=</span><span>"</span><span>[https://astro.build](https://astro.build)</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>&lt;</span><span>img</span><span> </span><span>src</span><span>=</span><span>"</span><span>[https://img.shields.io/badge/Powered%20by-Astro-BC52EE?style=flat&amp;logo=astro&amp;logoColor=white](https://img.shields.io/badge/Powered%20by-Astro-BC52EE?style=flat&amp;logo=astro&amp;logoColor=white)</span><span>"</span><span> </span><span>alt</span><span>=</span><span>"</span><span>Astro</span><span>"</span><span> /&gt;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>  </span><span>&lt;!-- Firefly 主题徽章 --&gt;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>href</span><span>=</span><span>"</span><span>[https://github.com/CuteLeaf/Firefly](https://github.com/CuteLeaf/Firefly)</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>&lt;</span><span>img</span><span> </span><span>src</span><span>=</span><span>"</span><span>[https://img.shields.io/badge/Theme-Firefly-ff69b4?style=flat](https://img.shields.io/badge/Theme-Firefly-00BA91?style=flat)</span><span>"</span><span> </span><span>alt</span><span>=</span><span>"</span><span>Firefly</span><span>"</span><span> /&gt;</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-black/30 dark:text-white/30</span><span>"</span><span>&gt;</span><span>|</span><span>&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>  </span><span>&lt;!-- Vercel 部署徽章 --&gt;</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>href</span><span>=</span><span>"</span><span>[https://vercel.com](https://vercel.com)</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>&lt;</span><span>img</span><span> </span><span>src</span><span>=</span><span>"</span><span>[https://img.shields.io/badge/Deployed%20on-Vercel-000000?style=flat&amp;logo=vercel&amp;logoColor=white](https://img.shields.io/badge/Deployed%20on-Vercel-000000?style=flat&amp;logo=vercel&amp;logoColor=white)</span><span>"</span><span> </span><span>alt</span><span>=</span><span>"</span><span>Vercel</span><span>"</span><span> /&gt;</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>19</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section></section>
<section><h2>💡 Shields.io 参数微调小结<a href="#-shieldsio-参数微调小结"><span>#</span></a></h2><p>在定制 Shields.io 徽章时，有几个参数特别实用：</p><ul>
<li><strong>URL 编码</strong>：<code>%C2%A9</code> 对应 <code>©</code> 符号，<code>%20</code> 对应空格。</li>
<li><strong><code>logo</code> &amp; <code>logoColor</code></strong>：指定内置的标准图标（如 <code>astro</code>, <code>vercel</code>）及图标颜色。</li>
<li><strong><code>style</code></strong>：默认使用 <code>flat</code> 样式，在现代轻量化界面中观感最为和谐；如果喜欢厚重的科技感，也可以尝试 <code>for-the-badge</code>。</li>
<li><strong>配色选择</strong>：版权徽章选用了中灰 (<code>#4B5563</code>)，既能保证可读性，又不会在页面底部抢走主内容的焦点。</li>
</ul></section>
<section><h2>📝 完整 <code>Footer.astro</code> 结构参考<a href="#-完整-footerastro-结构参考"><span>#</span></a></h2><p>最终重构后的代码块非常干练：</p><p><strong>代码段</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>import</span><span><span> </span><span>fs</span><span> </span></span><span>from</span><span> </span><span>"</span><span>node:fs</span><span>"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>import</span><span><span> </span><span>path</span><span> </span></span><span>from</span><span> </span><span>"</span><span>node:path</span><span>"</span><span>;</span></div></div><div><div><div>4</div></div><div><span>import</span><span> </span><span>{</span><span><span> </span><span>profileConfig</span><span> </span></span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>@/config</span><span>"</span><span>;</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>const</span><span><span> </span><span>currentYear</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>Date</span><span>().</span><span>getFullYear</span><span>();</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>let</span><span><span> </span><span>customFooterHtml</span><span> </span></span><span>=</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>9</div></div><div><span>try</span><span> </span><span>{</span></div></div><div><div><div>10</div></div><div><span>  </span><span>const</span><span><span> </span><span>footerHtmlPath</span><span> </span></span><span>=</span><span><span> </span><span>path</span></span><span>.</span><span>join</span><span>(</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>process</span><span>.</span><span>cwd</span><span>(),</span></div></div><div><div><div>12</div></div><div><span>    </span><span>"</span><span>src</span><span>"</span><span>,</span></div></div><div><div><div>13</div></div><div><span>    </span><span>"</span><span>config</span><span>"</span><span>,</span></div></div><div><div><div>14</div></div><div><span>    </span><span>"</span><span>FooterConfig.html</span><span>"</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>customFooterHtml</span><span> </span><span>=</span><span><span> </span><span>fs</span></span><span>.</span><span>readFileSync</span><span>(</span><span>footerHtmlPath</span><span>,</span><span> </span><span>"</span><span>utf-8</span><span>"</span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>customFooterHtml</span><span> </span><span>=</span><span><span> </span><span>customFooterHtml</span></span><span>.</span><span>replace</span><span>(</span><span>/</span><span>&lt;!--</span><span><span>[</span><span>\s\S</span><span>]*?</span></span><span>--&gt;</span><span>/</span><span>g</span><span>,</span><span> </span><span>""</span><span>).</span><span>trim</span><span>();</span></div></div><div><div><div>18</div></div><div><span>}</span><span> </span><span>catch</span><span> </span><span>(</span><span>error</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>console</span><span>.</span><span>warn</span><span>(</span></div></div><div><div><div>20</div></div><div><span>    </span><span>"</span><span>FooterConfig.html 读取失败:</span><span>"</span><span>,</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>error</span><span> </span><span>instanceof</span><span><span> </span><span>Error</span><span> </span></span><span>?</span><span><span> </span><span>error</span></span><span>.</span><span><span>message</span><span> </span></span><span>:</span><span> </span><span>String</span><span>(</span><span>error</span><span>),</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>);</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div><div><div><div>24</div></div><div><span>---</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>&lt;</span><span>footer</span><span> </span><span>class</span><span>=</span><span>"</span><span>site-footer pointer-events-auto pb-4 md:pb-6</span><span>"</span><span>&gt;</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>transition border-t border-black/10 dark:border-white/15 mt-10 mb-5 border-dashed mx-32</span><span>"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>28</div></div><div>
</div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>transition flex flex-col items-center justify-center px-6</span><span>"</span><span>&gt;</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>transition text-sm text-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>31</div></div><div><span>      </span><span>{</span><span><span>customFooterHtml</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>&lt;</span><span>div</span><span> </span><span>set</span><span>:</span><span>html</span><span><span>=</span><span>{</span></span><span>customFooterHtml</span><span><span>}</span><span> </span></span><span>class</span><span>=</span><span>"</span><span>mb-2</span><span>"</span><span><span> /&gt;</span><span>}</span></span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>      </span><span>&lt;!-- 版权行 --&gt;</span></div></div><div><div><div>34</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>mx-0.5 my-2 flex items-center justify-center gap-2 flex-wrap</span><span>"</span><span>&gt;</span></div></div><div><div><div>35</div></div><div><span><span>        </span></span><span>&lt;</span><span>a</span><span> </span><span>href</span><span>=</span><span>"</span><span>/</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>36</div></div><div><span><span>          </span></span><span>&lt;</span><span>img</span></div></div><div><div><div>37</div></div><div><span>            </span><span>src</span><span><span>=</span><span>{</span></span><span>`</span><span>[https://img.shields.io/badge/%C2%A9%20$](https://img.shields.io/badge/%C2%A9%20$){currentYear}-</span><span>${</span><span>profileConfig</span><span>.</span><span>name</span><span>}</span><span>%20All%20Rights%20Reserved.-4B5563?style=flat</span><span>`</span><span>}</span></div></div><div><div><div>38</div></div><div><span>            </span><span>alt</span><span><span>=</span><span>{</span></span><span>`</span><span>Copyright </span><span>${</span><span>currentYear</span><span>}</span><span> </span><span>${</span><span>profileConfig</span><span>.</span><span>name</span><span><span>}</span><span>`</span></span><span>}</span></div></div><div><div><div>39</div></div><div><span><span>          </span></span><span>/&gt;</span></div></div><div><div><div>40</div></div><div><span><span>        </span></span><span>&lt;/</span><span>a</span><span>&gt;</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;!-- 技术栈与部署徽章行 --&gt;</span></div></div><div><div><div>44</div></div><div><span><span>      </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>mx-0.5 my-2 flex items-center justify-center gap-2 flex-wrap</span><span>"</span><span>&gt;</span></div></div><div><div><div>45</div></div><div><span><span>        </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>href</span><span>=</span><span>"</span><span>[https://astro.build](https://astro.build)</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>46</div></div><div><span><span>          </span></span><span>&lt;</span><span>img</span><span> </span><span>src</span><span>=</span><span>"</span><span>[https://img.shields.io/badge/Powered%20by-Astro-BC52EE?style=flat&amp;logo=astro&amp;logoColor=white](https://img.shields.io/badge/Powered%20by-Astro-BC52EE?style=flat&amp;logo=astro&amp;logoColor=white)</span><span>"</span><span> </span><span>alt</span><span>=</span><span>"</span><span>Astro</span><span>"</span><span> /&gt;</span></div></div><div><div><div>47</div></div><div><span><span>        </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>48</div></div><div>
</div></div><div><div><div>49</div></div><div><span><span>        </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>href</span><span>=</span><span>"</span><span>[https://github.com/CuteLeaf/Firefly](https://github.com/CuteLeaf/Firefly)</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>50</div></div><div><span><span>          </span></span><span>&lt;</span><span>img</span><span> </span><span>src</span><span>=</span><span>"</span><span>[https://img.shields.io/badge/Theme-Firefly-ff69b4?style=flat](https://img.shields.io/badge/Theme-Firefly-ff69b4?style=flat)</span><span>"</span><span> </span><span>alt</span><span>=</span><span>"</span><span>Firefly</span><span>"</span><span> /&gt;</span></div></div><div><div><div>51</div></div><div><span><span>        </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>52</div></div><div>
</div></div><div><div><div>53</div></div><div><span><span>        </span></span><span>&lt;</span><span>span</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-black/30 dark:text-white/30</span><span>"</span><span>&gt;</span><span>|</span><span>&lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>54</div></div><div>
</div></div><div><div><div>55</div></div><div><span><span>        </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>href</span><span>=</span><span>"</span><span>[https://vercel.com](https://vercel.com)</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>inline-flex items-center</span><span>"</span><span>&gt;</span></div></div><div><div><div>56</div></div><div><span><span>          </span></span><span>&lt;</span><span>img</span><span> </span><span>src</span><span>=</span><span>"</span><span>[https://img.shields.io/badge/Deployed%20on-Vercel-000000?style=flat&amp;logo=vercel&amp;logoColor=white](https://img.shields.io/badge/Deployed%20on-Vercel-000000?style=flat&amp;logo=vercel&amp;logoColor=white)</span><span>"</span><span> </span><span>alt</span><span>=</span><span>"</span><span>Vercel</span><span>"</span><span> /&gt;</span></div></div><div><div><div>57</div></div><div><span><span>        </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>58</div></div><div><span><span>      </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>59</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>60</div></div><div><span><span>  </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>61</div></div><div><span>&lt;/</span><span>footer</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>修改完成后提交代码，Vercel 会自动完成构建与部署。看着页脚整齐规范的徽章，博客的整体精致感又提升了不少！</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/fix-pnpm-lockfile-error-vercel-codespaces/</id>
      <title type="text">在 GitHub Codespaces 中解决 Vercel + Astro 部署的 pnpm 锁文件报错</title>
      <published>2026-10-21T00:00:00.000Z</published>
      <updated>2026-11-01T10:38:00.921Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/fix-pnpm-lockfile-error-vercel-codespaces/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>在使用 Vercel 部署基于 Astro 构建的博客（如 Firefly 主题）时，我们常常需要接入诸如 <code>@vercel/analytics</code> 这样强大的访问统计工具。</p>
<p>如果你手头没有本地开发环境，想直接在 GitHub 网页端搞定这一切，可能会遇到一些小坑。本文将带你从零开始，完整走通“<strong>注入统计代码 -&gt; 触发云端报错 -&gt; 使用 GitHub Codespaces 优雅解决</strong>”的全过程。</p>
<section><h2>第一步：在 GitHub 网页端修改布局文件<a href="#第一步在-github-网页端修改布局文件"><span>#</span></a></h2><p>通常像 Astro 主题都会有一个全局的布局组件。我们可以直接在 GitHub 网页端找到它并植入统计代码。</p><ol>
<li>打开你的 GitHub 项目仓库。</li>
<li>导航找到布局文件，路径通常是 <code>src/layouts/Layout.astro</code>（或者 <code>BaseLayout.astro</code>）。</li>
<li>点击文件右上角的 <strong>铅笔图标（Edit file）</strong> 开始在线编辑。</li>
</ol><section><h3>1. 顶部导入 Analytics 组件<a href="#1-顶部导入-analytics-组件"><span>#</span></a></h3><p>在文件最顶部的 Frontmatter 区域（前后都有 <code>---</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>// 其他已有的 import 语句...</span></div></div><div><div><div>3</div></div><div><span>import</span><span><span> </span><span>Analytics</span><span> </span></span><span>from</span><span> </span><span>'</span><span>@vercel/analytics/astro</span><span>'</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>// 接收的 Props 或其他代码...</span></div></div><div><div><div>6</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div><div></div></figure></div></section><section><h3>2. 在 body 标签内部插入组件<a href="#2-在-body-标签内部插入组件"><span>#</span></a></h3><p>在 HTML 的 <code>&lt;body&gt;</code> 标签内部（建议放在页面的最底部、闭合标签 <code>&lt;/body&gt;</code> 的上方），加入组件代码：</p><p><strong>代码段</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>    </span></span><span>&lt;body&gt;</span></div></div><div><div><div>2</div></div><div><span><span>      </span></span><span>&lt;!-- 页面主体内容 --&gt;</span></div></div><div><div><div>3</div></div><div><span><span>      </span></span><span>&lt;slot /&gt;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>      </span></span><span>&lt;!-- 插入 Vercel Analytics --&gt;</span></div></div><div><div><div>6</div></div><div><span><span>      </span></span><span>&lt;Analytics/&gt;</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>&lt;/body&gt;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>&lt;/html&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>修改完成后，点击右上角的 <strong>Commit changes…</strong> 提交保存。</p></section></section>
<section><h2>第二步：Vercel 部署报错：pnpm 锁文件冲突<a href="#第二步vercel-部署报错pnpm-锁文件冲突"><span>#</span></a></h2><p>当你把代码提交到 GitHub 之后，Vercel 会自动触发重新部署。但如果你之前项目里没有安装过这个依赖，构建日志里大概率会抛出这样一个让人头疼的错误：</p><p><strong>Plaintext</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with &lt;ROOT&gt;/package.json</span></div></div><div><div><div>2</div></div><div><span>Error: [BUILD_UTILS_SPAWN_1] Command "pnpm install" exited with 1</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这是因为项目使用的是 <strong>pnpm</strong>，而 Vercel 在云端构建时默认开启了严格的锁文件检查（Frozen Lockfile）。因为你在代码里用了新依赖，但没有本地电脑去运行 <code>pnpm install</code> 更新 <code>pnpm-lock.yaml</code>，导致两边对不上，构建直接中断。</p><p>没有本地电脑，怎么更新锁文件呢？别慌，我们有终极武器——<strong>GitHub Codespaces</strong>。</p></section>
<section><h2>第三步：使用 GitHub Codespaces 妙解锁文件冲突<a href="#第三步使用-github-codespaces-妙解锁文件冲突"><span>#</span></a></h2><p>GitHub 官方自带的云端开发环境（Codespaces）可以让我们直接在浏览器里拥有一个完整的 Linux 终端。</p><section><h3>1. 召唤云端编辑器<a href="#1-召唤云端编辑器"><span>#</span></a></h3><ul>
<li>在你的 GitHub 项目仓库主页面，直接在键盘上按下英文句号键 <strong><code>.</code></strong>（点号）。</li>
<li>稍等几秒钟，页面就会神奇地变成一个网页版的 VS Code。</li>
</ul></section><section><h3>2. 在云端运行安装命令<a href="#2-在云端运行安装命令"><span>#</span></a></h3><ul>
<li>在页面下方自带的终端（Terminal）面板中，直接输入并回车：</li>
</ul><p><strong>Bash</strong></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm install</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li>这一步会让 pnpm 在云端自动帮你把依赖补全，并<strong>完美更新</strong>根目录下的 <code>pnpm-lock.yaml</code> 文件。</li>
</ul></section><section><h3>3. 提交并推送更改<a href="#3-提交并推送更改"><span>#</span></a></h3><ul>
<li>点击左侧边栏的<strong>源代码管理</strong>图标（分支图标）。</li>
<li>你会看到被自动更新的 <code>pnpm-lock.yaml</code> 和 <code>package.json</code> 文件。</li>
<li>在上方输入框写下提交信息，例如：<code>fix: update pnpm-lock.yaml for vercel analytics</code>。</li>
<li>点击 <strong>Commit</strong>，然后点击 <strong>Push</strong> 将代码推送到远程 GitHub 仓库。</li>
</ul></section></section>
<section><h2>🎉 大功告成<a href="#-大功告成"><span>#</span></a></h2><p>完成推送后，你可以直接关掉 Codespaces 标签页。</p><p>回到你的 <strong>Vercel 控制台</strong>，你会发现项目已经自动触发了新的一次部署。这一次，由于 <code>pnpm-lock.yaml</code> 已经和代码完全同步，Vercel 顺利通过了严格锁文件检查，项目完美构建成功！</p><blockquote><p><strong>小结</strong>：善用 GitHub 网页端与 Codespaces 的组合，即使身边的电脑没有配开发环境，也能随时随地轻松搞定静态站点的各类维护与部署难题。</p></blockquote></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/qexo-cannot-edit-astro/</id>
      <title type="text">Astro 博客排坑指南：使用 Qexo 管理时的日期类型校验错误排查</title>
      <published>2026-10-19T00:00:00.000Z</published>
      <updated>2026-10-21T17:53:57.319Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/qexo-cannot-edit-astro/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>在使用 <strong>Astro</strong> 搭建博客时，为了方便随时随地撰写和管理文章，许多小伙伴会引入轻量级的 Web CMS 系统，例如 <strong>Qexo</strong>。</p>
<p>然而，在配置好 Qexo 与 GitHub 仓库的连通性后，你可能会遇到一个诡异的现象：<strong>在 Qexo 后台修改网站配置（如修改站点名称）可以正常生效，但只要一添加或修改文章，系统就会报错，甚至直接在 GitHub 网页端手动修改文章也无法完成构建部署。</strong></p>
<p>本文将还原整个排坑过程，并提供一劳永逸的解决方案。</p>
<section><h2>🔍 问题现象与排查<a href="#-问题现象与排查"><span>#</span></a></h2><p>在 Qexo 端发布新文章后，触发 GitHub Actions 或 Vercel 构建，部署任务会瞬间标红中断，报错日志如下：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[InvalidContentEntryDataError] posts → draft data does not match collection schema.</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>published**: **published: Expected type "date", received "string"</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>updated**: **updated: Expected type "date", received "string"</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>Hint:</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>Error reference:</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>https://docs.astro.build/en/reference/errors/invalid-content-entry-data-error/</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>Location:</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>/vercel/path0/src/content/posts/draft.md:0:0</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>published**: **published: Expected type "date", received "object"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><section><h3>日志核心提示：<a href="#日志核心提示"><span>#</span></a></h3><ul>
<li><strong><code>posts → [文章文件名]</code></strong>：指出是某个 Content Collection 集合下的 Markdown 文件校验不通过。</li>
<li><strong><code>Expected type "date", received "string"</code></strong> 或 <strong><code>received "object"</code></strong>：指出 <code>published</code>（发布时间）字段收到的类型与期望的类型不符。</li>
</ul></section></section>
<section><h2>🛠️ 根源分析<a href="#️-根源分析"><span>#</span></a></h2><p>这个问题的根源在于 <strong>Astro Content Collections（内容集合）严格的 Schema 校验机制</strong> 与 <strong>通用 CMS / YAML 解析机制</strong> 之间的摩擦。</p><ol>
<li>
<p><strong>Astro 主题的严格校验</strong>： 在 Astro 主题中，<code>src/content.config.ts</code>（或 <code>src/content/config.ts</code>）通常会定义文章元数据（Frontmatter）的校验规则。大部分主题开发者会使用 Zod 库这样定义日期：</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>published: z.date(),</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>这里的 <code>z.date()</code><strong>只接受标准的 JavaScript <code>Date</code> 对象</strong>。</p>
</li>
<li>
<p><strong>Qexo 与 Frontmatter 的数据类型</strong>： Qexo 生成或修改 Markdown 时，写入文件顶部的 Frontmatter（YAML 格式）本质上是纯文本。当 Astro 构建时解析 YAML，传入 <code>published</code> 字段的值往往是一个 <strong>字符串</strong>（如 <code>"2026-10-18"</code>）或者由 YAML 解析器预处理出的某种 <strong>对象</strong>。</p>
</li>
<li>
<p><strong>冲突爆发</strong>： 当 <code>z.date()</code> 遇上字符串类型的日期数据，Astro 认为数据非法，触发 <code>InvalidContentEntryDataError</code> 保护机制并立即终止构建。</p>
</li>
</ol></section>
<section><h2>💡 解决方案：使用 <code>z.coerce.date()</code><a href="#-解决方案使用-zcoercedate"><span>#</span></a></h2><p>解决这个问题的关键，在于告诉 Zod 校验器：<strong>“不管传过来的是字符串还是 Date 对象，请尝试在构建时把它们自动转换为标准的 Date 对象。”</strong></p><p>Zod 提供了强大的类型强制转换（Coercion）工具：<code>z.coerce.date()</code>。</p><section><h3>操作步骤<a href="#操作步骤"><span>#</span></a></h3><ol>
<li>打开项目的配置文件 <code>src/content.config.ts</code>（或 <code>src/content/config.ts</code>）。</li>
<li>找到定义文章集合（如 <code>postsCollection</code>）的 <code>schema</code> 部分。</li>
<li>将所有的 <strong><code>z.date()</code></strong> 修改为 <strong><code>z.coerce.date()</code></strong>。</li>
</ol><section><h4>修改前代码：<a href="#修改前代码"><span>#</span></a></h4><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span><span> </span><span>postsCollection</span><span> </span></span><span>=</span><span> </span><span>defineCollection</span><span>({</span></div></div><div><div><div>2</div></div><div><span>  </span><span>loader</span><span>:</span><span> </span><span>glob</span><span>({</span><span> </span><span>pattern</span><span>:</span><span> </span><span>"</span><span>**/*.{md,mdx}</span><span>"</span><span>,</span><span> </span><span>base</span><span>:</span><span> </span><span>"</span><span>./src/content/posts</span><span>"</span><span> </span><span>}),</span></div></div><div><div><div>3</div></div><div><span>  </span><span>schema</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>object</span><span>({</span></div></div><div><div><div>4</div></div><div><span>    </span><span>title</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>string</span><span>(),</span></div></div><div><div><div>5</div></div><div><span>    </span><span>published</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>date</span><span>(),</span><span> </span><span>// ❌ 卡得很死，只认 Date 对象</span></div></div><div><div><div>6</div></div><div><span>    </span><span>updated</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>date</span><span>().</span><span>optional</span><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></div></div></code></pre><div><div></div><div></div></div><div></div></figure></div></section><section><h4>修改后代码：<a href="#修改后代码"><span>#</span></a></h4><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span><span> </span><span>postsCollection</span><span> </span></span><span>=</span><span> </span><span>defineCollection</span><span>({</span></div></div><div><div><div>2</div></div><div><span>  </span><span>loader</span><span>:</span><span> </span><span>glob</span><span>({</span><span> </span><span>pattern</span><span>:</span><span> </span><span>"</span><span>**/*.{md,mdx}</span><span>"</span><span>,</span><span> </span><span>base</span><span>:</span><span> </span><span>"</span><span>./src/content/posts</span><span>"</span><span> </span><span>}),</span></div></div><div><div><div>3</div></div><div><span>  </span><span>schema</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>object</span><span>({</span></div></div><div><div><div>4</div></div><div><span>    </span><span>title</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>string</span><span>(),</span></div></div><div><div><div>5</div></div><div><span>    </span><span>published</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>coerce</span><span>.</span><span>date</span><span>(),</span><span> </span><span>// ✅ 自动将字符串/对象转换为 Date 对象</span></div></div><div><div><div>6</div></div><div><span>    </span><span>updated</span><span>:</span><span><span> </span><span>z</span></span><span>.</span><span>coerce</span><span>.</span><span>date</span><span>().</span><span>optional</span><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></div></div></code></pre><div><div></div><div></div></div><div></div></figure></div><blockquote><p><strong>提示</strong>：如果你的配置文件中还有 <code>dynamic</code>（动态）、<code>projects</code>（项目）等其他带有 <code>published</code> 或时间字段的集合，建议一并修改为 <code>z.coerce.date()</code>。</p></blockquote><ol>
<li>保存并提交更改到 Git 仓库：
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>git add src/content.config.ts</span></div></div><div><div><div>2</div></div><div><span>git commit -m "fix: use z.coerce.date() to allow string dates from Qexo"</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>
</li>
</ol></section></section></section>
<section><h2>⚠️ 避坑补充指南<a href="#️-避坑补充指南"><span>#</span></a></h2><p>在排查过程中，如果你修改了代码依然报错，请检查以下细节：</p><ol>
<li><strong>双重后缀问题</strong>：Qexo 在极端情况下创建文件时可能会拼错文件名（例如生成了 <code>article.md.md</code>）。这类异常文件名会导致 Astro 加载器解析异常，请在 GitHub 仓库中清理掉多余的 <code>.md.md</code> 文件。</li>
<li><strong>必填字段缺失</strong>：Astro 校验非常严格，除了日期之外，如果文章头部缺少了 <code>title</code> 等必须字段（如新建了空白测试文件 <code>test.md</code>），也会触发 <code>title: Required</code> 报错。确保所有文章都包含完整的基础元数据：
<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>title: "文章标题"</span></div></div><div><div><div>3</div></div><div><span>published: 2026-10-18</span></div></div><div><div><div>4</div></div><div><span>draft: false</span></div></div><div><div><div>5</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
</ol></section>
<section><h2>🎉 总结<a href="#-总结"><span>#</span></a></h2><p>通过将 Zod 的 <code>z.date()</code> 升级为 <code>z.coerce.date()</code>，我们既保留了 TypeScript 的强类型检查，又赋予了 Content Collections 应对第三方 CMS 传入文本日期的容错能力。</p><p>现在，你可以继续享受 Astro 的极致性能，同时搭配 Qexo 随时随地愉快地创作博客了！</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/memos-as-dynamic/</id>
      <title type="text"> 让自建 memos 服务完美融合在 Astro 博客里面</title>
      <published>2026-10-18T00:00:00.000Z</published>
      <updated>2026-10-21T17:55:03.943Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/memos-as-dynamic/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>在独立博客中，我们常常希望有一个类似“微博/朋友圈”的角落，用来记录一些随想、短动态或生活碎片，而不需要像写正式文章那样绞尽脑汁去构思标题和排版。</p>
<p>如果你正在使用以轻量美观、支持丰富配置著称的 <strong>Astro 博客主题（如 Firefly）</strong>，同时又部署了轻量级开源笔记/动态服务 <strong>Memos</strong>，那么将两者结合可以说是绝配——通过将 Memos 作为动态的数据源，你既能享受 Memos 专属后台带来的便捷发布体验（甚至可以用手机随时随地发动态），又能完美保留博客主题的高颜值与极简风格。</p>
<p>本文将带你从零开始，在服务器上使用 Docker 自建 Memos 服务，并通过 Cloudflare Tunnel 实现安全的公网访问，最终将其无缝整合到 Astro 博客的动态页面中。</p>
<section><h2>使用 Docker 自建 Memos 服务<a href="#使用-docker-自建-memos-服务"><span>#</span></a></h2><p>Memos 是一个极佳的开源、自托管知识库与轻量动态记录工具。我们可以通过 Docker 快速将它部署到 VPS 上。</p><section><h3>1. 编写 Docker Compose 配置文件<a href="#1-编写-docker-compose-配置文件"><span>#</span></a></h3><p>在服务器的某个目录下（例如 <code>/opt/memos</code>），创建一个 <code>docker-compose.yml</code> 文件：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>services:</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>memos:</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>image: neosmemo/memos:latest</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>container_name: memos</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>restart: unless-stopped</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>ports:</span></div></div><div><div><div>7</div></div><div><span><span>      </span></span><span>- "5230:5230"</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>volumes:</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>- ./data:/var/opt/memos</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>environment:</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>- MEMOS_INSTANCE_URL=https://memos.example.com  # 替换为你实际绑定的域名</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><strong>💡 注意事项（关于版本与环境变量）</strong>： 从 Memos <code>0.30.0</code> 版本开始引入了较为严格的公开与私有模式破坏性变更。如果你的 Memos 版本号 <strong>≥ 0.30.0</strong>，请务必在环境变量中配置 <code>MEMOS_INSTANCE_URL</code>，并且该地址需要与你后续在博客里配置的 <code>apiUrl</code><strong>严格保持一致</strong>，否则前端跨域或 API 请求可能会遇到阻碍。</p></blockquote></section><section><h3>2. 启动容器<a href="#2-启动容器"><span>#</span></a></h3><p>在 <code>docker-compose.yml</code> 所在目录下执行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>docker compose up -d</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>使用 Cloudflare Tunnel 配置内网穿透与 HTTPS<a href="#使用-cloudflare-tunnel-配置内网穿透与-https"><span>#</span></a></h2><p>为了让博客能够安全地通过 HTTPS 访问 Memos 的 API（且无需繁琐配置 Nginx 和 SSL 证书），我们可以使用 <strong>Cloudflare Tunnels (<code>cloudflared</code>)</strong> 将本地的 <code>5230</code> 端口安全映射到公网域名。</p><section><h3>1. 在 Cloudflare Zero Trust 中创建 Tunnel<a href="#1-在-cloudflare-zero-trust-中创建-tunnel"><span>#</span></a></h3><ol>
<li>登录 <a href="https://dash.cloudflare.com/">Cloudflare Dashboard</a>，进入 <strong>Zero Trust</strong> 面板。</li>
<li>导航至 <strong>Networks</strong> -&gt; <strong>Tunnels</strong>，点击 <strong>Create a tunnel</strong>。</li>
<li>给隧道起个名字（例如 <code>memos-tunnel</code>），保存后选择你的操作系统或 Docker 部署方式获取运行命令。</li>
</ol></section><section><h3>2. 配置 Public Hostname（公网域名）<a href="#2-配置-public-hostname公网域名"><span>#</span></a></h3><p>在 Tunnel 创建完成后，为其绑定一个专属的子域名：</p><ul>
<li><strong>Subdomain &amp; Domain</strong>: 填入你想要的域名，例如 <code>memos.example.com</code>。</li>
<li><strong>Service</strong>:
<ul>
<li><strong>Type</strong>: <code>HTTP</code></li>
<li><strong>URL</strong>: <code>localhost:5230</code>（如果 <code>cloudflared</code> 与 memos 容器在同一台机器上）或容器名 <code>memos:5230</code>（如果在同一个 Docker 网络下）。</li>
</ul>
</li>
</ul><p>保存后，Cloudflare 会自动为你处理 DNS 解析和免费的 SSL/TLS 证书。此时直接访问 <code>[https://memos.example.com](https://memos.example.com)</code> 即可打开 Memos 初始化页面，完成管理员账号注册。</p></section></section>
<section><h2>在 Astro 博客（Firefly 主题为例）中接入 Memos 数据源<a href="#在-astro-博客firefly-主题为例中接入-memos-数据源"><span>#</span></a></h2><p>当 Memos 搭建完毕并通过 Tunnel 成功上线后，我们就可以让 Astro 博客直接读取 Memos 的内容，并在前端动态页面中实时展示。</p><section><h3>1. 开启动态页总开关<a href="#1-开启动态页总开关"><span>#</span></a></h3><p>首先，在站点配置文件（如 <code>src/config/siteConfig.ts</code>）中，确保开启了动态页面模块：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export const siteConfig = {</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>pages: {</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>dynamic: true, // 开启动态页</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></section><section><h3>2. 修改动态配置文件启用 Memos<a href="#2-修改动态配置文件启用-memos"><span>#</span></a></h3><p>打开动态专属的配置文件 <code>src/config/dynamicConfig.ts</code>，将 <code>memos</code> 的 <code>enable</code> 改为 <code>true</code>，并正确填写你的 Memos 实例地址以及用户标识 (<code>parent</code>)：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export const dynamicConfig = {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>title: "",</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>description: "",</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>profileUrl: "/about/",</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>showComment: true,</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>itemsPerPage: 20,</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>apiUrl: "/api/dynamic.json", // 当 memos.enable 为 true 时此项会被忽略</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>memos: {</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>enable: true,                            // 启用 Memos 数据源</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>apiUrl: "https://memos.example.com",     // 你的 Memos 实例地址（需与 Docker 环境变量及 Tunnel 域名一致）</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>parent: "users/your-username",           // 你的 Memos 用户标识（可在 Memos 后台或 API 路径中确认）</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>13</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><strong><code>enable</code></strong>: 设置为 <code>true</code> 后，博客客户端将绕过本地静态 JSON，直接从配置的 Memos API 实时拉取数据。</li>
<li><strong><code>apiUrl</code></strong>: 你的 Memos 站点根地址。</li>
<li><strong><code>parent</code></strong>: 目标用户的 Resource Name 格式（例如 <code>users/xiaye</code> 或你的实际用户名）。</li>
</ul></section></section>
<section><h2>效果与特性<a href="#效果与特性"><span>#</span></a></h2><p>完成上述配置并部署上线后（如果你习惯直接通过 GitHub 在线修改代码同步更新，也可以直接在 GitHub 仓库中调整对应配置文件触发 Vercel 等平台自动部署）：</p><ol>
<li><strong>实时同步</strong>：你在 Memos 后台（或手机客户端）发布的任何碎碎念、动态，都会实时同步展现到 Astro 博客的动态页中。</li>
<li><strong>特性支持</strong>：主题会自动处理 Memos 中的图片附件展示、置顶同步，甚至能将位置信息（<code>location</code>）完美映射并显示在动态发布时间旁。</li>
<li><strong>小组件联动</strong>：博客侧边栏的「最新动态」等小组件也会无缝从该 Memos 接口获取最新内容，保持全站数据流的一致性。</li>
</ol><p>通过这种方式，既省去了复杂的服务器运维与证书申请，又拥有了一个极其现代、方便管理的动态发布后台，可以说是独立开发者和博主们的理想搭配！</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/deploy-astro-firefly-vercel/</id>
      <title type="text">在 Vercel 部署 Astro 的 Firefly 主题模板</title>
      <published>2025-12-31T00:00:00.000Z</published>
      <updated>2026-10-21T17:55:44.179Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/deploy-astro-firefly-vercel/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>折腾静态博客好像有一种让人停不下来的魔力。之前对各种花哨的动态博客架构折腾了一圈后，最近反倒更向往那种轻量、干净、打开即用的静态站点。</p>
<p>**偶然刷到基于 Astro 框架开发的 **<strong>Firefly</strong> 主题（衍生自 Fuwari 模板），一眼就被它那清新现代的界面、细腻的动效以及丰富的内置组件给吸引住了。既然相中了，那就索性动手，把它直接部署到 Vercel 上，作为日常记录和分享的小天地。^^</p>
<p>整个过程出奇的顺畅，这里把折腾的步骤和一些个人感受简单记录下来。</p>
<section><h2>为什么选择 Astro + Firefly？<a href="#为什么选择-astro--firefly"><span>#</span></a></h2><ul>
<li><strong>极致的性能</strong>：Astro 默认输出零客户端 JavaScript 的静态 HTML，对于我这种追求加载速度和极简体验的人来说，简直直击痛点。</li>
<li><strong>现代且高颜值</strong>：<strong>Firefly 自身带有很多精致的细节，比如动态背景、暗色/亮色模式无缝切换、自定义主题色相，甚至还有项目展示、时间线、技能树等极具极客风格的页面，省去了自己从零写前端样式的大量精力。</strong>^^</li>
<li><strong>省心的托管</strong>：不需要操心复杂的服务器运维，配合 GitHub 和 Vercel 的联动，真正实现了“修改代码、推送到仓库、自动构建上线”的丝滑体验。</li>
</ul></section>
<section><h2>部署步骤：比想象中更简单<a href="#部署步骤比想象中更简单"><span>#</span></a></h2><p>得益于现代 Jamstack 架构的成熟，整个部署流程基本可以概括为：<strong>Fork 仓库 -&gt; 配置参数 -&gt; 绑定 Vercel 自动构建</strong>。</p><section><h3>1. 准备工作与仓库 Fork<a href="#1-准备工作与仓库-fork"><span>#</span></a></h3><p>首先来到 GitHub 上的 Firefly 项目仓库（CuteLeaf/Firefly）。点击右上角的 <strong>Fork</strong>，把项目复制到你自己的 GitHub 账号下。</p></section><section><h3>2. 基础配置与个性化<a href="#2-基础配置与个性化"><span>#</span></a></h3><p>Fork 下来之后，其实不用急着在本地搭建复杂的 Node.js 开发环境。因为平时更习惯直接在 GitHub 的在线编辑器里微调，或者通过 Vercel 触发同步更新，所以直接在线修改核心配置文件即可：</p><ul>
<li>找到 <code>src/config.ts</code>（或者项目根目录下的相关配置文件），在这里可以修改你的博客标题、作者签名、社交链接、导航栏以及各种功能开关。</li>
<li><strong>替换掉默认的头像、Banner 壁纸，换成自己喜欢的风格。</strong>^^</li>
</ul></section><section><h3>3. 在 Vercel 上一键托管<a href="#3-在-vercel-上一键托管"><span>#</span></a></h3><p>登录 Vercel 账号，点击 <strong>Add New… -&gt; Project</strong>：</p><ol>
<li>从 GitHub 列表中选择你刚刚 Fork 的 Firefly 仓库。</li>
<li>Vercel 会非常智能地自动识别出这是一个 <strong>Astro</strong> 项目，构建命令（Build Command）通常会默认填充为 <code>npm run build</code>，输出目录（Output Directory）为 <code>dist</code>。</li>
<li>点击 <strong>Deploy</strong>，剩下的就交给 Vercel 的云端构建流水线。</li>
</ol><p>看着终端里滚动几行日志，伴随着几秒钟的等待，站点就绿灯上线了。绑定好自己的自定义域名，顺便把 SSL 证书配置好，一个属于自己的全新 Firefly 博客就正式对外访问了。</p></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>折腾完之后，看着干净清爽的页面在浏览器里秒开，心情也会跟着平静下来。</p><p>静态博客的好处就在于此：它不要求你时刻去维护后端数据库的稳定，也不用担心哪天服务器过期。你只需要专注在 Markdown 文本里写下当下想说的话，然后点一下保存、推送到远端，剩下的交给云端去同步。</p><p>愿这盏小小的“Firefly”，能记录下更多技术与生活里的点滴微光。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/astro-qexo-setup/</id>
      <title type="text">博客迁移 Astro 并接入 Qexo 3.7.0 后台折腾手记</title>
      <published>2025-10-06T00:00:00.000Z</published>
      <updated>2026-10-21T17:56:16.398Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/astro-qexo-setup/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>一直以来，对于静态博客的折腾似乎总有一种停不下来的魔力。从早期的 Hexo 搭配各种主题，到后来对现代化组件化架构的向往，总觉得博客不仅是文字的容器，也是技术审美的自留地。</p>
<p>最近关注到 <strong>Qexo</strong> 发布了 <strong>3.7.0</strong> 新版本，其中最让人眼前一亮的就是<strong>终于原生支持了 Astro</strong>！这对于一直想尝试 Astro 现代轻量化体验，又舍不得 Qexo 网页端便捷管理后台的我来说，简直是久旱逢甘霖。</p>
<p>趁着周末的闲暇，索性一不做二不休，把博客整个迁移到了 Astro（选用了一款心仪的 Astronauts 主题），并顺势部署了最新版的 Qexo 后台进行现代化管理。在此记录下整个折腾的过程，也给同样想折腾的朋友做个参考。</p>
<section><h2>为什么选择 Astro 与 Qexo 3.7.0？<a href="#为什么选择-astro-与-qexo-370"><span>#</span></a></h2><p>在动手之前，先简单聊聊这次技术栈变动的初衷：</p><ol>
<li><strong>Astro 的现代化体验</strong>：<strong>相比传统 SSG，Astro 带来了极佳的组件化架构和零客户端 JavaScript 默认哲学，构建速度极快，页面轻量清爽，非常适合追求极致性能的个人独立博客。</strong>^^</li>
<li><strong>Qexo 3.7.0 的完美适配</strong>：以往 Qexo 对 Hexo、Halo 等支持较多，而 3.7.0 版本的更新对 Astro 的内容结构、Frontmatter 以及发布流程有了更好的兼容，使得“网页端随时随地写文章/改文章”的梦想照进现实。</li>
<li><strong>云端无缝协作</strong>：采用 Vercel 托管前端、GitHub 进行源码同步、结合 Qexo 后台与 PostgreSQL 数据库，整个链路完全跑通，免去了本地环境的束缚。</li>
</ol></section>
<section><h2>迁移与部署实录<a href="#迁移与部署实录"><span>#</span></a></h2><p>整个过程大致可以分为三个核心步骤：部署 Astro 主题、配置 Qexo 后台，以及打通两者的关联。</p><section><h3>1. 部署 Astro 博客前台<a href="#1-部署-astro-博客前台"><span>#</span></a></h3><p>首先在 GitHub 上准备好基于 Astro 的博客仓库（以 Astronauts 主题为例）：</p><ul>
<li><strong>仓库关联</strong>：将项目推送到 GitHub，直接在 Vercel 中导入该仓库进行一键托管部署。</li>
<li><strong>内容适配</strong>：检查 <code>src/content/posts</code> 下的 Markdown/MDX 文件结构，确保 Frontmatter 包含必要的字段（如 <code>title</code>, <code>published</code>, <code>description</code>, <code>tags</code> 等）。</li>
<li><strong>日期与 Schema 校验</strong>：在对接后台时，建议在 Astro 的内容集合（Content Collections）中对日期字段做好处理（例如使用 <code>z.coerce.date()</code>），以防后台提交的时间格式引起构建报错。</li>
</ul></section><section><h3>2. 部署 Qexo 3.7.0 后台<a href="#2-部署-qexo-370-后台"><span>#</span></a></h3><p>Qexo 的部署依然保持了一贯的轻量与灵活。这次我们将其部署在 Vercel 上，并连接到 PostgreSQL 数据库：</p><ol>
<li>
<p><strong>新建数据库</strong>：在 Vercel 或其他托管平台准备好 PostgreSQL 数据库实例。</p>
</li>
<li>
<p><strong>Fork / 部署 Qexo</strong>：将 Qexo 源码部署到 Vercel，配置好环境变量：</p>
<ul>
<li><code>DATABASE_URL</code>：连接你的 PostgreSQL 数据库。</li>
<li>其他安全与鉴权密钥。</li>
</ul>
</li>
<li>
<p><strong>初始化后台</strong>：访问部署好的域名，完成管理员账号的注册与初始化。</p>
</li>
</ol></section><section><h3>3. 对接与调优：让 Qexo 顺畅管理 Astro<a href="#3-对接与调优让-qexo-顺畅管理-astro"><span>#</span></a></h3><p>进入 Qexo 3.7.0 的后台设置，核心是配置好 <strong>GitHub 图床/仓库同步</strong> 以及 <strong>Astro 的API/Webhook 触发</strong>：</p><ul>
<li><strong>仓库连接</strong>：填入你的 GitHub 仓库信息、Personal Access Token（PAT），让 Qexo 能够直接读写仓库中的文章 Markdown 文件。</li>
<li><strong>分支与路径映射</strong>：指定文章存放的对应目录（例如 <code>src/content/posts/</code>）。</li>
<li><strong>自动触发构建</strong>：开启 Vercel 的 Deploy Hooks（部署钩子），这样每次在 Qexo 后台点击发布或修改文章时，都能自动触发 Vercel 重新编译部署，实现“网页端保存 <strong>$\rightarrow$</strong> 自动同步 GitHub <strong>$\rightarrow$</strong> 瞬间完成上线”的丝滑闭环。</li>
</ul></section></section>
<section><h2>遇到的一个小插曲<a href="#遇到的一个小插曲"><span>#</span></a></h2><p>在实际联调过程中，也遇到了一点小波折：由于 Astro 对文章 Frontmatter 的日期类型检查比较严格，如果后台提交的日期格式与本地定义的 Zod Schema 稍有出入，Vercel 构建时就会抛出类型错误。</p><p><strong>解决办法</strong>： 在 Astro 的 <code>config.ts</code> 中调整内容集合的 Schema 定义，针对日期字段加入类型强转（例如 <code>z.coerce.date()</code>），这样无论通过 Qexo 后台以何种时间格式写入，都能被正确解析，再也不用担心莫名其妙的构建失败了。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>折腾完毕，看着新版博客在轻盈的加载动画中缓缓展开，后台也能随时随地用手机或网页顺畅地敲字、修改、发布，这种掌控感和新鲜感确实让人心情愉悦。</p><p>技术总是不断向前，工具也在变得越来越聪明。愿这片赛博空间能承载更多安静的文字、绵长的思绪，以及对代码和生活纯粹的热爱。</p><p>晚安，世界。 🌙</p><p><em>如果你在部署 Qexo 3.7.0 或迁移 Astro 的过程中遇到了什么问题，欢迎在评论区一起交流探讨！</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/vercel-cloudflare-speedup-guide/</id>
      <title type="text">Vercel 与 Cloudflare 优选 IP 加速完整指南</title>
      <published>2025-05-31T00:00:00.000Z</published>
      <updated>2026-10-21T17:57:18.284Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/vercel-cloudflare-speedup-guide/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>对于独立开发者和前端工程师来说，<strong>Vercel</strong> 绝对是部署静态网站、Next.js 应用和 Serverless 函数的神仙级平台。然而，由于众所周知的原因，Vercel 官方分配给国内访问的边缘节点有时表现不尽如人意，甚至偶发阻断。</p>
<p>**今天我们就来聊聊如何通过 **<strong>Cloudflare 优选 IP / Cloudflare SaaS（自定义主机名）</strong> 的组合拳，让托管在 Vercel 上的网站实现国内极速访问。^^</p>
<section><h2>核心原理解析<a href="#核心原理解析"><span>#</span></a></h2><p>Vercel 的底层在全球使用了强大的 CDN 分发，但其默认的 Anycast IP 在国内的直连路由并不总是最优的。</p><p>**而 **<strong>Cloudflare SaaS 功能（Custom Hostnames，自定义主机名）</strong> 允许我们在自己的 Cloudflare 账号里接管一个域名，通过 CNAME 的方式把国内用户的流量引导到“优选 IP”上，再由 Cloudflare 安全回源到 Vercel。这样既能享受 Vercel 的免运维托管，又能享受到 Cloudflare 优选节点带来的低延迟和高稳定性。^^</p></section>
<section><h2>准备工作<a href="#准备工作"><span>#</span></a></h2><p>在开始配置之前，你需要准备好以下几项：</p><ol>
<li>**一个托管在 Vercel 上的项目，并已经绑定了你的自定义域名（例如 **<code>&lt;span class="citation-492"&gt;example.com&lt;/span&gt;</code>）。^^</li>
<li>一个可以正常使用的 <strong>Cloudflare 账号</strong>。</li>
<li>**辅助域名/主域名（建议准备两个域名，或者用主域名 + 子域名配合，例如主域名 **<code>&lt;span class="citation-491"&gt;example.com&lt;/span&gt;</code> 走优选，备用/回源域名 <code>&lt;span class="citation-491"&gt;origin.example.com&lt;/span&gt;</code> 指向源站）。^^</li>
</ol></section>
<section><h2>实操配置步骤<a href="#实操配置步骤"><span>#</span></a></h2><section><h3>1：在 Vercel 中绑定域名^^<strong><a href="http://www.aichuangshi.com">www.aichuangshi.com</a></strong><a href="#1在-vercel-中绑定域名wwwaichuangshicom"><span>#</span></a></h3><ol>
<li>登录 Vercel 控制台，进入你的项目，点击 <strong>Settings -&gt; Domains</strong>。</li>
<li><strong>输入你的自定义域名（如 <strong><code>&lt;span class="citation-489"&gt;example.com&lt;/span&gt;</code>），点击 <strong>Add</strong></strong>。</strong>^^</li>
<li>**Vercel 会提示让你添加一条 CNAME 记录指向 **<code>&lt;span class="citation-488"&gt;cname.vercel-dns.com&lt;/span&gt;</code>。先记下这个要求。^^</li>
</ol></section><section><h3>2：在 Cloudflare 配置 SaaS（自定义主机名）^^<strong>星の住処</strong><a href="#2在-cloudflare-配置-saas自定义主机名星の住処"><span>#</span></a></h3><p><strong>为了绕过 Cloudflare 默认分配的慢速节点，我们需要利用 SaaS 功能绑定回源：</strong>^^</p><ol>
<li>**准备一个托管在 Cloudflare 上的域名（假设叫 **<code>&lt;span class="citation-485"&gt;fales.net&lt;/span&gt;</code> 或使用你的主域名开启 SaaS）。^^</li>
<li><strong>在 Cloudflare 面板中，找到 <strong><strong>SSL/TLS -&gt; 自定义主机名 (Custom Hostnames)</strong></strong>。</strong>^^</li>
<li><strong>点击 <strong><strong>添加自定义主机名</strong></strong>：</strong>^^
<ul>
<li><strong>自定义主机名</strong>：**填入你要对外访问的域名（如 **<code>&lt;span class="citation-482"&gt;example.com&lt;/span&gt;</code>）。^^</li>
<li><strong>回退源 (Fallback Origin)****：</strong>^^填入 Vercel 的别名或者你的回源代理域名（例如 <code>&lt;span class="citation-480"&gt;cname.vercel-dns.com&lt;/span&gt;</code>）。^^</li>
</ul>
</li>
<li><strong>按照页面提示完成 <strong><strong>TXT 证书验证</strong></strong>（Cloudflare 会要求你在 DNS 解析里加一条 TXT 记录以证明你拥有该域名所有权）。</strong>^^</li>
</ol></section><section><h3>3：获取并配置“优选 IP / 优选 CNAME”^^<strong>NodeSeek</strong><a href="#3获取并配置优选-ip--优选-cnamenodeseek"><span>#</span></a></h3><p><strong>国内直连 Cloudflare 官方节点的带宽经常拥堵，我们需要通过测速工具（如 <strong><code>&lt;span class="citation-477"&gt;CloudflareSpeedTest&lt;/span&gt;</code>）选出对三网（电信、联通、移动）延迟最低、丢包最少的 <strong>优选 IP</strong> 或 <strong>优选 CNAME</strong></strong>。</strong>^^</p><ol>
<li><strong>DNS 分流解析设置</strong>**（**^^以 Cloudflare DNS 或第三方 DNS 为例）：^^
<ul>
<li><strong>如果使用第三方多线路 DNS（如 DNSPod、阿里云解析），可以针对不同运营商设置线路：</strong>^^
<ul>
<li><strong>电信线路</strong>：<strong>A 记录解析到 电信优选 IP</strong>^^</li>
<li><strong>联通线路</strong>**：**^^A 记录解析到 联通优选 IP^^</li>
<li><strong>移动线路</strong>**：**^^A 记录解析到 移动优选 IP^^</li>
</ul>
</li>
<li><strong>或者直接使用社区维护的稳定<strong><strong>优选 CNAME 域名</strong></strong>，将你的主域名 CNAME 指向该优选域名。</strong>^^</li>
</ul>
</li>
</ol></section></section>
<section><h2>效果测试与优化<a href="#效果测试与优化"><span>#</span></a></h2><p>配置完成并等待 DNS 生效（通常几分钟内）后：</p><ol>
<li>**使用多地 Ping 工具（如 **<a href="https://www.google.com/search?q=https://www.itdog.net/&amp;authuser=1">ITDOG</a>）测试你的自定义域名。^^</li>
<li>你会发现原本解析到海外高延迟节点的 IP 已经变成了优化后的国内或港台直连/优质中转节点，访问速度显著提升，网页秒开。</li>
</ol></section>
<section><h2>总结与注意事项<a href="#总结与注意事项"><span>#</span></a></h2><p>**通过 **<strong>Vercel + Cloudflare SaaS + 优选 IP</strong> 的方案，我们完美结合了云平台的便捷与自选节点的网速优势。^^</p><ul>
<li><strong>优点</strong>：大幅降低访问延迟、防DDoS攻击、国内访问更稳定。</li>
<li><strong>注意点</strong>：优选 IP 具有一定的时效性，部分免费优选 IP 可能会不定期失效，建议配合自动化脚本或关注相关社群定期更新优选 IP。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/from-purrfit-to-flaris/</id>
      <title type="text">从 Purrfit 到 Flaris：借 Gemini 之手，搭一座属于自己的数字小岛</title>
      <published>2025-05-20T00:00:00.000Z</published>
      <updated>2026-11-01T10:38:15.478Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/from-purrfit-to-flaris/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<blockquote><p><strong>摘要</strong>：上一个月给猫咪写了个体重记录小工具 Purrfit，这个月又想整理一下设备里杂乱的书签。于是，在 Gemini 的配合下，我搞出了一个纯基于 Cloudflare Workers 的极简导航页 —— Flaris。</p></blockquote>
<section><h2>独立开发的“轻量”执念<a href="#独立开发的轻量执念"><span>#</span></a></h2><p>前阵子为了跟踪猫咪的日常体重变化，我写了开源项目 <strong>Purrfit</strong>，发布并跑在了 Cloudflare Workers 上。那种不依赖传统服务器、不需要繁琐配置 Nginx、完全依靠 Serverless 边缘网关“即即用”的体验，着实让人上瘾。</p><p>最近在整理浏览器书签和多设备同步时，我再次感觉到了不便：市面上的导航页要么功能过于堆砌，背景广告和新闻资讯乱飞；要么就是样式老旧，缺少一点通透现代的气息。</p><p>既然现有的都不太趁手，不如顺着之前的思路，再搭一座纯粹、轻量的“数字小岛”。</p></section>
<section><h2>Flaris 的诞生<a href="#flaris-的诞生"><span>#</span></a></h2><p>这次我依然选择使用 <strong>Cloudflare Workers + KV</strong> 的单文件/轻量单体架构（Monolithic Script），前端则使用了轻量响应式的 Vue 3 + Tailwind CSS，并给项目起名 <strong>Flaris Navigation</strong>。</p><p>在和 Gemini 的多次互动交流与代码重构中，这个项目渐渐有了我理想中的模样：</p><ul>
<li><strong>通透的玻璃拟态 UI</strong>：告别厚重的背景块，采用高透明度的 Glassmorphism 卡片，让日光或暗夜背景自然透出来，视觉上更加轻盈。</li>
<li><strong>高精度的 Open-Meteo 天气</strong>：放弃了容易超时或气温偏离较大的公共 API，重构引入 Open-Meteo 结合 IP 自动定位，实时显示当前城市的气温与天气图标。</li>
<li><strong>农历与动态时间段问候</strong>：支持公历/农历一键切换，并按晨光、上午、午休、深夜等不同时间段，打字随机播放不同的小问候。</li>
<li><strong>完善又克制的后台管理</strong>：支持密码登录、多级分类、私有链接隐藏，以及数据 JSON 一键备份与还原。</li>
<li><strong>黑白猫的视觉灵魂</strong>：和 Purrfit 一样，在开屏动画与细节里，依然保留了我钟爱的黑白猫伸懒腰与小细节，给冰冷的代码凑上一丝温度。</li>
</ul></section>
<section><h2>AI 时代的工作流重塑<a href="#ai-时代的工作流重塑"><span>#</span></a></h2><p>回顾这次从想法到落地、再到在 V2EX 上发帖开源的过程，Gemini 在其中扮演的角色令人惊喜。</p><p>以前做 Web 开发，可能要花上大半天配置构建工具、解决复杂的依赖链，甚至为一段 WMO 天气代码解析或者农历转换算法头疼半天。而现在，我只需要把对界面交互的直觉、视觉透明度的微调诉求、以及逻辑上的边界异常告诉 Gemini，就能在几秒钟内拿到优雅整合且经过校验的代码片段。</p><p>从 <strong>Purrfit</strong> 的猫咪体重数据追踪，到 <strong>Flaris</strong> 的个人站点导航，这种“只专注于想法与体验，把繁琐实现交给 AI”的协同模式，让独立开发变得前所未有的顺畅与快乐。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>Flaris 的源代码已经全面开源在 GitHub 上，直接复制单文件部署到 Cloudflare Worker 绑定 KV 即可免费使用。</p><a href="https://github.com/zaocat/Flaris" target="_blank"><div><div><div><div></div><div>zaocat</div></div><div>/</div><div>Flaris</div></div><div></div></div><div>🧭 Flaris - Your bookmark galaxy on the edge. A minimalist dashboard powered by Cloudflare KV.  (🧭 Flaris - 边缘端的书签星系。由 Cloudflare KV 驱动的极简仪表盘。)</div><div><div>—</div><div>—</div><div>MIT</div><span>JavaScript</span></div></a><ul>
<li><strong>在线 Demo</strong>：<a href="https://nav.929255.xyz">https://nav.929255.xyz</a></li>
</ul><p>小岛已建好，猫咪正趴在侧栏晒太阳。希望能给同样喜欢干净、轻量体验的朋友带来一点点便利。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://929255.xyz/posts/domain-cat-and-purrfit/</id>
      <title type="text">域名、小猫与 Purrfit：用 Cloudflare 搭建一个猫咪体重计</title>
      <published>2025-04-18T00:00:00.000Z</published>
      <updated>2026-11-01T10:37:43.689Z</updated>
      <author><name>ZaoCat</name></author>
      <link rel="alternate" href="https://929255.xyz/posts/domain-cat-and-purrfit/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>不久前偶然注册到了一个 <code>.xx.kg</code> 的免费域名，当时还没想好拿它来做什么。直到前阵子家里迎来了一只新成员——一只可爱的小猫。</p>
<p>看着它每天蹦蹦跳跳、一天天长大，记录它体重变化的诉求变得明确起来。<code>kg</code>（公斤）与体重记录天然契合，这个域名便顺理成章地找到了它的使命。于是，<strong>Purrfit</strong> 就这样诞生了。</p>
<section><h2>为什么是 Purrfit？<a href="#为什么是-purrfit"><span>#</span></a></h2><p>市面上现有的健康或体重记录应用，要么功能过于繁重，带有各种无意义的社交属性；要么广告漫天，甚至连最基础的数据导出都要收费。</p><p>作为开发者，最舒服的解决方式自然是自己动手。我对这个小工具的诉求很纯粹：</p><ol>
<li><strong>轻量与优雅</strong>：界面要清爽，支持暗色模式，交互要足够细致（比如动态 Glow 光影与磨砂玻璃气泡）。</li>
<li><strong>零成本运行</strong>：无需维护重型服务器，借助 Cloudflare 的 Edge 边缘节点处理一切。</li>
<li><strong>数据完全自主</strong>：支持无痛的 CSV 导入导出，不依赖任何第三方平台。</li>
</ol></section>
<section><h2>技术架构与实现<a href="#技术架构与实现"><span>#</span></a></h2><p>整个项目基于 <strong>Cloudflare Workers + KV</strong> 构建，零服务器开销，部署极其简单：</p><ul>
<li>
<p><strong>后端处理与渲染</strong>：单个 Worker 承载了轻量级路由、身份验证以及服务端 HTML 渲染。</p>
</li>
<li>
<p><strong>数据持久化</strong>：使用 Cloudflare KV 存储体重记录与全局配置，读写极其迅捷。</p>
</li>
<li>
<p><strong>UI &amp; 细节打磨</strong>：</p>
<ul>
<li>采用了 <strong>Glassmorphism（毛玻璃）</strong> 视觉风格，自适应浅色与深色模式。</li>
<li>图表部分实现了动态趋势渲染，悬浮气泡具备半透明磨砂质感，能够精准呈现具体日期与备注。</li>
<li>内置了中文/英文双语言支持与多猫咪档案管理。</li>
</ul>
</li>
</ul></section>
<section><h2>搭建与部署流程<a href="#搭建与部署流程"><span>#</span></a></h2><p>如果你也想为自家的猫咪搭建一个，整个过程只需要几分钟：</p><ol>
<li>
<p><strong>准备 KV 绑定</strong>： 在 Cloudflare Dashboard 创建一个名为 <code>CAT_KV</code> 的 KV Namespace，并在 Worker 中完成绑定。</p>
</li>
<li>
<p><strong>环境变量配置</strong>：</p>
<ul>
<li><code>ADMIN_USER</code>：后台管理员账号</li>
<li><code>ADMIN_PASS</code>：后台管理员密码</li>
<li><code>CAT_NAMES</code>：猫咪名字（支持多只，用逗号分隔）</li>
</ul>
</li>
<li>
<p><strong>部署代码</strong>： 将开源的单个 <code>worker.js</code> 代码粘贴至 Worker 编辑器中，保存并发布。</p>
</li>
<li>
<p><strong>绑定域名</strong>： 在 Worker 的 Custom Domains 中解析你的专属域名（比如我的 <code>.xx.kg</code>），即可完成部署。</p>
</li>
</ol></section>
<section><h2>在线体验与开源<a href="#在线体验与开源"><span>#</span></a></h2><p>目前 Purrfit 已完全开源，并内置了演示环境：</p><a href="https://github.com/zaocat/Purrfit" target="_blank"><div><div><div><div></div><div>zaocat</div></div><div>/</div><div>Purrfit</div></div><div></div></div><div>🐱 Purrfit: A minimalist, serverless cat weight tracker built on Cloudflare Workers. ☁️ 一个基于 Cloudflare Workers 构建的简约、精美、无服务器的猫咪体重记录本。</div><div><div>—</div><div>—</div><div>MIT</div><span>JavaScript</span></div></a><ul>
<li><strong>在线 Demo</strong>：<a href="https://zao.qzz.io">zao.qzz.io</a>（测试账号：<code>test</code> / 密码：<code>123456</code>）</li>
</ul><p>域名有了归宿，小猫的成长有了记录。如果你也在寻找一个纯粹、安静的猫咪健康追踪工具，不妨也搭一个试试。</p></section>]]></content>
    </entry>
</feed>
