<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>我的个人天地</title>
  <icon>https://www.gravatar.com/avatar/f580c9553196632f300689515ab7b19c</icon>
  
  <link href="https://www.superheaoz.top/atom.xml" rel="self"/>
  
  <link href="https://www.superheaoz.top/"/>
  <updated>2026-09-09T07:00:00.000Z</updated>
  <id>https://www.superheaoz.top/</id>
  
  <author>
    <name>有次元袋的 tiger</name>
    <email>superheaoz@hotmail.com</email>
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>Homelab 搭建手记（10）实验室开发主机接入 W150 UPS 与 NUT 配置</title>
    <link href="https://www.superheaoz.top/2026/09/31179/"/>
    <id>https://www.superheaoz.top/2026/09/31179/</id>
    <published>2026-09-09T07:00:00.000Z</published>
    <updated>2026-09-09T07:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>我目前还在读博，这台开发服务器就放在实验室的桌面上，平时 24 小时开机。把开发环境集中在一台机器上之后，在宿舍、家里或者出差时，都能接着远程工作，不用每换一个地方就重新配置环境。</p><p>但实验室的供电并不总是那么省心，尤其是假期，经常遇到跳闸。电一断，远程连接也就没了，我还得想办法到实验室处理。这台联想来酷迷你主机又有一个让我头疼的问题：断电后重新开机会卡死。到现在还没确定是固件、硬件还是系统引导的问题，不能直接归因于 Debian；可以确定的是，我不能指望它每次恢复供电都能自己回到可访问状态。</p><p>所以这次给主机加了一台瓦力方程 W150 UPS。短时间停电时尽量维持运行，长时间停电时则希望给系统留出正常关机的机会。下面记录选购考虑、USB 状态读取、NUT 配置，以及接入过程中遇到的几个问题。</p><span id="more"></span><h2 id="一、从迷你主机的取舍说起">一、从迷你主机的取舍说起</h2><a href="/2026/06/40732/" title="Homelab 搭建手记（1）迷你主机选购与 Debian 系统安装">Homelab 搭建手记（1）迷你主机选购与 Debian 系统安装</a> 已经记录了当时的配件涨价背景和最终购入配置：联想来酷 Mini Pro，R7 8745H、24G 内存、512G 硬盘，购入价格为 2499 元。这里补一点第一篇没有展开的取舍。<p>最初选机器时，我也考虑过用手头的 DDR4 内存搭配旧平台准系统，尽量利用已有配件。后来选择这台成品机，很大程度上是受当时内存和整机价格影响，并不是觉得板载内存更适合开发服务器。这台机器的内存扩展空间有限，后面容器、开发服务和模型实验多起来，很难再靠加内存解决。若不是预算和配件价格的限制，我会更倾向于能更换、扩充内存的平台。</p><p>这些是当时购买时的取舍，不是现在的硬件购买推荐。继续使用已有机器后，供电和恢复能力也成了需要补上的部分。UPS 可以减少突然失电的机会，但不能修复主机断电后启动卡死的问题。</p><h2 id="二、为什么选-W150，没有买-W180">二、为什么选 W150，没有买 W180</h2><p>UPS 也要放在实验室桌面上，我更看重体积、适合长期放置的电池方案，以及能否接入 Linux 监控。这次只考虑保护这台迷你主机，没有打算把整张桌面的设备都接进去，也没有为了更大的标称功率上 W180。</p><p>厂商的 <a href="https://www.wallecube.com/products/differences/" title="W150 与 W180 产品对比" class="external-link" data-redirect="https%3A%2F%2Fwww.wallecube.com%2Fproducts%2Fdifferences%2F">W150 与 W180 对比页</a> 列出，W150 使用磷酸铁锂（LiFePO4），W180 使用三元锂。这与 W150 在本次 USB 读取中上报的 <code>battery.type: LiFePO4</code> 一致。考虑到放在实验室桌面、长期接电，我更偏向磷酸铁锂的热稳定性，因此选择了 W150。这是我的选购权衡，并不代表三元锂产品不能使用，整机安全也不能只看电芯化学体系。</p><p>W150 属于面向直流设备的 UPS。实际接入前，要按机器和 UPS 的说明核对输入输出电压、接口极性、线材以及负载功率，不能只凭接口插得上、型号里带有某个数字就判断合适。功率决定能否带动负载，电池能量和实际负载才共同影响续航。</p><p>供电和监控是两条连接：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">供电：市电 → 匹配的电源适配器 → W150 → 迷你主机监控：W150 → USB 数据线 → Debian 上的 NUT</code></div></pre><p>USB 让系统知道 UPS 当前的状态，并不代替主机供电。UPS 也只保护接在其输出上的设备；上游网络设备如果同时失电，即使主机还在运行，也未必能继续远程访问。</p><h2 id="三、让-Debian-先读到-UPS">三、让 Debian 先读到 UPS</h2><h3 id="3-1-安装与识别">3.1 安装与识别</h3><p>本次记录的环境是 Debian 13，NUT 版本为 2.8.1。先安装所需组件：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash">sudo apt updatesudo apt install nut-client nut-server usbutilslsusbupsc -Vsudo nut-scanner -U</code></div></pre><p>当时的 USB 识别结果为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">ID 04d8:d005 Microchip Technology, Inc. Smart UPS W150</code></div></pre><p>扫描结果推荐 <code>usbhid-ups</code> 驱动，随后实际读取也确认使用该驱动。不要只根据“UPS”这个设备类别就预先套驱动；同一个品牌的不同设备，也应以本机扫描和读取结果为准。<a href="https://networkupstools.org/docs/man/usbhid-ups.html" title="usbhid-ups 驱动" class="external-link" data-redirect="https%3A%2F%2Fnetworkupstools.org%2Fdocs%2Fman%2Fusbhid-ups.html">usbhid-ups 文档</a></p><p>后续会修改 <code>/etc/nut/</code> 中的配置。已有配置时，先备份：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo cp -a /etc/nut &quot;/etc/nut.backup-$(date +%Y%m%d-%H%M%S)&quot;</code></div></pre><p>下面列出关键有效项；编辑发行版已有配置时，修改对应项并保留相关默认设置，避免追加出重复定义。整个配置过程应在市电正常、可以处理主机状态的时段进行。</p><h3 id="3-2-单机模式与驱动配置">3.2 单机模式与驱动配置</h3><p>这次由 UPS 通过 USB 直接连接主机，NUT 的服务端和监控端都运行在本机。<code>/etc/nut/nut.conf</code> 设置为：</p><pre><div class="code-header"><span class="code-header-type">conf</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-conf">MODE=standalone</code></div></pre><p>在 <code>/etc/nut/ups.conf</code> 中加入设备：</p><pre><div class="code-header"><span class="code-header-type">conf</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-conf">[w150]    driver = usbhid-ups    port = auto    vendorid = 04d8    productid = d005    desc = &quot;WalleCube W150&quot;</code></div></pre><p><code>w150</code> 是本机配置中的逻辑名称，后面的查询、监控和服务实例都使用这个名称。只有一台对应设备时，这组标识已经能定位本次 UPS；多台同型号设备需要进一步区分，不能照搬为同一个实例。</p><p><code>/etc/nut/upsd.conf</code> 只保留本机监听：</p><pre><div class="code-header"><span class="code-header-type">conf</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-conf">LISTEN 127.0.0.1 3493LISTEN ::1 3493</code></div></pre><p>无需为了本机监控把 NUT 端口开放给整个网络。随后启动驱动枚举和信息服务：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-bash">sudo systemctl restart nut-driver-enumerator.servicesudo systemctl enable --now nut-server.servicesudo systemctl restart nut-server.servicesystemctl status nut-driver@w150.service --no-pagersystemctl status nut-server.service --no-pagerupsc w150@localhost</code></div></pre><p>这里使用 Debian 13 的 systemd 驱动实例管理。其他版本若没有对应单元，应先检查发行版提供的服务方式，不要同时用手工驱动进程和 systemd 管理同一台 USB 设备。</p><h3 id="3-3-当时遇到的两个错误">3.3 当时遇到的两个错误</h3><p>第一个是前台调试驱动时出现 USB <code>Resource busy</code>。检查后发现，<code>nut-driver@w150.service</code> 已经在运行，再启动一个 <code>usbhid-ups</code> 进程就会争用设备。因此，驱动服务正常时直接查询数据即可，无需再开第二个实例。</p><p>第二个是驱动已经运行，<code>upsc</code> 却返回 <code>Connection refused</code>。<code>upsc</code> 连接的是 <code>upsd</code>，不是直接读取 USB；启动 <code>nut-server</code> 后才打通了这段链路：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">W150 → usbhid-ups → upsd → upsc</code></div></pre><p>这两个问题分别发生在 USB 驱动层和本机服务层，检查顺序也应沿着这条链路进行。</p><h2 id="四、实际读到了哪些状态">四、实际读到了哪些状态</h2><p>市电正常时，日志中的部分字段如下，设备序列号已省略：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-text">battery.charge: 100battery.type: LiFePO4battery.runtime: 17354device.model: Smart UPS W150driver.name: usbhid-upsdriver.version: 2.8.1driver.version.data: openUPS HID 0.5ups.status: OL</code></div></pre><p>在当时的断电识别检查中，又读到了：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">input.current: 0.000ups.status: OB DISCHRG</code></div></pre><p><code>OL</code> 表示市电在线，<code>OB</code> 表示由电池供电，<code>DISCHRG</code> 表示正在放电。这次状态变化已经观察到，所以可以确认设备能够向 NUT 报告掉电。</p><p>不过 <code>battery.runtime</code> 是设备上报的估计值，不是我测出的续航。两次采样中它还出现了明显变化，不适合直接用来承诺运行时间。日志里的电压字段也有变化，读数的含义、不同供电状态下的输出和主机电压要求，都要结合设备说明核对；这里只把它们作为遥测，不能当作供电兼容性认证。</p><h2 id="五、配置监控与半小时延迟策略">五、配置监控与半小时延迟策略</h2><h3 id="5-1-先接通-upsmon">5.1 先接通 upsmon</h3><p><code>upsmon</code> 负责监视供电条件，并在达到关机条件时启动系统关机流程。先生成单独的监控密码：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">openssl rand -base64 24</code></div></pre><p>将生成的值填入 <code>/etc/nut/upsd.users</code>：</p><pre><div class="code-header"><span class="code-header-type">conf</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-conf">[upsmon]    password = your_random_password    upsmon primary</code></div></pre><p>再修改 <code>/etc/nut/upsmon.conf</code>，两处密码保持一致：</p><pre><div class="code-header"><span class="code-header-type">conf</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-conf">MONITOR w150@localhost 1 upsmon your_random_password primaryMINSUPPLIES 1SHUTDOWNCMD &quot;/sbin/shutdown -h +0&quot;POWERDOWNFLAG /etc/killpowerNOTIFYCMD /usr/sbin/upsschedNOTIFYFLAG ONLINE SYSLOG+EXECNOTIFYFLAG ONBATT SYSLOG+EXECNOTIFYFLAG LOWBATT SYSLOG+EXEC</code></div></pre><p>这是单 UPS、单主机的配置。<code>primary</code> 让本机承担该 UPS 的主要监控角色，<code>MINSUPPLIES 1</code> 与唯一的供电来源相对应。配置文件包含监控密码，应限制读取权限：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo chown root:nut /etc/nut/upsd.users /etc/nut/upsmon.confsudo chmod 640 /etc/nut/upsd.users /etc/nut/upsmon.conf</code></div></pre><p><code>NOTIFYCMD</code> 的路径需要按安装包核对。当时普通用户的 <code>command -v upssched</code> 没有输出，通过包文件列表才确认实际位置：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">dpkg -L nut-client nut-server | grep '/upssched$'</code></div></pre><p>结果为 <code>/usr/sbin/upssched</code>。PATH 中找不到程序，不等于程序没有安装。</p><h3 id="5-2-为什么增加计时器">5.2 为什么增加计时器</h3><p>我当时希望断电后留出半小时，给短时跳闸恢复供电留一点余量；超过这个时间，就进入关机流程。这个时间按自己的使用需求设置，实际部署时应结合负载和可用续航调整，给服务退出和文件系统写回留出余量。</p><p>计时策略为：</p><table><thead><tr><th style="text-align:left">事件</th><th style="text-align:left">处理</th></tr></thead><tbody><tr><td style="text-align:left">转为电池供电 <code>ONBATT</code></td><td style="text-align:left">启动 1800 秒计时器</td></tr><tr><td style="text-align:left">计时结束前恢复市电 <code>ONLINE</code></td><td style="text-align:left">取消尚未触发的计时器</td></tr><tr><td style="text-align:left">计时到期</td><td style="text-align:left">请求 NUT 执行 FSD 关机流程</td></tr><tr><td style="text-align:left">电池供电期间报告低电量 <code>LOWBATT</code></td><td style="text-align:left">提前进入关机处理，不等待半小时</td></tr></tbody></table><p>低电量本身也是 <code>upsmon</code> 的关键供电判断之一；下面的 LOWBATT 回调保留当时的配置，不能理解成“没有这个脚本，NUT 就不会处理低电量”。<a href="https://networkupstools.org/docs/man/upsmon.conf.html" title="upsmon.conf 配置" class="external-link" data-redirect="https%3A%2F%2Fnetworkupstools.org%2Fdocs%2Fman%2Fupsmon.conf.html">upsmon 配置说明</a></p><h3 id="5-3-upssched-配置">5.3 upssched 配置</h3><p><code>/etc/nut/upssched.conf</code> 的关键内容为：</p><pre><div class="code-header"><span class="code-header-type">conf</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-conf">CMDSCRIPT /usr/bin/upssched-cmdPIPEFN /run/nut/upssched/upssched.pipeLOCKFN /run/nut/upssched/upssched.lockAT ONBATT w150@localhost START-TIMER onbatt_shutdown 1800AT ONLINE w150@localhost CANCEL-TIMER onbatt_shutdownAT LOWBATT w150@localhost EXECUTE lowbatt_shutdown</code></div></pre><p><code>NOTIFYFLAG</code> 中的 <code>EXEC</code> 不能漏掉，否则事件不会调用 <code>NOTIFYCMD</code>。<code>START-TIMER</code> 到期后把事件名传给处理脚本，<code>CANCEL-TIMER</code> 用于撤销仍在等待的计时器。<a href="https://networkupstools.org/docs/man/upssched.conf.html" title="upssched.conf 配置" class="external-link" data-redirect="https%3A%2F%2Fnetworkupstools.org%2Fdocs%2Fman%2Fupssched.conf.html">upssched 配置说明</a></p><p>Debian 当时已经提供了专用运行目录：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">/run/nut/upssched/owner: nut:nutmode: 0770</code></div></pre><p>因此沿用这个目录存放管道和锁文件，没有把它们放进任何用户都能写的公共目录。<code>/run</code> 是运行期目录，重启后也需要由发行版的 tmpfiles/服务机制重建。</p><h3 id="5-4-处理脚本与关机权限">5.4 处理脚本与关机权限</h3><p>当时修改的是 Debian 提供的 <code>/usr/bin/upssched-cmd</code>，其原始内容只有示例事件。下面保留本次使用的核心逻辑：</p><pre><div class="code-header"><span class="code-header-type">sh</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div></div><code class="language-sh">#!/bin/shcase &quot;$1&quot; in    onbatt_shutdown)        logger -t upssched-cmd \            &quot;UPS has been on battery for 30 minutes, requesting FSD&quot;        /lib/nut/upsmon -c fsd        ;;    lowbatt_shutdown)        logger -t upssched-cmd \            &quot;UPS battery is low, requesting FSD&quot;        /lib/nut/upsmon -c fsd        ;;    *)        logger -t upssched-cmd &quot;Unrecognized command: $1&quot;        ;;esac</code></div></pre><p>脚本使用实际安装的 <code>/lib/nut/upsmon</code> 路径，请先通过包文件列表核对。编辑已有脚本前也要备份；本次脚本设置为 <code>root:root</code>、<code>0755</code>，使监控用户可执行但不可修改：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash">sudo cp -p /usr/bin/upssched-cmd &quot;/usr/bin/upssched-cmd.backup-$(date +%Y%m%d-%H%M%S)&quot;# 编辑完成后检查脚本语法和权限sh -n /usr/bin/upssched-cmdsudo chown root:root /usr/bin/upssched-cmdsudo chmod 755 /usr/bin/upssched-cmd</code></div></pre><p>这是当时直接修改发行版脚本的记录。若重新整理部署，我会把自定义脚本放在 <code>/etc/nut/upssched-cmd</code>，同时修改 <code>CMDSCRIPT</code>，避免软件包升级覆盖自己的逻辑。</p><p><code>upsmon -c fsd</code> 会请求真正的关机流程，不是无害的连通性测试。通知脚本通常在非特权监控用户下运行，最终的 <code>SHUTDOWNCMD</code> 才由有权限的部分执行。部署时要确保监控用户可执行处理脚本，并具备向运行中的 <code>upsmon</code> 发出控制请求所需的权限。<a href="https://manpages.debian.org/trixie/nut-client/upsmon.8.en.html" title="Debian upsmon 手册" class="external-link" data-redirect="https%3A%2F%2Fmanpages.debian.org%2Ftrixie%2Fnut-client%2Fupsmon.8.en.html">upsmon 手册</a></p><h3 id="5-5-启动监控">5.5 启动监控</h3><p>确认配置后启动监控：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash">sudo systemctl restart nut-server.servicesudo systemctl enable --now nut-monitor.servicesudo systemctl restart nut-monitor.servicesystemctl status nut-monitor.service --no-pagersudo journalctl -u nut-monitor -n 50 --no-pager</code></div></pre><p>当时的服务状态为 <code>active (running)</code>，日志也列出了 <code>w150@localhost (primary)</code>。监控进程至此已经加载目标 UPS，供电状态和事件处理也有了统一的日志入口。</p><h2 id="六、计时与恢复供电的处理">六、计时与恢复供电的处理</h2><p>这套配置通过 <code>ONBATT</code> 启动计时、<code>ONLINE</code> 取消计时，将短时停电和持续停电分开处理。<code>ONLINE</code> 只能取消仍在等待的计时器；一旦进入 FSD 关机流程，来电事件不会撤回关机。计时依赖正在运行的调度进程，也不应当作跨重启保存的停电时钟。<a href="https://manpages.debian.org/trixie/nut-client/upssched.8.en.html" title="Debian upssched 手册" class="external-link" data-redirect="https%3A%2F%2Fmanpages.debian.org%2Ftrixie%2Fnut-client%2Fupssched.8.en.html">upssched 手册</a></p><p>UPS 缓冲供电与主机恢复开机也有不同的条件。主机正常关机后，如果 UPS 一直维持输出，市电恢复时主板未必经历一次新的供电恢复事件。因此，BIOS 中的恢复供电自动启动选项，需要结合 UPS 的输出行为理解。</p><p>本次采用的是由 NUT 监视供电状态、按时间或低电量条件请求系统关机的配置。对于放在实验室长期运行的开发主机，这让突然失电之前多了一段缓冲时间，也把供电状态纳入了 Debian 的服务管理和日志中。</p><h2 id="参考">参考</h2><ul><li><a href="https://www.wallecube.com/products/differences/" title="W150 与 W180 产品对比" class="external-link" data-redirect="https%3A%2F%2Fwww.wallecube.com%2Fproducts%2Fdifferences%2F">瓦力方程 W150 与 W180 产品对比</a></li><li><a href="https://networkupstools.org/docs/man/usbhid-ups.html" title="usbhid-ups 驱动" class="external-link" data-redirect="https%3A%2F%2Fnetworkupstools.org%2Fdocs%2Fman%2Fusbhid-ups.html">NUT usbhid-ups 驱动</a></li><li><a href="https://manpages.debian.org/trixie/nut-client/upsmon.8.en.html" title="Debian upsmon 手册" class="external-link" data-redirect="https%3A%2F%2Fmanpages.debian.org%2Ftrixie%2Fnut-client%2Fupsmon.8.en.html">Debian trixie 的 upsmon 手册</a></li><li><a href="https://networkupstools.org/docs/man/upsmon.conf.html" title="upsmon.conf 配置" class="external-link" data-redirect="https%3A%2F%2Fnetworkupstools.org%2Fdocs%2Fman%2Fupsmon.conf.html">NUT upsmon.conf 配置</a></li><li><a href="https://networkupstools.org/docs/man/upssched.conf.html" title="upssched.conf 配置" class="external-link" data-redirect="https%3A%2F%2Fnetworkupstools.org%2Fdocs%2Fman%2Fupssched.conf.html">NUT upssched.conf 配置</a></li><li><a href="https://manpages.debian.org/trixie/nut-client/upssched.8.en.html" title="Debian upssched 手册" class="external-link" data-redirect="https%3A%2F%2Fmanpages.debian.org%2Ftrixie%2Fnut-client%2Fupssched.8.en.html">Debian trixie 的 upssched 手册</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;我目前还在读博，这台开发服务器就放在实验室的桌面上，平时 24 小时开机。把开发环境集中在一台机器上之后，在宿舍、家里或者出差时，都能接着远程工作，不用每换一个地方就重新配置环境。&lt;/p&gt;
&lt;p&gt;但实验室的供电并不总是那么省心，尤其是假期，经常遇到跳闸。电一断，远程连接也就没了，我还得想办法到实验室处理。这台联想来酷迷你主机又有一个让我头疼的问题：断电后重新开机会卡死。到现在还没确定是固件、硬件还是系统引导的问题，不能直接归因于 Debian；可以确定的是，我不能指望它每次恢复供电都能自己回到可访问状态。&lt;/p&gt;
&lt;p&gt;所以这次给主机加了一台瓦力方程 W150 UPS。短时间停电时尽量维持运行，长时间停电时则希望给系统留出正常关机的机会。下面记录选购考虑、USB 状态读取、NUT 配置，以及接入过程中遇到的几个问题。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="迷你主机" scheme="https://www.superheaoz.top/tags/%E8%BF%B7%E4%BD%A0%E4%B8%BB%E6%9C%BA/"/>
    
    <category term="homelab" scheme="https://www.superheaoz.top/tags/homelab/"/>
    
    <category term="debian" scheme="https://www.superheaoz.top/tags/debian/"/>
    
    <category term="ups" scheme="https://www.superheaoz.top/tags/ups/"/>
    
    <category term="nut" scheme="https://www.superheaoz.top/tags/nut/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（9）全局代理下的内网访问与桌面应用配置</title>
    <link href="https://www.superheaoz.top/2026/09/40338/"/>
    <id>https://www.superheaoz.top/2026/09/40338/</id>
    <published>2026-09-09T04:00:00.000Z</published>
    <updated>2026-09-09T04:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>在 Debian 上配置好开发环境和桌面工具后，为了正常访问 GitHub 等外部服务，我又通过 Clash 给日常使用的程序配置了代理。平时习惯把这个需求叫作“全局代理”，希望开着代理工作时，不用再为每个命令单独切换网络。</p><p>随后却遇到了另一个问题：Obsidian、Zotero 使用的内网服务突然访问不顺了。这些服务通过域名访问，在内网 DNS 中解析到局域网地址。原本以为已经把内网网段写进了代理排除列表，就不会受到影响，实际请求却还是进了代理。</p><p>同一段时间，ChatGPT 桌面端也出现了启动白屏。从终端指定代理后能很快打开，直接点击桌面图标却不行。这两件事刚好把代理配置的两头都碰了一遍：内网服务需要绕过代理，外部服务又需要让桌面启动器明确带上代理参数。</p><p>本文接着 <a href="/2026/06/38032/" title="Homelab 搭建手记（5）Obsidian 与 Zotero Linux 部署">Homelab 搭建手记（5）Obsidian 与 Zotero Linux 部署</a> 记录这次调整。下面的域名和内网地址均已替换为示例；排障日志以 WebDAV 为例，不涉及修改笔记库、文献库或服务端数据。</p><span id="more"></span><h2 id="一、先看请求到底发到了哪里">一、先看请求到底发到了哪里</h2><h3 id="1-1-DNS-能解析，不代表请求会直连">1.1 DNS 能解析，不代表请求会直连</h3><p>假设内网 WebDAV 使用 <code>webdav.home.example.com</code>，本机解析结果为 <code>192.168.50.10</code>。当时代理排除列表的关键问题是只有回环名称和 IP 网段，缺少访问时使用的域名：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">export https_proxy=http://127.0.0.1:7890export NO_PROXY=&quot;localhost,192.168.0.0/16&quot;</code></div></pre><p>我先对同一个地址做了两次请求：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash"># 替换为自己的内网服务域名getent ahosts webdav.home.example.com# 按当前终端代理配置访问curl -Iv --connect-timeout 10 https://webdav.home.example.com# 本次请求明确绕过 curl 的代理配置curl --noproxy '*' -Iv --connect-timeout 10 https://webdav.home.example.com</code></div></pre><p>第一次请求的关键日志如下，域名已脱敏，省略了证书细节：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-text">* Uses proxy env variable https_proxy == 'http://127.0.0.1:7890'*   Trying 127.0.0.1:7890...&gt; CONNECT webdav.home.example.com:443 HTTP/1.1&lt; HTTP/1.1 200 Connection established...&lt; HTTP/2 401</code></div></pre><p>加上 <code>--noproxy '*'</code> 后，连接对象变成了内网服务：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-text">* Host webdav.home.example.com:443 was resolved.* IPv4: 192.168.50.10*   Trying 192.168.50.10:443......&lt; HTTP/2 401</code></div></pre><p>这组结果能确认：默认请求进入了本地 HTTP 代理，强制直连时则使用了本机解析得到的内网地址。<strong>两次请求都收到了 <code>401</code>，不能把第一条日志写成“代理连接失败”，也不能据此判断 DNS 已经损坏。</strong></p><p>这里没有提供 WebDAV 账号密码，<code>401</code> 是服务要求认证的响应。它说明请求到达了某个要求认证的 HTTP 服务；是否为预期服务，还要结合证书和服务端信息确认。它也不代表 WebDAV 的目录访问、上传和同步已经通过验证。</p><h3 id="1-2-网段排除没有覆盖域名访问">1.2 网段排除没有覆盖域名访问</h3><p>我原先把“域名解析到内网 IP”和“命中代理绕过规则”当成了一件事。但这次请求使用的是 <code>webdav.home.example.com</code>，排除列表里写的却是 <code>192.168.0.0/16</code>。</p><p><code>NO_PROXY</code> 的匹配细节取决于客户端。不能假定程序都会先解析域名，再拿结果匹配其中的网段。对于这里的 curl 请求，已有日志已经说明网段规则没有实现预期的域名绕过，因此应直接把内网域名加入列表。</p><p>curl 支持逗号分隔的排除项，<code>.home.example.com</code> 这样的后缀可用于匹配该域下的主机；CIDR 网段写法从 curl 7.86.0 开始支持。其他应用是否支持相同语法，需要分别确认，不能把 curl 的配置当成所有桌面程序的统一标准。<a href="https://everything.curl.dev/usingcurl/proxies/env.html" title="curl 代理环境变量" class="external-link" data-redirect="https%3A%2F%2Feverything.curl.dev%2Fusingcurl%2Fproxies%2Fenv.html">curl 代理环境变量说明</a></p><h2 id="二、补全内网域名的代理绕过配置">二、补全内网域名的代理绕过配置</h2><h3 id="2-1-调整终端中的代理变量">2.1 调整终端中的代理变量</h3><p>我的代理配置放在 <code>~/.bashrc.d/proxy.sh</code>。这是自己组织 Bash 配置时使用的文件，Bash 不会自动扫描这个目录，仍需由 <code>~/.bashrc</code> 加载。</p><p>编辑前先备份已有文件，再把代理变量整理为下面这样：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash"># 已有配置时先保留副本cp -p ~/.bashrc.d/proxy.sh ~/.bashrc.d/proxy.sh.bak-$(date +%Y%m%d-%H%M%S)</code></div></pre><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div></div><code class="language-bash"># ~/.bashrc.d/proxy.sh# 端口按本机代理软件实际监听情况填写export http_proxy=&quot;http://127.0.0.1:7890&quot;export https_proxy=&quot;$http_proxy&quot;export HTTP_PROXY=&quot;$http_proxy&quot;export HTTPS_PROXY=&quot;$https_proxy&quot;# 仅当该端口也提供 SOCKS5 服务时使用export all_proxy=&quot;socks5://127.0.0.1:7890&quot;export ALL_PROXY=&quot;$all_proxy&quot;# 示例内网域名和网段，请自行替换export no_proxy=&quot;localhost,127.0.0.1,::1,.home.example.com,192.168.0.0/16&quot;export NO_PROXY=&quot;$no_proxy&quot;</code></div></pre><p>这里真正新增的是内网域名后缀，并让大小写两份排除列表保持一致。只应加入确定需要直连的范围；如果自己的主域名同时承载公网和内网服务，使用专门的内网子域或精确主机名，避免把公网服务也一并排除。</p><p>HTTP 和 SOCKS5 是否共用 <code>7890</code>，取决于代理软件的监听配置。数字相同不代表协议一定都可用。<code>https_proxy</code> 写成 <code>http://127.0.0.1:7890</code>，表示连接的是 HTTP 代理，访问 HTTPS 目标时可通过 CONNECT 建立隧道，不是把目标网站降级成 HTTP。</p><p>保留小写 <code>http_proxy</code> 很有必要：curl 不接受大写 <code>HTTP_PROXY</code> 作为 HTTP 代理变量。协议专用变量又优先于 <code>ALL_PROXY</code>，因此本例 HTTPS 请求首先使用 <code>https_proxy</code>。<a href="https://everything.curl.dev/usingcurl/proxies/env.html" title="curl 代理环境变量" class="external-link" data-redirect="https%3A%2F%2Feverything.curl.dev%2Fusingcurl%2Fproxies%2Fenv.html">curl 代理环境变量说明</a></p><h3 id="2-2-加载配置并验证访问路径">2.2 加载配置并验证访问路径</h3><p>如果还没有加载入口，可以在 <code>~/.bashrc</code> 中加入一次：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-bash">if [ -f &quot;$HOME/.bashrc.d/proxy.sh&quot; ]; then    . &quot;$HOME/.bashrc.d/proxy.sh&quot;fi</code></div></pre><p>当前终端直接加载后，再运行不带强制绕过参数的请求：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">. ~/.bashrc.d/proxy.shcurl -Iv --connect-timeout 10 https://webdav.home.example.com</code></div></pre><p>这时要看连接目标是否变成内网 IP，以及是否还出现连接本地代理的 CONNECT 请求。再选一个确实需要代理的外部站点检查，确认没有把所有请求都改成直连：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">curl -Iv --connect-timeout 10 https://github.com</code></div></pre><p>当时补上域名排除项后，内网访问问题得到了解决。不过终端测试只覆盖当前 shell；回到 Obsidian 和 Zotero，还要分别执行同步检查。可以用一个临时笔记或测试附件确认读写结果，不能只凭 curl 收到 HTTP 响应就认定应用同步正常。</p><h2 id="三、终端配置与桌面程序之间还有一层">三、终端配置与桌面程序之间还有一层</h2><p>从 XFCE 菜单启动应用，通常不会经过交互式 Bash。刚刚在终端执行的 <code>source</code>，也不会更新已经运行的桌面程序。</p><p>遇到“curl 已经直连，应用仍然失败”的情况，我会继续检查应用的启动方式、继承环境以及自身代理设置。Obsidian 的同步插件可能使用不同的请求实现，Zotero 也有自己的网络配置，不能因为它们在同一个桌面里运行就假定行为相同。</p><p>如需检查进程环境，先找到并人工确认主进程 PID，再仅筛选代理相关变量：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">pgrep -af 'obsidian|zotero'# 用实际确认的 PID 替换 12345tr '\0' '\n' &lt; /proc/12345/environ | grep -iE '^(http_proxy|https_proxy|all_proxy|no_proxy)='</code></div></pre><p>这些变量能帮助判断启动时继承了什么，但不能证明应用一定采用它们。输出也可能带有代理认证信息，排障时不要直接贴出未经检查的完整环境。</p><p>另外，“全局代理”这个叫法需要拆开理解：环境变量只影响采用它们的程序；桌面系统代理是否生效取决于应用；TUN 则可能在路由层接管流量。<code>curl --noproxy '*'</code> 只能取消 curl 自己使用的显式代理，不能绕过系统中的 TUN。</p><p>如果使用 TUN 或代理内核统一接管流量，就要在对应配置中同时检查内网直连和 DNS 策略；代理客户端真正处于 Global 模式时，也不能默认普通分流规则会执行。本次记录中的直接修复是补全环境变量排除项，没有把代理内核切换模式当成已经完成的操作。</p><h2 id="四、ChatGPT-桌面端白屏与-desktop-启动参数">四、ChatGPT 桌面端白屏与 <code>.desktop</code> 启动参数</h2><h3 id="4-1-先在终端确认代理参数有效">4.1 先在终端确认代理参数有效</h3><p>ChatGPT 桌面端的表现刚好相反：点击图标停在启动白屏，用下面的命令却能很快打开：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">chatgpt --proxy-server=&quot;socks5://127.0.0.1:7890&quot;</code></div></pre><p>这里记录的是我在 Debian + XFCE 环境中安装、命令名为 <code>chatgpt</code> 的客户端。这个结果说明显式指定代理改善了该次启动的网络访问，但单凭这一点，还不能断言白屏一定由环境变量缺失导致，更不能推广成所有平台、所有客户端版本的通用处理。</p><p><code>--proxy-server</code> 是 Electron 提供的代理参数之一；具体安装包是否接受或透传参数仍需实测。本例已经有终端启动成功的对照，接下来把同样的参数放进桌面入口即可。<a href="https://github.com/electron/electron/blob/main/docs/api/command-line-switches.md" title="Electron 支持的启动参数" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Felectron%2Felectron%2Fblob%2Fmain%2Fdocs%2Fapi%2Fcommand-line-switches.md">Electron 启动参数文档</a></p><h3 id="4-2-修改用户级启动器">4.2 修改用户级启动器</h3><p>先找当前应用的 <code>.desktop</code> 文件。常见位置是 <code>~/.local/share/applications/</code>、<code>/usr/local/share/applications/</code> 和 <code>/usr/share/applications/</code>，文件名不一定就是 <code>chatgpt.desktop</code>：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">find ~/.local/share/applications /usr/local/share/applications /usr/share/applications \  -iname '*chatgpt*.desktop' 2&gt;/dev/null</code></div></pre><p>如果已经存在用户级文件，先备份再编辑它。如果只有系统级文件，可以复制到用户级目录，保留同名入口。下面以 <code>chatgpt.desktop</code> 为例，实际路径以查找结果为准：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash">mkdir -p ~/.local/share/applications# -i 会在目标已存在时询问，避免覆盖原有用户配置cp -i /usr/share/applications/chatgpt.desktop ~/.local/share/applications/chatgpt.desktopcp -p ~/.local/share/applications/chatgpt.desktop \  ~/.local/share/applications/chatgpt.desktop.bak-$(date +%Y%m%d-%H%M%S)nano ~/.local/share/applications/chatgpt.desktop</code></div></pre><p>在主 <code>[Desktop Entry]</code> 段中，原先的启动命令是：</p><pre><div class="code-header"><span class="code-header-type">ini</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-ini">Exec=chatgpt %U</code></div></pre><p>修改为：</p><pre><div class="code-header"><span class="code-header-type">ini</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-ini">Exec=chatgpt --proxy-server=&quot;socks5://127.0.0.1:7890&quot; %U</code></div></pre><p>这也是我目前启动器里保留的配置。其他字段和原有参数沿用安装包提供的内容，不需要重新手写整份 <code>MimeType</code> 列表。</p><p><code>%U</code> 表示启动器传入的 URL 列表，每个 URL 作为独立参数传给应用，因此要保留为单独一项。<code>chatgpt</code> 没写绝对路径时，启动器使用桌面环境的 <code>PATH</code> 查找它；如果原文件已经使用绝对路径，就保留原路径。<a href="https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html" title="Desktop Entry Exec 规范" class="external-link" data-redirect="https%3A%2F%2Fspecifications.freedesktop.org%2Fdesktop-entry%2Flatest%2Fexec-variables.html">Desktop Entry 的 Exec 规范</a></p><p>这里的 <code>Exec</code> 也不是普通 shell 脚本。不要直接在其中拼接 <code>source ~/.bashrc &amp;&amp; ...</code>。若启动器对引号解析有差异，上面不含空格的代理地址也可写成 <code>--proxy-server=socks5://127.0.0.1:7890</code>，<code>%U</code> 仍单独保留。</p><h3 id="4-3-从实际使用的图标重新验证">4.3 从实际使用的图标重新验证</h3><p>保存后，先从应用菜单正常退出 ChatGPT，再点击菜单图标重新打开。只关闭窗口时应用可能仍留在后台，新一次启动也可能复用旧进程，导致改过的参数没有真正用于启动主进程。</p><p>如果终端有效、图标仍白屏，继续检查：</p><ul><li>点击的是应用菜单入口，还是桌面上另存的一份 <code>.desktop</code> 文件？后者需要检查自己的 <code>Exec</code>。</li><li>面板固定的启动器是否保存了旧命令？必要时重新固定正确的菜单项。</li><li>SOCKS5 监听是否已启动，端口是否与配置一致？</li><li>用户级覆盖文件是否仍保留旧版本启动参数？应用升级后应与新的系统入口比较。</li></ul><p><code>update-desktop-database</code> 主要更新 MIME 关联缓存，不能把它当成所有菜单和面板启动器的强制刷新命令。修改后应以实际点击结果为准。</p><p>如果需要撤销这次调整，恢复备份中的 <code>Exec</code> 即可。用户级覆盖文件也应持续维护，避免安装包更新了系统启动器，而自己的旧副本一直遮住新配置。</p><h2 id="五、保留下来的配置边界">五、保留下来的配置边界</h2><p>这次调整之后，我保留了两处明确配置：内网服务域名进入 <code>NO_PROXY/no_proxy</code>，ChatGPT 桌面入口带上已经在终端验证有效的代理参数。</p><p>以后再遇到类似问题，可以沿用同一组检查：内网域名解析是否符合预期、请求实际连接的是服务还是代理、应用同步是否可用，以及退出后从菜单启动是否仍然正常。这样既能保留访问外部服务所需的代理，也能让内网服务按预期直连。</p><h2 id="参考">参考</h2><ul><li><a href="https://everything.curl.dev/usingcurl/proxies/env.html" title="curl 代理环境变量" class="external-link" data-redirect="https%3A%2F%2Feverything.curl.dev%2Fusingcurl%2Fproxies%2Fenv.html">curl 代理环境变量</a></li><li><a href="https://github.com/electron/electron/blob/main/docs/api/command-line-switches.md" title="Electron 支持的启动参数" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Felectron%2Felectron%2Fblob%2Fmain%2Fdocs%2Fapi%2Fcommand-line-switches.md">Electron 支持的启动参数</a></li><li><a href="https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html" title="Desktop Entry Exec 规范" class="external-link" data-redirect="https%3A%2F%2Fspecifications.freedesktop.org%2Fdesktop-entry%2Flatest%2Fexec-variables.html">Desktop Entry：Exec 参数与字段代码</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;在 Debian 上配置好开发环境和桌面工具后，为了正常访问 GitHub 等外部服务，我又通过 Clash 给日常使用的程序配置了代理。平时习惯把这个需求叫作“全局代理”，希望开着代理工作时，不用再为每个命令单独切换网络。&lt;/p&gt;
&lt;p&gt;随后却遇到了另一个问题：Obsidian、Zotero 使用的内网服务突然访问不顺了。这些服务通过域名访问，在内网 DNS 中解析到局域网地址。原本以为已经把内网网段写进了代理排除列表，就不会受到影响，实际请求却还是进了代理。&lt;/p&gt;
&lt;p&gt;同一段时间，ChatGPT 桌面端也出现了启动白屏。从终端指定代理后能很快打开，直接点击桌面图标却不行。这两件事刚好把代理配置的两头都碰了一遍：内网服务需要绕过代理，外部服务又需要让桌面启动器明确带上代理参数。&lt;/p&gt;
&lt;p&gt;本文接着 &lt;a href=&quot;/2026/06/38032/&quot; title=&quot;Homelab 搭建手记（5）Obsidian 与 Zotero Linux 部署&quot;&gt;Homelab 搭建手记（5）Obsidian 与 Zotero Linux 部署&lt;/a&gt; 记录这次调整。下面的域名和内网地址均已替换为示例；排障日志以 WebDAV 为例，不涉及修改笔记库、文献库或服务端数据。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="homelab" scheme="https://www.superheaoz.top/tags/homelab/"/>
    
    <category term="debian" scheme="https://www.superheaoz.top/tags/debian/"/>
    
    <category term="代理" scheme="https://www.superheaoz.top/tags/%E4%BB%A3%E7%90%86/"/>
    
    <category term="obsidian" scheme="https://www.superheaoz.top/tags/obsidian/"/>
    
    <category term="zotero" scheme="https://www.superheaoz.top/tags/zotero/"/>
    
    <category term="chatgpt" scheme="https://www.superheaoz.top/tags/chatgpt/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（11）自建主题：视觉系统重构</title>
    <link href="https://www.superheaoz.top/2026/09/52265/"/>
    <id>https://www.superheaoz.top/2026/09/52265/</id>
    <published>2026-09-05T18:32:26.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>前面几篇已经介绍了主题的目录、搜索、评论和文章加密等功能。最近继续调整页面时，发现各个组件的颜色和边框不太统一，日间模式还有一些配色没有处理好，侧边栏和页脚在窄屏、短窗口下也有布局问题。</p><p>所以这次把相关样式和脚本一起整理了一遍。背景仍然保留星空，补充日间配色和日月切换，同时调整文章的滚动区域。本文记录具体改法，以及布局调整中遇到的几个问题。</p><span id="more"></span><h2 id="一、整理配色配置">一、整理配色配置</h2><h3 id="1-1-减少重复的颜色配置">1.1 减少重复的颜色配置</h3><p>旧主题把背景、正文卡片、侧边栏、按钮、代码块、边框和阴影都暴露为单独的配置项。开始看上去很灵活，但实际维护时有两个问题。</p><p>修改夜间背景时，还要同步调整侧栏、卡片和按钮；新增搜索弹窗或评论组件时，又容易漏掉日间颜色。各处单独配置，也容易出现文字和背景对比度不够、相似按钮颜色不同的问题。</p><p>这次减少了对外配置项，保留外观模式、两种强调色、基础字号、正文宽度和侧栏宽度。旧版逐组件设置颜色的配置需要随之调整。</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-yaml">style:  appearance: night # night | day | system  accent: &quot;#E7A63A&quot;  accent_secondary: &quot;#73C4F5&quot;  typography:    font_size: &quot;16px&quot;  layout:    content_width: &quot;46rem&quot;    sidebar_width: &quot;18rem&quot;</code></div></pre><p>其余颜色在主题内部通过 CSS 变量管理，按背景、正文、边框等用途区分。卡片、侧栏、搜索框引用对应变量，修改配色时就不用逐个查找组件样式。主要变量如下：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-text">--dt-canvas             页面天空与底色--dt-surface            文章等主要表面--dt-chrome             顶栏、侧栏、页脚的低对比度表面--dt-control            卡片内的次级按钮或 chip--dt-text / muted       正文与弱文字--dt-border             共享边界--dt-accent             当前状态和主操作--dt-accent-secondary   链接与辅助定位</code></div></pre><p>切换昼夜模式时，统一修改这些变量的取值，搜索、目录、评论、代码块和静态页面便能一起切换配色。</p><h3 id="1-2-日间配色与日月切换">1.2 日间配色与日月切换</h3><p>主题默认使用夜间模式，背景为深靛蓝，保留星光和流星，按钮与链接使用金色、冷蓝色。日间模式改用偏暖的浅色背景，加入日晕和云层光尘，并调整文字、卡片和边框颜色。</p><p>外观策略支持 <code>night</code>、<code>day</code> 和 <code>system</code>。前两者固定初始状态，<code>system</code> 读取浏览器的 <code>prefers-color-scheme</code>。页头的圆形日月按钮只保存浏览器本地偏好，不回写主题 YAML；这样站点默认策略和访问者个人选择可以共存。</p><p>日月本身也不再是两个始终并列的图标。背景中使用一个超出屏幕的轨道作为旋转面，切换时月亮沿轨道下沉、太阳从另一侧进入；按钮则只保留当前可见的一个符号。日间画布补充缓慢移动的云层和日晕，夜间保留星空与流星。持续动画在页面不可见、用户启用减少动效或屏幕过窄时会降低或停止，避免把装饰变成阅读负担。</p><h2 id="二、整理模板与浏览器脚本">二、整理模板与浏览器脚本</h2><h3 id="2-1-Pug-只描述页面结构">2.1 Pug 只描述页面结构</h3><p>这一轮曾暴露出一个很典型的问题：页面模板一边负责输出 HTML，一边绑定点击事件、拼接小段脚本。短期很快，长期会让行为散落在搜索、文章、侧栏和静态页面中，难以测试，也很难判断某次样式调整是否改坏了交互。</p><p>因此模板只保留语义结构、可访问性属性和模块所需的数据属性。例如搜索区域作为页面根层的 <code>dialog</code> 输出，而不是被锁在 header 内；目录/站点概览切换按钮只提供当前标签和状态；加密文章只输出密码表单与错误区域。事件绑定、焦点管理和状态同步都交给浏览器模块。</p><p>搜索、外链和加密功能分别做了以下调整：</p><ol><li>搜索对话框可以在全屏遮罩上居中，打开时隔离背景焦点，按 <code>Escape</code> 关闭，并把焦点还给触发按钮。</li><li>外链跳转拦截可以在全局注入脚本中一次处理，而不是在每篇文章生成时改写链接或重复绑定监听器。这样不会把正常外链在构建期改成重定向地址，避免影响文章的原始链接和 SEO。</li><li>二维码和文章解密等行为可以独立加载。模板不再内嵌密钥相关的运行时逻辑，错误提示也使用页面内的 <code>role=&quot;alert&quot;</code>，而不是浏览器原生弹窗。</li></ol><h3 id="2-2-浏览器代码按职责拆分">2.2 浏览器代码按职责拆分</h3><p>浏览器端仍以 <code>main.js</code> 为入口，但入口只负责初始化。背景、外观、header、侧栏、二维码、加密、对话框和外链处理分别位于对应模块中；通用的焦点隔离和对话框生命周期归入 <code>utils/</code>。</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-text">source/js/├── layout/       # 背景、外观、顶栏、侧栏等页面骨架├── features/     # 二维码、加密等可选能力└── utils/        # dialog、外链拦截、滚动等通用行为</code></div></pre><p>调整后，页面结构在 Pug 中修改，颜色和布局在 CSS 中修改，事件绑定在浏览器 JS 中处理。Hexo 的过滤器和 injector 负责构建时的生成与注入，排查问题时也可以按这几处分别查找。</p><h2 id="三、重新组织页面骨架">三、重新组织页面骨架</h2><h3 id="3-1-让页脚固定，文章独立滚动">3.1 让页脚固定，文章独立滚动</h3><p>传统文档流中，页脚会跟随文章内容向下移动。对长文来说没有问题，但在这个主题里，顶栏、侧栏、阅读进度和回到顶部都已经是页面框架的一部分；继续让整个 <code>body</code> 滚动，容易造成组件各自监听不同滚动源。</p><p>这里把主容器高度设为 <code>100dvh</code>，顶栏和页脚固定在页面上下两端，<code>#content-wrapper</code> 使用 <code>flex: 1</code>、<code>min-height: 0</code> 填满剩余空间，文章在这个区域内滚动。阅读进度、目录高亮和回顶的事件源也一并改到内容区域。</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-text">┌──────────────────────────────────────────┐│ header                                   │├──────────────┬───────────────────────────┤│ sidebar      │ content-wrapper（滚动）     ││              │   └── article stage        │├──────────────┴───────────────────────────┤│ footer                                   │└──────────────────────────────────────────┘</code></div></pre><p>这里要特别区分宽度与高度。屏幕窄不等于窗口矮：小于桌面阈值时，侧栏转换为抽屉或隐藏入口；高度较短时，则只压缩顶栏、页脚和辅助内容，不能误把桌面窗口切成手机布局。触摸设备通过 <code>pointer: coarse</code> 增大操作目标，而不是只凭宽度猜测输入方式。</p><h3 id="3-2-目录不再用符号模拟层级">3.2 目录不再用符号模拟层级</h3><p>文章目录曾在最左边使用 <code>&gt;&gt;</code> 作为前导符。它有明显的旧式终端感，但放在新的低对比度轨道中显得过于突兀，也无法自然表达当前阅读位置。</p><p>新目录使用一条弱对比度的垂直轨道和圆点节点：普通节点保持弱文字颜色，hover 和当前章节使用强调色与轻微的光晕。层级仍由缩进和编号配置表达，不再依赖重复的装饰符号。桌面侧栏中的目录与站点概览共享同一个切换入口，按钮文字随当前视图变化，避免“按钮写着站点概览，下面却显示文章目录”的状态错位。</p><h3 id="3-3-给日月背景留出空间">3.3 给日月背景留出空间</h3><p>背景右侧的日月天体需要空间，但内容卡片不能因此看起来向侧边栏倾斜。这个问题在宽屏上尤其明显：第一次实现使用了安全区加 <code>translateX</code> 左移，虽然右侧空出来了，文章舞台却不再处于主容器中心。</p><p>后来去掉了左移，改为计算正文可用宽度。设当前主容器可用宽度为 <code>W</code>，主题可读宽度为 <code>S</code>，单侧天体安全距离为 <code>C</code>，则桌面正文区域宽度为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">stage = min(S, W - 2C - gutter)</code></div></pre><p>左右对称扣除 <code>C</code> 后，卡片仍使用 <code>margin: auto</code> 居中。右侧不会贴近天体，左侧也不会被推向侧边栏；在侧栏隐藏的窄屏断点，安全距离归零，恢复普通单列宽度。4K 宽度下再设置单独的舞台上限，避免长文一行过长。</p><h2 id="四、检查搜索、评论和静态页面">四、检查搜索、评论和静态页面</h2><p>首页和文章页往往最先得到关注，但主题真正容易出问题的地方是低频页面和可选功能。这次样式令牌与页面骨架调整后，也逐项检查了以下内容：</p><table><thead><tr><th>范围</th><th>本轮关注点</th></tr></thead><tbody><tr><td>本地/Algolia 搜索</td><td>对话框置于全局层；两种后端共享触发入口、结果表面、背景遮罩、模糊、焦点隔离与语言包文案，不让本地搜索残留中文固定文案</td></tr><tr><td>评论</td><td>Gitment、Valine、Twikoo 等第三方容器使用统一表面、文字与控制器令牌</td></tr><tr><td>加密文章</td><td>密码表单、错误提示和解密后的内容延续文章表面层级</td></tr><tr><td>404、隐私、条款与重定向</td><td>使用相同的标题、空状态和弱文字规则，不再像独立页面</td></tr><tr><td>二维码与赞赏</td><td>提示文案允许回退到当前语言，避免主题默认中文覆盖英文页面</td></tr><tr><td>滚动条</td><td>内容区与侧栏分别使用昼夜令牌，而不是浏览器默认白色滚动条</td></tr></tbody></table><p>国际化同样不能只翻导航菜单。页面标题、搜索占位符、空结果、倒计时、加密提示、二维码和赞赏提示都应该优先读取语言包；配置留空时才回退到该语言的默认文本。这样站点可以用一份主题配置切换语言，而不会在英文页面里突然出现“本地搜索”几个中文字。</p><h2 id="五、构建与页面检查">五、构建与页面检查</h2><p>主题包含 Pug、Stylus、浏览器 ES Module、Hexo filter、injector 和 generator。直接执行某个 JS 文件，无法证明 Hexo 的配置合并、页面生成、资源注入和最终选择器仍然正确。因此这轮验证一直以 Hexo CLI 为核心。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash"># 在博客根目录npm run cleannpm run build# 在主题目录node tests/theme-contract.test.js</code></div></pre><p>契约测试覆盖了子路径资源、外观策略、搜索与静态页面开关、加密、站点地图、robots、本地搜索、语言包和 <code>themeinit</code> 配置初始化等场景。它不替代浏览器测试，但可以防止模板/配置/注入层在重构后悄悄失配。</p><p>浏览器端则分别检查首页、长文、带目录文章、归档、标签/分类、搜索、404、加密和重定向页面，并覆盖夜间、日间和系统跟随模式。重点视口包括常规桌面、短高度窗口、横向平板、手机和 4K 宽屏。对于天体安全区这类几何问题，还需要在真实构建页面中检查卡片中心线、侧栏距离和天体边界，而不能只相信 CSS 的字面公式。</p><h2 id="六、小结">六、小结</h2><p>这次主要补齐了日间配色，整理了颜色配置和脚本位置，并把文章滚动、目录进度和回顶统一到内容区域。修改主题布局时，短窗口和宽屏都需要单独检查，尤其是日月背景与正文之间的留白，只看常规桌面尺寸容易漏掉问题。</p>]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;前面几篇已经介绍了主题的目录、搜索、评论和文章加密等功能。最近继续调整页面时，发现各个组件的颜色和边框不太统一，日间模式还有一些配色没有处理好，侧边栏和页脚在窄屏、短窗口下也有布局问题。&lt;/p&gt;
&lt;p&gt;所以这次把相关样式和脚本一起整理了一遍。背景仍然保留星空，补充日间配色和日月切换，同时调整文章的滚动区域。本文记录具体改法，以及布局调整中遇到的几个问题。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="theme" scheme="https://www.superheaoz.top/tags/theme/"/>
    
    <category term="css" scheme="https://www.superheaoz.top/tags/css/"/>
    
    <category term="javascript" scheme="https://www.superheaoz.top/tags/javascript/"/>
    
    <category term="accessibility" scheme="https://www.superheaoz.top/tags/accessibility/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（8）通过 Apache Guacamole 访问 Debian 桌面</title>
    <link href="https://www.superheaoz.top/2026/09/51763/"/>
    <id>https://www.superheaoz.top/2026/09/51763/</id>
    <published>2026-09-05T02:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>在 <a href="/2026/09/34889/" title="Homelab 搭建手记（6）Debian Xfce 与 XRDP 同用户并发会话配置">Homelab 搭建手记（6）Debian Xfce 与 XRDP 同用户并发会话配置</a> 中，我已经把 <code>Debian</code>、<code>XFCE</code> 和 <code>XRDP</code> 的远程桌面环境调整到可以长期使用；上一篇又通过 <a href="/2026/09/22299/" title="Homelab 搭建手记（7）部署 code-server 并配置 HTTPS 访问">Homelab 搭建手记（7）部署 code-server 并配置 HTTPS 访问</a> 把浏览器开发环境接了出来。</p><p>不过 <code>code-server</code> 只能提供编辑器和终端，遇到必须使用完整图形界面的程序时，还是需要打开原生 <code>RDP</code> 客户端。这次增加一个浏览器入口：使用 <code>Apache Guacamole</code> 作为 Web Gateway，通过 <code>RDP</code> 连接已有的 <code>xrdp</code> 会话。这样既保留原生 RDP，也可以在平板、轻薄本或临时设备上直接用浏览器进入 Debian 桌面。</p><p>配套脚本放在我维护的 <a href="https://github.com/DoraTiger/homelab-setup" title="Homelab Debian 环境配置" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a> 项目中。这个仓库用于初始化 Debian 环境和部署常用服务；这次加入 Guacamole 时，我也整理了有状态服务的目录管理，让生成的配置有固定位置，重新执行脚本时保留已有账号和连接数据。下面会同时说明脚本用法和具体配置。</p><span id="more"></span><h2 id="一、目标架构">一、目标架构</h2><p>Guacamole 连接的是现有的远程 <code>XFCE</code> 会话，访问链路如下：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div></div><code class="language-text">浏览器  │ HTTPS  ▼Caddy  │ 反向代理  ▼Apache Guacamole  │ RDP  ▼xrdp  │  ▼已有的 XFCE 远程会话</code></div></pre><p><code>xrdp</code> 是远程桌面后端，<code>Guacamole</code> 把浏览器连接转换为 RDP，<code>Caddy</code> 负责 HTTPS 和外部入口。原生 RDP 客户端仍然可以直接连接 xrdp，两种访问方式互不替代。</p><h2 id="二、为什么选择-Guacamole">二、为什么选择 Guacamole</h2><p>如果只是想在浏览器中显示一个 Linux 桌面，也可以选择专门的 Web Desktop 容器。但那通常意味着重新创建用户、桌面环境和数据目录，和当前已经调通的 <code>xrdp</code> 会话变成两套互不相干的系统。</p><p><code>Guacamole</code> 更适合当前场景，因为它只负责把浏览器连接转发为 <code>RDP</code>，不需要安装另一套桌面。这样可以保留之前已经验证过的本地与远程会话共存、同用户会话复用，以及输入法、D-Bus 和密钥环等配置。Guacamole 自身通过 Docker 隔离，也不会向宿主机额外安装 Tomcat、Java 或 PostgreSQL 运行时。</p><h2 id="三、为什么把有状态服务统一放在-services-docker">三、为什么把有状态服务统一放在 <code>services/docker/</code></h2><p>过去部署 Docker 服务时，Compose 文件有时放在 <code>/opt</code>，有时放在用户目录。服务多了，就不容易记住配置、环境变量和初始化 SQL 分别放在哪里，备份时也容易漏掉。因此这次把生成的服务配置统一放到 <code>services/docker/</code> 下，每个服务使用自己的子目录。</p><p>因此我在 <a href="https://github.com/DoraTiger/homelab-setup" title="Homelab Debian 环境配置" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a> 中把服务入口和数据工作区分开：<code>setup/</code> 保存可公开的安装与生成脚本，工作区中的 <code>services/docker/</code> 保存本机服务配置；镜像和 PostgreSQL volume 仍由 Docker 管理，不会被错误地当成 Git 内容提交。</p><p>默认目录关系如下：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-text">~/workspace/├── setup/                              # 公开的 homelab-setup 仓库└── services/    └── docker/        └── guacamole/                  # 本机有状态服务配置            ├── compose.yaml            ├── .env                    # 本机凭据，权限 600，不提交            └── initdb.sql</code></div></pre><p>数据工作区默认是 <code>$HOME/workspace</code>，需要换到数据盘时可使用 <code>--workspace-root</code> 统一指定，省去逐个修改服务路径的操作。备份时要保存这里的 Compose 文件和 <code>.env</code>，并单独备份 Docker volume 中的数据。</p><p>Compose 文件本身占用很小空间，PostgreSQL 数据和镜像层实际位于 Docker 默认数据目录。部署前可以检查：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo du -sh /var/lib/docker 2&gt;/dev/nulldocker system df</code></div></pre><h2 id="四、通过-homelab-setup-部署并复用服务状态">四、通过 homelab-setup 部署并复用服务状态</h2><p>我为 <code>homelab-setup</code> 增加了 <code>services/docker/00-guacamole.sh</code>。它先检查 Docker 与 Docker Compose，再在上述目录中生成缺失的部署文件、校验 Compose 配置并启动容器。重复执行时不会覆盖已有 <code>.env</code>、<code>initdb.sql</code> 或数据库卷；默认只拉取本地缺少的固定版本镜像，只有显式使用 <code>--upgrade</code> 才会拉取更新。</p><p>克隆公开仓库后，在仓库目录执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash">git clone https://github.com/DoraTiger/homelab-setup.git &quot;$HOME/workspace/setup&quot;cd &quot;$HOME/workspace/setup&quot;# 按编号选择 Guacamole 服务bash service.sh --silent docker 00# 数据工作区位于其他磁盘时# bash service.sh --silent --workspace-root /data/homelab docker 00</code></div></pre><p>执行完成后，日常管理回到生成的服务目录：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">cd &quot;$HOME/workspace/services/docker/guacamole&quot;docker compose psdocker compose logs -fdocker compose restart</code></div></pre><p>如果不使用脚本，下面就是它所实现的关键原理。Guacamole 的 PostgreSQL 表结构由官方镜像生成，并固定版本以避免 <code>latest</code> 标签在升级时悄悄改变行为：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">docker run --rm guacamole/guacamole:1.6.0 \  /opt/guacamole/bin/initdb.sh --postgresql &gt; initdb.sqlls -lh initdb.sqlhead initdb.sql</code></div></pre><p>脚本会自动生成随机数据库密码并以 <code>600</code> 权限保存到部署目录的 <code>.env</code>。数据库密码只放在本机的 <code>.env</code> 中，不写入文章、Compose 文件或 Git 仓库：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">chmod 600 &quot;$HOME/workspace/services/docker/guacamole/.env&quot;</code></div></pre><pre><div class="code-header"><span class="code-header-type">dotenv</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-dotenv">GUACAMOLE_DB_PASSWORD=替换为随机生成的密码</code></div></pre><h2 id="五、编排-Guacamole、guacd-与-PostgreSQL">五、编排 Guacamole、guacd 与 PostgreSQL</h2><p><code>compose.yaml</code> 由三个容器组成：<code>postgres</code> 保存用户和连接配置，<code>guacd</code> 负责协议代理，<code>guacamole</code> 提供 Web 界面。</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div><div class="line-numbers-item">21</div><div class="line-numbers-item">22</div><div class="line-numbers-item">23</div><div class="line-numbers-item">24</div><div class="line-numbers-item">25</div><div class="line-numbers-item">26</div><div class="line-numbers-item">27</div><div class="line-numbers-item">28</div><div class="line-numbers-item">29</div><div class="line-numbers-item">30</div><div class="line-numbers-item">31</div><div class="line-numbers-item">32</div><div class="line-numbers-item">33</div><div class="line-numbers-item">34</div><div class="line-numbers-item">35</div><div class="line-numbers-item">36</div><div class="line-numbers-item">37</div><div class="line-numbers-item">38</div><div class="line-numbers-item">39</div></div><code class="language-yaml">services:  postgres:    image: postgres:17    restart: unless-stopped    environment:      POSTGRES_DB: guacamole_db      POSTGRES_USER: guacamole_user      POSTGRES_PASSWORD: $&#123;GUACAMOLE_DB_PASSWORD&#125;    volumes:      - postgres-data:/var/lib/postgresql/data      - ./initdb.sql:/docker-entrypoint-initdb.d/001-guacamole.sql:ro    networks: [guacamole]  guacd:    image: guacamole/guacd:1.6.0    restart: unless-stopped    networks: [guacamole]  guacamole:    image: guacamole/guacamole:1.6.0    restart: unless-stopped    depends_on: [postgres, guacd]    environment:      GUACD_HOSTNAME: guacd      POSTGRESQL_ENABLED: &quot;true&quot;      POSTGRESQL_HOSTNAME: postgres      POSTGRESQL_DATABASE: guacamole_db      POSTGRESQL_USERNAME: guacamole_user      POSTGRESQL_PASSWORD: $&#123;GUACAMOLE_DB_PASSWORD&#125;      WEBAPP_CONTEXT: ROOT    ports:      - &quot;127.0.0.1:30090:8080&quot;    networks: [guacamole]networks:  guacamole:volumes:  postgres-data:</code></div></pre><p>setup 模板设置了 <code>WEBAPP_CONTEXT: ROOT</code>，因此 Guacamole 直接位于 <code>/</code>。端口映射使用 <code>127.0.0.1:30090:8080</code>，宿主机上的 Web 端口只监听回环地址，浏览器通过 Caddy 访问。</p><p><code>30090</code> 延续了 <code>homelab-setup</code> 使用高位端口的约定，前文的 <code>code-server</code> 对应 <code>30080</code>。这样可以减少与常见低位端口的冲突，也便于区分反向代理的目标服务。高位端口本身不提供安全保护，仍需保留回环地址绑定和 HTTPS 配置。手动编写 Compose 文件时，也应与脚本生成的路径和端口保持一致。</p><p>启动并检查容器：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash">cd &quot;$HOME/workspace/services/docker/guacamole&quot;docker compose up -ddocker compose pscurl -I http://127.0.0.1:30090docker compose logs --tail=100</code></div></pre><h2 id="六、通过-Caddy-提供-HTTPS">六、通过 Caddy 提供 HTTPS</h2><p>浏览器远程桌面和 <code>code-server</code> 一样，需要一个稳定的 HTTPS 域名。实际部署时将示例域名替换成自己的域名：</p><p>本文沿用前文的证书签发方式：由 <code>Caddy</code> 通过阿里云 DNS-01 完成域名验证和 SSL 证书签发，<code>alidns</code> 插件读取阿里云 DNS API 凭据。也就是说，下面的配置适用于当前使用阿里云 DNS 的环境；如果改用 Cloudflare、腾讯云或其他 DNS 服务商，需要将 <code>alidns</code> 插件和对应的凭据配置一并替换，不能只修改域名。</p><pre><div class="code-header"><span class="code-header-type">caddy</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-caddy">desktop.example.com &#123;    tls &#123;        dns alidns &#123;            access_key_id &#123;env.ALIYUN_ACCESS_KEY_ID&#125;            access_key_secret &#123;env.ALIYUN_ACCESS_KEY_SECRET&#125;        &#125;    &#125;    reverse_proxy 127.0.0.1:30090&#125;</code></div></pre><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo caddy validate --config /etc/caddy/Caddyfilesudo systemctl reload caddy</code></div></pre><p><code>Caddy</code> 原生支持 WebSocket 反向代理，不需要手动添加 <code>Upgrade</code> 和 <code>Connection</code> 请求头。访问 <code>https://desktop.example.com/</code> 后，应该能看到 Guacamole 登录页。</p><h2 id="七、首次登录与-RDP-连接">七、首次登录与 RDP 连接</h2><p>初始化数据库后，Guacamole 的初始管理员账号是 <code>guacadmin</code>，初始密码也是 <code>guacadmin</code>。首次登录后，应立即新建一个自己的管理员账号并授予全部管理权限；退出后用新账号重新登录，确认权限和连接配置都正常，再删除默认的 <code>guacadmin</code> 账号。管理密码、RDP 密码和 DNS API 凭据不要复用，也不要把默认账号留在长期运行的实例中。</p><p>在 <code>Settings → Connections → New Connection</code> 中创建连接：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">Name: Debian XFCEProtocol: RDP</code></div></pre><p>连接由 <code>guacd</code> 容器发起，因此不能填写 <code>127.0.0.1</code>。对 <code>guacd</code> 来说，这个地址指向的是容器自身，而不是 Debian 宿主机。这里应该填写 Debian 在局域网中的地址：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">Hostname: YOUR_DEBIAN_LAN_IPPort: 3389</code></div></pre><p>第一次连接可以使用 <code>Security mode: Any</code>，并临时启用 <code>Ignore server certificate</code>，以兼容 xrdp 默认自签名证书。长期运行时应结合实际证书和网络边界重新评估。</p><h2 id="八、最容易踩的坑：RDP-不要误选成-VNC">八、最容易踩的坑：RDP 不要误选成 VNC</h2><p>这次排障中最有代表性的问题，是浏览器能够打开 Guacamole，点击连接后却一直停留在“已连接，等待应答”。<code>guacd</code> 日志显示它实际创建的是：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">Creating new client for protocol &quot;vnc&quot;</code></div></pre><p>而目标端口是 xrdp 的 <code>3389</code>，链路就变成了：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">Guacamole ── VNC ──→ Debian:3389 ──→ xrdp</code></div></pre><p>协议不匹配时，xrdp 日志会出现 X.224 握手失败。这不是 xrdp 本身损坏，而是连接类型选错。重新把连接类型改为 <code>RDP</code>，并确认日志出现：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">Creating new client for protocol &quot;rdp&quot;</code></div></pre><p>这是判断协议是否正确的最快方法。</p><h2 id="九、确认-WebSocket-和会话复用">九、确认 WebSocket 和会话复用</h2><p>连接建立后，可以在浏览器开发者工具的 <code>Network</code> 面板中搜索 <code>websocket</code> 或 <code>tunnel</code>，确认 <code>/websocket-tunnel</code> 返回 <code>101 Switching Protocols</code>。这说明浏览器、Caddy 和 Guacamole 之间的 WebSocket 链路正常。</p><p>正确使用 RDP 后，Guacamole 可以复用此前已经存在的远程 <code>XFCE</code> 会话。这次部署没有修改已经稳定的 <code>/etc/xrdp/startwm.sh</code>，也没有额外创建一套桌面环境。</p><h2 id="十、显示、动态分辨率与剪贴板">十、显示、动态分辨率与剪贴板</h2><p>这个浏览器入口主要用于 ChatGPT、Obsidian、浏览器和终端，连接参数应优先保证文字可读性和窗口适配：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-text">Resize method:          Display updateColor depth:            32-bitDPI:                    96Clipboard:              BidirectionalFont smoothing:         EnabledTheming:                EnabledWallpaper:              DisabledAnimations:             DisabledFull-window drag:       DisabledDrive redirection:      Disabled unless neededAudio:                  Enable only if needed</code></div></pre><p><code>Display update</code> 会在浏览器窗口大小变化时请求远程桌面调整分辨率。Guacamole 使用 Canvas 显示远程画面，清晰度不一定完全等同于原生 RDP 客户端；浏览器缩放建议保持 <code>100%</code>，再根据实际显示器测试 DPI。</p><h2 id="十一、最终访问体系">十一、最终访问体系</h2><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-text">原生 RDP 客户端 ─────────────→ xrdp ──→ XFCE浏览器 ─→ HTTPS ─→ Caddy ─→ Guacamole ─→ RDP ─→ xrdp ──→ XFCE浏览器 ─→ HTTPS ─→ Caddy ─→ code-server</code></div></pre><p>日常长时间使用时，我还是更倾向于原生 RDP，画质和延迟表现更好；临时设备、平板或无法安装客户端时，再通过 Guacamole 连接。</p><h2 id="十二、维护与安全边界">十二、维护与安全边界</h2><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash">cd &quot;$HOME/workspace/services/docker/guacamole&quot;docker compose psdocker compose logs --tail=100docker compose logs -f guacddocker compose restartdocker compose downdocker compose up -ddocker system df</code></div></pre><p>长期运行时需要注意：PostgreSQL volume 保存用户、连接、权限和连接参数，必须纳入备份；<code>.env</code> 权限应保持为 <code>600</code>；<code>30090</code> 继续只监听 <code>127.0.0.1</code>；真实客户端 IP 经过 Caddy 和 Docker bridge 后可能被代理地址替代，审计和暴力破解防护需要结合实际代理链路验证。</p><h2 id="参考">参考</h2><ul><li><a href="https://guacamole.apache.org/doc/gug/" title="Apache Guacamole 官方文档" class="external-link" data-redirect="https%3A%2F%2Fguacamole.apache.org%2Fdoc%2Fgug%2F">Apache Guacamole 官方文档</a></li><li><a href="https://hub.docker.com/r/guacamole/guacamole" title="Apache Guacamole Docker 镜像" class="external-link" data-redirect="https%3A%2F%2Fhub.docker.com%2Fr%2Fguacamole%2Fguacamole">Apache Guacamole Docker 镜像</a></li><li><a href="https://github.com/DoraTiger/homelab-setup" title="Homelab Debian 环境配置" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a></li><li><a href="https://github.com/DoraTiger/hexo-theme-doratiger" title="DoraTiger 主题仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhexo-theme-doratiger">DoraTiger 主题 GitHub</a></li><li><a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy" title="Caddy reverse_proxy 文档" class="external-link" data-redirect="https%3A%2F%2Fcaddyserver.com%2Fdocs%2Fcaddyfile%2Fdirectives%2Freverse_proxy">Caddy reverse_proxy 文档</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;在 &lt;a href=&quot;/2026/09/34889/&quot; title=&quot;Homelab 搭建手记（6）Debian Xfce 与 XRDP 同用户并发会话配置&quot;&gt;Homelab 搭建手记（6）Debian Xfce 与 XRDP 同用户并发会话配置&lt;/a&gt; 中，我已经把 &lt;code&gt;Debian&lt;/code&gt;、&lt;code&gt;XFCE&lt;/code&gt; 和 &lt;code&gt;XRDP&lt;/code&gt; 的远程桌面环境调整到可以长期使用；上一篇又通过 &lt;a href=&quot;/2026/09/22299/&quot; title=&quot;Homelab 搭建手记（7）部署 code-server 并配置 HTTPS 访问&quot;&gt;Homelab 搭建手记（7）部署 code-server 并配置 HTTPS 访问&lt;/a&gt; 把浏览器开发环境接了出来。&lt;/p&gt;
&lt;p&gt;不过 &lt;code&gt;code-server&lt;/code&gt; 只能提供编辑器和终端，遇到必须使用完整图形界面的程序时，还是需要打开原生 &lt;code&gt;RDP&lt;/code&gt; 客户端。这次增加一个浏览器入口：使用 &lt;code&gt;Apache Guacamole&lt;/code&gt; 作为 Web Gateway，通过 &lt;code&gt;RDP&lt;/code&gt; 连接已有的 &lt;code&gt;xrdp&lt;/code&gt; 会话。这样既保留原生 RDP，也可以在平板、轻薄本或临时设备上直接用浏览器进入 Debian 桌面。&lt;/p&gt;
&lt;p&gt;配套脚本放在我维护的 &lt;a href=&quot;https://github.com/DoraTiger/homelab-setup&quot; title=&quot;Homelab Debian 环境配置&quot; class=&quot;external-link&quot; data-redirect=&quot;https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup&quot;&gt;homelab-setup&lt;/a&gt; 项目中。这个仓库用于初始化 Debian 环境和部署常用服务；这次加入 Guacamole 时，我也整理了有状态服务的目录管理，让生成的配置有固定位置，重新执行脚本时保留已有账号和连接数据。下面会同时说明脚本用法和具体配置。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Docker" scheme="https://www.superheaoz.top/tags/Docker/"/>
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="XRDP" scheme="https://www.superheaoz.top/tags/XRDP/"/>
    
    <category term="Caddy" scheme="https://www.superheaoz.top/tags/Caddy/"/>
    
    <category term="XFCE" scheme="https://www.superheaoz.top/tags/XFCE/"/>
    
    <category term="Guacamole" scheme="https://www.superheaoz.top/tags/Guacamole/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（10）自建访问统计：用 doratiger-counter 替代卜算子</title>
    <link href="https://www.superheaoz.top/2026/09/46128/"/>
    <id>https://www.superheaoz.top/2026/09/46128/</id>
    <published>2026-09-04T02:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>在 <a href="/2026/06/1626/" title="HEXO 开发笔记（6）自建主题：核心功能实现">HEXO 开发笔记（6）自建主题：核心功能实现</a> 中，已经记录过主题统计功能从 <code>localStorage</code> 迁移到卜算子，再迁移到自建 <code>counter</code> 的过程。当时的实现能够满足日常使用，但计数数据主要停留在内存中，服务重启后 <code>UV</code> 会重新开始，配置和数据库迁移也还比较粗糙。</p><p>这次整理 <code>doratiger-counter</code>，主要补上了 <code>UV</code> 持久化、数据库迁移和退出前同步，也调整了来源校验，避免字符串匹配放过不该接受的域名。下面记录这些改动，以及 <code>DoraTiger</code> 主题如何调用统计接口。</p><span id="more"></span><h2 id="一、为什么不继续使用第三方统计">一、为什么不继续使用第三方统计</h2><p>最早的统计方案是浏览器端 <code>localStorage</code>。它不需要后端，部署成本最低，但统计数据只存在当前浏览器中，换设备或清理浏览器数据后就无法连续累计。后来接入卜算子，站点和页面的统计可以集中保存，但计数脚本依赖外部服务，服务可用性、网络环境和返回格式都不是主题能够控制的。</p><p>我的博客只需要站点和文章的 <code>PV/UV</code>，用一个 Go 程序加一个 <code>SQLite</code> 数据库文件就可以实现。主题通过 <code>HTTP API</code> 读取计数，统计口径和升级方式由自己维护，出了问题也方便检查代码和数据库。</p><h2 id="二、整体结构">二、整体结构</h2><p><code>doratiger-counter</code> 目前由三层组成：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-text">浏览器中的 DoraTiger 主题  → GET /count?page=&lt;path&gt;&amp;uid=&lt;visitor-id&gt;  → Go HTTP 服务  → 内存计数器与访客集合  → SQLite（定时同步，退出时最后同步）</code></div></pre><p>主题负责生成访问者标识和当前页面路径，服务端负责校验来源、递增计数并返回结果，数据库只负责保存可恢复的数据。这样划分以后，主题不需要知道数据库结构，服务端也不需要参与 Hexo 构建过程。</p><p>当前版本有两个接口：</p><pre><div class="code-header"><span class="code-header-type">http</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-http">GET /count?page=/posts/example/&amp;uid=visitor-idOrigin: https://blog.example.com</code></div></pre><pre><div class="code-header"><span class="code-header-type">json</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-json">&#123;  &quot;site_pv&quot;: 42,  &quot;page_pv&quot;: 3,  &quot;site_uv&quot;: 12,  &quot;page_uv&quot;: 2&#125;</code></div></pre><p><code>page</code> 是必填的页面键，<code>uid</code> 可选。没有 <code>uid</code> 时仍然会增加 <code>PV</code>，但不会增加 <code>UV</code>。另一个接口是 <code>GET /health</code>，只返回 <code>&#123;&quot;status&quot;:&quot;ok&quot;&#125;</code>，不要求请求带有站点来源，方便反向代理或监控系统做健康检查。</p><h2 id="三、PV-与-UV-如何统计">三、PV 与 UV 如何统计</h2><h3 id="3-1-PV-使用内存计数器">3.1 PV 使用内存计数器</h3><p>请求到达后，站点 <code>PV</code> 和当前页面 <code>PV</code> 都在内存中递增。服务每 30 秒把站点计数、页面计数和访客集合放进同一个事务写入 <code>SQLite</code>。收到 <code>SIGTERM</code> 或 <code>Ctrl-C</code> 时，HTTP 服务先停止接收新请求，再触发一次最后同步。</p><p>这种做法避免了每次页面访问都执行数据库写操作，适合个人站点的低到中等访问量。但它也意味着：如果进程被强制杀死，最近一次同步之后的计数可能丢失，最大窗口约为 30 秒。因此这套服务适合展示型统计，不适合财务、计费或审计场景。</p><h3 id="3-2-UV-使用持久化摘要去重">3.2 UV 使用持久化摘要去重</h3><p>主题第一次访问时在浏览器中生成一个 UUID，并通过 <code>Cookie</code> 保存一年。之后每次请求都把这个 UUID 作为 <code>uid</code> 发送给服务端。服务端不会保存原始 UUID，而是计算 <code>SHA-256</code> 摘要，再将摘要分别放进站点访客集合和页面访客集合中。</p><p>这样做有两个直接效果：同一个访客再次打开页面时，页面 <code>PV</code> 会增加，但页面 <code>UV</code> 不会重复增加；服务重启后，访客集合可以从数据库恢复，站点 <code>UV</code> 不会从零开始。摘要仍然是可以关联的假名标识，所以它不是“完全匿名数据”，部署时仍然应该在隐私说明中告知访客用途。</p><h2 id="四、来源限制与-CORS">四、来源限制与 CORS</h2><p>统计接口不是登录接口，但至少可以减少普通网页和脚本的误调用。配置 <code>allowed_origins</code> 后，服务会解析 <code>Origin</code> 或缺失时使用的 <code>Referer</code>，只按规范化后的主机名精确匹配允许列表：</p><pre><div class="code-header"><span class="code-header-type">toml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-toml">[counter]site_key = 'dtc_site'allowed_origins = ['blog.example.com']enable_cors = true</code></div></pre><p>这里有两个容易混淆的边界。第一，<code>example.com</code> 和 <code>example.com.evil.test</code> 不能按字符串包含关系判断，否则恶意后缀也可能通过。第二，<code>Origin</code>/<code>Referer</code> 可以被直接构造 <code>HTTP</code> 客户端伪造，所以它只能作为来源限制，不能当作身份认证，更不能用于授权、计费或其他安全决策。</p><p>开启 <code>CORS</code> 后，服务只会为通过白名单校验的来源返回 <code>Access-Control-Allow-Origin</code>，并带上 <code>Vary: Origin</code>。如果统计服务和博客由同一个反向代理提供，通常不需要打开宽泛的跨域策略。</p><h2 id="五、数据库迁移与恢复">五、数据库迁移与恢复</h2><p>数据库使用单调递增的 <code>schema version</code>。初始版本包含四类数据：页面 <code>PV</code>、站点 <code>PV</code>、站点访客摘要和页面访客摘要。启动时先检查数据库版本，再执行只增加表结构的迁移；如果发现数据库版本高于当前程序，服务会拒绝启动，避免旧程序误操作新数据。</p><p>对于早期只有 <code>page_stats</code> 和 <code>site_stats</code> 两张表的数据库，当前迁移会保留原有 <code>PV</code>，再补齐访客表。服务启动时将已有数据加载到内存，后续请求继续从原来的数值上递增。升级服务前仍建议同时备份 <code>counter.db</code>、<code>counter.db-wal</code> 和 <code>counter.db-shm</code> 文件。</p><h2 id="六、主题中的接入方式">六、主题中的接入方式</h2><p>主题配置只需要指定统计类型和 API 地址：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-yaml">statistics:  enable: true  type: counter  counter:    api: https://counter.example.com/count    uv: true</code></div></pre><p><code>footer.pug</code> 在文章页和普通页面中渲染统计占位符，浏览器脚本读取或创建 <code>dtc_uid</code>，再把 <code>location.pathname</code> 和访客标识拼到 API 请求中。请求成功时显示服务端返回的 <code>site_uv</code>、<code>page_uv</code>；请求失败或没有配置 API 时，则退回到 <code>localStorage</code> 的本地方案。</p><p>这个 fallback 很重要：统计服务短暂不可用时，主题不会因为一个附加功能失败而影响文章阅读。但两种方案的统计口径不同，<code>localStorage</code> 只知道当前浏览器访问过哪些路径，不能与服务端的站点总量直接比较，所以它更适合作为临时占位，而不是长期数据源。</p><h2 id="七、部署边界与当前限制">七、部署边界与当前限制</h2><p>服务可以直接运行，也可以通过 Docker 部署。生产环境建议让它监听内网地址，由 <code>Caddy</code>、<code>Nginx</code> 等反向代理提供 <code>HTTPS</code>，并限制后端端口的可访问范围。数据库目录需要持久化挂载，容器更新时不能把 <code>/app/data</code> 一并丢弃。</p><p>当前版本刻意保持简单，也保留了几个明确限制：</p><ul><li>只支持 <code>SQLite</code>，面向单实例和个人站点；</li><li>计数先写内存，再定时同步，异常退出存在短暂数据窗口；</li><li>访客摘要和页面访客集合会随访客量增长，暂不适合大规模多租户部署；</li><li>不提供后台管理页面、历史报表和数据导出接口；</li><li>来源白名单不是认证机制，不能防止有意伪造请求。</li></ul><p>目前我仍按单实例维护。外部数据库、后台报表和多实例协调暂时没有实际需求，后面根据访问量再考虑。</p><h2 id="八、总结">八、总结</h2><p>整理之后，统计服务可以从数据库恢复访客集合，升级时处理旧表结构，正常退出前也会同步内存计数。日常使用仍需备份数据库，并注意异常退出可能丢失尚未同步的数据。对我目前的博客来说，这些已经够用了。</p><h2 id="参考">参考</h2><ul><li><a href="https://github.com/DoraTiger/hexo-theme-doratiger" title="DoraTiger 主题仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhexo-theme-doratiger">DoraTiger 主题 GitHub</a></li><li><a href="https://hexo.io/zh-cn/docs/" title="Hexo 官方文档" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2F">Hexo 官方文档</a></li><li><a href="https://pkg.go.dev/database/sql" title="Go database/sql 文档" class="external-link" data-redirect="https%3A%2F%2Fpkg.go.dev%2Fdatabase%2Fsql">Go <code>database/sql</code> 文档</a></li><li><a href="https://www.sqlite.org/docs.html" title="SQLite 官方文档" class="external-link" data-redirect="https%3A%2F%2Fwww.sqlite.org%2Fdocs.html">SQLite 官方文档</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;在 &lt;a href=&quot;/2026/06/1626/&quot; title=&quot;HEXO 开发笔记（6）自建主题：核心功能实现&quot;&gt;HEXO 开发笔记（6）自建主题：核心功能实现&lt;/a&gt; 中，已经记录过主题统计功能从 &lt;code&gt;localStorage&lt;/code&gt; 迁移到卜算子，再迁移到自建 &lt;code&gt;counter&lt;/code&gt; 的过程。当时的实现能够满足日常使用，但计数数据主要停留在内存中，服务重启后 &lt;code&gt;UV&lt;/code&gt; 会重新开始，配置和数据库迁移也还比较粗糙。&lt;/p&gt;
&lt;p&gt;这次整理 &lt;code&gt;doratiger-counter&lt;/code&gt;，主要补上了 &lt;code&gt;UV&lt;/code&gt; 持久化、数据库迁移和退出前同步，也调整了来源校验，避免字符串匹配放过不该接受的域名。下面记录这些改动，以及 &lt;code&gt;DoraTiger&lt;/code&gt; 主题如何调用统计接口。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="Go" scheme="https://www.superheaoz.top/tags/Go/"/>
    
    <category term="SQLite" scheme="https://www.superheaoz.top/tags/SQLite/"/>
    
    <category term="访问统计" scheme="https://www.superheaoz.top/tags/%E8%AE%BF%E9%97%AE%E7%BB%9F%E8%AE%A1/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（7）部署 code-server 并配置 HTTPS 访问</title>
    <link href="https://www.superheaoz.top/2026/09/22299/"/>
    <id>https://www.superheaoz.top/2026/09/22299/</id>
    <published>2026-09-03T07:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>在之前的 <a href="/2026/06/35524/" title="Homelab 搭建手记（4）开发工具配置">Homelab 搭建手记（4）开发工具配置</a> 中，我已经把 <code>VS Code</code>、<code>Codex CLI</code> 等开发工具整理到了 Debian 工作站上。不过桌面远程只是其中一种使用方式：当手边只有平板、轻薄本，或者不方便建立完整的远程桌面时，如果能直接在浏览器中打开开发环境，会更加灵活。</p><p><code>code-server</code> 可以把接近 <code>VS Code</code> 的编辑体验放进浏览器，终端、代码和扩展仍运行在 Homelab 主机上。最开始我直接通过内网 IP 和端口访问，编辑与终端都能使用，看起来部署已经结束；直到在其中运行 Codex 扩展，才发现 WebView 无法正常拉起，并提示当前页面不是安全上下文。</p><p>为了解决这个问题，我用 <code>Caddy + AliDNS DNS-01</code> 配置了 <code>HTTPS</code>，并把 <code>code-server</code> 改为只监听本机。本文从安装开始，记录 HTTPS 配置和 Codex 扩展的检查过程。相关安装脚本放在 <a href="https://github.com/DoraTiger/homelab-setup" title="DoraTiger homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a> 项目中，可以配合下面的步骤使用。</p><span id="more"></span><h2 id="一、从“能够打开”到“能够正常开发”">一、从“能够打开”到“能够正常开发”</h2><h3 id="1-1-普通-HTTP-下的问题">1.1 普通 HTTP 下的问题</h3><p>直接访问下面这样的地址时，<code>code-server</code> 本身可以正常显示：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">http://&lt;SERVER_IP&gt;:&lt;PORT&gt;</code></div></pre><p>登录、编辑文件和使用终端通常没有问题，但 Codex 扩展的 WebView 可能无法工作，浏览器控制台会出现与 <code>crypto.subtle</code> 或 <code>Service Worker</code> 有关的错误。<code>code-server</code> 官方 FAQ 也说明，WebView 依赖 Service Worker，而 Service Worker 需要运行在 <code>Secure Context</code> 中。</p><p>可以在浏览器开发者工具中检查：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-javascript">window.isSecureContextwindow.crypto.subtle</code></div></pre><p>通过普通内网 IP 的 <code>HTTP</code> 页面访问时，结果可能是：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">falseundefined</code></div></pre><p>这也是最初容易误判的地方：<strong>code-server 进程已经正常运行，不代表其中所有浏览器能力都可用。</strong> 如果只是看服务状态或登录页面，很难发现这条链路还缺少 HTTPS。</p><h3 id="1-2-最终部署目标">1.2 最终部署目标</h3><p>本文使用的结构如下：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div></div><code class="language-text">浏览器  │  │ HTTPS  ▼code.example.com  │  ▼Caddy :443  │  │ HTTP，仅本机回环  ▼code-server 127.0.0.1:30080</code></div></pre><p>职责划分为：</p><ul><li><code>code-server</code> 提供编辑器、终端和扩展运行环境；</li><li><code>code-server</code> 只监听 <code>127.0.0.1</code>，不直接暴露应用端口；</li><li><code>Caddy</code> 负责 TLS 终结和反向代理；</li><li><code>AliDNS</code> Provider 负责完成 ACME DNS-01 验证；</li><li>浏览器只通过 <code>https://code.example.com</code> 访问服务。</li></ul><p>这里的 <code>code.example.com</code>、<code>30080</code> 都是示例值，需要按自己的域名和端口替换。本文不会展示实际使用的域名、IP、凭据或内部拓扑。</p><h2 id="二、安装-code-server">二、安装 code-server</h2><h3 id="2-1-使用自动化脚本安装">2.1 使用自动化脚本安装</h3><p>公开的 <a href="https://github.com/DoraTiger/homelab-setup" title="DoraTiger homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a> 中，<a href="https://github.com/DoraTiger/homelab-setup/blob/main/modules/17-code-server.sh" title="code-server 安装模块" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Fmodules%2F17-code-server.sh"><code>17-code-server.sh</code></a> 会从 code-server 官方 GitHub Release 获取与当前架构匹配的 Debian 安装包，并把下载结果保存在本地缓存目录中。脚本目前支持 <code>amd64</code> 和 <code>arm64</code>。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash"># 克隆公开仓库git clone https://github.com/DoraTiger/homelab-setup.gitcd homelab-setup# 安装 code-serverbash init.sh --silent 17</code></div></pre><p>如果还需要构建带 AliDNS Provider 的 Caddy，可以按照模块顺序一次执行 Go、Caddy 和 code-server：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">bash init.sh --silent 05 16 17</code></div></pre><p>其中 <code>05</code> 提供 Go 工具链，<code>16</code> 安装 Caddy 并加入 <code>dns.providers.alidns</code>，<code>17</code> 安装 code-server。Caddy 自定义构建当前要求 <code>Go 1.25</code> 或更高版本，脚本会在修改系统前检查这个条件。</p><p>自动化脚本有意只完成软件安装，不会：</p><ul><li>收集域名或 AliDNS AccessKey；</li><li>修改用户现有的 code-server 配置；</li><li>自动启动 code-server 服务；</li><li>把站点配置写入 Caddyfile。</li></ul><p>这些内容与每台主机的域名、端口和安全策略有关，保留为显式配置比隐藏在安装脚本中更稳妥。</p><h3 id="2-2-手动安装方式">2.2 手动安装方式</h3><p>如果不使用脚本，也可以从 <a href="https://github.com/coder/code-server/releases" title="code-server Releases" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fcoder%2Fcode-server%2Freleases">code-server Releases</a> 下载对应架构的 Debian 安装包：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo apt install ./code-server_&lt;VERSION&gt;_&lt;ARCH&gt;.deb</code></div></pre><p>安装完成后检查版本：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">code-server --version</code></div></pre><p>这里只确认程序已经安装，不急着直接运行。下一步先固定监听地址和认证方式，避免默认配置与最终服务状态不一致。</p><h2 id="三、配置并启动-code-server">三、配置并启动 code-server</h2><h3 id="3-1-配置监听地址">3.1 配置监听地址</h3><p>code-server 的用户配置位于：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">~/.config/code-server/config.yaml</code></div></pre><p>编辑配置文件：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">mkdir -p ~/.config/code-server$&#123;EDITOR:-nano&#125; ~/.config/code-server/config.yaml</code></div></pre><p>参考配置如下：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-yaml">bind-addr: 127.0.0.1:30080auth: passwordpassword: &lt;STRONG_PASSWORD&gt;cert: falselocale: zh-cn</code></div></pre><p>各项含义为：</p><ul><li><code>bind-addr</code>：只监听本机回环地址，端口可以自行调整；</li><li><code>auth</code>：保留 code-server 自身的密码认证；</li><li><code>password</code>：替换为独立的强密码，不要提交到 Git；</li><li><code>cert: false</code>：code-server 不直接处理 TLS，由 Caddy 统一负责；</li><li><code>locale</code>：将界面语言设置为简体中文。</li></ul><p>配置文件包含登录密码，建议限制权限：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">chmod 600 ~/.config/code-server/config.yaml</code></div></pre><h3 id="3-2-使用-systemd-管理服务">3.2 使用 systemd 管理服务</h3><p>Debian 安装包提供了用户实例化的 systemd 服务，可以使用当前用户名启用：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo systemctl enable --now code-server@$USER</code></div></pre><p>检查运行状态和日志：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">systemctl status code-server@$USER --no-pagerjournalctl -u code-server@$USER -n 100 --no-pager</code></div></pre><p>修改 <code>config.yaml</code> 后，需要重启正在运行的服务：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo systemctl restart code-server@$USER</code></div></pre><p>仅修改配置文件不会让旧进程自动切换端口，这一点也是后续出现 <code>502 Bad Gateway</code> 的常见原因。</p><h3 id="3-3-先验证本地服务">3.3 先验证本地服务</h3><p>在加入 Caddy 前，先确认 code-server 自身可用：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">ss -lntp | grep ':30080'curl --noproxy '*' -I http://127.0.0.1:30080</code></div></pre><p>正常情况下可以看到 code-server 只监听 <code>127.0.0.1:30080</code>，HTTP 请求返回登录页跳转：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">HTTP/1.1 302 FoundLocation: ./login</code></div></pre><p>如果这一步失败，应该先检查 code-server 的配置与日志，而不是继续调整 Caddy。把应用层和代理层分开验证，可以避免在多个组件之间来回猜测。</p><h2 id="四、为什么-HTTPS-需要-DNS-01">四、为什么 HTTPS 需要 DNS-01</h2><h3 id="4-1-内网服务无法使用常规公网验证">4.1 内网服务无法使用常规公网验证</h3><p>本文希望使用公网可信证书，但 code-server 仍然只在内网访问。域名可以通过 DNS 解析到 RFC 1918 私网地址，例如：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">code.example.com  A  &lt;PRIVATE_IP&gt;</code></div></pre><p>这种情况下，公共证书颁发机构无法从公网访问该私网地址。依赖公网访问 80 端口的 <code>HTTP-01</code>，以及依赖公网访问 443 端口的 <code>TLS-ALPN-01</code>，都不适合这套纯内网架构。</p><p><code>DNS-01</code> 验证的是域名 DNS 中临时创建的 TXT 记录：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-text">Caddy  │ AliDNS API  ▼_acme-challenge.code.example.com  TXT  &lt;CHALLENGE_VALUE&gt;  │  ▼证书颁发机构查询 TXT 记录  │  ▼验证域名控制权并签发证书</code></div></pre><p>根据 Caddy 官方文档，DNS Challenge 不要求开放入站端口，申请证书的服务器也不需要从公网可达，因此正好适合“公网域名 + 私网服务”的场景。</p><h3 id="4-2-为什么选择-Caddy">4.2 为什么选择 Caddy</h3><p>我用 Caddy 配置 HTTPS，主要考虑以下几点：</p><ul><li>Automatic HTTPS 会管理证书申请和续期；</li><li>Caddyfile 中反向代理配置较短；</li><li>可以通过 DNS Provider 扩展接入 AliDNS；</li><li>WebSocket 反向代理不需要额外堆叠大量配置；</li><li>配置适合使用 <code>fmt → validate → reload</code> 的固定流程维护。</li></ul><p>如果环境里已经稳定运行 Nginx、Traefik 或其他代理，没有必要为了 code-server 强行迁移。本文选择 Caddy，只是因为它在这台个人 Debian 工作站上用较低的配置成本补齐了 HTTPS。</p><h2 id="五、安装带-AliDNS-Provider-的-Caddy">五、安装带 AliDNS Provider 的 Caddy</h2><h3 id="5-1-标准二进制不包含-AliDNS-模块">5.1 标准二进制不包含 AliDNS 模块</h3><p>Caddy 的 DNS Provider 属于扩展模块，标准发行版不一定包含 <code>dns.providers.alidns</code>。安装后可以检查：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">caddy list-modules | grep '^dns.providers.alidns$'</code></div></pre><p>没有输出时，需要使用 <code>xcaddy</code> 构建自定义二进制：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">go install github.com/caddyserver/xcaddy/cmd/xcaddy@latestxcaddy build \  --with github.com/caddy-dns/alidns</code></div></pre><p>构建完成后，应先验证产物再替换系统版本：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">./caddy list-modules | grep '^dns.providers.alidns$'</code></div></pre><p>本文使用的 <a href="https://github.com/DoraTiger/homelab-setup/blob/main/modules/16-caddy.sh" title="Caddy 安装模块" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Fmodules%2F16-caddy.sh"><code>16-caddy.sh</code></a> 自动化模块会完成以下工作：</p><ol><li>安装 Caddy 官方 Debian 包，保留发行版提供的用户、目录和 systemd 服务；</li><li>检查当前二进制或本地缓存是否已经包含 AliDNS Provider；</li><li>必要时通过 <code>xcaddy</code> 构建自定义 Caddy；</li><li>使用 <code>dpkg-divert</code> 和 <code>update-alternatives</code> 管理官方与自定义二进制；</li><li>验证模块存在后才报告安装完成。</li></ol><p>这样既不直接破坏 Debian 软件包的管理关系，也能在重复执行时复用已经验证过的自定义构建。</p><h3 id="5-2-安装模块">5.2 安装模块</h3><p>只安装 Caddy 时执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">cd homelab-setupbash init.sh --silent 05 16</code></div></pre><p>安装后检查：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-bash">caddy versioncaddy list-modules | grep '^dns.providers.alidns$'systemctl status caddy --no-pager</code></div></pre><p>如果系统已有包含其他第三方模块的 Caddy，升级前应检查 <code>caddy build-info</code>。不能为了补一个 AliDNS 模块，静默覆盖掉用户已有的其他扩展。</p><h2 id="六、配置-code-server-的-HTTPS-入口">六、配置 code-server 的 HTTPS 入口</h2><h3 id="6-1-保存-AliDNS-凭据">6.1 保存 AliDNS 凭据</h3><p>不要把 AccessKey 直接写进 Caddyfile，更不能提交到仓库。创建单独的环境文件：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo install -m 640 -o root -g caddy /dev/null /etc/caddy/alidns.envsudoedit /etc/caddy/alidns.env</code></div></pre><p>使用 AliDNS Provider 当前支持的环境变量名称：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">ALIYUN_ACCESS_KEY_ID=&lt;ACCESS_KEY_ID&gt;ALIYUN_ACCESS_KEY_SECRET=&lt;ACCESS_KEY_SECRET&gt;</code></div></pre><p>建议为自动 DNS 验证创建独立的阿里云 RAM 身份，并限制到实际需要的 DNS 权限，不使用主账号 AccessKey。检查文件权限：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo stat -c '%A %U:%G %n' /etc/caddy/alidns.env</code></div></pre><p>预期为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">-rw-r----- root:caddy /etc/caddy/alidns.env</code></div></pre><h3 id="6-2-通过-systemd-注入凭据">6.2 通过 systemd 注入凭据</h3><p>不要直接修改软件包提供的 systemd unit，否则升级时可能被覆盖。使用 override：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo systemctl edit caddy</code></div></pre><p>写入：</p><pre><div class="code-header"><span class="code-header-type">ini</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-ini">[Service]EnvironmentFile=/etc/caddy/alidns.envExecStart=ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile</code></div></pre><p>这里空的 <code>ExecStart=</code> 用来清除原启动命令，再定义不带 <code>--environ</code> 的启动方式。Debian 的 Caddy unit 曾使用 <code>caddy run --environ</code> 输出运行环境；如果把 AccessKey 通过 <code>EnvironmentFile</code> 注入，同时保留该参数，敏感值可能进入 journal。</p><p>应用 override 前可以检查合并结果：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo systemctl daemon-reloadsystemctl cat caddy</code></div></pre><p>如果凭据曾经完整出现在终端记录、日志或其他非安全位置，仅删除日志并不足够，应立即轮换对应 AccessKey。</p><h3 id="6-3-配置-Caddyfile">6.3 配置 Caddyfile</h3><p>编辑 <code>/etc/caddy/Caddyfile</code>：</p><pre><div class="code-header"><span class="code-header-type">caddy</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-caddy">code.example.com &#123;    tls &#123;        dns alidns &#123;            access_key_id &#123;env.ALIYUN_ACCESS_KEY_ID&#125;            access_key_secret &#123;env.ALIYUN_ACCESS_KEY_SECRET&#125;        &#125;    &#125;    reverse_proxy 127.0.0.1:30080&#125;</code></div></pre><p>浏览器到 Caddy 使用 HTTPS，Caddy 到本机 code-server 使用 HTTP：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">Browser ──HTTPS──&gt; Caddy ──HTTP──&gt; 127.0.0.1:30080</code></div></pre><p>因为 code-server 已经配置为 <code>cert: false</code>，所以 <code>reverse_proxy</code> 不应误写成 <code>https://127.0.0.1:30080</code>。回环接口上的这一跳不需要重复配置 TLS。</p><h3 id="6-4-格式化、验证并启动">6.4 格式化、验证并启动</h3><p>每次修改 Caddyfile 后固定执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash">sudo caddy fmt --overwrite /etc/caddy/Caddyfilesudo caddy validate \  --config /etc/caddy/Caddyfile \  --adapter caddyfilesudo systemctl restart caddy</code></div></pre><p>首次申请证书需要创建 DNS TXT 记录，等待 DNS 传播可能需要一段时间。查看日志：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo journalctl -u caddy -n 100 --no-pager</code></div></pre><p>确认日志中的验证类型是 <code>dns-01</code>，并检查是否出现权限不足、TXT 传播超时或 ACME 限流。</p><h2 id="七、分层验证完整链路">七、分层验证完整链路</h2><h3 id="7-1-DNS-与本地-upstream">7.1 DNS 与本地 upstream</h3><p>先确认域名解析到了预期的私网地址：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">dig +short code.example.com</code></div></pre><p>再绕过代理环境变量，直接检查 code-server：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">curl --noproxy '*' -I http://127.0.0.1:30080</code></div></pre><p>只要这一步不是正常的登录跳转，就不应该继续判断 TLS 或 Caddy。</p><h3 id="7-2-HTTPS-与反向代理">7.2 HTTPS 与反向代理</h3><p>检查完整入口：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">curl --noproxy '*' -Iv https://code.example.com</code></div></pre><p>重点观察：</p><ul><li>证书主机名与域名一致；</li><li>证书链能够通过校验；</li><li>HTTP 响应来自 Caddy；</li><li>最终返回 code-server 登录页或对应跳转，而不是 502。</li></ul><p>完成登录后，再在浏览器 Console 检查：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-javascript">window.isSecureContextwindow.crypto.subtle</code></div></pre><p>预期分别得到：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">trueSubtleCrypto</code></div></pre><h2 id="八、实际遇到的问题">八、实际遇到的问题</h2><h3 id="8-1-HTTPS-正常但出现-502">8.1 HTTPS 正常但出现 502</h3><p>如果浏览器显示的证书有效，但响应为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">HTTP/2 502server: Caddy</code></div></pre><p>说明浏览器到 Caddy 的 TLS 链路已经正常，问题位于 Caddy 到 code-server 的 upstream：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">Browser ──HTTPS──&gt; Caddy    正常Caddy ──HTTP──&gt; code-server 异常</code></div></pre><p>本次部署中的原因是修改了 code-server 的监听端口，却没有重启旧进程。按顺序检查：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">sudo systemctl restart code-server@$USERss -lntp | grep ':30080'curl --noproxy '*' -I http://127.0.0.1:30080sudo journalctl -u caddy --since '5 minutes ago' --no-pager</code></div></pre><p>同时确认 Caddyfile 中的端口一致，且 upstream 协议没有误写成 HTTPS。</p><h3 id="8-2-Caddy-的-2019-端口冲突">8.2 Caddy 的 2019 端口冲突</h3><p>Caddy 默认在 <code>127.0.0.1:2019</code> 提供 Admin API，它不是 code-server 的业务端口。若日志出现：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">listen tcp 127.0.0.1:2019: bind: address already in use</code></div></pre><p>通常意味着系统里已经存在另一个 Caddy 进程。检查：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo ss -lntp | grep ':2019'ps aux | grep '[c]addy'</code></div></pre><p>不要简单把 Admin API 改到 2020 来绕过，因为两个实例仍可能继续竞争 80 和 443。正确做法是找出手工启动或遗留的实例，只保留 systemd 管理的一份 Caddy。</p><p>Admin API 应只监听本机，不要暴露到 LAN 或公网。</p><h3 id="8-3-代理变量干扰-curl-判断">8.3 代理变量干扰 curl 判断</h3><p>如果系统配置了 <code>http_proxy</code> 或 <code>https_proxy</code>，直接执行 <code>curl</code> 可能经过代理，得到的结果不能准确表示本机链路。排查时显式加入：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">curl --noproxy '*' -I http://127.0.0.1:30080curl --noproxy '*' -Iv https://code.example.com</code></div></pre><p>这样才能分别观察本地 upstream 和完整 HTTPS 入口。日常环境中也可以合理配置 <code>NO_PROXY</code>，但不要为了排障临时清空或覆盖整套代理配置。</p><h3 id="8-4-AccessKey-出现在-journal">8.4 AccessKey 出现在 journal</h3><p>如果发现 Caddy 启动日志打印了完整环境变量，应立即：</p><ol><li>停止继续复制或分享相关日志；</li><li>检查 systemd 实际的 <code>ExecStart</code> 是否包含 <code>--environ</code>；</li><li>使用 override 移除该参数；</li><li>在阿里云 RAM 控制台轮换已经暴露的 AccessKey；</li><li>重新验证 Caddy 能否完成 DNS-01。</li></ol><p>把凭据移出 Caddyfile 只是第一步，还要检查凭据在进程启动、日志和故障排查路径中是否会被再次输出。</p><h2 id="九、适用边界与总结">九、适用边界与总结</h2><p>配置完成后，各部分的关系如下：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-text">安装 code-server  ↓仅监听 localhost 并启用认证  ↓验证本地 HTTP upstream  ↓Caddy + AliDNS DNS-01 获取可信证书  ↓通过 HTTPS 反向代理 code-server  ↓验证 Secure Context、WebView 与 Codex 扩展</code></div></pre><p>这套方案适合拥有公开域名、DNS 托管在 AliDNS、服务实际只在内网访问的个人工作站或 Homelab。DNS-01 解决的是证书验证问题，不会自动赋予外网访问能力；域名解析、路由和防火墙仍决定哪些客户端能够连接到服务。</p><p>同时，HTTPS 和 code-server 密码也不是完整的公网暴露防护。如果需要从互联网直接访问，仍应结合 VPN、身份代理、访问控制、速率限制和网络边界设计。code-server 官方也明确不建议在缺少认证与加密的情况下直接暴露服务。</p><p>这次容易漏掉的是扩展检查：编辑器和终端能用，并不代表网页也能加载。部署后除了检查登录页，还要实际打开扩展面板，确认浏览器的安全上下文和 Web Crypto 均可用。</p><h2 id="参考">参考</h2><ul><li><a href="https://coder.com/docs/code-server/guide" title="code-server 安全访问指南" class="external-link" data-redirect="https%3A%2F%2Fcoder.com%2Fdocs%2Fcode-server%2Fguide">code-server 官方安全访问指南</a></li><li><a href="https://coder.com/docs/code-server/FAQ" title="code-server FAQ" class="external-link" data-redirect="https%3A%2F%2Fcoder.com%2Fdocs%2Fcode-server%2FFAQ">code-server FAQ</a></li><li><a href="https://caddyserver.com/docs/automatic-https" title="Caddy Automatic HTTPS" class="external-link" data-redirect="https%3A%2F%2Fcaddyserver.com%2Fdocs%2Fautomatic-https">Caddy Automatic HTTPS</a></li><li><a href="https://github.com/caddy-dns/alidns" title="Caddy AliDNS Provider" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fcaddy-dns%2Falidns">Caddy AliDNS Provider</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle" title="MDN Crypto.subtle" class="external-link" data-redirect="https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FCrypto%2Fsubtle">MDN：Crypto.subtle</a></li><li><a href="https://github.com/DoraTiger/homelab-setup" title="DoraTiger homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">DoraTiger/homelab-setup</a></li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/modules/17-code-server.sh" title="code-server 安装模块" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Fmodules%2F17-code-server.sh">homelab-setup：code-server 安装模块</a></li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/modules/16-caddy.sh" title="Caddy 安装模块" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Fmodules%2F16-caddy.sh">homelab-setup：Caddy 安装模块</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;在之前的 &lt;a href=&quot;/2026/06/35524/&quot; title=&quot;Homelab 搭建手记（4）开发工具配置&quot;&gt;Homelab 搭建手记（4）开发工具配置&lt;/a&gt; 中，我已经把 &lt;code&gt;VS Code&lt;/code&gt;、&lt;code&gt;Codex CLI&lt;/code&gt; 等开发工具整理到了 Debian 工作站上。不过桌面远程只是其中一种使用方式：当手边只有平板、轻薄本，或者不方便建立完整的远程桌面时，如果能直接在浏览器中打开开发环境，会更加灵活。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;code-server&lt;/code&gt; 可以把接近 &lt;code&gt;VS Code&lt;/code&gt; 的编辑体验放进浏览器，终端、代码和扩展仍运行在 Homelab 主机上。最开始我直接通过内网 IP 和端口访问，编辑与终端都能使用，看起来部署已经结束；直到在其中运行 Codex 扩展，才发现 WebView 无法正常拉起，并提示当前页面不是安全上下文。&lt;/p&gt;
&lt;p&gt;为了解决这个问题，我用 &lt;code&gt;Caddy + AliDNS DNS-01&lt;/code&gt; 配置了 &lt;code&gt;HTTPS&lt;/code&gt;，并把 &lt;code&gt;code-server&lt;/code&gt; 改为只监听本机。本文从安装开始，记录 HTTPS 配置和 Codex 扩展的检查过程。相关安装脚本放在 &lt;a href=&quot;https://github.com/DoraTiger/homelab-setup&quot; title=&quot;DoraTiger homelab-setup&quot; class=&quot;external-link&quot; data-redirect=&quot;https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup&quot;&gt;homelab-setup&lt;/a&gt; 项目中，可以配合下面的步骤使用。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="code-server" scheme="https://www.superheaoz.top/tags/code-server/"/>
    
    <category term="Caddy" scheme="https://www.superheaoz.top/tags/Caddy/"/>
    
    <category term="HTTPS" scheme="https://www.superheaoz.top/tags/HTTPS/"/>
    
    <category term="AliDNS" scheme="https://www.superheaoz.top/tags/AliDNS/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（6）Debian Xfce 与 XRDP 同用户并发会话配置</title>
    <link href="https://www.superheaoz.top/2026/09/34889/"/>
    <id>https://www.superheaoz.top/2026/09/34889/</id>
    <published>2026-09-03T02:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>在之前的 <a href="/2026/06/35187/" title="Homelab 搭建手记（2）更换无线网卡与 XRDP WiFi 扫描授权问题">Homelab 搭建手记（2）更换无线网卡与 XRDP WiFi 扫描授权问题</a> 中，记录过 <code>XRDP</code> 环境下的 <code>WiFi</code> 扫描授权问题。当时发现本地自动登录的 <code>Xfce</code> 和远程 <code>Xfce</code> 同时运行时容易出现 <code>D-Bus</code>、<code>polkit</code> 等额外冲突，所以给出的阶段性建议是关闭本地自动登录，只保留单一的 <code>XRDP</code> 图形会话。</p><p>不过实际使用中，偶尔还是需要让物理显示器上的本地桌面保持登录，同时从其他设备通过 <code>RDP</code> 进入同一个用户的独立桌面。经过日志排查和反复测试，发现第二个 <code>Xfce</code> 会话复用了已有图形会话的环境，端口、密码认证和 <code>Xorg</code> 显示编号都没有问题。</p><p>本文记录如何在 <code>Debian 13 + Xfce + XRDP</code> 环境中，让同一个 <code>Linux</code> 用户同时保持本地与远程两个桌面，并进一步补齐 <code>Fcitx5</code> 中文输入和 <code>GNOME Keyring</code>，使 <code>VS Code</code>、<code>GitHub Copilot</code> 等依赖系统密钥环的应用也能在远程桌面中正常使用。相关配置已经整合进公开的 <a href="https://github.com/DoraTiger/homelab-setup" title="homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a> 项目，可以通过脚本自动完成。</p><span id="more"></span><h2 id="一、问题现象与目标">一、问题现象与目标</h2><h3 id="1-1-同用户登录时黑屏退出">1.1 同用户登录时黑屏退出</h3><p>最初的故障现象比较固定：</p><ul><li>用户没有在本地登录时，<code>RDP</code> 可以正常进入 <code>Xfce</code>；</li><li>用户已经在本地登录后，再使用同一用户登录 <code>RDP</code>，输入密码后出现黑屏；</li><li>黑屏持续一段时间后，远程连接自动断开；</li><li>注销其中一个桌面后，另一个桌面又能正常登录。</li></ul><p>排查日志后可以看到，远程的 <code>Xorg :10</code> 已经成功启动，分辨率也已经完成设置，随后运行到了 <code>startwm.sh</code> 和 <code>xfce4-session</code>。但第二个 <code>xfce4-session</code> 很快退出，<code>xrdp-sesman</code> 随之关闭远程 <code>Xorg</code>。</p><p>这说明认证和 <code>xorgxrdp</code> 基本正常，真正失败的位置在远程 <code>Xfce</code> 会话初始化阶段。</p><h3 id="1-2-实测环境">1.2 实测环境</h3><p>本文配置基于以下环境完成验证：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-text">Debian        13.6Xfce          4.20xfce4-session 4.20.2xrdp          0.10.1xorgxrdp      0.10.2</code></div></pre><p>可以通过以下命令确认系统和软件包版本：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">cat /etc/debian_versiondpkg -l | grep -E 'xrdp|xorgxrdp|xfce4-session'</code></div></pre><p>不同 <code>xrdp</code> 版本支持的 session policy 可能存在差异，特别是网上常见的 <code>Policy=UBC</code> 多来自旧版本教程，不应在没有确认版本的情况下直接照搬。</p><h3 id="1-3-目标不是共享同一个桌面">1.3 目标不是共享同一个桌面</h3><p>本文希望实现的结构是：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">同一 Unix 用户├── 本地 Xfce：Xorg :0└── RDP Xfce：Xorg :10</code></div></pre><p>两个桌面共享同一个 <code>UID</code>、文件权限和 <code>$HOME</code>，但各自拥有独立的：</p><ul><li>X11 Display；</li><li>systemd-logind session；</li><li>D-Bus Session Bus；</li><li>Xfce Session Manager；</li><li>Keyring 控制目录。</li></ul><p>这不是让 <code>RDP</code> 接管物理显示器上的现有桌面。本地打开的窗口不会出现在远程桌面中，反之亦然。如果需要看到完全相同的桌面窗口，应使用桌面共享或 <code>VNC</code> 类方案。</p><h2 id="二、为什么常见配置没有解决问题">二、为什么常见配置没有解决问题</h2><h3 id="2-1-xsession-只决定启动哪个桌面">2.1 <code>~/.xsession</code> 只决定启动哪个桌面</h3><p>很多教程会创建 <code>~/.xsession</code>：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">printf '%s\n' 'startxfce4' &gt; ~/.xsessionchmod +x ~/.xsession</code></div></pre><p>默认启动链路大致如下：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-text">xrdp  ↓/etc/xrdp/startwm.sh  ↓/etc/X11/Xsession  ↓~/.xsession  ↓startxfce4</code></div></pre><p>这个配置回答的是“<code>XRDP</code> 应该启动哪个桌面环境”，并没有解决“同一个用户如何同时运行两个 <code>Xfce</code> session”。当远程 <code>Xorg</code> 已经成功创建而 <code>xfce4-session</code> 随即退出时，继续调整 <code>~/.xsession</code>、端口或 <code>MaxSessions</code> 并不能触及根因。</p><h3 id="2-2-冲突来自图形会话环境">2.2 冲突来自图形会话环境</h3><p>同一个用户已经拥有本地 <code>Xfce</code> 后，第二个桌面可能接触到已有会话的环境，重点包括：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-text">DBUS_SESSION_BUS_ADDRESSSESSION_MANAGERD-Bus Session BusXfce Session Manager</code></div></pre><p>仅清除变量后重新进入 <code>/etc/X11/Xsession</code>，在实测环境中仍然不能形成稳定隔离。最终采用的方案是绕过默认 <code>Xsession</code> 启动链路，为远程 <code>Xfce</code> 显式创建独立的 D-Bus Session Bus：</p><pre><div class="code-header"><span class="code-header-type">sh</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-sh">unset DBUS_SESSION_BUS_ADDRESSunset SESSION_MANAGERexec dbus-run-session -- /usr/local/bin/xrdp-xfce-session</code></div></pre><p>其中 <code>dbus-run-session</code> 会为后续程序启动一个新的 Session Bus，并在桌面会话退出后结束该总线。</p><h2 id="三、配置独立的-XRDP-Xfce-会话">三、配置独立的 XRDP Xfce 会话</h2><h3 id="3-1-安装依赖">3.1 安装依赖</h3><p>安装 <code>XRDP</code>、<code>Xfce</code>、D-Bus、输入法和密钥环组件：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash">sudo apt updatesudo apt install \  xrdp xorgxrdp xfce4 xfce4-goodies dbus-x11 \  gnome-keyring libsecret-1-0 libpam-gnome-keyring libsecret-tools \  fcitx5 fcitx5-chinese-addons fcitx5-frontend-all \  fcitx5-config-qt im-config</code></div></pre><p>本文使用 <code>xorgxrdp</code>。每次新建远程会话时会启动独立的 <code>Xorg Server</code>，而不是复用本地显示器上的 <code>:0</code>。</p><h3 id="3-2-配置-sesman-ini">3.2 配置 <code>sesman.ini</code></h3><p>修改系统配置前先备份：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo cp /etc/xrdp/sesman.ini /etc/xrdp/sesman.ini.bak</code></div></pre><p>检查 <code>[Sessions]</code> 段：</p><pre><div class="code-header"><span class="code-header-type">ini</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-ini">[Sessions]X11DisplayOffset=10MaxSessions=50KillDisconnected=falseDisconnectedTimeLimit=0IdleTimeLimit=0Policy=Default</code></div></pre><p>这些配置分别用于：</p><ul><li>从 <code>:10</code> 开始分配远程 Display，避开本地的 <code>:0</code>；</li><li>允许多个 <code>XRDP</code> session 存在；</li><li>客户端断开后保留远程 session；</li><li>不因断开或空闲超时自动结束 session；</li><li>使用 <code>xrdp 0.10</code> 实测可用的默认策略。</li></ul><p>典型的显示编号为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">本地 Xorg：     :0第一个 RDP：    :10后续 RDP：      :11、:12……</code></div></pre><p>独立 Display 只能避免 X Server 层冲突，还需要继续隔离 D-Bus 和 <code>Xfce</code> 会话。</p><h3 id="3-3-配置-startwm-sh">3.3 配置 <code>startwm.sh</code></h3><p>备份 <code>/etc/xrdp/startwm.sh</code>：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo cp /etc/xrdp/startwm.sh /etc/xrdp/startwm.sh.bak</code></div></pre><p>将文件调整为：</p><pre><div class="code-header"><span class="code-header-type">sh</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div></div><code class="language-sh">#!/bin/shif test -r /etc/profile; then    . /etc/profilefiif test -r ~/.profile; then    . ~/.profilefiunset DBUS_SESSION_BUS_ADDRESSunset SESSION_MANAGERexec dbus-run-session -- /usr/local/bin/xrdp-xfce-session</code></div></pre><p>设置权限：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo chmod 755 /etc/xrdp/startwm.sh</code></div></pre><p>这里不再执行默认的 <code>/etc/X11/Xsession</code>，而是先读取系统和用户环境，再清除可能继承自其他图形会话的变量，最后在新的 D-Bus Session Bus 中启动专用包装脚本。</p><h3 id="3-4-创建-Xfce-与-Keyring-包装脚本">3.4 创建 Xfce 与 Keyring 包装脚本</h3><p>在普通本地 <code>Xfce</code> 中，可以通过桌面自启动运行 <code>gnome-keyring-daemon</code>。这也是 <a href="/2026/06/35524/" title="Homelab 搭建手记（4）开发工具配置">Homelab 搭建手记（4）开发工具配置</a> 中解决 <code>VS Code</code> 密钥管理问题时采用的方案。</p><p>但本文为 <code>RDP</code> 创建了独立的 D-Bus。原本运行在 <code>/run/user/UID/bus</code> 上的 Secret Service 不会自动出现在新的私有 bus 中，因此还需要在远程 bus 内启动一个独立的 <code>gnome-keyring-daemon</code>。</p><p>创建 <code>/usr/local/bin/xrdp-xfce-session</code>：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div><div class="line-numbers-item">21</div><div class="line-numbers-item">22</div><div class="line-numbers-item">23</div><div class="line-numbers-item">24</div></div><code class="language-bash">#!/bin/bashset -euo pipefailruntime_dir=&quot;$&#123;XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR is required for an XRDP session&#125;&quot;display_id=&quot;$&#123;DISPLAY#:&#125;&quot;display_id=&quot;$&#123;display_id//[^A-Za-z0-9_.-]/_&#125;&quot;if [ ! -d &quot;$runtime_dir&quot; ] || [ ! -w &quot;$runtime_dir&quot; ]; then    echo &quot;XRDP runtime directory is not writable: $runtime_dir&quot; &gt;&amp;2    exit 1fiumask 077keyring_dir=&quot;$(mktemp -d &quot;$runtime_dir/keyring-rdp-$&#123;display_id:-unknown&#125;-XXXXXX&quot;)&quot;export GNOME_KEYRING_CONTROL=&quot;$keyring_dir&quot;gnome-keyring-daemon \    --start \    --components=secrets \    --control-directory=&quot;$keyring_dir&quot; \    &gt;/dev/nullexec startxfce4</code></div></pre><p>设置权限：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo chmod 755 /usr/local/bin/xrdp-xfce-session</code></div></pre><p>包装脚本根据当前 Display 创建权限为 <code>700</code> 的临时 Keyring 控制目录。每次启动使用唯一目录，避免重新连接或多个远程会话相互删除仍在使用的控制目录。</p><p>最终启动链路为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-text">xrdp-sesman  ↓/etc/xrdp/startwm.sh  ├── 读取 /etc/profile 与 ~/.profile  ├── 清除已有图形会话环境  └── dbus-run-session        ├── 创建 RDP 私有 Session Bus        └── xrdp-xfce-session              ├── 创建独立 Keyring 控制目录              ├── 启动 Secret Service              └── 启动 Xfce</code></div></pre><h3 id="3-5-配置-Fcitx5">3.5 配置 Fcitx5</h3><p>由于新的 <code>startwm.sh</code> 绕过了 Debian <code>/etc/X11/Xsession</code>，不能再假设 <code>~/.xprofile</code> 会被自动读取。本文明确读取 <code>~/.profile</code>，所以将输入法环境变量写入该文件：</p><pre><div class="code-header"><span class="code-header-type">sh</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-sh"># Fcitx5 input methodexport GTK_IM_MODULE=fcitxexport QT_IM_MODULE=fcitxexport XMODIFIERS=@im=fcitxexport GLFW_IM_MODULE=ibus</code></div></pre><p>这里只负责设置环境，不要直接在 <code>.profile</code> 中启动 <code>fcitx5</code>。创建 <code>~/.config/autostart/fcitx5.desktop</code>，让 daemon 在当前 <code>Xfce</code> 会话中启动：</p><pre><div class="code-header"><span class="code-header-type">ini</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-ini">[Desktop Entry]Type=ApplicationName=Fcitx 5Comment=Start Fcitx 5 Input MethodExec=fcitx5 -dTerminal=falseHidden=falseX-GNOME-Autostart-enabled=true</code></div></pre><p>职责划分为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-text">~/.profile  └── 设置输入法环境变量Xfce Autostart  └── 在当前图形会话中启动 fcitx5 daemon</code></div></pre><p><code>im-config -m</code> 中出现 <code>Active configuration: missing (normally missing)</code> 不一定表示配置错误。在自动模式下，只要当前选择和自动选择均为 <code>fcitx5</code>，就无需为了消除 <code>missing</code> 强制生成 <code>~/.xinputrc</code>。</p><h3 id="3-6-检查-PAM-Keyring-配置">3.6 检查 PAM Keyring 配置</h3><p>检查 <code>/etc/pam.d/xrdp-sesman</code>：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">grep -n 'pam_gnome_keyring' /etc/pam.d/xrdp-sesman</code></div></pre><p>Debian 软件包通常已经提供类似配置：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">-auth    optional  pam_gnome_keyring.so-session optional  pam_gnome_keyring.so auto_start</code></div></pre><p>如果已经存在，不要重复添加。PAM 认证发生在 <code>startwm.sh</code> 创建私有 D-Bus 之前，因此 PAM 启动的 Keyring 仍不能代替包装脚本中服务于远程私有 bus 的 Secret Service。</p><h2 id="四、使用公开脚本自动配置">四、使用公开脚本自动配置</h2><h3 id="4-1-执行-XRDP-模块">4.1 执行 XRDP 模块</h3><p>手工配置有助于理解启动链路，实际部署时可以直接使用公开的 <a href="https://github.com/DoraTiger/homelab-setup" title="homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a>。克隆项目后执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-bash">git clone https://github.com/DoraTiger/homelab-setup.gitcd homelab-setupbash init.sh --silent 12-xrdp.sh</code></div></pre><p>对应实现位于：</p><ul><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/modules/12-xrdp.sh" title="XRDP 配置模块" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Fmodules%2F12-xrdp.sh"><code>modules/12-xrdp.sh</code></a>：安装依赖并编排配置流程；</li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/lib/xrdp-config.sh" title="XRDP 配置辅助函数" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Flib%2Fxrdp-config.sh"><code>lib/xrdp-config.sh</code></a>：生成和收敛配置的纯函数；</li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/tests/integration/test-xrdp-config.sh" title="XRDP 配置集成测试" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Ftests%2Fintegration%2Ftest-xrdp-config.sh"><code>tests/integration/test-xrdp-config.sh</code></a>：验证幂等性、D-Bus 隔离、Keyring 和 Fcitx5 环境。</li></ul><h3 id="4-2-脚本的收敛策略">4.2 脚本的收敛策略</h3><p>脚本按配置归属分别处理这些文件：</p><ul><li>只修改 <code>sesman.ini</code> 的目标键，保留其他 section 和用户配置；</li><li>已经具备正确私有 D-Bus 启动链的 <code>startwm.sh</code> 保留原内容，只校正权限；</li><li>修改系统文件前集中备份，不在 <code>/etc</code> 和用户目录中散落多个 <code>.bak</code>；</li><li>Fcitx5 环境已经完整时保持用户文件不变；</li><li>只迁移内容与旧版脚本完全一致的 <code>.xsession</code> 和已知旧 <code>polkit</code> 规则；</li><li>用户自行维护的同名文件只提示，不自动删除；</li><li>配置完成后不会主动重启正在运行的 <code>XRDP</code> 服务，避免中断现有会话。</li></ul><p>这部分设计主要解决“脚本能重复运行”和“不能覆盖用户已有配置”两个问题。相关集成测试会在临时目录中连续执行两次配置收敛，确保结果保持一致。</p><h3 id="4-3-重新建立远程会话">4.3 重新建立远程会话</h3><p>配置完成后，应在方便时正常注销当前远程 <code>Xfce</code>，然后重启服务：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo systemctl restart xrdp xrdp-sesman</code></div></pre><p>只关闭 RDP 客户端窗口通常是断开连接，不代表远程 session 已经注销。为了确保新的 <code>startwm.sh</code>、D-Bus 和 Keyring 启动链完整生效，首次验证时应从 <code>Xfce</code> 菜单正常注销后重新登录。</p><h2 id="五、验证会话隔离与桌面功能">五、验证会话隔离与桌面功能</h2><h3 id="5-1-验证-logind-session">5.1 验证 logind session</h3><p>保持本地桌面登录，再使用同一个用户建立 <code>RDP</code> 连接。查看整机 session：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">loginctl list-sessions</code></div></pre><p>然后分别检查本地和远程 session：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash">loginctl show-session LOCAL_SESSION_ID \  -p Name -p Type -p Class -p Remote -p Seat -p Display -p Stateloginctl show-session RDP_SESSION_ID \  -p Name -p Type -p Class -p Remote -p Seat -p Display -p State</code></div></pre><p>预期核心结果为：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">本地：Display=:0   Remote=no   State=activeRDP： Display=:10  Remote=yes  State=active</code></div></pre><p><code>loginctl list-sessions</code> 查询的是整台机器的 systemd-logind 数据库，所以在本地和远程终端看到相同列表属于正常现象。</p><h3 id="5-2-验证-DISPLAY-与-D-Bus">5.2 验证 DISPLAY 与 D-Bus</h3><p>分别在两个桌面中执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-bash">echo &quot;DISPLAY=$DISPLAY&quot;echo &quot;DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS&quot;echo &quot;XDG_SESSION_ID=$XDG_SESSION_ID&quot;</code></div></pre><p>典型结果如下：</p><table><thead><tr><th style="text-align:center">项目</th><th style="text-align:center">本地 Xfce</th><th style="text-align:center">RDP Xfce</th></tr></thead><tbody><tr><td style="text-align:center">用户与 UID</td><td style="text-align:center">相同</td><td style="text-align:center">相同</td></tr><tr><td style="text-align:center">DISPLAY</td><td style="text-align:center"><code>:0.0</code></td><td style="text-align:center"><code>:10.0</code></td></tr><tr><td style="text-align:center">Session ID</td><td style="text-align:center">本地 session</td><td style="text-align:center">RDP session</td></tr><tr><td style="text-align:center">Remote</td><td style="text-align:center"><code>no</code></td><td style="text-align:center"><code>yes</code></td></tr><tr><td style="text-align:center">D-Bus</td><td style="text-align:center"><code>/run/user/UID/bus</code></td><td style="text-align:center"><code>/tmp/dbus-*</code></td></tr><tr><td style="text-align:center"><code>$HOME</code></td><td style="text-align:center">共享</td><td style="text-align:center">共享</td></tr></tbody></table><p>重点不是 session 编号必须与示例一致，而是两个桌面的 Display、Session ID 和 D-Bus 地址不同，并且都处于 <code>active</code> 状态。</p><h3 id="5-3-验证中文输入">5.3 验证中文输入</h3><p>在远程桌面执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash">echo &quot;$GTK_IM_MODULE&quot;echo &quot;$QT_IM_MODULE&quot;echo &quot;$XMODIFIERS&quot;echo &quot;$GLFW_IM_MODULE&quot;pgrep -a fcitx5im-config -m</code></div></pre><p>然后分别在本地和远程打开一个 GTK 编辑器，使用 <code>Ctrl+Space</code> 切换并输入中文。只有环境变量正确但没有 <code>fcitx5</code> 进程时，应检查 <code>~/.config/autostart/fcitx5.desktop</code>，而不是继续修改 Locale。</p><h3 id="5-4-验证-Secret-Service">5.4 验证 Secret Service</h3><p>确认远程桌面仍使用私有 D-Bus：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">echo &quot;$DBUS_SESSION_BUS_ADDRESS&quot;busctl --user list | grep -E 'secret|keyring'</code></div></pre><p>正常情况下，<code>org.freedesktop.secrets</code> 应显示实际 owner 和 PID，而不只是 <code>(activatable)</code>。</p><p>使用 <code>secret-tool</code> 完成读写测试：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash">secret-tool store \  --label='XRDP Persistence Test' \  test persistencesecret-tool lookup test persistence</code></div></pre><p>正常注销并重新建立远程会话后，再次执行 <code>lookup</code>。如果仍能读取保存的内容，说明新的 Keyring daemon 已经从 <code>~/.local/share/keyrings/</code> 加载持久化数据。测试完成后清理：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">secret-tool clear test persistence</code></div></pre><p>最后启动 <code>VS Code</code>，确认不再提示 OS Keyring 不可用，<code>GitHub Copilot</code> OAuth 可以调用当前远程会话中的浏览器，并且重新登录 <code>RDP</code> 后凭据仍然存在。</p><h2 id="六、问题排查">六、问题排查</h2><h3 id="6-1-按启动层级检查日志">6.1 按启动层级检查日志</h3><p>如果输入密码后仍然黑屏退出，不要同时修改多个参数。先确认软件版本、<code>sesman.ini</code> 和实际启动脚本：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">cat /etc/debian_versiondpkg -l | grep -E 'xrdp|xorgxrdp|xfce4-session'cat /etc/xrdp/startwm.shgrep -v '^[[:space:]]*[#;]' /etc/xrdp/sesman.ini</code></div></pre><p>然后查看 <code>XRDP</code> 日志：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo tail -200 /var/log/xrdp.logsudo tail -200 /var/log/xrdp-sesman.log</code></div></pre><p>如果认证成功、远程 <code>Xorg</code> 已经启动并运行到 <code>xfce4-session</code>，继续排查桌面与 D-Bus：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-bash">sudo journalctl -b --no-pager \  | grep -Ei 'xrdp|xfce|dbus|session|pam_systemd' \  | tail -250grep -RiE 'error|failed|already|session|dbus|display' \  ~/.xsession-errors* ~/.local/share/xrdp/ 2&gt;/dev/null \  | tail -200</code></div></pre><p>最后检查进程和会话：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">ps -u &quot;$USER&quot; -f | grep -E 'xfce|dbus|keyring|fcitx|xrdp|Xorg'loginctl list-sessions</code></div></pre><h3 id="6-2-几个容易误判的方向">6.2 几个容易误判的方向</h3><p><strong>只反复修改 <code>~/.xsession</code></strong>：它只负责选择桌面，不能隔离第二个 <code>Xfce</code> session。</p><p><strong>把显卡警告直接当成根因</strong>：日志中可能出现 <code>unsupported render node</code>、<code>amdgpu_device_initialize failed</code> 等警告。如果远程 <code>Xorg</code> 已经建立并运行到 <code>xfce4-session</code>，这些警告通常不是本次退出的直接原因。</p><p><strong>让 RDP 强制复用 <code>/run/user/UID/bus</code></strong>：这样可能暂时让应用找到本地 Keyring，却会破坏远程桌面的 D-Bus 隔离。实测中会进一步影响浏览器和 OAuth 调用。</p><p><strong>为了 <code>im-config</code> 的 <code>missing</code> 强制生成配置</strong>：应先确认自动选择是否已经是 <code>fcitx5</code>，不要只根据一个状态词判断输入法故障。</p><p><strong>直接清理整个 Keyring 目录</strong>：多个会话可能仍在使用各自的控制目录。当前脚本使用 <code>mktemp</code> 创建唯一目录，不会为了新会话删除旧会话的目录。</p><h2 id="七、适用边界">七、适用边界</h2><h3 id="7-1-两个桌面仍然共享-HOME">7.1 两个桌面仍然共享 <code>$HOME</code></h3><p>隔离 X11、D-Bus 和 Keyring 控制目录，并不等于把整个用户运行环境完全隔离。两个桌面仍然共享：</p><ul><li>用户文件和配置；</li><li>浏览器 profile；</li><li><code>~/.local/share/keyrings/</code> 中的持久化数据；</li><li>使用文件锁或单实例机制的应用状态。</li></ul><p>因此 Firefox、Chromium 等应用不适合在两个桌面中同时使用同一个 profile，某些托盘程序和单实例 GUI 软件也可能发生冲突。对于终端、IDE、文件管理和一般服务器运维，这种模式基本够用。</p><h3 id="7-2-会话保留与资源占用">7.2 会话保留与资源占用</h3><p>本文配置会保留断开的远程 session，并关闭空闲超时。这样可以在网络中断后重新连接原桌面，但也意味着长期不注销的 session 会持续占用内存和进程资源。完成工作后应从 <code>Xfce</code> 菜单正常注销，不要只关闭 RDP 客户端。</p><p>如果服务器需要严格的资源回收策略，应根据实际使用方式调整 <code>KillDisconnected</code>、<code>DisconnectedTimeLimit</code> 和 <code>IdleTimeLimit</code>，而不是直接沿用本文的长期保留配置。</p><h2 id="八、总结">八、总结</h2><p>同一个用户无法同时运行本地和远程 <code>Xfce</code>，表面上表现为 <code>RDP</code> 黑屏退出，实际根因并不在密码、端口或 Display 编号，而是第二个桌面没有形成完整的会话隔离。</p><p>最终方案的核心是：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-text">本地 Xfce :0  └── systemd User Bus + 本地 KeyringRDP Xfce :10  └── dbus-run-session        ├── 私有 D-Bus Session Bus        ├── RDP 专用 Secret Service        ├── Fcitx5        └── Xfce</code></div></pre><p>调整后，本地与远程桌面可以同时保持 <code>active</code>，远程桌面中的中文输入、<code>VS Code</code> Keyring 和 <code>Copilot</code> 登录也能够正常工作，可以保留本地自动登录了。</p><p>相关实现已经进入公开的 <code>homelab-setup</code>，手工配置用于解释工作原理，日常部署则由脚本负责幂等收敛、备份和验证。后续如果 <code>xrdp</code> 或桌面环境版本发生变化，仍应以完整启动链和实际日志为依据重新验证，而不是只依赖某一条历史配置。</p><h2 id="参考">参考</h2><ul><li><a href="https://github.com/DoraTiger/homelab-setup" title="homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a></li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/modules/12-xrdp.sh" title="XRDP 配置模块" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Fmodules%2F12-xrdp.sh">XRDP 配置模块</a></li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/lib/xrdp-config.sh" title="XRDP 配置辅助函数" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Flib%2Fxrdp-config.sh">XRDP 配置辅助函数</a></li><li><a href="https://github.com/DoraTiger/homelab-setup/blob/main/tests/integration/test-xrdp-config.sh" title="XRDP 配置集成测试" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup%2Fblob%2Fmain%2Ftests%2Fintegration%2Ftest-xrdp-config.sh">XRDP 配置集成测试</a></li><li><a href="https://github.com/neutrinolabs/xrdp" title="xrdp 官方项目" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fneutrinolabs%2Fxrdp">xrdp 官方项目</a></li><li><a href="https://dbus.freedesktop.org/doc/dbus-run-session.1.html" title="dbus-run-session 文档" class="external-link" data-redirect="https%3A%2F%2Fdbus.freedesktop.org%2Fdoc%2Fdbus-run-session.1.html">dbus-run-session 文档</a></li><li><a href="https://gitlab.gnome.org/GNOME/gnome-keyring" title="GNOME Keyring 项目" class="external-link" data-redirect="https%3A%2F%2Fgitlab.gnome.org%2FGNOME%2Fgnome-keyring">GNOME Keyring 项目</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;在之前的 &lt;a href=&quot;/2026/06/35187/&quot; title=&quot;Homelab 搭建手记（2）更换无线网卡与 XRDP WiFi 扫描授权问题&quot;&gt;Homelab 搭建手记（2）更换无线网卡与 XRDP WiFi 扫描授权问题&lt;/a&gt; 中，记录过 &lt;code&gt;XRDP&lt;/code&gt; 环境下的 &lt;code&gt;WiFi&lt;/code&gt; 扫描授权问题。当时发现本地自动登录的 &lt;code&gt;Xfce&lt;/code&gt; 和远程 &lt;code&gt;Xfce&lt;/code&gt; 同时运行时容易出现 &lt;code&gt;D-Bus&lt;/code&gt;、&lt;code&gt;polkit&lt;/code&gt; 等额外冲突，所以给出的阶段性建议是关闭本地自动登录，只保留单一的 &lt;code&gt;XRDP&lt;/code&gt; 图形会话。&lt;/p&gt;
&lt;p&gt;不过实际使用中，偶尔还是需要让物理显示器上的本地桌面保持登录，同时从其他设备通过 &lt;code&gt;RDP&lt;/code&gt; 进入同一个用户的独立桌面。经过日志排查和反复测试，发现第二个 &lt;code&gt;Xfce&lt;/code&gt; 会话复用了已有图形会话的环境，端口、密码认证和 &lt;code&gt;Xorg&lt;/code&gt; 显示编号都没有问题。&lt;/p&gt;
&lt;p&gt;本文记录如何在 &lt;code&gt;Debian 13 + Xfce + XRDP&lt;/code&gt; 环境中，让同一个 &lt;code&gt;Linux&lt;/code&gt; 用户同时保持本地与远程两个桌面，并进一步补齐 &lt;code&gt;Fcitx5&lt;/code&gt; 中文输入和 &lt;code&gt;GNOME Keyring&lt;/code&gt;，使 &lt;code&gt;VS Code&lt;/code&gt;、&lt;code&gt;GitHub Copilot&lt;/code&gt; 等依赖系统密钥环的应用也能在远程桌面中正常使用。相关配置已经整合进公开的 &lt;a href=&quot;https://github.com/DoraTiger/homelab-setup&quot; title=&quot;homelab-setup&quot; class=&quot;external-link&quot; data-redirect=&quot;https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup&quot;&gt;homelab-setup&lt;/a&gt; 项目，可以通过脚本自动完成。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="XRDP" scheme="https://www.superheaoz.top/tags/XRDP/"/>
    
    <category term="Xfce" scheme="https://www.superheaoz.top/tags/Xfce/"/>
    
    <category term="Fcitx5" scheme="https://www.superheaoz.top/tags/Fcitx5/"/>
    
    <category term="GNOME Keyring" scheme="https://www.superheaoz.top/tags/GNOME-Keyring/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（5）Obsidian 与 Zotero Linux 部署</title>
    <link href="https://www.superheaoz.top/2026/06/38032/"/>
    <id>https://www.superheaoz.top/2026/06/38032/</id>
    <published>2026-06-21T01:44:17.000Z</published>
    <updated>2026-06-21T02:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>知识管理是 <code>Homelab</code> 的核心用途之一。<code>Obsidian</code> 是基于 <code>Markdown</code> 的笔记工具，之前在 <a href="/2022/06/57091/" title="Obsidan 日记、记账与自动同步">Obsidan 日记、记账与自动同步</a> 中记录了 <code>Windows</code> 下的完整配置，包括 <code>Templater</code> 模板、<code>Dataview</code> 数据查询、<code>Remotely Save</code> 同步等。<code>Zotero</code> 则是文献管理工具，支持论文收集、标注和引用，是学术工作流的核心组件。两个工具配合使用，前者管理日常笔记和知识图谱，后者管理文献和引用，形成完整的个人知识管理体系。</p><p>现在需要在 <code>Debian</code> 服务器上也部署这两个工具，为后续与本地大模型、<code>RAGFlow</code> 等智能体服务对接打下基础。本文聚焦于 <code>Linux</code> 下的官方安装方式和基础 <code>CLI</code> 使用，插件配置和模板设置不再赘述。</p><span id="more"></span><h2 id="一、Obsidian-安装">一、Obsidian 安装</h2><h3 id="1-1-安装方式">1.1 安装方式</h3><p><code>Obsidian</code> 官方提供 <code>AppImage</code>、<code>Snap</code>、<code>Deb</code>、<code>Flatpak</code> 四种 <code>Linux</code> 安装方式。考虑到 <code>Debian</code> 系统的兼容性和包管理的一致性，选择官方提供的 <code>Deb</code> 包进行安装。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash"># 下载官方 Deb 包wget https://github.com/obsidianmd/obsidian-releases/releases/download/v1.12.7/obsidian_1.12.7_amd64.deb# 安装sudo dpkg -i obsidian_1.12.7_amd64.deb# 修复依赖（如果有缺失）sudo apt install -f</code></div></pre><p><strong>注意：版本号可能会更新，安装前请前往 <a href="https://obsidian.md/download" title="Obsidian 下载页面" class="external-link" data-redirect="https%3A%2F%2Fobsidian.md%2Fdownload">Obsidian 下载页面</a> 确认最新版本。</strong></p><h3 id="1-2-CLI-激活">1.2 CLI 激活</h3><p><code>Obsidian</code> 的 <code>CLI</code> 默认未启用。首次安装后需要在 <code>Obsidian</code> 图形界面中激活：<code>设置</code> → <code>通用</code> → <code>命令行接口</code>，点击启用并按照提示将 <code>CLI</code> 链接到系统 <code>PATH</code>（<code>Linux</code> 下会链接到 <code>~/.local/bin/obsidian</code>）。</p><p><strong>注意：<code>CLI</code> 启用后需要重启终端才能生效。</strong></p><h3 id="1-3-基础-CLI-使用">1.3 基础 CLI 使用</h3><p><code>Obsidian CLI</code> 是一个功能完整的命令行工具，支持子命令模式操作。在 <code>SSH</code> 远程场景下尤其有用，可以直接在终端中管理笔记库，无需依赖图形界面。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div><div class="line-numbers-item">21</div><div class="line-numbers-item">22</div><div class="line-numbers-item">23</div><div class="line-numbers-item">24</div><div class="line-numbers-item">25</div><div class="line-numbers-item">26</div></div><code class="language-bash"># 查看版本obsidian version# 列出已知的 vaultobsidian vaults# 查看当前 vault 信息obsidian vault# 打开今天的日记obsidian daily# 搜索笔记obsidian search query=&quot;关键词&quot;# 创建新笔记obsidian create name=&quot;笔记标题&quot; content=&quot;初始内容&quot;# 读取当前文件obsidian read# 列出所有标签及使用次数obsidian tags counts# 在日记末尾追加内容obsidian daily:append content=&quot;- [ ] 待办事项&quot;</code></div></pre><p><code>Obsidian CLI</code> 还支持插件管理（<code>obsidian plugins</code>）、主题管理（<code>obsidian themes</code>）、文件历史（<code>obsidian history</code>）等功能，完整命令列表可通过 <code>obsidian help</code> 查看。在远程桌面或 <code>SSH</code> 场景下，<code>CLI</code> 方式比图形界面启动更灵活，也可以配合 <code>cron</code> 或 <code>systemd</code> 实现自动化笔记操作。</p><h3 id="1-4-Vault-同步方案">1.4 Vault 同步方案</h3><p>在 <code>Homelab</code> 场景下，<code>Obsidian</code> 的笔记同步可以通过以下方式实现：</p><table><thead><tr><th style="text-align:center">方案</th><th style="text-align:left">原理</th><th style="text-align:left">优点</th><th style="text-align:left">缺点</th></tr></thead><tbody><tr><td style="text-align:center"><code>Syncthing</code></td><td style="text-align:left">去中心化点对点同步</td><td style="text-align:left">无第三方依赖、速度快</td><td style="text-align:left">需要两端同时在线</td></tr><tr><td style="text-align:center"><code>Git</code></td><td style="text-align:left">版本控制 + 云端同步</td><td style="text-align:left">有版本历史、可回溯</td><td style="text-align:left">二进制附件不友好</td></tr><tr><td style="text-align:center"><code>Remotely Save</code> 插件</td><td style="text-align:left"><code>WebDAV</code> / <code>S3</code> / <code>iCloud</code></td><td style="text-align:left">跨平台、手机端可用</td><td style="text-align:left">依赖第三方存储</td></tr><tr><td style="text-align:center"><code>Obsidian Sync</code></td><td style="text-align:left">官方付费服务</td><td style="text-align:left">端到端加密、最稳定</td><td style="text-align:left">收费</td></tr></tbody></table><p>原文中使用的 <code>Remotely Save</code> 插件基于坚果云 <code>WebDAV</code> 的方案在 <code>Linux</code> 上同样适用。如果追求去中心化和更快的同步速度，推荐后续部署 <code>Syncthing</code>。</p><h2 id="二、Zotero-安装">二、Zotero 安装</h2><h3 id="2-1-安装方式">2.1 安装方式</h3><p><code>Zotero</code> 官方 <code>Linux</code> 安装方式为下载编译好的 <code>tarball</code>。官方不提供 <code>.deb</code> 包，社区维护的 <code>zotero-deb</code> 虽然推荐用于 <code>Debian</code> 系统，但不在官方支持范围内。按照 <a href="https://www.zotero.org/support/installation" title="Zotero 安装文档" class="external-link" data-redirect="https%3A%2F%2Fwww.zotero.org%2Fsupport%2Finstallation">官方安装文档</a> 指引，选择 <code>Official Tarball</code> 方式安装。</p><p>与 <code>Obsidian</code> 不同，<code>Zotero</code> 的 <code>tarball</code> 安装不需要 <code>sudo</code> 权限。安装到用户目录 <code>~/.local/opt/zotero</code> 即可，和 <code>Node.js</code>（<code>fnm</code>）、<code>Go</code> 等用户级工具保持一致的安装模式。</p><ol><li><p>前往 <a href="https://www.zotero.org/download/" title="Zotero 下载页面" class="external-link" data-redirect="https%3A%2F%2Fwww.zotero.org%2Fdownload%2F">Zotero 下载页面</a> 下载 <code>Linux 64-bit</code> 版本（当前为 <code>Zotero 9</code>）。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash"># 下载 Zotero tarball（当前格式为 .tar.xz，版本号以实际页面为准）wget https://download.zotero.org/client/release/9.0.4/Zotero-9.0.4_linux-x86_64.tar.xz</code></div></pre></li><li><p>解压并将目录移动到用户目录。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash"># 解压tar -xJf Zotero-9.0.4_linux-x86_64.tar.xz# 移动到用户目录（无需 sudo）mv Zotero_linux_x86_64 ~/.local/opt/zotero</code></div></pre></li><li><p>运行 <code>set_launcher_icon</code> 脚本更新 <code>.desktop</code> 文件中的图标路径（<code>.desktop</code> 文件要求绝对路径）。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">cd ~/.local/opt/zotero./set_launcher_icon</code></div></pre></li><li><p>创建符号链接到 <code>~/.local/share/applications/</code>，使其出现在应用启动器中。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">ln -s ~/.local/opt/zotero/zotero.desktop ~/.local/share/applications/zotero.desktop</code></div></pre></li><li><p>启动 Zotero。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">~/.local/opt/zotero/zotero</code></div></pre></li></ol><p><strong>注意：Zotero 更新后可能需要重新运行 <code>set_launcher_icon</code> 脚本。如果启动器中图标消失，删除旧的符号链接、等待几秒后重建即可。</strong></p><h3 id="2-2-Zotero-Connector-配置">2.2 Zotero Connector 配置</h3><p>在浏览器中安装 <code>Zotero Connector</code> 扩展后，可以实现一键保存网页文献到本地 <code>Zotero</code> 库。在 <code>Homelab</code> 服务器上，如果通过 <code>XRDP</code> 远程桌面使用浏览器，<code>Connector</code> 同样可以正常工作。配置步骤：</p><ol><li>在浏览器扩展商店搜索 <code>Zotero Connector</code> 并安装。</li><li>首次使用时，<code>Connector</code> 会自动检测 <code>Zotero</code> 客户端运行状态。</li><li>在 <code>Zotero</code> 设置中配置数据同步和文件同步（可选使用 <code>WebDAV</code> 自建同步服务）。</li></ol><h3 id="2-3-Zotero-数据目录">2.3 Zotero 数据目录</h3><p><code>Zotero</code> 默认将数据存储在用户目录下，建议将数据目录迁移到 <code>/home</code> 分区的独立位置，方便备份和管理：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-bash"># 修改 Zotero 数据目录（在 Zotero 编辑 - 首选项 - 高级 - 文件和文件夹中设置）# 默认路径：~/Zotero# 建议路径：~/data/zotero</code></div></pre><h2 id="三、自动化安装脚本">三、自动化安装脚本</h2><p>上述手动安装步骤已整理为 <code>Homelab</code> 环境配置工具的模块脚本，支持一键安装和幂等性升级。这两个模块是 <code>setup/</code> 仓库（<a href="https://github.com/DoraTiger/homelab-setup" title="Homelab 环境配置工具仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a>）在<a href="/2026/06/3470/" title="Homelab 搭建手记（3）开发环境一键配置">Homelab 搭建手记（3）开发环境一键配置</a>发布后新增的，编号为 <code>14-obsidian</code> 和 <code>15-zotero</code>。</p><h3 id="3-1-使用方式">3.1 使用方式</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash"># 通过 init.sh 执行（交互式菜单，会自动扫描 modules/ 下所有脚本）cd setup &amp;&amp; bash init.sh# 直接执行单个模块bash setup/modules/14-obsidian.shbash setup/modules/15-zotero.sh</code></div></pre><h3 id="3-2-脚本特性">3.2 脚本特性</h3><table><thead><tr><th style="text-align:left">特性</th><th style="text-align:left">Obsidian</th><th style="text-align:left">Zotero</th></tr></thead><tbody><tr><td style="text-align:left">编号</td><td style="text-align:left"><code>14-obsidian</code></td><td style="text-align:left"><code>15-zotero</code></td></tr><tr><td style="text-align:left">安装方式</td><td style="text-align:left">官方 <code>Deb</code> 包</td><td style="text-align:left">官方 <code>Tarball</code></td></tr><tr><td style="text-align:left">安装路径</td><td style="text-align:left">系统级（<code>dpkg</code>）</td><td style="text-align:left">用户级 <code>~/.local/opt/zotero</code></td></tr><tr><td style="text-align:left">需要 sudo</td><td style="text-align:left">是</td><td style="text-align:left">否</td></tr><tr><td style="text-align:left">版本检测</td><td style="text-align:left"><code>GitHub API</code></td><td style="text-align:left">下载页面 JSON</td></tr><tr><td style="text-align:left">自动升级</td><td style="text-align:left">版本不同则下载新 <code>Deb</code> 安装</td><td style="text-align:left">版本不同则下载新 <code>Tarball</code> 替换</td></tr><tr><td style="text-align:left">缓存</td><td style="text-align:left"><code>cache/obsidian/</code></td><td style="text-align:left"><code>cache/zotero/</code></td></tr><tr><td style="text-align:left">桌面快捷方式</td><td style="text-align:left">系统自动管理</td><td style="text-align:left"><code>set_launcher_icon</code> + 符号链接</td></tr></tbody></table><h3 id="3-3-脚本实现要点">3.3 脚本实现要点</h3><p>两个脚本都遵循 <code>setup/</code> 仓库的模块规范（详见<a href="/2026/06/3470/" title="Homelab 搭建手记（3）开发环境一键配置">Homelab 搭建手记（3）开发环境一键配置</a>）：</p><ul><li>公共函数统一使用 <code>common.sh</code> 中的 <code>ensure_sudo</code>、<code>log_info</code>、<code>run_with_optional_proxy</code> 等</li><li>幂等性检查：已安装且版本一致则跳过，版本不同则自动升级</li><li>安装包缓存在 <code>$CACHE_DIR/&lt;tool&gt;/</code>，重复执行不重复下载</li><li><code>Zotero</code> 特殊处理：下载页面是 <code>JS</code> 动态渲染，版本号从页面内嵌的 <code>JSON</code> 数据中提取；下载链接通过重定向获取实际 <code>tar.xz</code> 地址（非 <code>tar.bz2</code>）；安装到用户目录无需 <code>sudo</code></li></ul><h2 id="四、总结">四、总结</h2><p>本文记录了 <code>Obsidian</code> 和 <code>Zotero</code> 在 <code>Debian</code> 系统上的官方安装方式。两者都是知识管理的核心工具，<code>Obsidian</code> 专注于笔记和知识图谱，<code>Zotero</code> 专注于文献管理和引用。后续文章会继续搭建 <code>Syncthing</code> 同步方案，将 <code>Windows</code> 端和 <code>Debian</code> 端的笔记库打通。</p><h2 id="参考">参考</h2><ul><li><a href="https://obsidian.md/download" title="Obsidian 下载页面" class="external-link" data-redirect="https%3A%2F%2Fobsidian.md%2Fdownload">Obsidian 下载页面</a></li><li><a href="https://obsidian.md/cli" title="Obsidian CLI 文档" class="external-link" data-redirect="https%3A%2F%2Fobsidian.md%2Fcli">Obsidian CLI 文档</a></li><li><a href="https://www.zotero.org/support/installation" title="Zotero 安装文档" class="external-link" data-redirect="https%3A%2F%2Fwww.zotero.org%2Fsupport%2Finstallation">Zotero 安装文档</a></li><li><a href="https://www.zotero.org/download/" title="Zotero 下载页面" class="external-link" data-redirect="https%3A%2F%2Fwww.zotero.org%2Fdownload%2F">Zotero 下载页面</a></li><li><a href="https://github.com/DoraTiger/homelab-setup" title="Homelab 环境配置工具仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">Homelab Setup 仓库</a></li><li><a href="/2022/06/57091/" title="Obsidan 日记、记账与自动同步">Obsidan 日记、记账与自动同步</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;知识管理是 &lt;code&gt;Homelab&lt;/code&gt; 的核心用途之一。&lt;code&gt;Obsidian&lt;/code&gt; 是基于 &lt;code&gt;Markdown&lt;/code&gt; 的笔记工具，之前在 &lt;a href=&quot;/2022/06/57091/&quot; title=&quot;Obsidan 日记、记账与自动同步&quot;&gt;Obsidan 日记、记账与自动同步&lt;/a&gt; 中记录了 &lt;code&gt;Windows&lt;/code&gt; 下的完整配置，包括 &lt;code&gt;Templater&lt;/code&gt; 模板、&lt;code&gt;Dataview&lt;/code&gt; 数据查询、&lt;code&gt;Remotely Save&lt;/code&gt; 同步等。&lt;code&gt;Zotero&lt;/code&gt; 则是文献管理工具，支持论文收集、标注和引用，是学术工作流的核心组件。两个工具配合使用，前者管理日常笔记和知识图谱，后者管理文献和引用，形成完整的个人知识管理体系。&lt;/p&gt;
&lt;p&gt;现在需要在 &lt;code&gt;Debian&lt;/code&gt; 服务器上也部署这两个工具，为后续与本地大模型、&lt;code&gt;RAGFlow&lt;/code&gt; 等智能体服务对接打下基础。本文聚焦于 &lt;code&gt;Linux&lt;/code&gt; 下的官方安装方式和基础 &lt;code&gt;CLI&lt;/code&gt; 使用，插件配置和模板设置不再赘述。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Obsidian" scheme="https://www.superheaoz.top/tags/Obsidian/"/>
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="Zotero" scheme="https://www.superheaoz.top/tags/Zotero/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（4）开发工具配置</title>
    <link href="https://www.superheaoz.top/2026/06/35524/"/>
    <id>https://www.superheaoz.top/2026/06/35524/</id>
    <published>2026-06-19T09:00:00.000Z</published>
    <updated>2026-06-21T14:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>前几篇记录了硬件选购、系统安装和开发环境配置。环境搭好后，下一步是配置日常开发工具。本篇聚焦日常开发的核心工具链：<code>VS Code</code>（远程开发 IDE）、<code>Claude Code</code> 和 <code>MiMo Code</code>（AI 编程助手），以及 <code>Zellij</code>（终端复用器）。特别是 <code>Zellij</code> 的会话持久化能力，让 <code>TUI</code> 工具断连后依然保持运行。</p><span id="more"></span><ul><li>20260621：新增「五、Git 管理工具」，介绍 <code>GitHub CLI</code>（gh）和 <code>Gitea CLI</code>（tea-cli）的安装与登录配置。</li></ul><h2 id="一、VS-Code-安装与配置">一、VS Code 安装与配置</h2><h3 id="1-1-安装">1.1 安装</h3><p>从官网下载 <code>.deb</code> 包，本地安装：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash"># 下载（或从官网下载后上传到服务器）wget -O /tmp/vscode.deb &quot;https://code.visualstudio.com/sha/download?build=stable&amp;os=linux-deb-x64&quot;# 安装sudo dpkg -i /tmp/vscode.debsudo apt-get install -f  # 修复依赖</code></div></pre><h3 id="1-2-XFCE-环境下的密钥管理问题">1.2 XFCE 环境下的密钥管理问题</h3><p><code>XFCE</code> 桌面默认不启动 <code>gnome-keyring</code> 守护进程，导致 <code>VS Code</code> 等应用无法正常访问系统密钥环，无法登录账号和同步配置。需要手动启用 <code>gnome-keyring</code> 自启动。</p><h3 id="1-3-配置-gnome-keyring-自启动">1.3 配置 gnome-keyring 自启动</h3><p>关键一步 — <code>gnome-keyring</code> 的自动启动配置文件默认只对 <code>GNOME</code> 和 <code>Unity</code> 桌面生效，需要把 <code>XFCE</code> 也加进去：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo nano /etc/xdg/autostart/gnome-keyring-pkcs11.desktop</code></div></pre><p>找到 <code>OnlyShowIn=</code> 这一行，在末尾加上 <code>XFCE;</code>（注意用分号隔开）：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">OnlyShowIn=GNOME;Unity;XFCE;</code></div></pre><h3 id="1-4-VS-Code-远程开发">1.4 VS Code 远程开发</h3><p>配置好密钥管理后，<code>VS Code</code> 可以正常登录账号、同步配置。<code>XFCE</code> 桌面环境下有两种方式启用远程隧道：</p><p><strong>方式一：GUI 启用（推荐）</strong></p><p>在 <code>VS Code</code> 的账号菜单中选择 <code>Turn on Remote Tunnel Access</code>，登录 <code>GitHub</code> 或微软账号后自动启动隧道。国内环境下微软账号更稳定，推荐优先使用。启用后保持 <code>VS Code</code> 运行即可，从其他设备的浏览器或 <code>VS Code</code> 打开 <code>vscode.dev</code> 隧道 URL 即可远程连接。</p><p><strong>方式二：CLI 启动</strong></p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">code tunnel</code></div></pre><p>适用于没有桌面环境或需要在后台持续运行的场景。命令会自动下载并启动 <code>VS Code Server</code>，生成 <code>vscode.dev</code> 隧道 URL，其他设备直接通过浏览器或 <code>VS Code</code> 连接即可。</p><h2 id="二、Claude-Code">二、Claude Code</h2><h3 id="2-1-安装">2.1 安装</h3><p><code>Claude Code</code> 已切换到原生安装方式，不再依赖 <code>Node.js</code>：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash"># Linux/macOS 原生安装（推荐）curl -fsSL https://claude.ai/install.sh | bash# 或通过 claude 命令安装claude install</code></div></pre><p>验证安装：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">claude --version</code></div></pre><h3 id="2-2-API-配置">2.2 API 配置</h3><p><code>Claude Code</code> 支持多种 API 提供商，通过 <code>~/.claude/settings.json</code> 配置：</p><pre><div class="code-header"><span class="code-header-type">json</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-json">&#123;  &quot;env&quot;: &#123;    &quot;ANTHROPIC_BASE_URL&quot;: &quot;https://api.anthropic.com&quot;,    &quot;ANTHROPIC_AUTH_TOKEN&quot;: &quot;your-api-key&quot;,    &quot;ANTHROPIC_MODEL&quot;: &quot;claude-sonnet-4-20250514&quot;  &#125;&#125;</code></div></pre><p>也可以使用第三方兼容 API（<code>DeepSeek</code>、<code>MiMo</code> 等），只需修改 <code>BASE_URL</code> 和 <code>AUTH_TOKEN</code>。配置完成后重启终端生效。</p><h3 id="2-3-推荐插件">2.3 推荐插件</h3><p>安装 <code>superpower</code> 插件可以显著增强 <code>Claude Code</code> 的能力：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">claude install-skill superpower</code></div></pre><p>这个插件提供了代码审查、测试生成、重构建议等高级功能。</p><h2 id="三、MiMo-Code">三、MiMo Code</h2><h3 id="3-1-MiMo-Code-是什么">3.1 MiMo Code 是什么</h3><p><code>MiMo Code</code> 是小米 <code>MiMo</code> 团队基于 <code>OpenCode</code> 构建的<strong>独立开源</strong>终端 <code>AI</code> 编程助手（<code>MIT</code> 协议），支持代码读写、命令执行、<code>Git</code> 管理等核心功能。可以接入 <code>MiMo V2.5</code>、<code>DeepSeek</code> 等多种模型。</p><h3 id="3-2-安装">3.2 安装</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash"># Mac/Linux（推荐）curl -fsSL https://mimo.xiaomi.com/install | bash# Windowsnpm install -g @mimo-ai/cli</code></div></pre><p>安装完成后运行 <code>mimo</code> 启动。</p><h3 id="3-3-连接模型">3.3 连接模型</h3><p>首次运行自动引导配置，或使用 <code>/connect</code> 命令连接模型提供商：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-bash">mimo# 首次启动后执行/connect</code></div></pre><p>支持接入多种模型：</p><ul><li><code>MiMo V2.5</code>（免费，多模态，可识图看视频）</li><li><code>DeepSeek</code> 等第三方模型</li><li>登录后可使用更快的付费模型</li></ul><h3 id="3-4-使用建议">3.4 使用建议</h3><ul><li><strong>推荐常开 <code>compose</code> 模式</strong>：适合多步骤的复杂任务，可以自动拆分和执行</li><li><strong>免费 <code>MiMo V2.5</code></strong>：适合日常编程，速度较慢但零成本</li><li><strong>付费模型</strong>：登录后使用，适合对响应速度有要求的场景</li></ul><h3 id="3-5-推广码">3.5 推广码</h3><p>如果你也想体验 <code>MiMo</code>，可以使用我的邀请码注册，双方各得 ¥10 <code>API</code> 体验金 + 首单 9 折：</p><p>邀请码：<strong>67VNVN</strong></p><p>注册地址：<a href="https://platform.xiaomimimo.com?ref=67VNVN" class="external-link" data-redirect="https%3A%2F%2Fplatform.xiaomimimo.com%3Fref%3D67VNVN">https://platform.xiaomimimo.com?ref=67VNVN</a></p><p>（注册后自动填入，体验金 40 天有效）</p><img src="https://cdn.superheaoz.top/images/2026/06/35524/67VNVN.png" data-cdn-image-fallback-src="/2026/06/35524/67VNVN.png" class="" title="MiMo 推广码"><h2 id="四、Zellij-终端复用器">四、Zellij 终端复用器</h2><h3 id="4-1-为什么选-Zellij">4.1 为什么选 Zellij</h3><p><code>Zellij</code> 是一个现代的终端复用器，类似 <code>tmux</code>/<code>screen</code>，但设计更现代化。我选择它的核心原因是<strong>保证 TUI 工具的连续开发</strong>：</p><ul><li><code>Claude Code</code> 和 <code>MiMo Code</code> 都是终端 <code>TUI</code> 工具，断开连接后进程会终止</li><li>在 <code>Zellij</code> 会话中运行它们，断开 <code>SSH</code> 后会话和里面的 <code>TUI</code> 继续在后台运行</li><li>重新连接后 <code>zja</code> 附加回去，<code>Claude Code</code>/<code>MiMo Code</code> 的上下文、文件状态全部保持</li></ul><p>没有 <code>Zellij</code>，每次重连都要重新启动 <code>AI</code> 工具、重新加载上下文，效率很低。</p><h3 id="4-2-安装与配置">4.2 安装与配置</h3><p><code>Zellij</code> 已在搭建手记（3）中通过 <code>setup</code> 脚本安装，配置文件在 <code>~/.config/zellij/config.kdl</code>：</p><pre><div class="code-header"><span class="code-header-type">kdl</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-kdl">default_shell &quot;bash&quot;theme &quot;dracula&quot;</code></div></pre><h3 id="4-3-别名配置">4.3 别名配置</h3><p>为了快速操作，配置了几个常用别名：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash">alias zj=&quot;zellij&quot;                    # 快速启动alias zjl=&quot;zellij list-sessions&quot;     # 列出所有会话alias zja=&quot;zellij attach&quot;            # 附加到已有会话alias zjn=&quot;zellij -s&quot;                # 新建命名会话</code></div></pre><h3 id="4-4-我的会话管理实践">4.4 我的会话管理实践</h3><p>我为不同的工作场景创建了固定的命名会话：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash"># 创建会话（首次使用）zjn mimo          # MiMo Code 编程环境zjn claude        # Claude Code 编程环境zjn homelab       # Homelab 管理环境# 从其他设备连接zja mimo          # 附加到 MiMo 会话zja claude        # 附加到 Claude 会话</code></div></pre><p>每次使用 <code>zja mimo</code> 就能立即回到上次的工作状态 — 打开的文件、运行的进程、终端布局全部保持不变。</p><h3 id="4-5-Zellij-的多窗口调度">4.5 Zellij 的多窗口调度</h3><p><code>Zellij</code> 的窗口管理比 <code>tmux</code> 更直观：</p><table><thead><tr><th>操作</th><th>快捷键</th><th>说明</th></tr></thead><tbody><tr><td>新建窗格</td><td><code>Ctrl+p</code> + <code>d</code></td><td>水平分割</td></tr><tr><td>新建窗格</td><td><code>Ctrl+p</code> + <code>r</code></td><td>垂直分割</td></tr><tr><td>切换窗格</td><td><code>Ctrl+p</code> + 方向键</td><td>在窗格间移动</td></tr><tr><td>新建标签页</td><td><code>Ctrl+t</code> + <code>n</code></td><td>类似浏览器标签</td></tr><tr><td>浮动窗格</td><td><code>Ctrl+p</code> + <code>f</code></td><td>悬浮在当前窗格上方</td></tr></tbody></table><p>浮动窗格特别适合快速查看日志或执行临时命令，不需要打断当前工作流。</p><h2 id="五、Git-管理工具">五、Git 管理工具</h2><p>开发离不开代码托管平台。<code>GitHub</code> 和 <code>Gitea</code> 是目前最常用的两个平台，各自有对应的命令行工具：<code>gh</code>（GitHub CLI）和 <code>tea-cli</code>（Gitea CLI）。这两个工具可以完成大部分在网页端才能做的操作：创建仓库、管理 PR/Issue、查看 CI 状态等。</p><p>配置这两个工具的一个重要用途是让 <code>AI</code> 智能体（如 <code>Claude Code</code>、<code>MiMo Code</code>）能够自动化调用 Git 平台功能，实现从代码编写到仓库管理的全流程自动化。</p><h3 id="5-1-GitHub-CLI（gh）">5.1 GitHub CLI（gh）</h3><h4 id="安装">安装</h4><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-bash"># Debian/Ubuntucurl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpgecho &quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main&quot; | sudo tee /etc/apt/sources.list.d/github-cli.list &gt; /dev/nullsudo apt update &amp;&amp; sudo apt install gh -y</code></div></pre><h4 id="登录">登录</h4><p><code>gh</code> 的登录流程非常简洁，会自动跳转浏览器完成授权：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">gh auth login</code></div></pre><p>交互过程：</p><ol><li>选择账号：<code>GitHub.com</code></li><li>选择协议：<code>SSH</code>（推荐）或 <code>HTTPS</code></li><li>浏览器自动打开，显示 8 位授权码</li><li>在浏览器中确认授权即可</li></ol><p>登录完成后可以用 <code>gh auth status</code> 验证。</p><h4 id="常用命令">常用命令</h4><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div></div><code class="language-bash"># 克隆仓库gh repo clone owner/repo# 创建仓库gh repo create my-repo --public# 查看/创建 Issuegh issue listgh issue create --title &quot;bug&quot; --body &quot;描述&quot;# 管理 PRgh pr listgh pr create --title &quot;feat&quot; --body &quot;改动说明&quot;gh pr checkout 123# 查看 CI 状态gh run listgh run view 123</code></div></pre><h3 id="5-2-Gitea-CLI（tea-cli）">5.2 Gitea CLI（tea-cli）</h3><h4 id="安装-2">安装</h4><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo apt install tea-cli -y</code></div></pre><blockquote><p><strong>注意命名差异</strong>：<code>apt</code> 安装的包名和命令都是 <code>tea-cli</code>，但执行 <code>tea-cli help</code> 后会发现帮助文档中自称 <code>tea</code>（这是上游项目原名）。<code>apt search tea</code> 出来的 <code>tea</code> 是另一个不相关的包，不要混淆。</p></blockquote><h4 id="登录-2">登录</h4><p>与 <code>gh</code> 不同，<code>tea-cli</code> 需要手动添加登录配置，且建议使用 <strong>Access Token</strong> 方式（用户名密码方式在某些 Gitea 实例上会失败）。</p><p><strong>第一步：在 Gitea 网页端创建 Access Token</strong></p><p>进入 Gitea → 用户设置 → Applications → 生成新的令牌：</p><ul><li>令牌名称：随意填写（如 <code>gitea-cli</code>）</li><li>仓库和组织访问权限：选择「全部（公开、私有和受限）」</li><li>各项权限：全部选择「读写」</li></ul><img src="https://cdn.superheaoz.top/images/2026/06/35524/gitea-cli-access-token-permission.jpeg" data-cdn-image-fallback-src="/2026/06/35524/gitea-cli-access-token-permission.jpeg" class="" title="Gitea Token 权限配置"><p>点击「生成令牌」后，<strong>立即复制保存</strong>，页面关闭后将无法再次查看。</p><p><strong>第二步：通过 tea-cli 添加登录</strong></p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">tea-cli login add</code></div></pre><p>交互过程：</p><ol><li><code>URL of Gitea instance:</code> — 输入 Gitea 仓库地址（如 <code>https://gitea.example.com</code>）</li><li><code>Name for this login:</code> — 输入别名（如 <code>my-gitea</code>）</li><li><code>Access token:</code> — 粘贴刚才生成的 Token</li><li><code>Configure SSH key?</code> — 输入 <code>n</code>（使用默认 SSH 密钥即可）</li><li><code>Skip TLS verification?</code> — 输入 <code>n</code>（除非你的 Gitea 使用自签证书）</li></ol><p>登录完成后用 <code>tea-cli login ls</code> 验证。</p><h4 id="常用命令-2">常用命令</h4><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div></div><code class="language-bash"># 查看登录列表tea-cli login ls# 设置默认登录tea-cli login default my-gitea# 查看仓库tea-cli repo ls# 创建仓库tea-cli repo create --name my-repo# 管理 Issuetea-cli issue lstea-cli issue create --title &quot;bug&quot; --body &quot;描述&quot;# 管理 PR（Gitea 称为 Pull Request）tea-cli pr lstea-cli pr create --title &quot;feat&quot; --body &quot;改动说明&quot;</code></div></pre><h3 id="5-3-两者对比">5.3 两者对比</h3><table><thead><tr><th>特性</th><th>gh（GitHub）</th><th>tea-cli（Gitea）</th></tr></thead><tbody><tr><td>安装</td><td>需添加 APT 源</td><td><code>apt install tea-cli</code></td></tr><tr><td>登录方式</td><td>浏览器自动跳转</td><td>手动添加 Token</td></tr><tr><td>支持平台</td><td><a href="http://GitHub.com" class="external-link" data-redirect="http%3A%2F%2FGitHub.com">GitHub.com</a></td><td>自建 Gitea 实例</td></tr><tr><td>命令风格</td><td><code>gh repo</code>、<code>gh pr</code>、<code>gh issue</code></td><td><code>tea-cli repo</code>、<code>tea-cli pr</code>、<code>tea-cli issue</code></td></tr><tr><td>适用场景</td><td>开源项目、GitHub Actions</td><td>私有仓库、内部团队协作</td></tr><tr><td>AI 智能体调用</td><td>支持，可自动化操作 GitHub</td><td>支持，可自动化操作 Gitea</td></tr></tbody></table><h2 id="六、工具组合建议">六、工具组合建议</h2><p>这套工具组合覆盖了日常开发的核心场景：</p><table><thead><tr><th>场景</th><th>工具</th><th>说明</th></tr></thead><tbody><tr><td>日常编码</td><td>Claude Code / MiMo Code</td><td>AI 辅助编程，自动修复和重构</td></tr><tr><td>终端持久化</td><td>Zellij</td><td>保证 TUI 工具断连不中断</td></tr><tr><td>复杂任务</td><td>MiMo Code compose</td><td>多步骤自动拆分执行</td></tr><tr><td>代码审查</td><td>Claude Code + superpower</td><td>AI 驱动的代码审查</td></tr><tr><td>远程开发</td><td>VS Code Remote Tunnels</td><td>无需 SSH，浏览器/客户端直连</td></tr><tr><td>跨设备终端</td><td>Zellij + SSH</td><td>TUI 会话持久化，重连即恢复</td></tr><tr><td>代码托管</td><td>gh / tea-cli</td><td>GitHub / Gitea 命令行管理</td></tr></tbody></table><h2 id="七、总结">七、总结</h2><p><code>VS Code</code> 负责远程开发和代码编辑，<code>Claude Code</code> 和 <code>MiMo Code</code> 代表了两种不同的 <code>AI</code> 编程路径 — 前者生态成熟，后者免费友好。<code>Zellij</code> 则保证了终端 <code>TUI</code> 工具的连续性，断连不中断。<code>gh</code> 和 <code>tea-cli</code> 分别打通了 <code>GitHub</code> 和 <code>Gitea</code> 的命令行工作流。这些工具组合使用，基本覆盖了从编码到部署的全流程。下一篇将继续 Homelab 系列，介绍容器与基础服务的部署。</p><h2 id="参考">参考</h2><ul><li><a href="https://docs.anthropic.com/en/docs/claude-code" title="Claude Code 官方文档" class="external-link" data-redirect="https%3A%2F%2Fdocs.anthropic.com%2Fen%2Fdocs%2Fclaude-code">Claude Code 官方文档</a></li><li><a href="https://platform.xiaomimimo.com" title="MiMo 开放平台" class="external-link" data-redirect="https%3A%2F%2Fplatform.xiaomimimo.com">MiMo 开放平台</a></li><li><a href="https://mimo.mi.com/docs/zh-CN/tokenplan/integration/claudecode" title="MiMo Code API 文档" class="external-link" data-redirect="https%3A%2F%2Fmimo.mi.com%2Fdocs%2Fzh-CN%2Ftokenplan%2Fintegration%2Fclaudecode">MiMo Code API 文档</a></li><li><a href="https://api-docs.deepseek.com/zh-cn/quick_start/agent_integrations/claude_code" title="DeepSeek Claude Code 集成" class="external-link" data-redirect="https%3A%2F%2Fapi-docs.deepseek.com%2Fzh-cn%2Fquick_start%2Fagent_integrations%2Fclaude_code">DeepSeek Claude Code 集成</a></li><li><a href="https://zellij.dev/documentation" title="Zellij 官方文档" class="external-link" data-redirect="https%3A%2F%2Fzellij.dev%2Fdocumentation">Zellij 官方文档</a></li><li><a href="https://github.com/zellij-org/zellij" title="Zellij GitHub" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fzellij-org%2Fzellij">Zellij GitHub</a></li><li><a href="https://cli.github.com/manual" title="GitHub CLI 官方文档" class="external-link" data-redirect="https%3A%2F%2Fcli.github.com%2Fmanual">GitHub CLI 官方文档</a></li><li><a href="https://gitea.com/gitea/tea" title="Gitea CLI 官方文档" class="external-link" data-redirect="https%3A%2F%2Fgitea.com%2Fgitea%2Ftea">Gitea CLI（tea）官方文档</a></li></ul><!-- image --><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;前几篇记录了硬件选购、系统安装和开发环境配置。环境搭好后，下一步是配置日常开发工具。本篇聚焦日常开发的核心工具链：&lt;code&gt;VS Code&lt;/code&gt;（远程开发 IDE）、&lt;code&gt;Claude Code&lt;/code&gt; 和 &lt;code&gt;MiMo Code&lt;/code&gt;（AI 编程助手），以及 &lt;code&gt;Zellij&lt;/code&gt;（终端复用器）。特别是 &lt;code&gt;Zellij&lt;/code&gt; 的会话持久化能力，让 &lt;code&gt;TUI&lt;/code&gt; 工具断连后依然保持运行。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Claude Code" scheme="https://www.superheaoz.top/tags/Claude-Code/"/>
    
    <category term="MiMo" scheme="https://www.superheaoz.top/tags/MiMo/"/>
    
    <category term="Zellij" scheme="https://www.superheaoz.top/tags/Zellij/"/>
    
    <category term="AI" scheme="https://www.superheaoz.top/tags/AI/"/>
    
    <category term="GitHub CLI" scheme="https://www.superheaoz.top/tags/GitHub-CLI/"/>
    
    <category term="Gitea" scheme="https://www.superheaoz.top/tags/Gitea/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（9）自建主题：工程化与发布</title>
    <link href="https://www.superheaoz.top/2026/06/9337/"/>
    <id>https://www.superheaoz.top/2026/06/9337/</id>
    <published>2026-06-19T07:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>前几篇记录了 <code>DoraTiger</code> 的功能实现。这篇补上平时维护主题时用到的命令、资源管理和发布流程，也记录一次子模块提交没有推送导致构建失败的问题。</p><span id="more"></span><h2 id="一、CLI-命令">一、CLI 命令</h2><h3 id="1-1-设计原则">1.1 设计原则</h3><p>清理、构建和更新资源都是维护主题时反复执行的操作，我把它们整理成了 <code>CLI</code> 命令，减少每次手动输入的步骤。</p><h3 id="1-2-hexo-themeinit">1.2 hexo themeinit</h3><p>初始化主题配置文件，将默认配置复制到用户可编辑的位置：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-javascript">// scripts/console/lib/themeinit.jsexports.handler = async function(args) &#123;    const force = args.force || false;    const legacy = args.legacy || false;    if (exists(targetFile) &amp;&amp; !force) &#123;        log.info(&quot;配置文件已存在，跳过（使用 --force 覆盖）&quot;);        return;    &#125;    // 复制默认配置到目标文件&#125;;</code></div></pre><h3 id="1-3-hexo-algolia">1.3 hexo algolia</h3><p>上传文章索引到 <code>Algolia</code>，支持清空重建和预览模式：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div></div><code class="language-javascript">// scripts/console/lib/algolia.jsexports.handler = async function(args) &#123;    const client = algoliasearch(appId, apiKey);    const index = client.initIndex(indexName);    if (args.clean) await index.clearObjects();    if (args['dry-run']) &#123; /* 仅预览不上传 */ &#125;    const posts = hexo.locals.get('posts').toArray();    const records = posts.map(post =&gt; (&#123;        objectID: post._id,        title: post.title,        content: post.content.substring(0, 5000),        tags: post.tags.map(t =&gt; t.name),    &#125;));    await index.saveObjects(records);&#125;;</code></div></pre><h2 id="二、CI-CD-自动化">二、CI/CD 自动化</h2><h3 id="2-1-完整流程">2.1 完整流程</h3><p>目前的部署流程已经完全自动化：<code>push</code> 到 <code>Gitea</code> 后自动触发构建。</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div><div class="line-numbers-item">21</div></div><code class="language-yaml"># .gitea/workflows/publish.yamlname: Generate and Deploy Hexo Siteon:  push:    branches: [main]    paths:      - &quot;source/_posts/**&quot;      - &quot;themes/**&quot;      - &quot;_config.yml&quot;      - &quot;_config.*.yml&quot;jobs:  generate-and-deploy:    runs-on: ubuntu-latest    steps:      - Checkout（含 submodules）      - 安装 Node.js + hexo-cli      - hexo clean &amp;&amp; hexo generate      - hexo algolia（更新搜索索引）      - hexo deploy（SSH 推送到服务器）</code></div></pre><p>触发条件精确到文件路径 — 只有文章、主题、配置文件变动才触发构建，避免无关提交浪费资源。</p><h3 id="2-2-主题与博客的脱钩问题">2.2 主题与博客的脱钩问题</h3><p>这是实际使用中唯一遇到过的坑：主题修订后忘记 <code>push</code>，<code>Gitea</code> 拉取的远端版本还是旧的，导致构建出来的博客没有最新的主题改动。解决方法就是养成习惯：改完主题先 <code>push</code> 主题仓库，再 <code>push</code> 博客仓库（更新子模块引用）。</p><h2 id="三、CDN-与本地资源管理">三、CDN 与本地资源管理</h2><h3 id="3-1-双轨设计的实际使用">3.1 双轨设计的实际使用</h3><p>之前在视觉系统篇介绍过 <code>CDN</code> + 本地的双重机制。实际开发中<strong>基本没有切换过</strong> — 日常写博客都在本地环境，直接用本地资源；只有在服务器上构建时才可能用到 <code>CDN</code>。这个设计更多是为&quot;万一&quot;准备的，属于防御性设计。</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-yaml">resource:  enable_cdn: false        # 全局默认：本地  highlight:    enable_cdn: true       # 代码高亮强制 CDN（库体积大）</code></div></pre><h3 id="3-2-内置库清单">3.2 内置库清单</h3><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-text">source/lib/├── highlight.js/@11.10.0/     # 代码高亮├── mathjax/@3.2.2/            # 数学渲染├── font-awesome/@6.7.2/       # 图标字体├── twikoo/@1.6.40/            # 评论系统├── valine/@1.5.3/             # 评论系统├── instantsearch.js/          # Algolia 搜索 UI├── pretext/                   # Canvas 文字排版└── prism.js/@1.29.0/          # 备用代码高亮</code></div></pre><h2 id="四、配置文档化">四、配置文档化</h2><h3 id="4-1-按功能整理配置说明">4.1 按功能整理配置说明</h3><p><code>DoraTiger</code> 的配置文档是我个人比较满意的部分 — 一种强迫症式的、划分明确的设计：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-text">配置体系三层结构：  _config.yml（993 行）          ← 主题默认值，含所有配置项注释  docs/CONFIG.md（1051 行）      ← 完整配置说明，按功能分章节  _config.hexo-theme-doratiger.yml ← 用户覆盖文件，只写差异项</code></div></pre><h3 id="4-2-中英文双注释">4.2 中英文双注释</h3><p>每个配置项都采用中英文双注释，既方便中文用户理解，也方便国际用户参考：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-yaml"># 是否开启 PV 统计# Whether to enable PV statisticsenable: true# 统计系统# Statistics system: busuanzi | countertype: &quot;counter&quot;</code></div></pre><p>配置项旁边保留了中英文说明，修改配置时可以直接查看。</p><h3 id="4-3-配置分区">4.3 配置分区</h3><p>配置文件按功能域分区，每个分区有清晰的边界注释：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div></div><code class="language-yaml"># --------------------------------------------------# 样式配置# Style Configuration# --------------------------------------------------style:  color:    theme: &quot;rgba(230, 119, 0, 1)&quot;    # ...# --------------------------------------------------# 评论功能配置# Comment Function Configuration# --------------------------------------------------comment:  type: &quot;twikoo&quot;  # ...</code></div></pre><p>每个功能的配置项集中在同一区域。新增配置时，同时更新旁边的注释和 <code>docs/CONFIG.md</code> 对应章节，避免两处说明不一致。</p><h2 id="五、发布流程">五、发布流程</h2><h3 id="5-1-发布检查清单">5.1 发布检查清单</h3><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-text">□ 版本号更新（package.json）□ CHANGELOG.md 更新□ README.md / README_en.md 同步□ 配置文档 docs/CONFIG.md 同步□ git commit + tag□ push 到 Gitea□ CI 自动构建 + 部署</code></div></pre><h3 id="5-2-发布后验证">5.2 发布后验证</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash"># 确认部署成功curl -s https://www.superheaoz.top/ | head -5# 确认子模块指针正确cd themes/hexo-theme-doratiger &amp;&amp; git log --oneline -1</code></div></pre><h2 id="六、系列回顾">六、系列回顾</h2><table><thead><tr><th>篇目</th><th>主题</th><th>关键词</th></tr></thead><tbody><tr><td>（1）主题</td><td>Hexo 基础、主题结构、Pug 模板</td><td>入门</td></tr><tr><td>（2）插件</td><td>插件分类、安装、自定义开发</td><td>生态</td></tr><tr><td>（3）进阶功能</td><td>标签插件、数据文件、生成器、i18n</td><td>进阶</td></tr><tr><td>（4）架构与设计</td><td>三层配置、脚本注入、目录设计</td><td>架构</td></tr><tr><td>（5）视觉系统</td><td>暗色主题、Canvas 动画、响应式布局</td><td>视觉</td></tr><tr><td>（6）核心功能</td><td>渲染管线、侧边栏、分页、统计</td><td>功能</td></tr><tr><td>（7）安全与 SEO</td><td>加密、外链拦截、Sitemap、JSON-LD</td><td>安全</td></tr><tr><td>（8）搜索与国际化</td><td>本地搜索、Algolia、i18n、评论</td><td>搜索</td></tr><tr><td>（9）工程化与发布</td><td>CLI、CI/CD、配置文档、发布流程</td><td>收尾</td></tr></tbody></table><p>以上整理了这一阶段从博客搭建到主题开发的记录。主题还会继续修改，后续改动另写文章记录。</p><h2 id="参考">参考</h2><ul><li><a href="https://github.com/DoraTiger/hexo-theme-doratiger" title="DoraTiger 主题仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhexo-theme-doratiger">DoraTiger 主题 GitHub</a></li><li><a href="https://hexo.io/zh-cn/docs/" title="Hexo 官方文档" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2F">Hexo 官方文档</a></li><li><a href="https://docs.gitea.com/usage/actions/overview" title="Gitea Actions" class="external-link" data-redirect="https%3A%2F%2Fdocs.gitea.com%2Fusage%2Factions%2Foverview">Gitea Actions 文档</a></li><li><a href="https://www.algolia.com/" title="Algolia 搜索服务" class="external-link" data-redirect="https%3A%2F%2Fwww.algolia.com%2F">Algolia 搜索服务</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;前几篇记录了 &lt;code&gt;DoraTiger&lt;/code&gt; 的功能实现。这篇补上平时维护主题时用到的命令、资源管理和发布流程，也记录一次子模块提交没有推送导致构建失败的问题。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="DevOps" scheme="https://www.superheaoz.top/tags/DevOps/"/>
    
    <category term="Git" scheme="https://www.superheaoz.top/tags/Git/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（8）自建主题：搜索与国际化</title>
    <link href="https://www.superheaoz.top/2026/06/57042/"/>
    <id>https://www.superheaoz.top/2026/06/57042/</id>
    <published>2026-06-19T06:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>博客文章多了以后，查找旧内容主要依靠搜索。<code>DoraTiger</code> 保留了 <code>Algolia</code> 和本地搜索两种方式，这篇先记录它们的实现，再整理界面文字的多语言配置，以及评论系统的选型和停用原因。</p><span id="more"></span><h2 id="一、搜索系统">一、搜索系统</h2><h3 id="1-1-从-Fan-到-DoraTiger-的搜索演进">1.1 从 Fan 到 DoraTiger 的搜索演进</h3><p>搜索功能的演进和主题本身的历史紧密相关。最早在 <code>Fan</code> 主题上就遇到过 <code>Algolia</code> 适配出问题的情况 — 那也是我给 <code>Fan</code> 贡献代码的原因之一。后来在 <code>DoraTiger</code> 重构时，彻底升级了 <code>Algolia</code> 依赖版本（从 <code>instantsearch.js</code> v2 升级到 v4），基于新 <code>API</code> 全面重写了搜索模块。</p><p>本地搜索（<code>local-search</code>）用于不依赖外部服务的场景。<code>Algolia</code> 配额用完或服务不可用时，还可以使用站点生成的本地索引。</p><h3 id="1-2-Algolia-搜索">1.2 Algolia 搜索</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash"># 构建时上传索引npx hexo algolia</code></div></pre><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-pug">// layout/_include/header/algolia.pug// instantsearch.js v4 + algoliasearch liteconst search = instantsearch(&#123;    indexName: '#&#123;algoliaConfig.index_id&#125;',    searchClient: algoliasearch('#&#123;algoliaConfig.app_id&#125;', '#&#123;algoliaConfig.api_key&#125;'),&#125;);search.addWidget(instantsearch.widgets.searchBox(&#123; container: '#search-box' &#125;));search.addWidget(instantsearch.widgets.hits(&#123; container: '#hits' &#125;));</code></div></pre><h3 id="1-3-搜索框的适配">1.3 搜索框的适配</h3><p>搜索框的 <code>UI</code> 适配花了不少功夫。主要挑战是搜索结果列表需要在不同内容宽度下正确显示 — 文章标题过长时截断、搜索高亮文字不溢出、分页控件在窄屏下折叠。</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-stylus">// source/css/_layout/search.styl — 100 行// source/css/_layout/algolia.styl — 92 行</code></div></pre><p>两个样式文件加起来 192 行，覆盖了搜索框、搜索结果、高亮文字、分页、空状态等所有交互状态。</p><h3 id="1-4-本地搜索">1.4 本地搜索</h3><p>作为 <code>Algolia</code> 的备用方案，本地搜索完全在客户端运行：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div><div class="line-numbers-item">21</div><div class="line-numbers-item">22</div><div class="line-numbers-item">23</div></div><code class="language-javascript">// source/js/utils/localSearch.jsclass LocalSearch &#123;    loadIndex() &#123;        // 懒加载：首次搜索时才 fetch JSON 索引        fetch(indexPath)            .then(r =&gt; r.json())            .then(data =&gt; &#123; this.index = data.items; this.state = &quot;ready&quot;; &#125;);    &#125;    search(query) &#123;        // 200ms 防抖 + 子串匹配        return this.index.filter(item =&gt; &#123;            const text = [item.title, item.excerpt, item.content,                         ...item.tags, ...item.categories].join(&quot; &quot;).toLowerCase();            return text.includes(query.toLowerCase());        &#125;).slice(0, this.perPage);    &#125;    highlight(text, query) &#123;        const regex = new RegExp(query.replace(/[.*+?^$&#123;&#125;()|[\]\\]/g, &quot;\\$&amp;&quot;), &quot;ig&quot;);        return text.replace(regex, m =&gt; `&lt;mark&gt;$&#123;m&#125;&lt;/mark&gt;`);    &#125;&#125;</code></div></pre><h3 id="1-5-字段合并策略">1.5 字段合并策略</h3><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-yaml">search:  field_merge_strategy: merge  # merge（追加）| replace（替换）  fields:    - title    - content    - tags</code></div></pre><p><code>merge</code> 模式在默认字段基础上追加用户自定义字段，<code>replace</code> 模式完全替换。这个设计来自 <code>hexo-generator-search</code> 的思路，保留了扩展灵活性。</p><h2 id="二、国际化（i18n）">二、国际化（i18n）</h2><h3 id="2-1-一种强迫症式的设计偏好">2.1 一种强迫症式的设计偏好</h3><p>国际化属于个人的一种强迫症式习惯 — 喜欢这种可配置化、可扩展的方案设计。<code>Hexo</code> 本身支持 <code>i18n</code>，有相关文档，所以积极引入。虽然博客主要是中文，但 <code>i18n</code> 体系让主题具备了国际化能力，未来如果有需要可以直接扩展。</p><h3 id="2-2-语言文件结构">2.2 语言文件结构</h3><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div></div><code class="language-yaml"># languages/zh-Hans.ymlhome:  read_more: &quot;阅读全文&quot;archives:  count:    zero: &quot;暂无文章&quot;           # _p() 复数：零    other: &quot;目前共计 %d 篇文章&quot;  # _p() 复数：其他footer:  statistics:    site_uv: &quot;本站总访客数 &#123;&#125; 人&quot;   # JS 运行时插值    page_pv: &quot;本文总访问量 &#123;&#125; 次&quot;copy:  success: &quot;复制成功&quot;  error: &quot;复制错误&quot;</code></div></pre><h3 id="2-3-三种占位符">2.3 三种占位符</h3><table><thead><tr><th>占位符</th><th>用途</th><th>使用场景</th></tr></thead><tbody><tr><td><code>%d</code></td><td><code>_p()</code> 复数形式</td><td>Pug 模板 <code>&#123;&#123; archives.count &#125;&#125;</code></td></tr><tr><td><code>&#123;&#125;</code></td><td>JS 运行时插值</td><td>统计数字显示</td></tr><tr><td><code>$&#123;name&#125;</code></td><td>搜索结果模板</td><td>搜索组件</td></tr></tbody></table><h3 id="2-4-覆盖范围">2.4 覆盖范围</h3><p>导航菜单、页面标题、文章元信息（创建时间/更新时间）、版权模板、复制按钮状态、搜索占位符和空结果提示、评论占位符、重定向提示、统计格式字符串等，都放在语言文件中，通过 <code>_p()</code> 或 <code>__()</code> 引用。</p><h3 id="2-5-双语日志">2.5 双语日志</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-javascript">// scripts/utils/log.jsfunction logInfo(key, ...args) &#123;    const lang = hexo.theme.i18n.languages[0];    const msg = lang.startsWith('zh') ? zhMessages[key] : enMessages[key];    console.log(msg, ...args);&#125;</code></div></pre><h2 id="三、评论系统">三、评论系统</h2><h3 id="3-1-选型与继承">3.1 选型与继承</h3><p>评论系统继承自 <code>Fan</code> 主题的架构，支持 <code>Twikoo</code>、<code>Valine</code>、<code>Gitment</code> 三种。选择 <code>Twikoo</code> 的原因是看到其他站点在用，感觉好用就尝试往里加了。</p><h3 id="3-2-动态注入">3.2 动态注入</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-javascript">// scripts/injectors/lib/injector-comments.jsswitch (commentType) &#123;    case &quot;twikoo&quot;:        return `&lt;script&gt;new Twikoo(&#123; envId: '$&#123;envId&#125;' &#125;)&lt;/script&gt;`;    case &quot;valine&quot;:        return `&lt;script&gt;new Valine(&#123; appId: '$&#123;appId&#125;' &#125;)&lt;/script&gt;`;&#125;</code></div></pre><h3 id="3-3-为什么关闭了评论">3.3 为什么关闭了评论</h3><p>目前评论系统是<strong>关闭状态</strong>。原因是网站备案是个人网页性质，没有交互属性，索性就关了。</p><p>之前还考虑过一个更复杂的方案 — 结合 <code>GitHub Pages</code> 做双栈部署，国外 <code>IP</code> 访问路由到 <code>Pages</code> 上并开启评论功能。但最后嫌麻烦放弃了。</p><h2 id="四、总结">四、总结</h2><p>目前搜索保留两种实现，界面文字集中在语言文件中。评论功能虽然保留在主题里，但我的博客因合规原因关闭了评论，没有继续部署两套评论服务。</p><h2 id="参考">参考</h2><ul><li><a href="https://www.algolia.com/doc/" title="Algolia 文档" class="external-link" data-redirect="https%3A%2F%2Fwww.algolia.com%2Fdoc%2F">Algolia InstantSearch</a></li><li><a href="https://twikoo.js.org/" title="Twikoo 评论系统" class="external-link" data-redirect="https%3A%2F%2Ftwikoo.js.org%2F">Twikoo 评论系统</a></li><li><a href="https://github.com/wzpan/hexo-generator-search" title="hexo-generator-search" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fwzpan%2Fhexo-generator-search">hexo-generator-search</a></li><li><a href="https://github.com/thom4parisot/hexo-algolia" title="hexo-algolia" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fthom4parisot%2Fhexo-algolia">hexo-algolia</a></li><li><a href="https://hexo.io/zh-cn/docs/helpers#Internationalization" title="Hexo i18n" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Fhelpers%23Internationalization">Hexo 国际化文档</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;博客文章多了以后，查找旧内容主要依靠搜索。&lt;code&gt;DoraTiger&lt;/code&gt; 保留了 &lt;code&gt;Algolia&lt;/code&gt; 和本地搜索两种方式，这篇先记录它们的实现，再整理界面文字的多语言配置，以及评论系统的选型和停用原因。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="搜索" scheme="https://www.superheaoz.top/tags/%E6%90%9C%E7%B4%A2/"/>
    
    <category term="i18n" scheme="https://www.superheaoz.top/tags/i18n/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（7）自建主题：安全与SEO</title>
    <link href="https://www.superheaoz.top/2026/06/9008/"/>
    <id>https://www.superheaoz.top/2026/06/9008/</id>
    <published>2026-06-19T05:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>文章加密、外链跳转和 <code>SEO</code> 最早都是通过第三方插件实现的。重构主题时，我把它们移到了内部，方便与页面模板一起调整。下面记录 <code>AES-256-GCM</code> 加密、外链拦截，以及 <code>Sitemap</code>、<code>Robots.txt</code> 和 <code>JSON-LD</code> 的生成方式。</p><span id="more"></span><h2 id="一、文章加密">一、文章加密</h2><h3 id="1-1-为什么需要加密">1.1 为什么需要加密</h3><p>加密功能最初也是用的第三方插件（<code>hexo-blog-encrypt</code>），重构时一体化集成到主题内部。原因是加密功能和文章渲染管线耦合很深 — 加密后的文章需要特殊的渲染流程（先解密再渲染），第三方插件的实现和主题的渲染管线配合起来有各种边界问题。</p><p>目前只加密了一篇文章 — 本科同学录。属于私密内容，不适合公开。</p><h3 id="1-2-加密方案">1.2 加密方案</h3><p>采用构建时加密 + 运行时解密的方案，无需后端服务：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-text">构建时（Node.js 服务端）：  明文内容 → PBKDF2 密钥派生（100K 迭代）→ AES-256-GCM 加密  → 加密数据 + Salt + IV + Auth Tag 嵌入 HTML data 属性运行时（浏览器 Web Crypto API）：  用户输入密码 → PBKDF2 派生相同密钥 → AES-GCM 解密 → 显示内容</code></div></pre><h3 id="1-3-构建端加密">1.3 构建端加密</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-javascript">// scripts/filters/lib/encrypt.jsfunction encryptContent(content, password) &#123;    const salt = crypto.randomBytes(16);       // 16 字节随机盐值    const iv = crypto.randomBytes(12);          // 12 字节随机 IV    const key = crypto.pbkdf2Sync(              // PBKDF2 派生密钥        password, salt, 100000, 32, &quot;sha256&quot;    // 100K 迭代，SHA-256    );    const cipher = crypto.createCipheriv(&quot;aes-256-gcm&quot;, key, iv);    // ... 加密 + 提取 auth tag&#125;</code></div></pre><h3 id="1-4-运行时解密">1.4 运行时解密</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-javascript">// 内联在 footer.pug 中async function decrypt(pwd) &#123;    var keyMat = await crypto.subtle.importKey('raw', ...);    var key = await crypto.subtle.deriveKey(&#123;        name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256'    &#125;, keyMat, ...);    var pt = await crypto.subtle.decrypt(&#123;        name: 'AES-GCM', iv, tagLength: 128    &#125;, key, combined);    return new TextDecoder().decode(pt);&#125;</code></div></pre><h3 id="1-5-使用方式">1.5 使用方式</h3><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-yaml"># front-matterpassword: &quot;my-secret-password&quot;abstract: &quot;本文已加密，请输入密码查看&quot;   # 可选，覆盖默认提示message: &quot;请输入密码&quot;                     # 可选，覆盖默认按钮文字</code></div></pre><p>加密后对搜索引擎的影响：加密页面的内容对爬虫不可见，不会被收录。对于私密内容这正是期望的效果。</p><h2 id="二、外链重定向拦截">二、外链重定向拦截</h2><h3 id="2-1-从公安备案到外链安全">2.1 从公安备案到外链安全</h3><p>这个功能的由来有一个小故事。博客最初只做了 <code>ICP</code> 备案，后来接到公安电话通知需要做<strong>公安备案</strong>。备案完成后，自然就考虑到了外链安全性的问题 — 如果博客上的链接指向了恶意网站，作为站长是有责任的。</p><p>最早是利用 <code>Yourls</code> 短链服务做的外链跳转 — 每个外链先转成短链，用户点击时经过 <code>Yourls</code> 中间页。有一段时间 <code>Hexo</code> 的提交记录全是改链接的，把旧的直接链接替换成短链格式（顺便说一下，这也催生了那篇 <a href="/2023/10/32630/" title="服务器操作指北（6）Yourls 短链接服务部署">服务器操作指北（6）Yourls 短链接服务部署</a>）。</p><p>但后来觉得每次加外链都得去 <code>Yourls</code> 后台手动加一条记录，太麻烦了。就像图片之前用七牛云 <code>CDN</code>，最后也是嫌麻烦放弃了。所以参考知乎的外链跳转风格，构建了一个主题自用的方案 — 构建时自动识别外链，运行时通过中间页拦截。同时因为做了 <a href="/2023/10/54005/" title="网站公安备案小记">网站公安备案小记</a>，外链安全性也成了必须考虑的问题。</p><h3 id="2-2-构建时注入">2.2 构建时注入</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-javascript">// scripts/filters/lib/redirect.jsdata.content = data.content.replace(    /&lt;a\s+([^&gt;]*?)href=[&quot;']([^&quot;']+)[&quot;']([^&gt;]*)&gt;/gi,    (match, pre, url, post) =&gt; &#123;        if (!shouldRedirect(url)) return match;        return `&lt;a ... class=&quot;external-link&quot;             data-redirect=&quot;$&#123;encodeURIComponent(url)&#125;&quot;             target=&quot;_blank&quot; ...&gt;`;    &#125;);</code></div></pre><h3 id="2-3-运行时拦截">2.3 运行时拦截</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-pug">// layout/_include/_layout.pugscript.    document.addEventListener('click', (e) =&gt; &#123;        const link = e.target.closest('a[data-redirect]');        if (link) &#123;            e.preventDefault();            window.open('/redirect/?url=' + link.dataset.redirect);        &#125;    &#125;);</code></div></pre><h3 id="2-4-配置">2.4 配置</h3><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-yaml">redirect:  method: exclude          # exclude（默认）| include  exclude:    - &quot;superheaoz.top&quot;     # 自己的域名不拦截    - &quot;github.com&quot;</code></div></pre><h2 id="三、原生-Sitemap-与-SEO">三、原生 Sitemap 与 SEO</h2><h3 id="3-1-从插件到内置">3.1 从插件到内置</h3><p><code>Sitemap</code> 和 <code>Robots.txt</code> 最初也是用的第三方插件（<code>hexo-generator-sitemap</code>），重构时一体化集成。原因是这两个生成器逻辑不复杂，但和主题的配置体系（<code>theme-config()</code>）结合后可以做到更精细的控制。</p><h3 id="3-2-Sitemap-生成器">3.2 Sitemap 生成器</h3><p>支持 <code>XML</code>、<code>TXT</code> 或两者同时输出，优先级和更新频率可通过配置调整：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div></div><code class="language-javascript">// scripts/generators/lib/sitemap.jsmodule.exports = function (locals) &#123;    const urls = [];    urls.push(&#123; loc: siteUrl + &quot;/&quot;, freq: &quot;daily&quot;, priority: 1.0 &#125;);    locals.posts.sort(&quot;-date&quot;).forEach(post =&gt; &#123;        urls.push(&#123;            loc: siteUrl + &quot;/&quot; + post.path,            freq: changefreq,            priority: 0.8,            lastmod: post.updated        &#125;);    &#125;);    if (format === &quot;xml&quot; || format === &quot;both&quot;)        results.push(&#123; path: &quot;sitemap.xml&quot;, data: buildXml(urls) &#125;);    if (format === &quot;txt&quot; || format === &quot;both&quot;)        results.push(&#123; path: &quot;sitemap.txt&quot;, data: buildTxt(urls) &#125;);&#125;;</code></div></pre><h3 id="3-3-SEO-实际效果">3.3 SEO 实际效果</h3><p>这个博客主要还是以学习为目的，没有专门做 <code>SEO</code> 优化。简单对接了百度、谷歌、微软的站长统计功能 — 提交了 <code>sitemap</code>，配置了 <code>robots.txt</code>，加了 <code>JSON-LD</code> 结构化数据。对搜索引擎收录有一定帮助，但没有做过系统的 <code>SEO</code> 策略分析。</p><h3 id="3-4-JSON-LD-结构化数据">3.4 JSON-LD 结构化数据</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div></div><code class="language-pug">// layout/_include/head.pugif(is_post())    script(type=&quot;application/ld+json&quot;).        &#123;            &quot;@type&quot;: &quot;Article&quot;,            &quot;headline&quot;: &quot;#&#123;page.title&#125;&quot;,            &quot;author&quot;: &#123; &quot;@type&quot;: &quot;Person&quot;, &quot;name&quot;: &quot;#&#123;config.author&#125;&quot; &#125;,            &quot;datePublished&quot;: &quot;#&#123;page.date&#125;&quot;,            &quot;dateModified&quot;: &quot;#&#123;page.updated&#125;&quot;        &#125;else    script(type=&quot;application/ld+json&quot;).        &#123;            &quot;@type&quot;: &quot;WebSite&quot;,            &quot;name&quot;: &quot;#&#123;config.title&#125;&quot;,            &quot;url&quot;: &quot;#&#123;config.url&#125;&quot;        &#125;</code></div></pre><h2 id="四、总结">四、总结</h2><p>这些功能现在随主题一起维护。加密需要配合文章渲染，外链跳转需要处理页面中的链接，<code>Sitemap</code> 和结构化数据则使用站点与文章元信息。调整模板或渲染流程时，也要检查这些输出是否仍然正确。</p><h2 id="参考">参考</h2><ul><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Crypto_API" title="Web Crypto API" class="external-link" data-redirect="https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FWeb_Crypto_API">Web Crypto API</a></li><li><a href="https://schema.org/Article" title="Schema.org Article" class="external-link" data-redirect="https%3A%2F%2Fschema.org%2FArticle">Schema.org Article</a></li><li><a href="https://search.google.com/test/rich-results" title="Google 结构化数据测试" class="external-link" data-redirect="https%3A%2F%2Fsearch.google.com%2Ftest%2Frich-results">Google 结构化数据测试</a></li><li><a href="https://www.robotstxt.org/robotstxt.html" title="robots.txt 规范" class="external-link" data-redirect="https%3A%2F%2Fwww.robotstxt.org%2Frobotstxt.html">robots.txt 规范</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;文章加密、外链跳转和 &lt;code&gt;SEO&lt;/code&gt; 最早都是通过第三方插件实现的。重构主题时，我把它们移到了内部，方便与页面模板一起调整。下面记录 &lt;code&gt;AES-256-GCM&lt;/code&gt; 加密、外链拦截，以及 &lt;code&gt;Sitemap&lt;/code&gt;、&lt;code&gt;Robots.txt&lt;/code&gt; 和 &lt;code&gt;JSON-LD&lt;/code&gt; 的生成方式。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="安全" scheme="https://www.superheaoz.top/tags/%E5%AE%89%E5%85%A8/"/>
    
    <category term="SEO" scheme="https://www.superheaoz.top/tags/SEO/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（6）自建主题：核心功能实现</title>
    <link href="https://www.superheaoz.top/2026/06/1626/"/>
    <id>https://www.superheaoz.top/2026/06/1626/</id>
    <published>2026-06-19T04:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>主题的文章页需要处理代码复制、目录和外链，列表页则涉及分页、归档与统计。这篇按这些功能记录实现过程，其中复制按钮的对齐和加密文章的目录适配，来回调整了比较多次。</p><span id="more"></span><h2 id="一、文章渲染管线">一、文章渲染管线</h2><h3 id="1-1-渲染流程">1.1 渲染流程</h3><p>文章从 <code>Markdown</code> 到最终页面经过多个处理阶段：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-text">Hexo 解析 Markdown  → after_post_render 过滤器链    → code.js：代码块增强（行号 + 复制按钮 + 语言标签）    → redirect.js：外链重定向注入 data-redirect 属性    → encrypt.js：文章 AES-256-GCM 加密（可选）  → Pug 模板渲染    → post.pug：标题 / 元信息 / 内容 / 版权 / QR码 / 评论</code></div></pre><h3 id="1-2-代码块过滤器">1.2 代码块过滤器</h3><p>将 <code>&lt;pre&gt;&lt;code&gt;</code> 块增强为带语言标签、行号和复制按钮的容器：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-javascript">// scripts/filters/lib/code.jsconst reg = /&lt;pre&gt;&lt;code(.*?)&gt;([\s\S]*?)&lt;\/code&gt;&lt;\/pre&gt;/g;data.content = data.content.replace(reg, (match, attrs, content) =&gt; &#123;    const lang = attrs.match(/class=&quot;language-(.*?)&quot;/)?.[1] || &quot;code&quot;;    const codeHeader = `&lt;div class=&quot;code-header&quot;&gt;        &lt;span class=&quot;code-type&quot;&gt;$&#123;lang&#125;&lt;/span&gt;        &lt;div class=&quot;code-copy&quot;&gt;$&#123;copyButton&#125;&lt;/div&gt;    &lt;/div&gt;`;    // ... 行号生成&#125;);</code></div></pre><h3 id="1-3-复制按钮的-UI-调试">1.3 复制按钮的 UI 调试</h3><p>复制按钮本身逻辑不复杂，但<strong>对齐代码块的 UI 花了不少功夫</strong>。最初想做一个带动效的花哨设计 — 点击时有个旋转/缩放反馈，hover 时有渐变背景。来来回回调整了好几版 DOM 结构和 <code>CSS</code> 动画，最后发现效果反而不稳定，在不同代码块宽度下对齐经常出问题。</p><p>最终放弃了花里胡哨的 <code>DOM</code> 方案，改用<strong>纯 <code>JS</code> 实现</strong> — 直接操作 <code>classList</code> 和 <code>textContent</code>，不依赖复杂的 <code>CSS</code> 动画。效果虽然朴素，但稳定性好得多，各种代码块宽度下都能正确对齐。</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-javascript">// 简化后的复制逻辑button.addEventListener('click', () =&gt; &#123;    navigator.clipboard.writeText(codeContent).then(() =&gt; &#123;        button.textContent = copiedText;        setTimeout(() =&gt; &#123; button.textContent = copyText; &#125;, 2000);    &#125;);&#125;);</code></div></pre><h3 id="1-4-外链重定向">1.4 外链重定向</h3><p>构建时给外链添加 <code>data-redirect</code> 属性，运行时拦截点击：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-javascript">// scripts/filters/lib/redirect.jsconst shouldRedirect = (url) =&gt; &#123;    const host = new URL(url).hostname;    if (host === siteHostname) return false;  // 内链排除    if (method === &quot;include&quot;) return hostMatches(host, include);    if (hostMatches(host, exclude)) return false;  // 黑名单排除    return true;&#125;;</code></div></pre><p>支持两种模式：<code>exclude</code>（默认，所有外链重定向，黑名单除外）和 <code>include</code>（仅白名单域名重定向）。</p><h2 id="二、侧边栏双面板">二、侧边栏双面板</h2><h3 id="2-1-面板切换">2.1 面板切换</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-pug">// layout/_include/sidebar.pug- let showToc = enableToc &amp;&amp; is_post()if showToc  #sidebar-toc    != toc(page.content, &#123;list_number: ...&#125;)  .sidebar-menu-item  // 切换按钮  #sidebar-info.hideelse  #sidebar-info  #sidebar-toc.hide</code></div></pre><h3 id="2-2-TOC-面板的反复适配">2.2 TOC 面板的反复适配</h3><p>TOC 面板是调试次数最多的组件之一。它需要同时适配两个场景：</p><p><strong>场景一：侧边栏折叠/展开</strong></p><ul><li>侧边栏宽度从 <code>300px</code> 切换到 <code>0</code> 时，TOC 内容需要重新排版</li><li>高嵌套目录（三级以上）在窄宽度下文字溢出</li></ul><p><strong>场景二：文章加密</strong></p><ul><li>加密文章的 <code>page.content</code> 在构建时被替换为密文</li><li><code>toc()</code> 函数无法从密文生成目录</li><li>需要在加密前单独提取目录结构</li></ul><p>这两个场景反复调整了好多次。把目录逻辑放在主题内，修改时可以一起检查侧边栏样式和加密前的处理顺序，不用分头适配。</p><h3 id="2-3-滚动高亮">2.3 滚动高亮</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div></div><code class="language-javascript">// source/js/utils/scroll.js// 滚动时自动高亮当前章节updateActiveHeading() &#123;    const headings = document.querySelectorAll('.toc-item a');    const scrollTop = contentWrapper.scrollTop;    // 找到当前视口顶部最近的标题    headings.forEach(link =&gt; &#123;        const target = document.querySelector(link.getAttribute('href'));        if (target &amp;&amp; target.offsetTop &lt;= scrollTop + offset) &#123;            link.parentElement.classList.add('toc-active');        &#125;    &#125;);&#125;</code></div></pre><h2 id="三、分页系统">三、分页系统</h2><h3 id="3-1-首页分页">3.1 首页分页</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div></div><code class="language-javascript">// scripts/generators/lib/index.jsconst pagination = require(&quot;hexo-pagination&quot;);module.exports = function (locals) &#123;    const posts = locals.posts.sort(this.config.index_generator.order_by);    const stickyPosts = posts.data.sort((a, b) =&gt; (b.sticky || 0) - (a.sticky || 0));    return pagination(path, stickyPosts, &#123;        format: 'page/%d/',        layout: ['index'],        perPage: this.config.index_generator.per_page,    &#125;);&#125;;</code></div></pre><p>置顶文章通过 <code>sticky</code> 字段排序，确保始终显示在首页最前。</p><h3 id="3-2-文章前后导航">3.2 文章前后导航</h3><p>非分页页面（文章页）使用上一篇/下一篇文章导航，带 <code>border-animation</code> 效果。</p><h2 id="四、归档-标签-分类页面">四、归档/标签/分类页面</h2><h3 id="4-1-Timeline-样式归档">4.1 Timeline 样式归档</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-pug">// 按年份分组，年份 = 大圆点，文章 = 小圆点，竖线连接each year in Object.keys(groupedPosts)  .archive-year    .archive-year-marker     // 大圆点    .archive-year-title= year  each post in groupedPosts[year]    .archive-post-item      .archive-post-marker  // 小圆点      a(href=url_for(post.path))= post.title</code></div></pre><h3 id="4-2-标签云">4.2 标签云</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-pug">.tagcloud  != tagcloud(&#123;min_font: 0.75, max_font: 2, amount: 100,               color: true, start_color: '#A4D8FA', end_color: '#0790E8'&#125;)</code></div></pre><p>颜色从浅蓝渐变到深蓝，字号从 <code>0.75rem</code> 到 <code>2rem</code>。</p><h2 id="五、内置-PV-UV-统计计数器">五、内置 PV/UV 统计计数器</h2><h3 id="5-1-从卜算子到自建">5.1 从卜算子到自建</h3><p>统计功能的演进路径：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-text">localStorage（最早）  → 不跨浏览器，换设备数据丢失  → 卜算子 busuanzi（第三方服务）    → 服务不稳定，经常挂掉  → 自建 counter 服务    → 完全可控，支持 PV + UV</code></div></pre><p>卜算子不好用了，逼得只能自己写。这也是选择一体化集成的又一个原因 — 外部服务不可控，不如自己掌握。</p><h3 id="5-2-前端-UUID-Cookie">5.2 前端 UUID + Cookie</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div></div><code class="language-pug">// layout/_include/footer.pug — inline IIFE(function() &#123;    function getVisitorId() &#123;        var match = document.cookie.match(/dtc_uid=([^;]+)/);        if (match) return match[1];        var uid = crypto.randomUUID();        document.cookie = 'dtc_uid=' + uid + '; max-age=31536000; path=/; SameSite=Lax';        return uid;    &#125;    var visitorId = getVisitorId();    fetch(api + '?page=' + encodeURIComponent(location.pathname) + '&amp;uid=' + visitorId)        .then(r =&gt; r.json())        .then(d =&gt; &#123;            sv.textContent = d.site_uv || d.site_pv || 0;            pv.textContent = d.page_uv || d.page_pv || 0;        &#125;);&#125;)();</code></div></pre><h3 id="5-3-后端-counter-服务">5.3 后端 counter 服务</h3><p><code>doratiger-counter</code> 服务（<a href="https://github.com/DoraTiger/doratiger-counter" title="doratiger-counter GitHub" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fdoratiger-counter">GitHub 仓库</a>）使用内存布隆过滤器做 UV 去重：</p><ul><li><strong>PV</strong>：内存计数器，每 30 秒持久化到 <code>SQLite</code></li><li><strong>Site UV</strong>：布隆过滤器（100 万容量，7 个哈希函数）</li><li><strong>Page UV</strong>：<code>map[page]map[uid]struct&#123;&#125;</code></li><li><strong>安全</strong>：Origin 白名单校验，只允许来自博客域名的请求</li></ul><p>布隆过滤器的选择是因为它在内存占用和查询速度之间取得了很好的平衡 — 100 万容量只需要约 120KB 内存，远小于存储每个 <code>uid</code> 的 <code>map</code>。对于个人博客的访问量级别，误判率可以接受。</p><h2 id="六、文章附加功能">六、文章附加功能</h2><h3 id="6-1-阅读时间估算">6.1 阅读时间估算</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-pug">// layout/_include/post.pug- let wpm = theme.post_extend?.reading_time?.wpm ?? 300- let wordCount = page.content.replace(/&lt;[^&gt;]+&gt;/g, '').replace(/\s+/g, '').length- let readMin = Math.max(1, Math.ceil(wordCount / wpm))span= wordCount + ' 字 · 约 ' + readMin + ' 分钟'</code></div></pre><p><code>WPM</code> 参数可配置（默认 300），但中英文混排的字数统计有局限 — 去除 <code>HTML</code> 标签后按字符数计算，中文和英文都算一个字符，实际上中文的阅读速度比英文慢不少。300 这个值是参考了一些博客的通用设定，实际体验下来偏差不大，够用。</p><h3 id="6-2-文章-QR-码">6.2 文章 QR 码</h3><p>自行实现了 <code>ISO 18004</code> 标准的 <code>QR</code> 码生成器（纯 <code>JS</code>，无外部依赖），渲染为 <code>SVG</code> <code>data URL</code>。选择自己实现的原因和其他本地化组件一样 — 不需要在主题编译阶段引入第三方包，让 <code>node</code> 依赖尽量少，做到自包含。</p><h3 id="6-3-其他功能">6.3 其他功能</h3><table><thead><tr><th>功能</th><th>实现</th></tr></thead><tbody><tr><td>置顶标记</td><td>front-matter <code>sticky: 100</code>，样式 <code>.post-item-header-sticky</code></td></tr><tr><td>版权声明</td><td>CC BY-NC-SA 4.0，可在配置中修改</td></tr><tr><td>打赏按钮</td><td>支付宝/微信收款码展示</td></tr></tbody></table><p>类似的实现思路贯穿整个主题 — 很多插件功能都是参考学习后自行实现的，就是为了尽量减少第三方包的安装，保持主题的自包含性。</p><h2 id="七、总结">七、总结</h2><p>复制按钮和目录的逻辑都不算复杂，实际调试却花了不少时间。前者要适应不同宽度的代码块，后者还要处理侧边栏折叠和文章加密。以后修改文章页时，这几种情况都需要重新检查。</p><h2 id="参考">参考</h2><ul><li><a href="https://hexo.io/zh-cn/docs/helpers" title="Hexo 辅助函数" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Fhelpers">Hexo 过滤器文档</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Crypto_API" title="Web Crypto API" class="external-link" data-redirect="https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FWeb_Crypto_API">Web Crypto API</a></li><li><a href="https://en.wikipedia.org/wiki/Bloom_filter" title="布隆过滤器" class="external-link" data-redirect="https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FBloom_filter">布隆过滤器</a></li><li><a href="https://www.iso.org/standard/15172.html" title="ISO 18004 QR 码标准" class="external-link" data-redirect="https%3A%2F%2Fwww.iso.org%2Fstandard%2F15172.html">ISO 18004 QR 码标准</a></li><li><a href="https://github.com/DoraTiger/doratiger-counter" title="doratiger-counter GitHub" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fdoratiger-counter">doratiger-counter 项目</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;主题的文章页需要处理代码复制、目录和外链，列表页则涉及分页、归档与统计。这篇按这些功能记录实现过程，其中复制按钮的对齐和加密文章的目录适配，来回调整了比较多次。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="JavaScript" scheme="https://www.superheaoz.top/tags/JavaScript/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（5）自建主题：视觉系统</title>
    <link href="https://www.superheaoz.top/2026/06/50649/"/>
    <id>https://www.superheaoz.top/2026/06/50649/</id>
    <published>2026-06-19T03:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>上一篇整理了主题架构，这篇记录配色、背景动画和布局的实现。<code>DoraTiger</code> 最初沿用了 <code>Fan</code> 的暗色风格，后来逐步把颜色和尺寸提取成配置，也尝试用 <code>Canvas</code> 绘制星空和首页文字。</p><span id="more"></span><h2 id="一、暗色主题设计语言">一、暗色主题设计语言</h2><h3 id="1-1-为什么是暗色主题">1.1 为什么是暗色主题</h3><p><code>DoraTiger</code> 的暗色主题继承自 <code>Fan</code> 主题 — <code>Fan</code> 本身就采用了星空背景，整体偏暗色风格。从 <code>Fan</code> 重构时延续了这个基调，一方面视觉上已经习惯了，另一方面星空背景确实很适合 <code>Canvas</code> 动画的发挥。</p><p>中间考虑过加一个亮色主题，但一直没想好亮色对应的背景该用什么。纯白太单调，渐变又容易和内容区域冲突，就搁置了。所以目前只有暗色方案。</p><h3 id="1-2-颜色体系">1.2 颜色体系</h3><p>所有颜色通过 <code>theme-config()</code> 从配置文件读取，支持运行时覆盖：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-stylus">// source/css/_variable/variable.styl$color-theme = theme-config('style.color.theme', 'rgba(230, 119, 0, 1)');         // 强调色（橙）$color-sub-theme = theme-config('style.color.sub_theme', 'rgba(7, 144, 232, 1)'); // 辅助色（蓝）$color-text = theme-config('style.color.text', 'rgba(255, 255, 255, 1)');         // 主文字（白）$color-background = theme-config('style.color.background',  'radial-gradient(100% 100% at 70% 120%, rgba(33, 39, 80, 1) 10%, #020409 100%)');</code></div></pre><p>配色方案同样继承自 <code>Fan</code> 主题 — 橙色强调 + 蓝色辅助的组合在暗色背景下辨识度很高，一直沿用至今。亮色方案因为没找到合适的背景搭配，所以配色也没有重新调整。</p><p>设计原则：</p><ul><li><strong>深蓝黑径向渐变</strong>背景，不是纯黑 — 径向渐变让视觉有纵深感</li><li><strong>白色文字</strong> + <code>rgba</code> 透明度控制层级 — 主文字 <code>1.0</code>，次要文字 <code>0.6</code></li><li><strong>橙色强调</strong>用于交互反馈（<code>hover</code>、激活态），<strong>蓝色辅助</strong>用于链接</li></ul><h3 id="1-3-毛玻璃效果">1.3 毛玻璃效果</h3><p>内容区域使用半透明白色背景，配合边框和阴影实现毛玻璃感：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-stylus">$color-content-background = theme-config('style.color.content_background',  'rgba(255, 255, 255, 0.1)');</code></div></pre><h3 id="1-4-随机色板">1.4 随机色板</h3><p>自动生成 11 个颜色 class，用于社交链接圆点等动态着色场景：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-stylus">$colors = #495057 #f03e3e #ae3ec9 #7048e8 #4263eb #1098ad          #0ca678 #37b24d #f59f00 #f76707 #6f42c1;for color, i in $colors &#123;  .bg-color&#123;i&#125; &#123; background: color; &#125;&#125;</code></div></pre><h2 id="二、Canvas-动画系统">二、Canvas 动画系统</h2><h3 id="2-1-星空背景">2.1 星空背景</h3><p>全屏 <code>&lt;canvas&gt;</code> 元素 <code>#universe</code>，通过 <code>requestAnimationFrame</code> 驱动三种粒子效果：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div></div><code class="language-javascript">// source/js/layout/background.jsclass Background &#123;    constructor() &#123;        this.canva = document.getElementById(&quot;universe&quot;);        this.starDensity = 0.001; // 每像素星星密度        this.createUniverse();    &#125;    createUniverse() &#123;        // 三种星星：        // 1. 巨星：大尺寸，缓慢水平漂移        // 2. 彗星：快速对角线划过，带拖尾        // 3. 普通星：随机闪烁（opacity 变化）    &#125;&#125;</code></div></pre><p>画布覆盖在所有内容之下（<code>z-index: -1</code>），不影响交互。星星数量根据屏幕尺寸动态计算（<code>starDensity * width</code>），保证不同设备上都有合适的粒子密度。</p><p>移动端的 <code>Canvas</code> 性能表现一般 — 粒子数量虽然会根据屏幕缩小，但在低端设备上仍然有掉帧。目前没有做更激进的降级处理（比如关闭动画），算是一个已知的体验短板。</p><h3 id="2-2-Hero-文字渲染">2.2 Hero 文字渲染</h3><p>首页顶部的标题和副标题使用 <code>Canvas</code> 渲染，配合 <code>pretext</code> 排版引擎做精确文字布局：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div></div><code class="language-javascript">// source/js/layout/hero.jsclass Hero &#123;    resize() &#123;        const rect = this.canvas.parentElement.getBoundingClientRect();        this.width = rect.width;        // 根据容器宽度动态计算字体大小        const titleFontSize = Math.min(this.width / 10, 64);        // 使用 pretext 做精确排版（支持自动换行）        const prepared = prepareWithSegments(this.title, font);        const &#123; lines &#125; = layoutWithLines(prepared, maxWidth * dpr, lineHeight * dpr);    &#125;    draw(progress) &#123;        // 每行文字渐入动画（stagger + easeOutExpo）        const lineDelay = line.type === 'title' ? i * 0.08 : 0.4 + i * 0.06;        ctx.globalAlpha = eased;        ctx.translate(0, (1 - eased) * 20); // 滑入效果    &#125;&#125;</code></div></pre><p><code>pretext</code> 库是前一阵刷技术博客时偶然发现的，看着能做 <code>Canvas</code> 文字排版就随手加进来了，没有做太多调研。实际用下来基本能用，但在响应式布局上还有一些小 <code>bug</code>（比如侧边栏切换时文字居中计算不准确），目前还没完全修好。</p><h3 id="2-3-404-页面">2.3 404 页面</h3><p>Canvas 绘制发光的 “404” 文字 + 浮动粒子，倒计时后自动跳转首页：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-javascript">// source/js/layout/page404.jsclass Page404 &#123;    draw() &#123;        // 文字描边 + 发光效果        ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';        ctx.shadowBlur = 20;        ctx.strokeText('404', ...);        // 浮动粒子    &#125;&#125;</code></div></pre><h2 id="三、响应式布局">三、响应式布局</h2><h3 id="3-1-为什么用-JS-而非媒体查询">3.1 为什么用 JS 而非媒体查询</h3><p>最初考虑过媒体查询方案，但实际对比后选择了 <code>JS</code> 动态计算，原因有两个：</p><p><strong>媒体查询的局限</strong>：断点是固定的（比如 <code>768px</code>、<code>1024px</code>），但实际布局中每个区域的可用宽度不同。<code>header</code> 的宽度不等于 <code>sidebar</code> 的宽度，用统一的断点无法精确控制每个区域的行为。</p><p><strong>JS 的灵活性</strong>：通过 <code>getBoundingClientRect()</code> 获取实际可用宽度，可以精确到像素级别控制显示/隐藏。而且 <code>JS</code> 可以通过 <code>hook</code> 机制按需嵌入，媒体查询则需要在 <code>CSS</code> 中写死所有断点规则，维护成本更高。</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-javascript">// source/js/layout/header.jsfunction autoResizeHeaderRight() &#123;    const containerWidth = headerWrapper.getBoundingClientRect().width;    // 空间不足时依次隐藏：搜索按钮 → 站点标题 → 时钟    if (containerWidth &lt; 600) searchButton.style.display = 'none';    if (containerWidth &lt; 400) titleElement.style.display = 'none';&#125;</code></div></pre><p>不过移动端的自适应效果一般 — 主要是因为之前开发 <code>Spring Boot</code> + <code>Bootstrap</code> 项目时习惯了容器化排版，对 <code>flex</code> 布局有一种天然的偏好，所以做了自适应。但移动端屏幕尺寸有限，很多在桌面端好看的效果在手机上效果平平，算是一个妥协。</p><h3 id="3-2-侧边栏折叠">3.2 侧边栏折叠</h3><p>侧边栏通过 <code>CSS class</code> 切换控制显隐，配合 <code>transition</code> 实现平滑动画：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-stylus">// CSS#sidebar-container  width $sidebar-width  transition $transition-delay  &amp;.closed    width 0    min-width 0    visibility hidden</code></div></pre><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-javascript">// JS — 切换时通知其他组件function toggleSidebar() &#123;    sidebarContainer.classList.toggle(&quot;closed&quot;);    window.dispatchEvent(new Event('layoutchange'));&#125;</code></div></pre><p><code>layoutchange</code> 事件让 <code>Hero</code> 等组件感知布局变化，重新计算尺寸。</p><h3 id="3-3-Meta-项自动换行">3.3 Meta 项自动换行</h3><p>文章列表中，日期、分类、标签等 <code>meta</code> 项在内容过多时自动换行：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-stylus">.post-item-header-meta  display flex  flex-direction row  flex-wrap wrap        // 关键：内容超宽时换行  gap 0.25rem 0        // 行间垂直间距</code></div></pre><h2 id="四、可复用动画-Mixin">四、可复用动画 Mixin</h2><p>三个核心 <code>Mixin</code> 覆盖了大部分交互动画场景：</p><h3 id="4-1-下划线展开">4.1 下划线展开</h3><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-stylus">hover-underline($left = 0)  &amp;:after    left $left; width 0; height 0.125rem    background $color-theme  &amp;:hover    &amp;:after      left 0; width 100%</code></div></pre><p>下划线从指定位置展开到全宽，用于菜单项、链接等。</p><h3 id="4-2-四边框动画">4.2 四边框动画</h3><p>四个子元素 <code>.line-top/right/bottom/left</code>，<code>hover</code> 时从外部展开到完整边框。用于统计数字、友情链接等卡片。</p><h3 id="4-3-光扫按钮">4.3 光扫按钮</h3><p><code>::before</code> 伪元素实现光扫效果：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-stylus">button-hover-effect()  &amp;::before    left -100%    background linear-gradient(to left, rgba(255,255,255,0), rgba(255,255,255,0.5), rgba(255,255,255,0))  &amp;:hover::before    left 100%; transition left 0.5s ease</code></div></pre><p><code>CSS</code> 的动画语法确实绕，尤其是 <code>::before</code>/<code>::after</code> 伪元素配合 <code>transition</code> 的各种组合，踩了不少坑。最终真正熟练掌握的主要是 <code>Stylus</code> 的变量、<code>Mixin</code> 和函数体系，底层 <code>CSS</code> 动画还是在实践中慢慢摸索出来的。</p><h2 id="五、设计令牌系统">五、设计令牌系统</h2><p>所有视觉参数通过 <code>theme-config()</code> 从配置文件读取，用户可在 <code>_config.hexo-theme-doratiger.yml</code> 中覆盖：</p><table><thead><tr><th>令牌</th><th>默认值</th><th>可配置</th></tr></thead><tbody><tr><td><code>$color-theme</code></td><td>橙色</td><td>✅</td></tr><tr><td><code>$color-background</code></td><td>深蓝黑径向渐变</td><td>✅</td></tr><tr><td><code>$sidebar-width</code></td><td>300px</td><td>✅</td></tr><tr><td><code>$main-content-max-width</code></td><td>800px</td><td>✅</td></tr><tr><td><code>$transition-delay</code></td><td>all 0.5s</td><td>✅</td></tr></tbody></table><p>最初写样式的时候，颜色值直接写在各个 <code>.styl</code> 文件里：<code>post.styl</code> 里写一个 <code>rgba(230, 119, 0, 1)</code>，<code>sidebar.styl</code> 里又写一个，<code>header.styl</code> 里再来一个。后来想让主题色可配置，才发现到处都要改，费了不少功夫把十几个文件里的颜色逐一换成 <code>theme-config()</code> 引用，集中到 <code>_variable/variable.styl</code> 中管理。</p><p>这意味着用户无需修改任何 <code>Stylus</code> 代码，只改配置文件就能调整整个主题的视觉风格。</p><h2 id="六、总结">六、总结</h2><p>写这篇时，暗色方案已经能日常使用，亮色背景还没有想好。移动端的动画性能和布局效果一般，<code>pretext</code> 在侧边栏切换时也还有排版问题。这些地方仍需要继续调整。</p><h2 id="参考">参考</h2><ul><li><a href="https://www.stylus-lang.cn/" title="Stylus 中文文档" class="external-link" data-redirect="https%3A%2F%2Fwww.stylus-lang.cn%2F">Stylus 中文文档</a></li><li><a href="https://github.com/nicholasgasior/gopher-pretext" title="pretext Canvas 排版引擎" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fnicholasgasior%2Fgopher-pretext">pretext Canvas 排版引擎</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API" title="MDN Canvas API" class="external-link" data-redirect="https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FCanvas_API">MDN Canvas API</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Flexible_Box_Layout" title="CSS Flexbox 布局" class="external-link" data-redirect="https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FCSS%2FCSS_Flexible_Box_Layout">CSS Flexbox 布局</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;上一篇整理了主题架构，这篇记录配色、背景动画和布局的实现。&lt;code&gt;DoraTiger&lt;/code&gt; 最初沿用了 &lt;code&gt;Fan&lt;/code&gt; 的暗色风格，后来逐步把颜色和尺寸提取成配置，也尝试用 &lt;code&gt;Canvas&lt;/code&gt; 绘制星空和首页文字。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="CSS" scheme="https://www.superheaoz.top/tags/CSS/"/>
    
    <category term="Canvas" scheme="https://www.superheaoz.top/tags/Canvas/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（4）自建主题：架构与设计哲学</title>
    <link href="https://www.superheaoz.top/2026/06/13838/"/>
    <id>https://www.superheaoz.top/2026/06/13838/</id>
    <published>2026-06-19T02:00:00.000Z</published>
    <updated>2026-09-08T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>前几篇记录了 <code>Hexo</code> 的基础用法，下面开始整理自己开发 <code>DoraTiger</code> 主题的过程。这一篇先说为什么从 <code>Fan</code> 迁移，以及主题的配置如何合并、脚本如何加载。</p><span id="more"></span><h2 id="一、从贡献者到独立开发者">一、从贡献者到独立开发者</h2><h3 id="1-1-与-Fan-主题的渊源">1.1 与 Fan 主题的渊源</h3><p>我最初使用的是 <code>Fan</code> 主题，并且在 <code>GitHub</code> 上为其贡献过代码，算是 <code>Fan</code> 主题的贡献者之一。使用了相当长一段时间，整体体验不错。</p><p>但后来作者长期没有更新，而 <code>Hexo</code> 版本在不断迭代。随着 <code>Hexo 7.x</code> 的发布，一些 <code>API</code> 和行为发生了变化，<code>Fan</code> 主题逐渐出现了兼容性问题。加上我当时正好有空，想深入学习一下 <code>Pug</code>、<code>Stylus</code>、<code>ES6 Modules</code> 等前端技术，所以决定从头构建一个自己的主题。</p><h3 id="1-2-为什么选择一体化集成">1.2 为什么选择一体化集成</h3><p>最终没有选择继续维护 <code>Fan</code> 的 fork，而是完全重写，主要考虑的是一体化集成的设计理念。<code>Hexo</code> 主题生态中有大量优秀的第三方插件（加密、搜索、<code>sitemap</code>、统计等），但在实际使用中，将这些功能分散在不同插件中会带来一些问题：</p><ul><li>各插件的配置格式不统一，维护成本高</li><li>插件之间可能存在兼容性冲突</li><li>功能逻辑分散在多处，排查问题困难</li><li>升级 <code>Hexo</code> 版本时，需要逐个检查插件兼容性</li></ul><p>我把这些常用功能集成到 <code>DoraTiger</code> 内部，统一处理配置和加载逻辑。相关实现的原始来源和引用链接保留在主题 <code>README.md</code> 中。</p><h3 id="1-3-重写目标">1.3 重写目标</h3><ul><li><strong>一体化集成</strong> — 加密、搜索、<code>sitemap</code>、统计、外链拦截等功能直接内置</li><li>暗色主题 + <code>Canvas</code> 动画</li><li>配置文档（<code>docs/CONFIG.md</code>）</li><li>模块化脚本架构，方便扩展</li></ul><h2 id="二、技术选型">二、技术选型</h2><h3 id="2-1-模板引擎：Pug">2.1 模板引擎：Pug</h3><p><code>Hexo</code> 主题支持 <code>Pug</code> 和 <code>EJS</code> 两种模板引擎。选择 <code>Pug</code> 的原因：</p><ul><li><strong>缩进语法</strong>：嵌套结构一目了然，不需要成对的 <code>&lt;% %&gt;</code> 标签</li><li><strong>原生支持</strong>：<code>extends</code>/<code>block</code>/<code>include</code>/<code>mixin</code> 等模板功能</li><li><strong>与 Stylus 一致</strong>：同为简洁表达力强的设计哲学</li></ul><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-pug">// Pug 示例：条件渲染 + 循环if is_post()  .post-item-header-meta    each tag in post.tags.data      .post-item-header-meta-item        a(href=url_for(tag.path))= tag.name</code></div></pre><p>从 <code>Fan</code> 主题迁移到 <code>Pug</code> 的过程中，<code>Hexo</code> 版本升级没有遇到严重的不兼容问题。主要是一些小的加载逻辑 <code>bug</code>，比如模板继承顺序、变量作用域等，调试后都能解决。<code>Pug</code> 本身在 <code>Hexo</code> 各版本间保持了良好的向后兼容性。</p><h3 id="2-2-样式预处理：Stylus">2.2 样式预处理：Stylus</h3><p><code>Stylus</code> 的缩进语法与 <code>Pug</code> 一致，支持变量、<code>Mixin</code>、函数，适合构建设计令牌系统：</p><pre><div class="code-header"><span class="code-header-type">stylus</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-stylus">// 设计令牌：所有值通过 theme-config() 从配置文件读取$color-theme = theme-config('style.color.theme', 'rgba(230, 119, 0, 1)');$color-background = theme-config('style.color.background',  'radial-gradient(100% 100% at 70% 120%, rgba(33, 39, 80, 1) 10%, #020409 100%)');</code></div></pre><h3 id="2-3-客户端脚本：ES6-Modules">2.3 客户端脚本：ES6 Modules</h3><p>所有客户端脚本使用 <code>ES6 Modules</code>，浏览器原生支持，无需构建步骤：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div></div><code class="language-javascript">// source/js/main.js — 入口文件import &#123; initClock &#125; from &quot;./layout/header.js&quot;;import &#123; initToggleSidebar &#125; from &quot;./layout/sidebar.js&quot;;import ScrollHandler from &quot;./utils/scroll.js&quot;;import Background from &quot;./layout/background.js&quot;;document.addEventListener(&quot;DOMContentLoaded&quot;, () =&gt; &#123;    initClock();    initToggleSidebar();    new ScrollHandler();  // 有状态组件用 class    new Background();     // Canvas 动画&#125;);</code></div></pre><p>两种初始化模式并存：无状态工具用 <code>init*()</code> 函数，有状态组件用 <code>new Class()</code> 实例化。</p><h2 id="三、三层配置合并与加载调试">三、三层配置合并与加载调试</h2><p>直接修改主题的 <code>_config.yml</code>，升级时需要处理自己的配置与上游改动。因此我把用户配置放在主仓库中，加载时再与主题默认值合并：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-text">优先级（高 → 低）：  _config.hexo-theme-doratiger.yml  ← 用户覆盖（推荐）  source/_data/doratiger_config.yml ← 已废弃  themes/xxx/_config.yml            ← 主题默认（993 行）</code></div></pre><h3 id="3-1-合并实现">3.1 合并实现</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-javascript">// scripts/events/lib/themeConfig.js — 核心合并逻辑themeMergeConfig = merge(&#123;&#125;, defaultThemeConfig);           // 层 1：主题默认值if (isNotEmptyObject(dataThemeConfig)) &#123;    themeMergeConfig = merge(&#123;&#125;, themeMergeConfig, dataThemeConfig);  // 层 2：用户数据&#125;if (isNotEmptyObject(rootThemeConfig)) &#123;    themeMergeConfig = merge(&#123;&#125;, themeMergeConfig, rootThemeConfig);  // 层 3：根目录覆盖（最高优先级）&#125;</code></div></pre><p>使用深度合并（<code>deep merge</code>），嵌套对象不会被整体替换，而是逐字段合并。</p><h3 id="3-2-调试过程中的最大坑：配置加载时序">3.2 调试过程中的最大坑：配置加载时序</h3><p>最初实现三层合并时遇到了一个很大的问题：<strong>多处配置的加载顺序不一致</strong>。<code>Hexo</code> 的生命周期中，配置在不同阶段被读取，但有些模块需要的环境变量在配置还没加载完的时候就被引用了。结果就是部分功能的配置项读不到值，表现为&quot;配置写了但不生效&quot;。</p><p>排查后发现根本原因是：<code>Hexo</code> 的 <code>ready</code> 事件、<code>generateBefore</code> 事件、模板渲染阶段各自读取配置的时机不同，如果没有统一的加载器，就很容易出现时序问题。</p><p>最终参考了其他主题的实现思路，<strong>完全自行构建了变量加载器</strong>，在 <code>ready</code> 事件中一次性完成所有配置的读取和合并，然后在 <code>generateBefore</code> 阶段统一写回。这样无论后续哪个模块读取配置，拿到的都是完整且正确的值。</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-text">修复前：  ready → 部分模块读配置 → generateBefore → 其他模块读配置（不一致）修复后：  ready → themeConfig.js 统一加载三层配置 → mergeConfig.js 一次性写回  generateBefore → 所有模块读到的配置一致</code></div></pre><h3 id="3-3-两步模式的优势">3.3 两步模式的优势</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-javascript">// scripts/events/lib/mergeConfig.jshexo.theme.config = merge(&#123;&#125;, themeConfig, doratiger.config);hexo.theme.i18n.data = merge(&#123;&#125;, themeI18nConfig.data, doratiger.i18n.data);</code></div></pre><p>这种两步模式（<code>themeConfig</code> 收集 → <code>mergeConfig</code> 应用）将配置解析与 <code>Hexo</code> 内部生命周期解耦。配置的&quot;计算&quot;和&quot;生效&quot;分离，便于调试和扩展。</p><h2 id="四、脚本注入架构">四、脚本注入架构</h2><h3 id="4-1-Hook-的生效机制">4.1 Hook 的生效机制</h3><p><code>Hexo</code> 的 <code>injector</code> 系统提供四个生命周期钩子：<code>head_begin</code>、<code>head_end</code>、<code>body_begin</code>、<code>body_end</code>。理解这些钩子的生效时机是正确注入内容的关键：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-text">HTML 渲染顺序：  &lt;head&gt;    head_begin  ← 非常早期，DOM 还没构建    head_end    ← head 尾部，适合 CSS 和配置脚本  &lt;/head&gt;  &lt;body&gt;    body_begin  ← body 开头    [页面内容]    body_end    ← body 尾部，适合 JS 和评论初始化  &lt;/body&gt;</code></div></pre><h3 id="4-2-空-hook-的意义">4.2 空 hook 的意义</h3><p><code>DoraTiger</code> 中有些 hook 当前是空的（如 <code>head_begin</code>），但仍然注册了默认内容。这是有意为之 — 为将来可能的功能预留注入点，避免后续添加功能时需要修改核心注入逻辑：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-javascript">// scripts/injectors/index.js// 即使当前为空，也保持注册hexo.extend.injector.register(&quot;head_begin&quot;, () =&gt; &#123;&#125;, &quot;default&quot;);hexo.extend.injector.register(&quot;body_begin&quot;, () =&gt; &#123;&#125;, &quot;default&quot;);</code></div></pre><h3 id="4-3-实际注入内容">4.3 实际注入内容</h3><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div></div><code class="language-javascript">// head_end：注入 CSS + 搜索配置hexo.extend.injector.register(&quot;head_end&quot;, function () &#123;    let inject_content = [];    inject_content.push(require(&quot;./lib/injector-config.js&quot;)(hexo));    inject_content.push(require(&quot;./lib/injector-search.js&quot;)(hexo));    inject_content.push(require(&quot;./lib/injector-resource.js&quot;)(hexo, &quot;css&quot;));    return inject_content.join(&quot;\n&quot;);&#125;, &quot;default&quot;);// body_end：注入 JS + 评论 + 统计hexo.extend.injector.register(&quot;body_end&quot;, () =&gt; &#123;    let inject_content = [];    inject_content.push(require(&quot;./lib/injector-resource.js&quot;)(hexo, &quot;js&quot;));    inject_content.push(require(&quot;./lib/injector-resource.js&quot;)(hexo, &quot;script&quot;));    inject_content.push(require(&quot;./lib/injector-comments.js&quot;)(hexo));    return inject_content.join(&quot;\n&quot;);&#125;, &quot;default&quot;);</code></div></pre><h3 id="4-4-按功能开关加载资源">4.4 按功能开关加载资源</h3><p>资源注入按功能开关控制 — 只有启用的功能才注入对应资源：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-javascript">// scripts/injectors/lib/injector-resource.jsconst search = theme.search || &#123;&#125;;if (search.enable &amp;&amp; search_type) &#123;    resources.push(loadResource(resource[search_type], resourceType, globalCDN));&#125;if (statistics.enable) &#123;    resources.push(loadResource(resource[statistics_type], resourceType, globalCDN));&#125;</code></div></pre><h2 id="五、内置第三方库的设计决策">五、内置第三方库的设计决策</h2><p><code>DoraTiger</code> 将 <code>highlight.js</code>、<code>mathjax</code>、<code>font-awesome</code> 等第三方库内置在 <code>source/lib/</code> 下，而非通过 <code>CDN</code> 引用。这个决策背后有两个实际原因：</p><p><strong>国内网络问题</strong>：部分 <code>CDN</code> 在国内访问不稳定，<code>jsDelivr</code> 等偶尔会被限速或不可达。把库随站点一起提供，可以减少对这些外部地址的依赖。</p><p><strong>内网开发需求</strong>：写博客时经常处于内网环境，没有外网连接。如果依赖 <code>CDN</code>，本地预览时样式和功能都会缺失。内置库让离线开发成为可能。</p><p>为此设计了<strong>本地 + CDN 双重机制</strong>，每个资源都可以独立切换：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-yaml">resource:  enable_cdn: false        # 全局默认：本地  highlight:    enable_cdn: true       # 代码高亮强制 CDN（库体积大）  mathjax:    enable_cdn: false      # 数学渲染用本地（稳定性优先）</code></div></pre><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-text">source/lib/ 内置库清单：├── highlight.js/@11.10.0/     # 代码高亮├── mathjax/@3.2.2/            # 数学渲染├── font-awesome/@6.7.2/       # 图标字体├── twikoo/@1.6.40/            # 评论系统├── valine/@1.5.3/             # 评论系统├── instantsearch.js/          # Algolia 搜索 UI├── pretext/                   # Canvas 文字排版└── prism.js/@1.29.0/          # 备用代码高亮</code></div></pre><h2 id="六、目录结构设计">六、目录结构设计</h2><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div></div><code class="language-text">themes/hexo-theme-doratiger/├── layout/                    # 模板层│   ├── *.pug                  # 页面模板（index/post/archive...）│   └── _include/              # 可复用组件│       ├── _layout.pug        # 主布局骨架│       ├── head.pug           # SEO/OG/JSON-LD│       ├── header.pug         # 导航栏│       ├── footer.pug         # 底栏 + 统计 + 备案│       └── sidebar.pug        # 侧边栏路由├── scripts/                   # 服务端脚本层│   ├── events/                # 生命周期钩子│   ├── filters/               # 内容过滤器│   ├── generators/            # 页面生成器│   ├── injectors/             # 资源注入器│   └── console/               # CLI 命令├── source/                    # 静态资源层│   ├── css/                   # Stylus 样式│   ├── js/                    # ES6 客户端脚本│   └── lib/                   # 内置第三方库└── languages/                 # i18n 翻译</code></div></pre><p>关键设计决策：</p><table><thead><tr><th>决策</th><th>选择</th><th>原因</th></tr></thead><tbody><tr><td>模板引擎</td><td>Pug</td><td>缩进语法，嵌套清晰</td></tr><tr><td>样式预处理</td><td>Stylus</td><td>与 Pug 设计哲学一致</td></tr><tr><td>JS 模块化</td><td>ES6 Modules</td><td>浏览器原生支持，无构建步骤</td></tr><tr><td>第三方库</td><td>内置 <code>source/lib/</code></td><td>国网不稳定 + 内网离线需求</td></tr><tr><td>配置方式</td><td>三层合并 + 统一加载器</td><td>解决多处配置加载时序问题</td></tr><tr><td>主题色</td><td>深蓝黑 + 橙色</td><td>暗色为主，橙色强调</td></tr><tr><td>Hook 注册</td><td>全部注册（含空 hook）</td><td>预留扩展点，保持架构一致</td></tr></tbody></table><h2 id="七、总结">七、总结</h2><p>这部分开发中，花时间最多的是配置加载时序。配置写在文件里却没有生效时，需要检查读取发生在哪个阶段。统一加载器之后，加密、搜索和统计都从合并后的配置取值，排查起来方便了不少。第三方库则保留本地文件，方便在没有外网的环境下预览。</p><h2 id="参考">参考</h2><ul><li><a href="https://github.com/DoraTiger/hexo-theme-doratiger" title="DoraTiger 主题仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhexo-theme-doratiger">DoraTiger 主题 GitHub</a></li><li><a href="https://hexo.io/zh-cn/docs/themes" title="Hexo 主题文档" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Fthemes">Hexo 主题开发文档</a></li><li><a href="https://pugjs.org/" title="Pug 模板引擎" class="external-link" data-redirect="https%3A%2F%2Fpugjs.org%2F">Pug 模板引擎</a></li><li><a href="https://stylus-lang.com/" title="Stylus CSS 预处理器" class="external-link" data-redirect="https%3A%2F%2Fstylus-lang.com%2F">Stylus CSS 预处理器</a></li><li><a href="https://github.com/nicehash/hexo-theme-fan" title="Fan 主题" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Fnicehash%2Fhexo-theme-fan">Fan 主题</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;前几篇记录了 &lt;code&gt;Hexo&lt;/code&gt; 的基础用法，下面开始整理自己开发 &lt;code&gt;DoraTiger&lt;/code&gt; 主题的过程。这一篇先说为什么从 &lt;code&gt;Fan&lt;/code&gt; 迁移，以及主题的配置如何合并、脚本如何加载。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="主题开发" scheme="https://www.superheaoz.top/tags/%E4%B8%BB%E9%A2%98%E5%BC%80%E5%8F%91/"/>
    
    <category term="架构设计" scheme="https://www.superheaoz.top/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
  </entry>
  
  <entry>
    <title>HEXO 开发笔记（3）进阶功能</title>
    <link href="https://www.superheaoz.top/2026/06/5806/"/>
    <id>https://www.superheaoz.top/2026/06/5806/</id>
    <published>2026-06-19T01:00:00.000Z</published>
    <updated>2026-06-19T01:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>前两篇分别介绍了 <code>Hexo</code> 的基础知识与插件生态。本文作为系列第三篇，聚焦官方文档中相对进阶但实用的功能模块：标签插件、数据文件夹、服务器配置、生成器原理和国际化支持。这些功能在日常博客维护和主题开发中经常用到，但容易被忽略。</p><span id="more"></span><h2 id="一、标签插件">一、标签插件</h2><p>标签插件是 <code>Hexo</code> 内置的模板标签，可以在 <code>Markdown</code> 文章中直接使用，无需安装额外插件。它们的语法与普通 <code>Markdown</code> 不同，需要直接写在 <code>Markdown</code> 中（不能被 <code>Markdown</code> 语法包裹）。</p><h3 id="1-1-引用块（blockquote）">1.1 引用块（blockquote）</h3><p>用于添加引文，支持作者、来源、链接：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">&#123;% blockquote [作者[, 来源]] [链接] [链接标题] %&#125;内容&#123;% endblockquote %&#125;</code></div></pre><p>示例：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">&#123;% blockquote David Levithan, Wide Awake %&#125;Do not just seek happiness for yourself. Seek happiness for all.&#123;% endblockquote %&#125;</code></div></pre><h3 id="1-2-代码块（codeblock）">1.2 代码块（codeblock）</h3><p>比普通 <code>```</code> 代码块功能更丰富，支持标题、链接、行号高亮：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">&#123;% codeblock [标题] [lang:语言] [URL] [链接文字] [line_number:false] [mark:1,4-7] %&#125;代码&#123;% endcodeblock %&#125;</code></div></pre><p>也支持反引号语法：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">``` [language] [title] [url] [link text]代码```</code></div></pre><h3 id="1-3-已废弃标签（Hexo-7-0-）">1.3 已废弃标签（Hexo 7.0+）</h3><p>以下标签在 <code>Hexo 7.0.0</code> 中已移除，需使用 <code>hexo-tag-embed</code> 插件替代：</p><table><thead><tr><th>标签</th><th>状态</th></tr></thead><tbody><tr><td><code>&#123;% jsfiddle %&#125;</code></td><td>❌ 已移除</td></tr><tr><td><code>&#123;% gist %&#125;</code></td><td>❌ 已移除</td></tr><tr><td><code>&#123;% youtube %&#125;</code></td><td>❌ 已移除</td></tr><tr><td><code>&#123;% vimeo %&#125;</code></td><td>❌ 已移除</td></tr></tbody></table><h3 id="1-4-文章引用">1.4 文章引用</h3><p>在文章中引用其他文章，自动解析路径：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">&#123;% post_link 文章文件名 %&#125;&#123;% post_link 文章文件名 '自定义文字' %&#125;&#123;% post_link 文章文件名 '标题' false %&#125;</code></div></pre><p>这是系列文章间互相引用的常用方式。</p><h3 id="1-4-资源引用">1.4 资源引用</h3><p>配合资源文件夹使用，引用文章的附件资源：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">&#123;% asset_img 图片名 %&#125;&#123;% asset_img 图片名 宽度 高度 %&#125;&#123;% asset_link 文件名 %&#125;</code></div></pre><h3 id="1-5-其他标签">1.5 其他标签</h3><table><thead><tr><th>标签</th><th>用途</th></tr></thead><tbody><tr><td><code>...</code></td><td>阻止内容被渲染</td></tr><tr><td><code>&#123;% iframe URL [宽] [高] %&#125;</code></td><td>嵌入 iframe</td></tr><tr><td><code>&#123;% img class /path [宽] [高] "标题" "替代文字" %&#125;</code></td><td>插入图片</td></tr><tr><td><code>&#123;% link 文字 URL [external] %&#125;</code></td><td>插入链接</td></tr><tr><td><code>&#123;% include_code [标题] lang:语言 path %&#125;</code></td><td>嵌入代码文件</td></tr><tr><td><code>&lt;!-- more --&gt;</code></td><td>文章摘要截断点</td></tr></tbody></table><h2 id="二、数据文件夹">二、数据文件夹</h2><p><code>Hexo</code> 提供两种数据文件机制，用于在模板中使用自定义数据。</p><h3 id="2-1-source-data-目录">2.1 source/_data 目录</h3><p>在 <code>source/_data/</code> 下创建 <code>YAML</code> 或 <code>JSON</code> 文件，即可在模板中通过 <code>site.data.文件名</code> 访问：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-yaml"># source/_data/menu.ymlHome: /Gallery: /gallery/Archives: /archives/</code></div></pre><p>模板中使用（EJS / Pug 语法对照）：</p><pre><div class="code-header"><span class="code-header-type">ejs</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-ejs">&lt;% for (var link in site.data.menu) &#123; %&gt;  &lt;a href=&quot;&lt;%= site.data.menu[link] %&gt;&quot;&gt;&lt;%= link %&gt;&lt;/a&gt;&lt;% &#125; %&gt;</code></div></pre><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-pug">each link, name in site.data.menu  a(href=link)= name</code></div></pre><p><strong>典型用途</strong>：自定义导航菜单、友情链接列表、站点配置数据等。</p><h3 id="2-2-资源文件夹（post-asset-folder）">2.2 资源文件夹（post_asset_folder）</h3><p>在 <code>_config.yml</code> 中启用 <code>post_asset_folder: true</code> 后，每篇文章可以有同名的资源目录：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-text">source/_posts/├── my-article.md└── my-article/    ├── image1.png    └── data.csv</code></div></pre><p>文章中引用：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">&#123;% asset_img image1.png %&#125;</code></div></pre><h2 id="三、服务器">三、服务器</h2><h3 id="3-1-基本使用">3.1 基本使用</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash">npm install hexo-server --savehexo server          # 默认 http://localhost:4000hexo server -p 5000  # 指定端口hexo server -i 127.0.0.1  # 指定 IPhexo server -s       # 静态模式（不监视文件变动）</code></div></pre><h3 id="3-2-后台运行">3.2 后台运行</h3><p><code>hexo server</code> 默认在前台运行，终端关闭后进程会被杀掉。需要后台持久运行时：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash"># 脱离终端会话setsid hexo server -p 4000 &gt; /tmp/hexo.log 2&gt;&amp;1 &amp;# 或使用 nohupnohup hexo server -p 4000 &gt; /tmp/hexo.log 2&gt;&amp;1 &amp;</code></div></pre><h3 id="3-3-常用场景">3.3 常用场景</h3><table><thead><tr><th>场景</th><th>命令</th></tr></thead><tbody><tr><td>本地开发</td><td><code>hexo server</code></td></tr><tr><td>局域网访问</td><td><code>hexo server -i 0.0.0.0</code></td></tr><tr><td>调试生产构建</td><td><code>hexo generate &amp;&amp; hexo server -s</code></td></tr><tr><td>端口被占用</td><td><code>hexo server -p 5000</code></td></tr><tr><td>后台运行</td><td><code>setsid hexo server -p 4000 &gt; /tmp/hexo.log 2&gt;&amp;1 &amp;</code></td></tr></tbody></table><h3 id="3-4-静态模式与动态模式">3.4 静态模式与动态模式</h3><ul><li><strong>动态模式</strong>（默认）：监视文件变动，修改 <code>Markdown</code> 后自动刷新</li><li><strong>静态模式</strong>（<code>-s</code>）：只处理 <code>public/</code> 目录，需要先 <code>hexo generate</code>，适合模拟生产环境</li></ul><h3 id="3-5-注意事项">3.5 注意事项</h3><ul><li><code>hexo server</code> 的 <code>0.0.0.0</code> 监听在生产环境不安全，应通过 <code>nginx</code> 反代</li><li>静态模式下修改源文件后需要重新 <code>hexo generate</code> 才能生效</li><li>后台运行时建议使用 <code>setsid</code> 而非 <code>nohup</code>，以完全脱离终端会话</li></ul><h2 id="四、生成器">四、生成器</h2><p><code>Hexo</code> 的生成器（Generator）负责将 <code>source/</code> 目录中的文件转换为 <code>public/</code> 目录中的静态文件。理解生成器的工作原理，有助于排查构建问题和开发自定义功能。</p><h3 id="4-1-内置生成器">4.1 内置生成器</h3><table><thead><tr><th>生成器</th><th>功能</th><th>输出</th></tr></thead><tbody><tr><td><code>index</code></td><td>首页分页</td><td><code>index.html</code>, <code>page/2/index.html</code></td></tr><tr><td><code>archive</code></td><td>归档页</td><td><code>archives/index.html</code></td></tr><tr><td><code>tag</code></td><td>标签归档</td><td><code>tags/tag-name/index.html</code></td></tr><tr><td><code>category</code></td><td>分类归档</td><td><code>categories/cat-name/index.html</code></td></tr><tr><td><code>post</code></td><td>文章页</td><td><code>2026/06/xxxxx/index.html</code></td></tr><tr><td><code>page</code></td><td>独立页面</td><td><code>about/index.html</code></td></tr></tbody></table><h3 id="4-2-自定义生成器">4.2 自定义生成器</h3><p>插件可以注册自定义生成器，通过 <code>hexo.extend.generator.register</code> 注册：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-javascript">hexo.extend.generator.register('my-generator', function(locals) &#123;  return &#123;    path: 'output-file.html',    data: locals,    layout: 'my-layout'  &#125;;&#125;);</code></div></pre><p>实际项目中常用的分页生成器借助 <code>hexo-pagination</code> 模块：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-javascript">const pagination = require(&quot;hexo-pagination&quot;);module.exports = function (locals) &#123;  const posts = locals.posts.sort(this.config.index_generator.order_by);  return pagination('', posts, &#123;    format: 'page/%d/',    layout: ['index'],    perPage: this.config.index_generator.per_page,  &#125;);&#125;;</code></div></pre><p>例如我的 <code>DoraTiger</code> 主题就自建了 11 个生成器（<code>sitemap</code>、<code>robots</code>、<code>404</code>、<code>about</code>、<code>tags</code>、<code>categories</code>、<code>redirect</code>、<code>terms</code>、<code>privacy</code>、<code>local-search</code>、<code>index</code>），替代了多个第三方插件。这些生成器在 <code>scripts/generators/lib/</code> 下独立实现，通过 <code>scripts/generators/index.js</code> 统一注册。</p><h3 id="4-3-数据流">4.3 数据流</h3><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-text">source/ → Generator → data 对象 → Layout 模板 → public/</code></div></pre><p><code>Generator</code> 接收 <code>locals</code>（全站数据），输出 <code>&#123;path, data, layout&#125;</code> 对象，<code>Hexo</code> 根据 <code>layout</code> 查找对应的模板文件进行渲染。</p><h2 id="五、国际化（i18n）">五、国际化（i18n）</h2><h3 id="5-1-语言文件">5.1 语言文件</h3><p>在 <code>languages/</code> 目录下创建语言文件：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div></div><code class="language-text">languages/├── zh-Hans.yml    # 简体中文├── zh-Hant.yml    # 繁体中文└── en.yml         # 英文</code></div></pre><p>文件内容为键值对：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-yaml"># languages/zh-Hans.ymlpost.created: 创建于post.modified: 更新于post.untitled: 无标题home.read_more: 阅读更多</code></div></pre><h3 id="5-2-模板中使用">5.2 模板中使用</h3><pre><div class="code-header"><span class="code-header-type">pug</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-pug">// Pug 模板span= _p(&quot;post.created&quot;) + &quot; &quot; + date(post.date)// EJS 模板&lt;span&gt;&lt;%= _p(&quot;post.created&quot;) %&gt; &lt;%= date(post.date) %&gt;&lt;/span&gt;</code></div></pre><h3 id="5-3-语言检测">5.3 语言检测</h3><p>根据 <code>_config.yml</code> 中的 <code>language</code> 设置自动选择：</p><pre><div class="code-header"><span class="code-header-type">yaml</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-yaml"># _config.ymllanguage: zh-Hans</code></div></pre><p><code>Hexo</code> 会优先查找精确匹配（<code>zh-Hans.yml</code>），找不到则回退到默认语言。</p><h2 id="六、总结">六、总结</h2><p>本文补充了 <code>Hexo</code> 官方文档中相对进阶但实用的功能模块。标签插件让文章排版更灵活，数据文件夹支持模板数据复用，服务器配置影响开发体验，生成器是 <code>Hexo</code> 构建的核心机制，国际化则为多语言站点提供基础。下一篇将进入自建主题的实战开发。</p><h2 id="参考">参考</h2><ul><li><a href="https://hexo.io/zh-cn/docs/" title="Hexo 官方文档" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2F">Hexo 官方文档</a></li><li><a href="https://hexo.io/zh-cn/docs/tag-plugins" title="Hexo 标签插件" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Ftag-plugins">Hexo 标签插件</a></li><li><a href="https://hexo.io/zh-cn/docs/data-files" title="Hexo 数据文件" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Fdata-files">Hexo 数据文件</a></li><li><a href="https://hexo.io/zh-cn/docs/server" title="Hexo 服务器" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Fserver">Hexo 服务器</a></li><li><a href="https://hexo.io/zh-cn/docs/themes" title="Hexo 主题" class="external-link" data-redirect="https%3A%2F%2Fhexo.io%2Fzh-cn%2Fdocs%2Fthemes">Hexo 模版</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;前两篇分别介绍了 &lt;code&gt;Hexo&lt;/code&gt; 的基础知识与插件生态。本文作为系列第三篇，聚焦官方文档中相对进阶但实用的功能模块：标签插件、数据文件夹、服务器配置、生成器原理和国际化支持。这些功能在日常博客维护和主题开发中经常用到，但容易被忽略。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="hexo" scheme="https://www.superheaoz.top/tags/hexo/"/>
    
    <category term="开发" scheme="https://www.superheaoz.top/tags/%E5%BC%80%E5%8F%91/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（3）开发环境一键配置</title>
    <link href="https://www.superheaoz.top/2026/06/3470/"/>
    <id>https://www.superheaoz.top/2026/06/3470/</id>
    <published>2026-06-18T14:00:00.000Z</published>
    <updated>2026-09-10T10:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>前两篇记录了迷你主机的选购和 <code>Debian</code> 系统的安装。系统装好后，接下来面临的问题是：如何快速配置完整的开发环境？之前在 <code>WSL</code> 下每次重装都要手动装 <code>Go</code>、<code>Java</code>、<code>Node.js</code>，还要配各种镜像源，流程繁琐且容易遗漏。这次决定把所有配置脚本化，形成一套可重复执行的 <code>setup</code> 工具，一键完成环境部署。本文记录整个配置体系的设计和实现过程。</p><span id="more"></span><ul><li>20260621：<code>01-apt-sources</code> 模块新增 <code>chrony</code> 时间同步服务安装，解决新装系统时间未校准导致 SSL 证书验证失败等问题。</li></ul><h2 id="一、设计思路">一、设计思路</h2><h3 id="1-1-核心原则">1.1 核心原则</h3><p>配置脚本的设计遵循几条基本原则：</p><ul><li><strong>幂等性</strong> — 每个脚本可反复执行，已安装的跳过、配置相同的跳过，不会产生覆盖或冗余</li><li><strong>缓存统一</strong> — 包管理器缓存（<code>apt</code>、<code>npm</code>、<code>pip</code>、<code>Go</code>、<code>Maven</code>）统一放在 <code>cache/</code> 目录下，重装系统不丢失</li><li><strong>安装包归档</strong> — 下载的安装包存放在 <code>packages/</code> 目录，按软件分类，支持离线部署</li><li><strong>国内镜像加速</strong> — 所有包管理器统一配置清华/阿里云镜像，解决国内下载慢的问题</li><li><strong>交互/静默双模式</strong> — 支持交互式菜单选择执行，也支持 <code>--silent</code> 参数用于自动化部署</li></ul><h3 id="1-2-目录结构">1.2 目录结构</h3><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div></div><code class="language-text">workspace/├── setup/│   ├── common.sh          # 公共函数（日志、路径、交互工具）│   ├── init.sh            # 入口脚本（菜单/静默模式）│   ├── modules/           # 模块脚本，按编号顺序执行│   │   ├── 00-ssh.sh│   │   ├── 01-apt-sources.sh│   │   └── ...│   ├── keys/              # SSH 公钥目录│   └── README.md          # 完整文档├── cache/                 # 运行时缓存│   ├── go/│   ├── maven/│   └── npm/└── packages/              # 安装包离线归档    ├── golang/    ├── miniconda/    ├── texlive/    └── zellij/</code></div></pre><h2 id="二、使用方式">二、使用方式</h2><h3 id="2-1-快速开始">2.1 快速开始</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div></div><code class="language-bash"># 克隆仓库mkdir -p &quot;$HOME/workspace&quot;git clone https://github.com/DoraTiger/homelab-setup.git &quot;$HOME/workspace/setup&quot;cd &quot;$HOME/workspace/setup&quot;# 交互式菜单bash init.sh# 静默执行全部bash init.sh --silent</code></div></pre><p>默认数据工作区为 <code>~/workspace</code>，可通过菜单中的 <code>w</code>、<code>--workspace-root</code> 参数或 <code>HOMELAB_WORKSPACE_ROOT</code> 环境变量修改。缓存和安装包默认放在工作区的 <code>cache/</code>、<code>packages/</code> 中；备份和公钥目录分别位于当前 setup 仓库的 <code>backup/</code>、<code>keys/</code>，不随数据工作区切换。</p><h3 id="2-2-交互式菜单">2.2 交互式菜单</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">bash init.sh</code></div></pre><p>启动后显示路径信息和模块列表，输入编号选择执行。以下按默认目录示意，使用 <code>~</code> 代指用户主目录；实际菜单显示展开后的绝对路径，模块列表作了省略：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div><div class="line-numbers-item">20</div><div class="line-numbers-item">21</div></div><code class="language-text">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  Homelab Debian + Xfce 环境配置━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  Setup:    ~/workspace/setup  工作区:   ~/workspace  缓存:     ~/workspace/cache  安装包:   ~/workspace/packages  备份:     ~/workspace/setup/backup  代理:     未配置  升级模式: 关闭━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  可用模块:  ────────────────────────────────────────────────    [ 0] 00-ssh.sh            SSH 密钥生成与 authorized_keys 配置    [ 1] 01-apt-sources.sh    APT 软件源配置    [ 2] 02-git-config.sh     Git 全局配置    ...    [13] 13-zellij.sh         Zellij 终端复用器安装  ────────────────────────────────────────────────    [a] 全部  [p] 代理  [u] 升级模式  [w] 工作区  [q] 退出</code></div></pre><h3 id="2-3-静默模式">2.3 静默模式</h3><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-bash"># 执行全部模块bash init.sh --silent# 执行指定模块（按编号）bash init.sh --silent 1 3# 使用代理bash init.sh --silent --proxy socks5://127.0.0.1:7890</code></div></pre><h3 id="2-3-代理配置">2.3 代理配置</h3><p>网络受限时可通过代理确保下载稳定。支持 <code>http://</code>、<code>https://</code>、<code>socks5://</code> 格式。模块中的 <code>wget</code>/<code>curl</code> 下载会自动走代理。</p><h2 id="三、模块详解">三、模块详解</h2><h3 id="3-1-基础设施">3.1 基础设施</h3><table><thead><tr><th>编号</th><th>模块</th><th>功能</th><th>升级方式</th></tr></thead><tbody><tr><td>00</td><td>ssh</td><td>生成密钥 + 合并远程公钥 + 自动设权限</td><td>—</td></tr><tr><td>01</td><td>apt-sources</td><td>清华镜像源（自动 DEB822/传统）+ 基础工具 + apt upgrade + chrony 时间同步</td><td>apt upgrade</td></tr><tr><td>02</td><td>git-config</td><td>交互式配置用户名/邮箱/默认分支/常用别名</td><td>—</td></tr></tbody></table><h3 id="3-2-容器与运行时">3.2 容器与运行时</h3><table><thead><tr><th>编号</th><th>模块</th><th>功能</th><th>升级方式</th></tr></thead><tbody><tr><td>03</td><td>docker</td><td>Docker CE + Compose + Buildx + 镜像加速 + daemon 配置</td><td>apt --only-upgrade</td></tr><tr><td>04</td><td>miniconda</td><td>Miniconda3 + conda/pip 清华镜像 + Shell 初始化</td><td>conda update --all</td></tr><tr><td>05</td><td>golang</td><td>Go 多版本管理 + GOPROXY 清华镜像 + symlink 切换</td><td>自动下载最新版</td></tr><tr><td>06</td><td>java</td><td>SDKMAN + Java 21 (Temurin) + Maven + 阿里云镜像</td><td>sdk selfupdate + sdk upgrade</td></tr><tr><td>07</td><td>nodejs</td><td>fnm + Node.js LTS + npmmirror + npm 全局目录</td><td>fnm upgrade + npm install -g npm@latest</td></tr></tbody></table><h3 id="3-3-语言工具链">3.3 语言工具链</h3><table><thead><tr><th>编号</th><th>模块</th><th>功能</th><th>升级方式</th></tr></thead><tbody><tr><td>08</td><td>perl-cpan</td><td>CPAN 清华镜像 + local::lib 用户级模块管理</td><td>—</td></tr><tr><td>09</td><td>r-lang</td><td>CRAN 清华镜像 + r-base-dev</td><td>apt upgrade</td></tr><tr><td>10</td><td>rust</td><td>rustup + Rust stable + <a href="http://crates.io" class="external-link" data-redirect="http%3A%2F%2Fcrates.io">crates.io</a> 清华镜像</td><td>rustup update stable</td></tr><tr><td>11</td><td>texlive</td><td>TeX Live 全量安装 + CTAN 清华镜像 + tlmgr</td><td>tlmgr update --all</td></tr></tbody></table><h3 id="3-4-桌面与工具">3.4 桌面与工具</h3><table><thead><tr><th>编号</th><th>模块</th><th>功能</th><th>升级方式</th></tr></thead><tbody><tr><td>12</td><td>xrdp</td><td>XRDP + XFCE4 + polkit WiFi 修复</td><td>apt upgrade</td></tr><tr><td>13</td><td>zellij</td><td>预编译二进制 + dracula 主题 + 快捷别名</td><td>重新下载覆盖</td></tr></tbody></table><h2 id="四、技术细节">四、技术细节</h2><h3 id="4-1-交互工具">4.1 交互工具</h3><p><code>common.sh</code> 提供统一的交互函数，所有模块共享：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div></div><code class="language-bash"># 单选菜单type=$(prompt_choice &quot;选择密钥类型:&quot; &quot;ed25519&quot; &quot;ed25519&quot; &quot;rsa-4096&quot; &quot;ecdsa&quot;)# 是/否确认if prompt_yesno &quot;是否配置镜像源?&quot; &quot;y&quot;; then ... fi# 文本输入name=$(prompt_input &quot;用户名:&quot; &quot;$(whoami)&quot;)# 密码输入（隐藏回显）pass=$(prompt_secret &quot;请输入密码&quot;)# 表格展示prompt_table &quot;文件名|类型|说明&quot; &quot;id_ed25519.pub|ed25519|本机密钥&quot;</code></div></pre><p>静默模式下所有函数自动使用默认值，不阻塞等待输入。</p><h3 id="4-2-幂等性设计">4.2 幂等性设计</h3><p>每个模块脚本遵循统一的幂等模式：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-bash"># 检测是否已安装if [ -f &quot;$INSTALL_DIR/bin/xxx&quot; ]; then    log_success &quot;xxx 已安装&quot;    # 检测是否需要升级    log_info &quot;检查升级...&quot;    xxx upgrade 2&gt;/dev/null || trueelse    # 执行安装    log_info &quot;安装 xxx...&quot;fi</code></div></pre><h3 id="4-3-环境变量管理">4.3 环境变量管理</h3><p>所有环境变量通过 <code>.bashrc.d/</code> 目录管理，每个模块一个文件：</p><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-text">~/.bashrc.d/├── conda.sh       # Miniconda├── go.sh          # Go├── nodejs.sh      # fnm + Node.js├── perl.sh        # Perl local::lib├── rust.sh        # Rust├── sdkman.sh      # SDKMAN├── texlive.sh     # TeX Live└── zellij.sh      # Zellij 别名</code></div></pre><p><code>.bashrc</code> 中通过统一的加载器遍历执行：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div></div><code class="language-bash"># Load environment snippetsif [ -d &quot;$HOME/.bashrc.d&quot; ]; then    for f in &quot;$HOME&quot;/.bashrc.d/*.sh; do        [ -f &quot;$f&quot; ] &amp;&amp; . &quot;$f&quot;    donefi</code></div></pre><h3 id="4-4-镜像源汇总">4.4 镜像源汇总</h3><table><thead><tr><th>工具</th><th>镜像源</th></tr></thead><tbody><tr><td>APT</td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/debian" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fdebian">https://mirrors.tuna.tsinghua.edu.cn/debian</a></td></tr><tr><td>Docker</td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/docker-ce" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fdocker-ce">https://mirrors.tuna.tsinghua.edu.cn/docker-ce</a></td></tr><tr><td>conda</td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/anaconda" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fanaconda">https://mirrors.tuna.tsinghua.edu.cn/anaconda</a></td></tr><tr><td>pip</td><td><a href="https://pypi.tuna.tsinghua.edu.cn" class="external-link" data-redirect="https%3A%2F%2Fpypi.tuna.tsinghua.edu.cn">https://pypi.tuna.tsinghua.edu.cn</a></td></tr><tr><td>Go</td><td><a href="https://goproxy.cn" class="external-link" data-redirect="https%3A%2F%2Fgoproxy.cn">https://goproxy.cn</a></td></tr><tr><td>Maven</td><td><a href="https://maven.aliyun.com" class="external-link" data-redirect="https%3A%2F%2Fmaven.aliyun.com">https://maven.aliyun.com</a></td></tr><tr><td>npm</td><td><a href="https://registry.npmmirror.com" class="external-link" data-redirect="https%3A%2F%2Fregistry.npmmirror.com">https://registry.npmmirror.com</a></td></tr><tr><td><a href="http://crates.io" class="external-link" data-redirect="http%3A%2F%2Fcrates.io">crates.io</a></td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/crates.io-index" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fcrates.io-index">https://mirrors.tuna.tsinghua.edu.cn/crates.io-index</a></td></tr><tr><td>CRAN</td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/CRAN" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2FCRAN">https://mirrors.tuna.tsinghua.edu.cn/CRAN</a></td></tr><tr><td>CTAN</td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/CTAN" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2FCTAN">https://mirrors.tuna.tsinghua.edu.cn/CTAN</a></td></tr><tr><td>rustup</td><td><a href="https://mirrors.tuna.tsinghua.edu.cn/rustup" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Frustup">https://mirrors.tuna.tsinghua.edu.cn/rustup</a></td></tr></tbody></table><h2 id="五、总结">五、总结</h2><p>整套 <code>setup</code> 工具覆盖了从 <code>SSH</code> 密钥到桌面环境的全链路配置，14 个模块脚本按依赖优先级排序，支持交互式选择和静默批量执行。所有工具统一安装到 <code>~/.local/opt/</code>，环境变量通过 <code>.bashrc.d/</code> 管理，国内镜像源统一配置，重复执行安全无副作用。后续新增模块只需在 <code>modules/</code> 下添加脚本，菜单自动识别。</p><p>本文涉及的所有脚本已开源在 <a href="https://github.com/DoraTiger/homelab-setup" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup</a> 仓库中，欢迎 <code>clone</code> 使用和反馈。</p><h2 id="参考">参考</h2><ul><li><a href="https://github.com/DoraTiger/homelab-setup" title="homelab-setup GitHub 仓库" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FDoraTiger%2Fhomelab-setup">homelab-setup GitHub</a></li><li><a href="https://mirrors.tuna.tsinghua.edu.cn/" title="清华大学开源软件镜像站" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2F">清华大学开源软件镜像站</a></li><li><a href="https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/" title="Docker CE 清华镜像" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fhelp%2Fdocker-ce%2F">Docker CE 清华镜像</a></li><li><a href="https://sdkman.io/install" title="SDKMAN 安装文档" class="external-link" data-redirect="https%3A%2F%2Fsdkman.io%2Finstall">SDKMAN 安装文档</a></li><li><a href="https://github.com/Schniz/fnm" title="fnm GitHub" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2FSchniz%2Ffnm">fnm GitHub</a></li><li><a href="https://mirrors.tuna.tsinghua.edu.cn/CTAN/systems/texlive/tlnet/" title="TeX Live 清华镜像" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2FCTAN%2Fsystems%2Ftexlive%2Ftlnet%2F">TeX Live 清华镜像</a></li><li><a href="https://mirrors.tuna.tsinghua.edu.cn/CTAN/info/install-latex-guide-zh-cn/install-latex-guide-zh-cn.pdf" title="TeX Live 安装指南（中文）" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2FCTAN%2Finfo%2Finstall-latex-guide-zh-cn%2Finstall-latex-guide-zh-cn.pdf">TeX Live 安装指南</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;前两篇记录了迷你主机的选购和 &lt;code&gt;Debian&lt;/code&gt; 系统的安装。系统装好后，接下来面临的问题是：如何快速配置完整的开发环境？之前在 &lt;code&gt;WSL&lt;/code&gt; 下每次重装都要手动装 &lt;code&gt;Go&lt;/code&gt;、&lt;code&gt;Java&lt;/code&gt;、&lt;code&gt;Node.js&lt;/code&gt;，还要配各种镜像源，流程繁琐且容易遗漏。这次决定把所有配置脚本化，形成一套可重复执行的 &lt;code&gt;setup&lt;/code&gt; 工具，一键完成环境部署。本文记录整个配置体系的设计和实现过程。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Linux" scheme="https://www.superheaoz.top/tags/Linux/"/>
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="开发环境" scheme="https://www.superheaoz.top/tags/%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（2）更换无线网卡与 XRDP WiFi 扫描授权问题</title>
    <link href="https://www.superheaoz.top/2026/06/35187/"/>
    <id>https://www.superheaoz.top/2026/06/35187/</id>
    <published>2026-06-16T15:00:00.000Z</published>
    <updated>2026-06-16T15:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>上一篇记录了迷你主机的选购和 <code>Debian</code> 系统的安装过程。本篇是一个小的硬件更新：将原装的联发科 <code>MT7921</code> 无线网卡更换为 <code>Intel AX210</code>，同时记录更换后在 <code>XRDP</code> 远程桌面环境下遇到的 <code>WiFi</code> 扫描授权问题及解决方案。</p><span id="more"></span><h2 id="一、更换无线网卡">一、更换无线网卡</h2><h3 id="1-1-为什么要换">1.1 为什么要换</h3><p><code>联想来酷 Mini Pro</code> 原装无线网卡是联发科 <code>MT7921</code>，网上普遍反映该网卡在 <code>Linux</code> 下存在偶发断连、休眠唤醒后无法恢复等稳定性问题。<code>Intel AX210</code> 是目前 <code>Linux</code> 兼容性最好的 <code>WiFi 6E</code> 网卡之一，<code>iwlwifi</code> 驱动由 <code>Intel</code> 官方维护，稳定性有保障。</p><h3 id="1-2-更换过程中的散热细节">1.2 更换过程中的散热细节</h3><p>拆机后发现一个值得注意的设计细节：<strong>原装硬盘和网卡是叠叠乐放置的</strong>，<code>NVMe</code> 硬盘直接叠在无线网卡上方，两者之间没有额外的散热隔层。这种布局下硬盘的热量会直接传导到网卡，长时间运行可能影响网卡稳定性。</p><p>更换网卡的同时，建议将第一块硬盘挪到另一个 <code>M.2</code> 插槽位置，拉开两者距离，改善散热条件。如果两个插槽位置固定无法更换，也可以在硬盘和网卡之间加一层导热垫或铜片辅助散热。</p><h3 id="1-3-AX210-基本信息">1.3 AX210 基本信息</h3><table><thead><tr><th style="text-align:left">项目</th><th style="text-align:left">规格</th></tr></thead><tbody><tr><td style="text-align:left">型号</td><td style="text-align:left"><code>Intel Wi-Fi 6E AX210</code></td></tr><tr><td style="text-align:left">协议</td><td style="text-align:left"><code>WiFi 6E</code>（<code>802.11ax</code>）</td></tr><tr><td style="text-align:left">频段</td><td style="text-align:left"><code>2.4GHz</code> / <code>5GHz</code> / <code>6GHz</code></td></tr><tr><td style="text-align:left">驱动</td><td style="text-align:left"><code>iwlwifi</code>（<code>Intel</code> 官方维护）</td></tr><tr><td style="text-align:left">蓝牙</td><td style="text-align:left"><code>Bluetooth 5.3</code></td></tr></tbody></table><p>更换后通过 <code>dmesg</code> 确认驱动正常加载：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">dmesg | grep -i &quot;iwlwifi\|ax210&quot;</code></div></pre><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-text">loaded firmware version ...Detected Intel(R) Wi-Fi 6 AX210 160MHz</code></div></pre><p>日志中可能会出现 <code>failed to load iwl-debug-yoyo.bin</code> 的提示，这是可选的 <code>debug firmware</code> 缺失，不影响正常使用。</p><h2 id="二、XRDP-环境下-WiFi-扫描授权问题">二、XRDP 环境下 WiFi 扫描授权问题</h2><h3 id="2-1-问题现象">2.1 问题现象</h3><p>更换 <code>AX210</code> 后，系统本地登录一切正常，但在 <code>XRDP</code> 远程桌面环境中出现以下问题：</p><ul><li>点击 <code>XFCE</code> 右上角 <code>NetworkManager</code> 的无线网络图标时弹出授权窗口</li><li>提示：<code>System policy prevents Wi-Fi scans</code></li><li>即使不输入密码，<code>WiFi</code> 网络实际上仍然能扫描出来</li><li>一段时间后会再次弹出授权窗口</li><li>某些情况下 <code>XFCE GUI</code> 会出现卡死，<code>NetworkManager applet</code> 无响应</li></ul><p>本地物理登录则完全正常，不会出现任何授权提示。</p><h3 id="2-2-问题排查">2.2 问题排查</h3><p>首先排除驱动问题：</p><ul><li><code>AX210</code> 驱动工作正常</li><li><code>firmware</code> 已正确加载</li><li>未使用 <code>iwd</code></li><li><code>NetworkManager</code> 服务运行正常</li></ul><p>通过 <code>loginctl</code> 查看会话状态，发现了关键线索：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">loginctl</code></div></pre><pre><div class="code-header"><span class="code-header-type">text</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div></div><code class="language-text">SESSION  UID USER    SEAT4        1000 tiger  seat0c6       1000 tiger  -</code></div></pre><p>其中 <code>seat0</code> 表示本地物理桌面，<code>-</code> 表示远程 <code>XRDP</code> 会话。<code>XRDP</code> 远程会话不被 <code>systemd-logind</code> / <code>polkit</code> 视为本地 <code>active session</code>，因此 <code>WiFi</code> 扫描操作会触发额外的权限校验。</p><p><strong>本质上这不是网卡驱动问题，而是 <code>XRDP</code> 会话的权限模型与本地会话不一致导致的。</strong></p><h3 id="2-3-为什么-MT7921-没有触发该问题">2.3 为什么 MT7921 没有触发该问题</h3><p>不同无线网卡驱动在 <code>NetworkManager</code> / <code>polkit</code> 权限路径上的行为并不完全一致。<code>Intel AX210</code> 的 <code>iwlwifi</code> 驱动对权限模型实现更加严格，因此更容易暴露 <code>XRDP</code> 会话的权限问题。简单来说：</p><ul><li><code>MT7921</code> 的驱动在权限校验上更&quot;宽松&quot;</li><li><code>AX210</code> 的 <code>iwlwifi</code> 更符合标准权限模型</li></ul><h3 id="2-4-解决方案：添加-polkit-规则">2.4 解决方案：添加 polkit 规则</h3><p>创建 <code>polkit</code> 规则文件：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">sudo vim /etc/polkit-1/rules.d/50-networkmanager.rules</code></div></pre><p>写入以下内容：</p><pre><div class="code-header"><span class="code-header-type">javascript</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div></div><code class="language-javascript">polkit.addRule(function(action, subject) &#123;    if (        subject.isInGroup(&quot;sudo&quot;) &amp;&amp;        action.id.indexOf(&quot;org.freedesktop.NetworkManager&quot;) == 0    ) &#123;        return polkit.Result.YES;    &#125;&#125;);</code></div></pre><p>该规则表示：允许 <code>sudo</code> 组用户直接执行 <code>NetworkManager</code> 相关操作，不再弹出 <code>WiFi</code> 扫描授权窗口。</p><p>重启相关服务使规则生效：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">sudo systemctl restart polkitsudo systemctl restart NetworkManager</code></div></pre><p>重新登录 <code>XRDP</code> 即可。</p><h3 id="2-5-结果">2.5 结果</h3><p>配置完成后：</p><ul><li>不再出现 <code>System policy prevents Wi-Fi scans</code> 提示</li><li>无线网络扫描恢复正常</li><li><code>XFCE</code> 不再出现 <code>GUI</code> 假死</li><li>无需安装额外 <code>GNOME</code> 桌面组件</li><li>无需修改 <code>AX210</code> 驱动配置</li></ul><h2 id="三、补充说明">三、补充说明</h2><h3 id="3-1-关于-policykit-1-gnome">3.1 关于 policykit-1-gnome</h3><p>很多资料会提到通过安装 <code>policykit-1-gnome</code> 来解决类似问题。需要注意的是，<code>policykit-1-gnome</code> 并不是完整的 <code>GNOME</code> 桌面，仅仅是一个 <code>polkit authentication agent</code>，不会安装 <code>GNOME Shell</code>，也不会破坏 <code>XFCE</code> 环境。不过本问题中仅通过 <code>polkit rule</code> 即可解决，无需额外安装。</p><h3 id="3-2-关于-XRDP-与本地图形会话共存">3.2 关于 XRDP 与本地图形会话共存</h3><p>如果系统同时存在本地自动登录的 <code>XFCE</code> 和 <code>XRDP</code> 远程的 <code>XFCE</code>，可能导致 <code>DBus</code>、<code>polkit</code>、<code>NetworkManager applet</code> 之间出现额外冲突。对于长期远程使用的 <code>Homelab</code> 主机，更建议：</p><ul><li>关闭本地自动登录</li><li>使用单一 <code>XRDP</code> 图形会话</li></ul><p>以减少桌面环境相关的问题。</p><h2 id="参考">参考</h2><ul><li><a href="https://wiki.archlinux.org/title/NetworkManager" title="Arch Wiki - NetworkManager" class="external-link" data-redirect="https%3A%2F%2Fwiki.archlinux.org%2Ftitle%2FNetworkManager">Arch Wiki - NetworkManager</a></li><li><a href="https://www.freedesktop.org/software/polkit/docs/latest/" title="Polkit Documentation" class="external-link" data-redirect="https%3A%2F%2Fwww.freedesktop.org%2Fsoftware%2Fpolkit%2Fdocs%2Flatest%2F">Polkit Documentation</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;上一篇记录了迷你主机的选购和 &lt;code&gt;Debian&lt;/code&gt; 系统的安装过程。本篇是一个小的硬件更新：将原装的联发科 &lt;code&gt;MT7921&lt;/code&gt; 无线网卡更换为 &lt;code&gt;Intel AX210&lt;/code&gt;，同时记录更换后在 &lt;code&gt;XRDP&lt;/code&gt; 远程桌面环境下遇到的 &lt;code&gt;WiFi&lt;/code&gt; 扫描授权问题及解决方案。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="AX210" scheme="https://www.superheaoz.top/tags/AX210/"/>
    
    <category term="XRDP" scheme="https://www.superheaoz.top/tags/XRDP/"/>
    
  </entry>
  
  <entry>
    <title>Homelab 搭建手记（1）迷你主机选购与 Debian 系统安装</title>
    <link href="https://www.superheaoz.top/2026/06/40732/"/>
    <id>https://www.superheaoz.top/2026/06/40732/</id>
    <published>2026-06-16T14:00:00.000Z</published>
    <updated>2026-06-16T14:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>之前手上的开发环境分散在多台电脑上，每台设备都要单独配置 <code>WSL</code>、安装依赖、同步配置，维护成本很高。加上一直想在本地跑一些小模型做 <code>AI Agent</code> 相关的实验，所以决定搭建一台集中式的 <code>Homelab</code> 服务器，统一承载开发环境、推理服务和各种自托管应用。本文作为 <code>Homelab</code> 系列第一篇，主要记录迷你主机的选购过程、<code>Linux</code> 发行版的选择思路，以及 <code>Debian</code> 系统的安装流程。</p><span id="more"></span><h2 id="一、迷你主机选购">一、迷你主机选购</h2><h3 id="1-1-背景">1.1 背景</h3><p>2026 年上半年，<code>DDR5</code> 内存和 <code>NVMe</code> 固态硬盘的价格持续上涨，<code>32G</code> 内存条的价格相比去年同期涨了不少。在这个背景下，直接组装一台 <code>ITX</code> 小主机的成本偏高，转而考虑品牌迷你主机。品牌迷你主机的优势在于出厂已经完成内存和硬盘的搭配，整体价格比自己单买配件组装要划算不少，而且 <code>618</code> 大促期间往往有比较好的价格。</p><h3 id="1-2-选购标准">1.2 选购标准</h3><p>选购迷你主机主要关注以下几点：</p><table><thead><tr><th style="text-align:center">关注点</th><th style="text-align:center">要求</th></tr></thead><tbody><tr><td style="text-align:center">CPU</td><td style="text-align:center">性能够用，最好 8 核以上，支持虚拟化</td></tr><tr><td style="text-align:center">内存</td><td style="text-align:center">32G 起步，跑模型和容器编排都需要</td></tr><tr><td style="text-align:center">硬盘</td><td style="text-align:center">512G 以上 NVMe，最好有双 M.2 插槽方便扩展</td></tr><tr><td style="text-align:center">散热</td><td style="text-align:center">迷你主机散热是老问题，需要关注风道设计</td></tr><tr><td style="text-align:center">价格</td><td style="text-align:center">控制在 <code>2500</code> 以内</td></tr></tbody></table><h3 id="1-3-最终选择">1.3 最终选择</h3><p>综合对比了几款热门迷你主机后，最终在淘宝 618 活动期间以 2499 元购入了联想来酷 Mini Pro，具体配置如下：</p><table><thead><tr><th style="text-align:left">硬件</th><th style="text-align:left">规格</th></tr></thead><tbody><tr><td style="text-align:left">CPU</td><td style="text-align:left">AMD Ryzen 7 8745H（8 核 16 线程，Zen 4，最大睿频 4.97GHz）</td></tr><tr><td style="text-align:left">核显</td><td style="text-align:left">AMD Radeon 780M（RDNA 3 架构）</td></tr><tr><td style="text-align:left">内存</td><td style="text-align:left">24G DDR5</td></tr><tr><td style="text-align:left">硬盘</td><td style="text-align:left">512G NVMe（UMIS RPEYJ512MML1QWQ）</td></tr><tr><td style="text-align:left">接口</td><td style="text-align:left">双 M.2 硬盘位、USB、HDMI、DP、Type-C 等</td></tr></tbody></table><p>Ryzen 7 8745H 本质是 8845H 的略微降频版本，实际使用中性能差异不大，但价格更有优势。780M 核显虽然是集显，但 RDNA 3 架构的性能已经可以胜任一些轻量的推理任务。双 M.2 硬盘位是加分项，后续可以加装第二块硬盘做数据盘或 RAID。</p><h2 id="二、Linux-发行版选择">二、Linux 发行版选择</h2><h3 id="2-1-为什么不用-Ubuntu">2.1 为什么不用 Ubuntu</h3><p>之前一直在用 <code>Ubuntu</code>，但这次决定换掉它，主要有以下原因：</p><ol><li><strong><code>snap</code> 的问题</strong>：<code>Ubuntu</code> 从 <code>20.04</code> 开始强推 <code>snap</code> 包管理，很多系统组件（如 <code>Firefox</code>）被替换为 <code>snap</code> 版本。<code>snap</code> 启动慢、占用空间大、自动更新无法关闭，对于服务器场景来说不够可控。</li><li><strong><code>Ubuntu 26</code> 的方向</strong>：<code>Ubuntu 26.04</code> 的宣传方向继续向 <code>snap</code> 倾斜，甚至有传言系统核心组件进一步 <code>snap</code> 化，这让我不想继续跟进。</li><li><strong><code>LTS</code> 周期</strong>：<code>Ubuntu LTS</code> 的 <code>5</code> 年支持期（付费可延长至 <code>10</code> 年）虽然够用，但相比 <code>Debian</code> 的社区驱动模式，总觉得受制于 <code>Canonical</code> 的商业决策。</li></ol><h3 id="2-2-发行版对比">2.2 发行版对比</h3><table><thead><tr><th style="text-align:center">发行版</th><th style="text-align:left">优点</th><th style="text-align:left">缺点</th><th style="text-align:center">适合场景</th></tr></thead><tbody><tr><td style="text-align:center"><code>Debian</code></td><td style="text-align:left">极其稳定、社区驱动、软件包经过严格测试、无商业绑定</td><td style="text-align:left">软件版本偏旧</td><td style="text-align:center">服务器、<code>Homelab</code></td></tr><tr><td style="text-align:center"><code>Ubuntu</code></td><td style="text-align:left">生态好、教程多、硬件支持广</td><td style="text-align:left"><code>snap</code> 强绑定、<code>Canonical</code> 商业化倾向明显</td><td style="text-align:center">桌面、快速上手</td></tr><tr><td style="text-align:center"><code>Arch Linux</code></td><td style="text-align:left">滚动更新、软件最新、<code>AUR</code> 社区丰富</td><td style="text-align:left">不够稳定、需要持续维护</td><td style="text-align:center">桌面、开发者</td></tr><tr><td style="text-align:center"><code>Fedora</code></td><td style="text-align:left">软件较新、<code>Red Hat</code> 生态、<code>SELinux</code> 默认启用</td><td style="text-align:left">版本生命周期短（<code>13</code> 个月）</td><td style="text-align:center">桌面、前沿技术体验</td></tr><tr><td style="text-align:center"><code>openSUSE</code></td><td style="text-align:left"><code>YaST</code> 配置工具强大、<code>Btrfs</code> 默认支持</td><td style="text-align:left">社区相对小众</td><td style="text-align:center">企业、桌面</td></tr></tbody></table><p>最终选择 <code>Debian 13 (trixie)</code>，核心理由就是<strong>稳定</strong>。<code>Homelab</code> 服务器不需要追新，稳定运行才是第一优先级。而且 <code>Debian</code> 的软件包虽然版本不是最新的，但通过 <code>backports</code> 或手动编译也可以获取需要的新版本。</p><h3 id="2-3-macOS-为什么不选">2.3 macOS 为什么不选</h3><p><code>macOS</code> 确实是很好的开发环境，<code>Unix</code> 内核、优秀的终端体验、完善的开发者工具链。但是 <code>Mac Mini M4</code> 配置 <code>24G</code> 内存 + <code>512G</code> 硬盘要 <code>5999</code> 元，是这台迷你主机的两倍多，而且后续扩展内存和硬盘基本不可能。作为 <code>Homelab</code> 服务器，性价比太低。（主要还是穷）</p><h2 id="三、桌面环境选择">三、桌面环境选择</h2><h3 id="3-1-为什么需要桌面">3.1 为什么需要桌面</h3><p>这台机器主要通过 <code>SSH</code> 远程访问，但偶尔也需要本地操作（比如接显示器调试、使用图形化的工具），所以还是安装一个轻量桌面环境，而不是纯命令行。</p><h3 id="3-2-桌面环境对比">3.2 桌面环境对比</h3><table><thead><tr><th style="text-align:center">桌面环境</th><th style="text-align:center">内存占用</th><th style="text-align:center">界面风格</th><th style="text-align:left">特点</th></tr></thead><tbody><tr><td style="text-align:center"><code>GNOME</code></td><td style="text-align:center">较高（<code>~1.5G</code>）</td><td style="text-align:center">现代、简洁</td><td style="text-align:left"><code>Ubuntu</code> 默认桌面，扩展丰富，但资源消耗大</td></tr><tr><td style="text-align:center"><code>KDE Plasma</code></td><td style="text-align:center">中等（<code>~1G</code>）</td><td style="text-align:center">类 <code>Windows</code>、高度可定制</td><td style="text-align:left">功能最全面，但配置项过多</td></tr><tr><td style="text-align:center"><code>XFCE</code></td><td style="text-align:center">较低（<code>~500M</code>）</td><td style="text-align:center">经典、朴素</td><td style="text-align:left">轻量稳定，适合服务器和低配机器</td></tr><tr><td style="text-align:center"><code>LXQt</code></td><td style="text-align:center">最低（<code>~300M</code>）</td><td style="text-align:center">极简</td><td style="text-align:left">最轻量，但功能和美观度有限</td></tr><tr><td style="text-align:center"><code>MATE</code></td><td style="text-align:center">较低（<code>~600M</code>）</td><td style="text-align:center"><code>GNOME 2</code> 风格</td><td style="text-align:left">经典布局，稳定可靠</td></tr></tbody></table><p>最终选择 <code>XFCE</code>，理由很简单：<strong>轻量</strong>。<code>~500M</code> 的内存占用在 <code>24G</code> 的机器上几乎可以忽略，而且 <code>XFCE</code> 启动快、响应流畅，作为偶尔使用的备用桌面完全够用。更重要的是，<code>XFCE</code> 没有太多后台服务，不会和服务器上运行的其他服务产生冲突。</p><h2 id="四、Debian-系统安装">四、Debian 系统安装</h2><h3 id="4-1-安装前准备">4.1 安装前准备</h3><p>镜像下载直接使用清华大学开源镜像站，选择 <code>Debian 13</code> 的 <code>XFCE</code> 桌面版本 <code>DVD</code> 镜像。<code>DVD</code> 镜像内置了常用桌面环境的软件包，安装时选择 <code>XFCE</code> 后无需额外从网络下载桌面组件，适合网络环境不太稳定的场景。</p><ul><li><a href="https://mirrors.tuna.tsinghua.edu.cn/debian-cd/" title="清华大学 Debian 镜像站" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fdebian-cd%2F">清华大学 Debian 镜像站</a></li></ul><p>写入 <code>U</code> 盘推荐使用 <a href="https://rufus.ie/" title="Rufus 官网" class="external-link" data-redirect="https%3A%2F%2Frufus.ie%2F">Rufus</a>（<code>Windows</code> 下），操作简单直观：选择镜像文件、选择目标 <code>U</code> 盘、点击开始即可。<code>Linux</code> 下也可以使用 <code>Ventoy</code> 或 <code>balenaEtcher</code> 等工具。<strong>不建议直接使用 <code>dd</code> 命令</strong>，虽然功能上等价，但 <code>dd</code> 没有确认步骤，写错目标设备会直接丢数据。</p><h3 id="4-2-分区方案">4.2 分区方案</h3><p>这台迷你主机有一个 <code>512G NVMe</code> 硬盘，同时有第二个 <code>M.2</code> 插槽可供后续扩展。安装时选择<strong>手动分区</strong>（<code>Manual</code>），将 <code>/home</code> 目录单独分区，方便后续扩展或迁移。</p><p>最终分区方案如下：</p><table><thead><tr><th style="text-align:center">分区</th><th style="text-align:center">大小</th><th style="text-align:center">挂载点</th><th style="text-align:center">文件系统</th><th style="text-align:left">用途</th></tr></thead><tbody><tr><td style="text-align:center"><code>nvme0n1p1</code></td><td style="text-align:center"><code>976M</code></td><td style="text-align:center"><code>/boot/efi</code></td><td style="text-align:center"><code>EFI</code></td><td style="text-align:left"><code>EFI</code> 引导分区</td></tr><tr><td style="text-align:center"><code>nvme0n1p2</code></td><td style="text-align:center"><code>8G</code></td><td style="text-align:center"><code>[SWAP]</code></td><td style="text-align:center"><code>swap</code></td><td style="text-align:left">交换分区</td></tr><tr><td style="text-align:center"><code>nvme0n1p3</code></td><td style="text-align:center"><code>139.7G</code></td><td style="text-align:center"><code>/</code></td><td style="text-align:center"><code>ext4</code></td><td style="text-align:left">系统根目录</td></tr><tr><td style="text-align:center"><code>nvme0n1p4</code></td><td style="text-align:center"><code>328.8G</code></td><td style="text-align:center"><code>/home</code></td><td style="text-align:center"><code>ext4</code></td><td style="text-align:left">用户数据目录</td></tr></tbody></table><h4 id="swap-大小选择">swap 大小选择</h4><p><code>Debian</code> 自动分区时会将 <code>swap</code> 设置为与物理内存等大（<code>24G</code>），这在服务器场景下没有必要。<code>swap</code> 的主要作用是作为内存溢出时的缓冲，以及支持休眠（<code>hibernate</code>）功能。<code>Homelab</code> 服务器不存在休眠需求，<code>8G</code> 的 <code>swap</code> 已经足够应对内存峰值。如果后续跑大模型内存不够，可以通过 <code>zswap</code> 或增加第二块硬盘扩展 <code>swap</code> 来解决。</p><h4 id="与-home-参数优化">/ 与 /home 参数优化</h4><p>在分区配置中，<code>/</code> 和 <code>/home</code> 的挂载参数建议加入以下选项：</p><table><thead><tr><th style="text-align:left">参数</th><th style="text-align:left">作用</th></tr></thead><tbody><tr><td style="text-align:left"><code>noatime</code></td><td style="text-align:left">不记录文件访问时间，减少磁盘写入，对 <code>NVMe</code> 寿命和性能都有好处</td></tr><tr><td style="text-align:left"><code>discard</code></td><td style="text-align:left">启用 <code>TRIM</code> 指令，让 <code>SSD</code> 及时回收已删除数据的物理块，保持写入性能</td></tr></tbody></table><p>同时建议降低保留块（<code>reserved blocks</code>）比例。<code>ext4</code> 默认为 <code>root</code> 用户保留 <code>5%</code> 的空间，在 <code>512G</code> 硬盘上意味着 <code>~26G</code> 空间被锁定。对于 <code>Homelab</code> 场景，降低到 <code>1%</code>（<code>~5G</code>）即可。在安装过程的分区配置步骤中，选中对应分区后进入分区设置页面，可以直接修改保留块比例。如果安装时忘记调整，也可以在安装完成后通过 <code>tune2fs</code> 命令二次修改：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-bash"># 将 / 分区的保留块降低到 1%sudo tune2fs -m 1 /dev/nvme0n1p3# 将 /home 分区的保留块降低到 1%sudo tune2fs -m 1 /dev/nvme0n1p4</code></div></pre><h3 id="4-3-单分区与-root-home-分区对比">4.3 单分区与 root + home 分区对比</h3><table><thead><tr><th style="text-align:center">方案</th><th style="text-align:left">优点</th><th style="text-align:left">缺点</th></tr></thead><tbody><tr><td style="text-align:center">单 <code>/</code> 分区</td><td style="text-align:left">简单、空间利用率灵活</td><td style="text-align:left">重装系统时 <code>/home</code> 数据需要备份恢复，数据和系统混在一起</td></tr><tr><td style="text-align:center"><code>/</code> + <code>/home</code></td><td style="text-align:left">系统与数据分离，重装系统不影响 <code>/home</code>，方便扩展</td><td style="text-align:left">分区大小需要提前规划，可能造成空间浪费</td></tr></tbody></table><p>对于 <code>Homelab</code> 场景，推荐 <code>/</code> + <code>/home</code> 分区方案：</p><ul><li><code>/</code> 分区 <code>140G</code> 左右，足够系统、软件包、容器运行时等使用</li><li><code>/home</code> 分区 <code>330G</code>，存放用户数据、项目代码、模型文件等</li><li>未来加装第二块硬盘后，可以将 <code>/home</code> 迁移到新硬盘，或者挂载为独立数据盘</li></ul><h3 id="4-4-安装过程">4.4 安装过程</h3><p><code>Debian</code> 安装过程比较标准，按照向导操作即可。几个值得注意的地方：</p><ol><li><strong>网络配置</strong>：安装过程中会自动检测网络，建议使用有线网络连接，确保安装过程中能正常下载软件包。</li><li><strong>软件源选择</strong>：安装时可以选择国内镜像源（如清华源、阿里源），加快下载速度。</li><li><strong>桌面环境</strong>：由于之前已经下载了 <code>XFCE</code> 版本的 <code>DVD</code> 镜像，安装过程中会自动选择 <code>XFCE</code> 桌面，无需手动切换。</li><li><strong><code>root</code> 账户</strong>：<code>Debian</code> 安装时会要求设置 <code>root</code> 密码，同时创建一个普通用户。建议禁用 <code>root</code> 的 <code>SSH</code> 登录，使用普通用户 + <code>sudo</code> 的方式管理。</li></ol><p>安装完成后，系统信息如下：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div></div><code class="language-bash"># 系统信息Static hostname: Tiger-MiniPC-DebianOperating System: Debian GNU/Linux 13 (trixie)Kernel: Linux 6.12.90+deb13.1-amd64Architecture: x86-64Hardware Vendor: LecooHardware Model: MINI PRO-AHP</code></div></pre><h3 id="4-5-安装后基本配置">4.5 安装后基本配置</h3><p><code>Debian</code> 安装完成后需要做几项基础配置：</p><ol><li><p><strong>添加 <code>sudo</code> 权限</strong> — <code>Debian</code> 安装时创建的普通用户默认没有 <code>sudo</code> 权限，需要切换到 <code>root</code> 用户通过 <code>usermod</code> 将普通用户加入 <code>sudo</code> 组。注意 <code>Debian</code> 的 <code>/usr/sbin</code> 默认不在 <code>PATH</code> 中，需使用完整路径 <code>/usr/sbin/usermod</code>。</p></li><li><p><strong>更新系统</strong> — <code>apt update &amp;&amp; apt upgrade</code>。</p></li><li><p><strong>安装基础工具</strong> — <code>curl</code>、<code>wget</code>、<code>git</code>、<code>vim</code>、<code>make</code>、<code>build-essential</code> 等。</p></li><li><p><strong>配置 SSH</strong> — 禁用 root 登录、配置密钥认证。</p></li><li><p><strong>安装 XRDP</strong> — 远程桌面方案，配合 <code>XFCE</code> 使用。</p></li></ol><p>以上所有配置，以及后续的 <code>Go</code>、<code>Java</code>、<code>Node.js</code>、<code>Docker</code> 等开发环境安装，已整理为自动化脚本，详见下一篇 <a href="/2026/06/3470/" title="Homelab 搭建手记（3）开发环境一键配置">Homelab 搭建手记（3）开发环境一键配置</a>。</p><h2 id="五、总结">五、总结</h2><p>本文记录了 <code>Homelab</code> 搭建的第一步：从迷你主机选购到 <code>Debian</code> 系统安装。<code>联想来酷 Mini Pro</code> 以 <code>2499</code> 元的价格提供了 <code>R7 8745H</code> + <code>24G</code> 内存 + <code>512G</code> 硬盘的配置，作为 <code>Homelab</code> 服务器性价比不错。选择 <code>Debian</code> 而非 <code>Ubuntu</code> 主要是看中其稳定性和无商业绑定的特点，<code>XFCE</code> 桌面环境则在轻量和功能之间取得了平衡。</p><p>后续文章会陆续记录 <code>Node.js</code> 环境配置、<code>Gitea</code> 连接、<code>Docker</code> 部署、本地模型搭建等内容。</p><h2 id="参考">参考</h2><ul><li><a href="https://www.debian.org/releases/trixie/installmanual" title="Debian 官方安装指南" class="external-link" data-redirect="https%3A%2F%2Fwww.debian.org%2Freleases%2Ftrixie%2Finstallmanual">Debian 官方安装指南</a></li><li><a href="https://mirrors.tuna.tsinghua.edu.cn/debian-cd/" title="清华大学 Debian 镜像站" class="external-link" data-redirect="https%3A%2F%2Fmirrors.tuna.tsinghua.edu.cn%2Fdebian-cd%2F">清华大学 Debian 镜像站</a></li><li><a href="https://rufus.ie/" title="Rufus 官网" class="external-link" data-redirect="https%3A%2F%2Frufus.ie%2F">Rufus 官网</a></li><li><a href="https://www.lenovo.com.cn/" title="联想来酷 Mini Pro 产品页" class="external-link" data-redirect="https%3A%2F%2Fwww.lenovo.com.cn%2F">联想来酷 Mini Pro 产品页</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;之前手上的开发环境分散在多台电脑上，每台设备都要单独配置 &lt;code&gt;WSL&lt;/code&gt;、安装依赖、同步配置，维护成本很高。加上一直想在本地跑一些小模型做 &lt;code&gt;AI Agent&lt;/code&gt; 相关的实验，所以决定搭建一台集中式的 &lt;code&gt;Homelab&lt;/code&gt; 服务器，统一承载开发环境、推理服务和各种自托管应用。本文作为 &lt;code&gt;Homelab&lt;/code&gt; 系列第一篇，主要记录迷你主机的选购过程、&lt;code&gt;Linux&lt;/code&gt; 发行版的选择思路，以及 &lt;code&gt;Debian&lt;/code&gt; 系统的安装流程。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="Linux" scheme="https://www.superheaoz.top/tags/Linux/"/>
    
    <category term="Homelab" scheme="https://www.superheaoz.top/tags/Homelab/"/>
    
    <category term="Debian" scheme="https://www.superheaoz.top/tags/Debian/"/>
    
    <category term="迷你主机" scheme="https://www.superheaoz.top/tags/%E8%BF%B7%E4%BD%A0%E4%B8%BB%E6%9C%BA/"/>
    
  </entry>
  
  <entry>
    <title>服务器操作指北（7）本地部署 overleaf</title>
    <link href="https://www.superheaoz.top/2025/05/5139/"/>
    <id>https://www.superheaoz.top/2025/05/5139/</id>
    <published>2025-05-15T07:00:52.000Z</published>
    <updated>2025-05-15T07:00:52.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="前言">前言</h2><p>由于目前撰写论文涉及到了跨高校的团队合作，故而在基于之前文章<a href="/2022/06/8279/" title="VScode 整合 MikTex 论文写作">VScode 整合 MikTex 论文写作</a>以及 git 协同的基础上，寻找其他更高效的论文协作方案。免费版的 overleaf 在高峰期或者编译大篇幅论文时经常编译失败，在师兄的提醒下，发现该项目可以本地部署，进而记录本篇文章。撰写文章时，overleaf 版本为 <code>5.4.0</code> 。</p><span id="more"></span><h2 id="一、安装">一、安装</h2><p>整体安装流程，在 overleaf 提供的官方安装工具<a href="https://github.com/overleaf/toolkit" title="toolkit" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Foverleaf%2Ftoolkit">overleaf-toolkit</a>中都有较为详细的描述。但由于国内网络环境问题，建议通过代理手段进行安装。</p><h3 id="1-1-代理设置">1.1 代理设置</h3><h4 id="1-1-1-git-配置代理">1.1.1 git 配置代理</h4><p>官方工具源码保存在 github 中，国内访问存在不稳定性，可以通过配置代理链接进行访问。可以参考该网站<a href="https://gh-proxy.com/" title="gh-proxy" class="external-link" data-redirect="https%3A%2F%2Fgh-proxy.com%2F">GitHub 文件加速代理</a>。</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div></div><code class="language-bash">git clone https://gh-proxy.com/https://github.com/overleaf/toolkit.git ./overleaf-toolkit</code></div></pre><h4 id="1-1-2-docker-配置代理">1.1.2 docker 配置代理</h4><p>官方工具目前通过容器方式进行本地部署，需要配置 docker 镜像以提升容器拉取速度。具体可用镜像站可参考<a href="https://status.1panel.top/status/docker" title="1panel" class="external-link" data-redirect="https%3A%2F%2Fstatus.1panel.top%2Fstatus%2Fdocker">国内 Docker 服务状态 &amp; 镜像加速监控</a>进行部署。相关脚本命令如下：</p><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-bash"># 将内容写入 /etc/docker/daemon.json 文件，root 用户可以去掉 sudo# 配置 Docker 镜像，使用多个镜像源来提高镜像下载速度echo '&#123;  &quot;registry-mirrors&quot;: [    &quot;https://docker.1ms.run&quot;,    &quot;https://docker.1panel.live&quot;,    &quot;https://docker.ketches.cn&quot;  ]&#125;' | sudo tee /etc/docker/daemon.json# 重启 Docker 服务以使配置生效sudo systemctl restart docker</code></div></pre><h3 id="1-2-安装设置">1.2 安装设置</h3><ol><li>首先进入<code>overleaf-toolkit</code>目录，执行<code>bin/init</code>命令，在<code>config</code>目录生成基本配置文件。</li></ol><pre><div class="code-header"><span class="code-header-type">bash</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div></div><code class="language-bash">cd ./overleaf-toolkitbin/init</code></div></pre><ol start="2"><li>由于默认使用的是免费社区版，部分 Pro 版本功能无法使用，需要对配置文件<code>config/overleaf.rc</code>进行修改。具体修改内容如下：</li></ol><pre><div class="code-header"><span class="code-header-type">YML</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div></div><code class="language-YML">## 改为自己需要的名称，默认会显示在docker镜像列表中PROJECT_NAME=example## 改为自己服务器的IP以及对外公开的端口，由于本服务直接部署在内容，无需ssl，所以没有对nginx部分进行配置OVERLEAF_LISTEN_IP=YOUR_IPOVERLEAF_PORT=YOUR_PORT## 关闭Sibling Containers，该技术为pro版本独有，但是在默认配置文件中默认启用了。SIBLING_CONTAINERS_ENABLED=false</code></div></pre><ol start="3"><li>同时为了优化页面显示，需要对<code>config/variables.env</code>文件进行修改。具体修改内容如下：</li></ol><pre><div class="code-header"><span class="code-header-type">YML</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div></div><code class="language-YML">## 默认的overleaf名称，会显示在浏览器标题中OVERLEAF_APP_NAME=&quot;Example Team's Overleaf&quot;## 默认的overleaf名称，会显示在导航栏中OVERLEAF_NAV_TITLE=&quot;Example Team's Overleaf&quot;## 默认的URL链接地址，要与overleaf.rc的修改保持一致，以便后续用户添加OVERLEAF_SITE_URL=http://YOUR_IP:YOUR_PORT## 默认的overleaf显示语言，手动添加即可，配置文件无该项OVERLEAF_SITE_LANGUAGE=zh-CN</code></div></pre><h3 id="1-3-安装及优化">1.3 安装及优化</h3><h4 id="1-3-1-启动-overleaf-服务">1.3.1 启动 overleaf 服务</h4><p>在 <code>overleaf-toolkit</code> 目录执行如下命令。</p><pre><div class="code-header"><span class="code-header-type">BASH</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-BASH">## 第一次启动，可以使用该命令，后续若修改配置，也需要执行一次该命令bin/up## bin/up 命令会将输出定向到命令行中，在确认系统正常运行后，可改用如下命令，以正常容器模式启动bin/start</code></div></pre><h4 id="1-3-2-优化环境">1.3.2 优化环境</h4><ol><li>安装完整版本 latex。</li></ol><p>默认的 overleaf 中，使用的 latax 不是完整版本，需要进入容器并执行更新，在 <code>overleaf-toolkit</code> 目录执行如下命令。</p><pre><div class="code-header"><span class="code-header-type">BASH</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div></div><code class="language-BASH">## 进入容器bin/shell## 进入容器后，通过如下命令安装完整latex## 切换国内更新源tlmgr option repository https://mirrors.tuna.tsinghua.edu.cn/tlpretest## 安装完整包tlmgr update --alltlmgr install scheme-full## （可选）安装字库apt install -y latex-cjk-all texlive-lang-chinese texlive-lang-english## 退出容器exit</code></div></pre><p>2.（可选）安装中文字体。</p><p>overleaf 的默认字体，对中文支持较差，可以通过添加字库的方式进行优化。首先可以从 Windows 系统的字库目录 <code>C:\Windows\Fonts</code> 中复制需要的字体文件，打包为 <code>fonts.tar.gz</code> 压缩包并上传到服务器中。</p><pre><div class="code-header"><span class="code-header-type">shell</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div></div><code class="language-shell">## 上传到服务器步骤省略，请参考scp相关文档，假设文件上传至overleaf-toolkit目录cd overleaf-toolkit## 通过docker命令复制文件至容器中docker cp fonts.tar.gz sharelatex:/overleaf</code></div></pre><p>然后通过如下命令进行安装。</p><pre><div class="code-header"><span class="code-header-type">shell</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div><div class="line-numbers-item">14</div><div class="line-numbers-item">15</div><div class="line-numbers-item">16</div><div class="line-numbers-item">17</div><div class="line-numbers-item">18</div><div class="line-numbers-item">19</div></div><code class="language-shell">## 进入容器bin/shell## 解压字库文件tar -zxvf fonts.tar.gz -C /usr/share/fonts/winFonts/## 删除字库文件以节省空间rm fonts.tar.gz## 更新字库## 进入字库所在目录cd /usr/share/fonts/winFonts/## 更新字库mkfontscalemkfontdir## 刷新字库fc-cache -fv## 测试fc-list :lang=zh-cn</code></div></pre><ol start="3"><li>（可选）持久化镜像</li></ol><p>以上的所有优化操作都是针对容器实例进行的，一旦更新配置重新执行 up 命令，就会丢失，所以需要进行持久化操作。</p><pre><div class="code-header"><span class="code-header-type">BASH</span><button type="button" class="code-header-copy" aria-label="复制"><span class="code-header-copy-tips" data-copy="复制" data-copy-success="复制成功" data-copy-error="复制错误"></span><span class="code-header-copy-button" ><i class="fa fa-clipboard" aria-hidden="true"></i></span></button></div><div class="code-content"><div class="line-numbers"><div class="line-numbers-item">1</div><div class="line-numbers-item">2</div><div class="line-numbers-item">3</div><div class="line-numbers-item">4</div><div class="line-numbers-item">5</div><div class="line-numbers-item">6</div><div class="line-numbers-item">7</div><div class="line-numbers-item">8</div><div class="line-numbers-item">9</div><div class="line-numbers-item">10</div><div class="line-numbers-item">11</div><div class="line-numbers-item">12</div><div class="line-numbers-item">13</div></div><code class="language-BASH">## 获取容器的CONTAINER IDdocker ps | grep &quot;sharelatex/sharelatex&quot;## 新建镜像docker commit &lt;CONTAINER ID&gt;  5.4.0-with-texlive-full:latest## 修改版本## 注意，overleaf默认脚本仅识别 `版本号-with-texlive-full` 与 `版本号` 这两种版本echo &quot;5.4.0-with-texlive-full&quot; &gt; config/version## 重启镜像bin/upbin/start</code></div></pre><h2 id="二、使用">二、使用</h2><p>在网站部署完成以后，可以访问 <code>http://IP:PORT/launchpad</code> 进入管理页面，注册管理员用户。后续用户注册时，由管理员账号在 <code>http://IP:PORT/admin/register</code> 页面手动添加。（若未修改 OVERLEAF_SITE_URL，此处生成的注册链接会默认为 localhost，请自行前缀域名）</p><h3 id="2-1-对比">2.1 对比</h3><p>本地部署与在线服务相比，编译效率确实提升，但是也缺少了一些协同功能。个人总结如下：</p><table><thead><tr><th style="text-align:center">功能</th><th style="text-align:center">本地部署</th><th style="text-align:center">在线服务</th></tr></thead><tbody><tr><td style="text-align:center">评论</td><td style="text-align:center">❌</td><td style="text-align:center">✅</td></tr><tr><td style="text-align:center">引文自动检索</td><td style="text-align:center">❌</td><td style="text-align:center">✅</td></tr><tr><td style="text-align:center">聊天</td><td style="text-align:center">✅</td><td style="text-align:center">✅</td></tr><tr><td style="text-align:center">编译效率</td><td style="text-align:center">✅✅✅</td><td style="text-align:center">✅</td></tr><tr><td style="text-align:center">远程协同</td><td style="text-align:center">自行端口映射至公网</td><td style="text-align:center">✅</td></tr></tbody></table><p>总体而言，如果单纯以论文的编译效率为标准，本地部署是更好的选择，也更稳定，不会出现类似于 <code>顶会截稿前服务崩溃</code> 的问题，数据保存在本地也更安全。但是只是简单的体验或者需要远程的协同，还是在线版本更为合适。</p><h2 id="参考">参考</h2><ul><li><a href="https://github.com/overleaf/toolkit" title="toolkit" class="external-link" data-redirect="https%3A%2F%2Fgithub.com%2Foverleaf%2Ftoolkit">overleaf-toolkit</a></li></ul><!-- url -->]]></content>
    
    
    <summary type="html">&lt;h2 id=&quot;前言&quot;&gt;前言&lt;/h2&gt;
&lt;p&gt;由于目前撰写论文涉及到了跨高校的团队合作，故而在基于之前文章&lt;a href=&quot;/2022/06/8279/&quot; title=&quot;VScode 整合 MikTex 论文写作&quot;&gt;VScode 整合 MikTex 论文写作&lt;/a&gt;以及 git 协同的基础上，寻找其他更高效的论文协作方案。免费版的 overleaf 在高峰期或者编译大篇幅论文时经常编译失败，在师兄的提醒下，发现该项目可以本地部署，进而记录本篇文章。撰写文章时，overleaf 版本为 &lt;code&gt;5.4.0&lt;/code&gt; 。&lt;/p&gt;</summary>
    
    
    
    <category term="科技" scheme="https://www.superheaoz.top/categories/%E7%A7%91%E6%8A%80/"/>
    
    
    <category term="latex" scheme="https://www.superheaoz.top/tags/latex/"/>
    
    <category term="overleaf" scheme="https://www.superheaoz.top/tags/overleaf/"/>
    
  </entry>
  
</feed>
