<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>风飞扬itzhouq · Build in Public</title>
  <link>https://itzhouq.cn</link>
  <description>记录独立开发、AI 工具与副业探索的 Build in Public 日常</description>
  <language>zh-CN</language>
  <lastBuildDate>Tue, 06 Oct 2026 16:19:40 GMT</lastBuildDate>
  
    <item>
      <title>给博客挂上第一个 AI 小工具</title>
      <link>https://itzhouq.cn/blog/first-ai-tool</link>
      <guid isPermaLink="true">https://itzhouq.cn/blog/first-ai-tool</guid>
      <pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[从设计决策到上线只花了一个下午：BYOK 模式、流式输出、混合内容的坑——本站第一个工具「AI Chat 游乐场」的完整复盘。]]></description>
      <content:encoded><![CDATA[<p>上个月写<a href="/blog/build-blog-from-scratch">从零手写博客</a>的时候留了个伏笔：内容站的尽头是"内容 → 工具 → 产品"。这个伏笔今天兑现了——本站第一个小工具 <a href="/tools/chat">AI Chat 游乐场</a>上线，这篇文章复盘它的设计决策和踩坑。</p>
<h2 id="为什么第一个工具是-chat"><a href="#为什么第一个工具是-chat">为什么第一个工具是 Chat</a></h2>
<p>选型时想过文档问答、文案助手，最后选了最"朴素"的对话：它是验证整条链路（页面 → 大模型 API → 流式渲染）最短路径的工具。后面所有工具——无论文案、问答还是智能体——底层都是这一条链路，先把水管铺通，以后接什么都方便。</p>
<h2 id="关键决策byokbring-your-own-key"><a href="#关键决策byokbring-your-own-key">关键决策：BYOK（Bring Your Own Key）</a></h2>
<p>第一个要回答的问题：<strong>API Key 从哪来？</strong></p>
<p>方案 A 是我在服务端放一个 Key 大家共用——但本站是纯静态站，没有服务端；要加就得引入后端和计费，违背"从零手写、无数据库"的初衷，而且等于提前做了一遍 API 中转生意（那是路线图的后续项）。</p>
<p>所以选了方案 B：<strong>BYOK</strong>——访客填自己的 OpenAI 兼容端点 + Key，浏览器直连。好处很直接：</p>
<ul>
<li>零服务器成本，纯静态托管扛得住任意流量；</li>
<li>Key 只存访客自己的 localStorage，不经过任何第三方（包括我）；</li>
<li>合规简单：没有代充值、没有账号体系。</li>
</ul>
<p>代价是访问门槛：用户得有一个 API Key。对目标读者（折腾 AI 的开发者）来说，这门槛约等于零。</p>
<h2 id="技术点一流式输出"><a href="#技术点一流式输出">技术点一：流式输出</a></h2>
<p>对话体验的生命线是流式。OpenAI 兼容接口的流式响应是 SSE 格式，浏览器端用 <code>fetch</code> + <code>ReadableStream</code> 手动解析：</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">const</span> reader = res.<span class="hljs-property">body</span>.<span class="hljs-title function_">getReader</span>();
<span class="hljs-keyword">const</span> decoder = <span class="hljs-keyword">new</span> <span class="hljs-title class_">TextDecoder</span>();
<span class="hljs-keyword">let</span> buf = <span class="hljs-string">""</span>;
<span class="hljs-keyword">for</span> (;;) {
  <span class="hljs-keyword">const</span> { done, value } = <span class="hljs-keyword">await</span> reader.<span class="hljs-title function_">read</span>();
  <span class="hljs-keyword">if</span> (done) <span class="hljs-keyword">break</span>;
  buf += decoder.<span class="hljs-title function_">decode</span>(value, { <span class="hljs-attr">stream</span>: <span class="hljs-literal">true</span> });
  <span class="hljs-keyword">const</span> lines = buf.<span class="hljs-title function_">split</span>(<span class="hljs-string">"\n"</span>);
  buf = lines.<span class="hljs-title function_">pop</span>() || <span class="hljs-string">""</span>; <span class="hljs-comment">// 半行留到下一轮</span>
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> line <span class="hljs-keyword">of</span> lines) {
    <span class="hljs-keyword">if</span> (!line.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">"data:"</span>)) <span class="hljs-keyword">continue</span>;
    <span class="hljs-keyword">const</span> payload = line.<span class="hljs-title function_">slice</span>(<span class="hljs-number">5</span>).<span class="hljs-title function_">trim</span>();
    <span class="hljs-keyword">if</span> (payload === <span class="hljs-string">"[DONE]"</span>) <span class="hljs-keyword">continue</span>;
    <span class="hljs-keyword">const</span> delta = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(payload)?.<span class="hljs-property">choices</span>?.[<span class="hljs-number">0</span>]?.<span class="hljs-property">delta</span>?.<span class="hljs-property">content</span>;
    <span class="hljs-keyword">if</span> (delta) <span class="hljs-title function_">appendToLastMessage</span>(delta);
  }
}
</code></pre>
<p>三个细节：<code>buf.pop()</code> 处理跨 chunk 的半行；<code>[DONE]</code> 是结束标记；解析失败的行直接跳过（有的网关会夹带注释行）。另外做了降级：如果响应不是 <code>text/event-stream</code>，按普通 JSON 处理，兼容不支持流式的端点。</p>
<h2 id="技术点二混合内容的坑"><a href="#技术点二混合内容的坑">技术点二：混合内容的坑</a></h2>
<p>上线前自测差点翻车：<strong>HTTPS 页面调用 HTTP 接口会被浏览器直接拦截</strong>（混合内容），报错还长得像 CORS，容易误诊。</p>
<p>规则记一下：</p>
<ul>
<li><code>https://</code> 页面 → <code>http://</code> 接口：<strong>拦截</strong>；</li>
<li>例外只有 <code>localhost</code> / <code>127.0.0.1</code>（浏览器视为安全上下文）。</li>
</ul>
<p>所以我在本机填 <code>http://localhost:20128/v1</code>（自建网关）一切正常，局域网其他电脑填 <code>http://10.1.74.64:20128/v1</code> 就会被拦。想让局域网机器也能连，得给网关套一层 HTTPS（Caddy 两行配置的事，之后单开一篇）。对公网访客则完全无感——主流模型服务商都是 HTTPS 端点。</p>
<h2 id="技术点三被聪明坑到的-details-组件"><a href="#技术点三被聪明坑到的-details-组件">技术点三：被"聪明"坑到的 details 组件</a></h2>
<p>配置面板我用了原生 <code>&#x3C;details></code>，第一版偷懒写了 <code>open={!ready}</code>（没配好就展开）——结果填完 Key 的瞬间面板"啪"地自动收起，其他字段还没填完。受控组件会忠实执行你给的每个状态，哪怕这个状态并不符合用户意图。改成用户手动控制展开收起，问题消失。</p>
<p><strong>教训：UI 的"自动化体贴"要克制，用户操作到一半的界面不要自作主张。</strong></p>
<h2 id="效果"><a href="#效果">效果</a></h2>
<ul>
<li>页面：<a href="/tools/chat">itzhouq.cn/tools/chat</a>，支持流式输出、多轮对话、系统提示词、Ctrl+Enter 发送、停止生成；</li>
<li>配置持久化在 localStorage，第二次打开即用；</li>
<li>全部代码在本仓库 <code>app/tools/chat/</code>，共一个页面文件，没有新增任何依赖。</li>
</ul>
<h2 id="下一步"><a href="#下一步">下一步</a></h2>
<p>路线图更新：工具矩阵的管子通了。接下来是变现主线——API 中转商店（new-api 底座 + 自建网关做上游），到时候继续公开数据。</p>]]></content:encoded>
      <category>AI</category><category>Next.js</category><category>Build in Public</category>
    </item>
    <item>
      <title>为什么我决定 Build in Public</title>
      <link>https://itzhouq.cn/blog/hello-build-in-public</link>
      <guid isPermaLink="true">https://itzhouq.cn/blog/hello-build-in-public</guid>
      <pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[从今天起，把做过的事、踩过的坑、赚到的每一分钱都公开记录。这是第一篇。]]></description>
      <content:encoded><![CDATA[<p>想了很久，决定把"做东西"这件事本身公开出来。这就是 Build in Public——边做边说，把过程当产品。</p>
<h2 id="为什么是现在"><a href="#为什么是现在">为什么是现在</a></h2>
<p>过去我习惯闷头做事：学一个技术、写一个脚本、搭一个服务，做完就完了，没有沉淀，也没有反馈。结果就是：</p>
<ul>
<li>同样的坑踩了两遍，因为上一次的笔记随手丢在了聊天记录里；</li>
<li>做出来的东西没人知道，也就没人用、没人提意见；</li>
<li>没有外部压力，很多"想做"的事情最后都变成了"想过的"事情。</li>
</ul>
<p>公开，是这三个问题的共同解法。</p>
<h2 id="我打算公开什么"><a href="#我打算公开什么">我打算公开什么</a></h2>
<ol>
<li><strong>技术踩坑实录</strong>：比如怎么把聚合了全网免费额度的大模型网关跑起来、怎么开放给局域网的其他电脑用，这类文章对同路人是真有用的。</li>
<li><strong>产品构建过程</strong>：我会在本站陆续挂上自己的小工具，从选型、写代码到上线的数据，全部记录。</li>
<li><strong>变现尝试</strong>：包括但不限于 API 服务、工具订阅、返利合作。赚到多少、亏掉多少，都摊开说。</li>
</ol>
<h2 id="这个网站的路线图"><a href="#这个网站的路线图">这个网站的路线图</a></h2>
<ul>
<li>✅ 个人博客网站（你正在看的这个）</li>
<li>🚧 每周一篇 build in public 记录</li>
<li>🚧 第一个挂载的小工具</li>
<li>🚧 大模型 API 服务上线</li>
</ul>
<h2 id="写在最后"><a href="#写在最后">写在最后</a></h2>
<p>Build in public 最大的好处不是涨粉，而是<strong>倒逼自己把事情做完</strong>。当一周的进展要在下周公之于众时，拖延就变得很昂贵。</p>
<p>如果你也在做类似的事，欢迎通过 <a href="https://github.com">GitHub</a> 或 RSS 订阅找到我，我们一起把事情做完。</p>]]></content:encoded>
      <category>随笔</category><category>Build in Public</category>
    </item>
    <item>
      <title>聚合全网免费大模型额度的思路与实践</title>
      <link>https://itzhouq.cn/blog/aggregate-free-llm-quota</link>
      <guid isPermaLink="true">https://itzhouq.cn/blog/aggregate-free-llm-quota</guid>
      <pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[各家厂商的免费额度东一块西一块，单看都不够用，合起来就是一笔可观的资产。聊聊聚合的思路、通道分类和真实产出。]]></description>
      <content:encoded><![CDATA[<p>大模型的免费额度有个特点：<strong>单看不够用，合起来很可观</strong>。这篇文章聊聊怎么把散落各处的免费额度聚合成一个可用的"模型底座"。</p>
<h2 id="免费额度的几种来源"><a href="#免费额度的几种来源">免费额度的几种来源</a></h2>
<p>按获取门槛从低到高，大致分四类：</p>
<table>
<thead>
<tr>
<th>类型</th>
<th>例子</th>
<th>特点</th>
</tr>
</thead>
<tbody>
<tr>
<td>免注册通道</td>
<td>AI Horde、部分 Playground</td>
<td>零门槛但限速、不稳定</td>
</tr>
<tr>
<td>开发者免费层</td>
<td>Groq、Cloudflare Workers AI、Gemini AI Studio</td>
<td>注册即送，速率有限额</td>
</tr>
<tr>
<td>订阅附带额度</td>
<td>各类 IDE/CLI 的 AI 助手免费层</td>
<td>质量高，但常限"仅限其产品内使用"</td>
</tr>
<tr>
<td>活动/教育计划</td>
<td>新用户赠金、学生包</td>
<td>量大，但有时效</td>
</tr>
</tbody>
</table>
<p>需要特别提醒：第三类（订阅附带）往往在 ToS 里写了"仅限其 CLI/IDE 内使用"，绕过限制拿去开 API 属于灰色操作，风险自担。</p>
<h2 id="聚合的核心统一网关"><a href="#聚合的核心统一网关">聚合的核心：统一网关</a></h2>
<p>思路很简单——在所有通道前面加一层<strong>自建网关</strong>：</p>
<ol>
<li>每个通道作为一个 provider 接入网关，填上各自的凭证；</li>
<li>网关对外只暴露一个 <strong>OpenAI 兼容接口</strong>（<code>/v1/chat/completions</code>）；</li>
<li>请求进来后由网关做路由：按模型能力、速率、剩余额度挑一个可用通道；</li>
<li>单个通道撞了限制（402/403/429）自动 fallback 到下一个。</li>
</ol>
<p>对上层应用（编码工具、聊天客户端、你自己的产品）来说，它看到的只是一个"永远有额度的模型"，模型名甚至可以只填一个 <code>auto</code>。</p>
<h2 id="真实产出"><a href="#真实产出">真实产出</a></h2>
<p>我把 5 个免注册通道接进自建网关跑了一周日常任务，粗算下来：</p>
<ul>
<li><strong>~16 亿 tokens/月</strong> 的名义额度池（各家宣传额度加总）；</li>
<li>日常编码、问答、翻译类任务完全覆盖，一周只遇到 2 次限流，重试即恢复；</li>
<li>编码场景建议固定走 <code>best-coding</code> 路由组，比 <code>auto</code> 稳定。</li>
</ul>
<h2 id="下一步"><a href="#下一步">下一步</a></h2>
<p>网关只是底座。接下来我会把它接到自己的产品上：小工具用它的 API，中转服务拿它当上游。到时候继续在这里公开数据和代码。</p>
<blockquote>
<p>一句话总结：免费额度是零成本的原料，网关是厨房，产品才是菜。</p>
</blockquote>]]></content:encoded>
      <category>AI</category><category>工具</category>
    </item>
    <item>
      <title>用 OmniRoute 自建 AI 网关并开放局域网访问（踩坑实录）</title>
      <link>https://itzhouq.cn/blog/deploy-omniroute-on-lan</link>
      <guid isPermaLink="true">https://itzhouq.cn/blog/deploy-omniroute-on-lan</guid>
      <pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[Next.js 写的 AI 网关在 dev 模式下局域网访问会无限转圈，生产模式 + 三个配置项才是正解。完整过程与三个坑。]]></description>
      <content:encoded><![CDATA[<p><a href="https://github.com">OmniRoute</a> 是一个开源的"免费 AI 网关"：聚合 358 个提供商的免费额度，对外提供 OpenAI 兼容 API，带 Dashboard。这篇文章记录我把它部署到本机、并开放给局域网其他电脑使用的完整过程，重点是三个坑。</p>
<h2 id="部署主流程"><a href="#部署主流程">部署主流程</a></h2>
<pre><code class="hljs language-bash">git <span class="hljs-built_in">clone</span> &#x3C;omniroute> &#x26;&#x26; <span class="hljs-built_in">cd</span> OmniRoute
<span class="hljs-built_in">cp</span> .env.example .<span class="hljs-built_in">env</span>          <span class="hljs-comment"># 生成 JWT_SECRET / API_KEY_SECRET</span>
npm install
npm run build                 <span class="hljs-comment"># 务必生产模式</span>
PORT=20128 npm start
</code></pre>
<p>接入免注册 provider 通过 Dashboard 完成，然后就能用标准 OpenAI SDK 调用了：</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> <span class="hljs-title class_">OpenAI</span> <span class="hljs-keyword">from</span> <span class="hljs-string">"openai"</span>;

<span class="hljs-keyword">const</span> client = <span class="hljs-keyword">new</span> <span class="hljs-title class_">OpenAI</span>({
  <span class="hljs-attr">baseURL</span>: <span class="hljs-string">"http://10.1.74.64:20128/v1"</span>,
  <span class="hljs-attr">apiKey</span>: <span class="hljs-string">"sk-xxx"</span>, <span class="hljs-comment">// 网关生成的 key</span>
});

<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> client.<span class="hljs-property">chat</span>.<span class="hljs-property">completions</span>.<span class="hljs-title function_">create</span>({
  <span class="hljs-attr">model</span>: <span class="hljs-string">"auto"</span>, <span class="hljs-comment">// 网关自动路由</span>
  <span class="hljs-attr">messages</span>: [{ <span class="hljs-attr">role</span>: <span class="hljs-string">"user"</span>, <span class="hljs-attr">content</span>: <span class="hljs-string">"ping"</span> }],
});
</code></pre>
<h2 id="坑一dev-模式下局域网访问无限转圈"><a href="#坑一dev-模式下局域网访问无限转圈">坑一：dev 模式下局域网访问无限转圈</a></h2>
<p>现象：本机 <code>localhost:20128</code> 一切正常，局域网其他电脑打开页面——登录页能出，但点任何东西都转圈。</p>
<p>排查：浏览器 DevTools 看到大量 <code>/_next/hmr</code> 请求被 CORS 拦截。<strong>根因是 Next.js dev 模式的热更新（HMR）WebSocket 只认 localhost</strong>，即使 <code>.env</code> 里配了 <code>HOSTS</code> 白名单也不够。</p>
<p>解法：局域网使用必须走生产模式：</p>
<pre><code class="hljs language-bash">npm run build
PORT=20128 npm start
</code></pre>
<p>同时在 <code>next.config.mjs</code> 的 <code>allowedDevOrigins</code> 里加入本机局域网 IP 兜底（仅影响 dev 模式）。</p>
<h2 id="坑二npm-11-会静默跳过原生模块"><a href="#坑二npm-11-会静默跳过原生模块">坑二：npm 11 会静默跳过原生模块</a></h2>
<p>构建报 <code>better-sqlite3</code> 相关错误。原因：npm 11 对带原生编译的依赖处理有变化，某些情况下 <code>npm install</code> 没有真正编译它。</p>
<p>解法：显式补装并看到编译日志：</p>
<pre><code class="hljs language-bash">npm install better-sqlite3 --no-save --foreground-scripts
</code></pre>
<p><code>--foreground-scripts</code> 让 node-gyp 的输出直接打到终端，方便确认编译成功。</p>
<h2 id="坑三windows-防火墙默认拦入站"><a href="#坑三windows-防火墙默认拦入站">坑三：Windows 防火墙默认拦入站</a></h2>
<p>局域网其他电脑连不上，但本机 telnet 正常——典型的 Windows 防火墙入站拦截。给两个端口开规则（限制来源网段，别全放）：</p>
<pre><code class="hljs language-powershell">New-NetFirewallRule -DisplayName "OmniRoute Dashboard" -Direction Inbound `
  -Protocol TCP -LocalPort 20128 -RemoteAddress 10.1.0.0/16 -Action Allow
</code></pre>
<ul>
<li><code>20128</code>：Dashboard + API（需要开放）</li>
<li><code>20132</code>：Live WebSocket（需要开放）</li>
<li><code>20131</code>：仅本机使用的服务（不要开放）</li>
</ul>
<h2 id="效果"><a href="#效果">效果</a></h2>
<p>完成后，局域网里任何一台电脑都能：</p>
<ul>
<li>打开 <code>http://10.1.74.64:20128/dashboard</code> 管理通道；</li>
<li>用 OpenAI 兼容客户端把 base URL 指到 <code>http://10.1.74.64:20128/v1</code>，模型填 <code>auto</code>，白嫖聚合额度。</li>
</ul>
<h2 id="两个小提醒"><a href="#两个小提醒">两个小提醒</a></h2>
<ol>
<li>局域网 IP 是 DHCP 分配的，一旦变了要同步更新 <code>.env</code> 白名单和防火墙规则——建议给这台机器配静态 IP 或 DHCP 保留。</li>
<li><code>auto</code> 路由偶尔会先撞到某个免费层的限制返回 402/403，重发一次就会换通道；嫌烦可以在 Dashboard 停用不稳定的 provider。</li>
</ol>
<p>整个过程中最值得记的一句话：<strong>本机能跑 ≠ 局域网能用，dev 模式 ≠ 生产模式</strong>。</p>]]></content:encoded>
      <category>AI</category><category>教程</category><category>Next.js</category>
    </item>
    <item>
      <title>从零手写一个 Next.js 博客（本站的技术栈与实现）</title>
      <link>https://itzhouq.cn/blog/build-blog-from-scratch</link>
      <guid isPermaLink="true">https://itzhouq.cn/blog/build-blog-from-scratch</guid>
      <pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[不用模板，从空目录开始手写：Markdown 管线、深浅色主题、RSS/SEO、搜索。本站就是用它搭的，这篇是自曝实现细节。]]></description>
      <content:encoded><![CDATA[<p>你现在看到的这个博客，是我从空目录开始手写的——没有用任何现成博客模板。这篇文章公开全部实现细节，也算给自己留一份"为什么这么设计"的备忘。</p>
<h2 id="技术选型"><a href="#技术选型">技术选型</a></h2>
<ul>
<li><strong>Next.js 16 + React 19 + TypeScript</strong>：生态成熟，SEO 开箱即用，以后加小工具/商城都是同一个框架；</li>
<li><strong>Tailwind CSS v4</strong>：原子类 + CSS 变量做主题，深浅色切换只需一个 <code>dark</code> class；</li>
<li><strong>unified/remark/rehype</strong> 管线渲染 Markdown，<strong>不引入 MDX、不引入 CMS</strong>——内容就是 <code>content/posts/*.md</code>，git 即后台。</li>
</ul>
<p>依赖刻意保持在个位数，博客的核心是<strong>内容和排版</strong>，不是技术堆砌。</p>
<h2 id="markdown-管线"><a href="#markdown-管线">Markdown 管线</a></h2>
<p>一篇文章从 <code>.md</code> 到 HTML 的流水线：</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">const</span> processor = <span class="hljs-title function_">unified</span>()
  .<span class="hljs-title function_">use</span>(remarkParse)              <span class="hljs-comment">// 解析为 AST</span>
  .<span class="hljs-title function_">use</span>(remarkGfm)                <span class="hljs-comment">// 表格/任务列表/删除线</span>
  .<span class="hljs-title function_">use</span>(remarkRehype)             <span class="hljs-comment">// 转 HTML 树</span>
  .<span class="hljs-title function_">use</span>(rehypeSlug)               <span class="hljs-comment">// 标题加 id</span>
  .<span class="hljs-title function_">use</span>(rehypeAutolinkHeadings)   <span class="hljs-comment">// 标题可点击锚点</span>
  .<span class="hljs-title function_">use</span>(rehypeHighlight)          <span class="hljs-comment">// 代码高亮</span>
  .<span class="hljs-title function_">use</span>(rehypeStringify);         <span class="hljs-comment">// 输出 HTML 字符串</span>
</code></pre>
<p>有个细节：文章页右侧的目录（TOC）需要标题的 id。我单独用 <code>remark-parse</code> 遍历 AST 提取 h2/h3，再用 <code>github-slugger</code> 生成 slug——它和 <code>rehype-slug</code> 内部用的是同一个库，保证目录锚点能对上。</p>
<h2 id="内容层"><a href="#内容层">内容层</a></h2>
<p>frontmatter 用 <code>gray-matter</code> 解析，字段极简：</p>
<pre><code class="hljs language-yaml"><span class="hljs-meta">---</span>
<span class="hljs-attr">title:</span> <span class="hljs-string">文章标题</span>
<span class="hljs-attr">date:</span> <span class="hljs-number">2026-10-03</span>
<span class="hljs-attr">tags:</span> [<span class="hljs-string">Next.js</span>, <span class="hljs-string">建站</span>]
<span class="hljs-attr">summary:</span> <span class="hljs-string">一句话摘要，会出现在列表页和</span> <span class="hljs-string">SEO</span> <span class="hljs-string">描述里</span>
<span class="hljs-meta">---
</span></code></pre>
<p><code>draft: true</code> 的文章在开发模式可见、生产构建自动剔除，方便写一半的东西先躺仓库里。中文阅读时长按 CJK 字符数（380 字/分钟）+ 英文词数（200 词/分钟）估算。</p>
<h2 id="主题与配色"><a href="#主题与配色">主题与配色</a></h2>
<p>设计走"纸感编辑风"：米白纸底 + 墨色正文 + <strong>朱砂红</strong>做点缀色（链接 hover、选中高亮、标签）。所有颜色收敛为 6 个 CSS 变量，深色模式只是换了一组变量值：</p>
<pre><code class="hljs language-css"><span class="hljs-selector-pseudo">:root</span> { <span class="hljs-attr">--accent</span>: <span class="hljs-number">#c73e3a</span>; <span class="hljs-attr">--bg</span>: <span class="hljs-number">#faf8f5</span>; <span class="hljs-comment">/* ... */</span> }
<span class="hljs-selector-class">.dark</span> { <span class="hljs-attr">--accent</span>: <span class="hljs-number">#f08080</span>; <span class="hljs-attr">--bg</span>: <span class="hljs-number">#0f0d0c</span>; <span class="hljs-comment">/* ... */</span> }
</code></pre>
<p>防闪白的关键是在 <code>&#x3C;head></code> 里塞一段同步脚本，首帧渲染前就把 <code>dark</code> class 挂到 <code>&#x3C;html></code> 上。</p>
<h2 id="站点功能清单"><a href="#站点功能清单">站点功能清单</a></h2>
<p>这些全部是标准 Next.js 能力，没用第三方服务：</p>
<table>
<thead>
<tr>
<th>功能</th>
<th>实现</th>
</tr>
</thead>
<tbody>
<tr>
<td>RSS 全文输出</td>
<td><code>app/rss.xml/route.ts</code> 动态生成</td>
</tr>
<tr>
<td>sitemap / robots.txt</td>
<td><code>sitemap.ts</code> / <code>robots.ts</code> 约定文件</td>
</tr>
<tr>
<td>站内搜索</td>
<td>预生成 JSON 索引 + 纯前端过滤</td>
</tr>
<tr>
<td>代码复制按钮</td>
<td>客户端渐进增强，给每个 <code>pre</code> 注入按钮</td>
</tr>
<tr>
<td>评论</td>
<td>giscus（GitHub Discussions），配置留空即隐藏</td>
</tr>
<tr>
<td>统计</td>
<td>umami 自托管，同样配置驱动</td>
</tr>
<tr>
<td>SEO</td>
<td>每篇文章独立 <code>generateMetadata</code> + OG 标签</td>
</tr>
</tbody>
</table>
<h2 id="为什么不直接用模板"><a href="#为什么不直接用模板">为什么不直接用模板</a></h2>
<p>模板（比如 tailwind-nextjs-starter-blog）很好，但我想得到三样东西：</p>
<ol>
<li><strong>对每一行代码的理解</strong>——以后要做分发的、要挂工具的、要接支付的，都得自己改；</li>
<li><strong>恰好需要的功能</strong>——没有后台、没有数据库、没有用户系统，也就没有攻击面；</li>
<li><strong>可迁移的判断力</strong>——亲手写一遍 unifed 管线，下次遇到任何渲染需求都不虚。</li>
</ol>
<p>全部代码就在这个仓库里，欢迎 fork 自用。下一篇我会写怎么在这套底座上挂第一个小工具。</p>]]></content:encoded>
      <category>Next.js</category><category>建站</category>
    </item>
</channel>
</rss>