<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh_CN">
  <title>番茄主理人</title>
  <subtitle>fqzlr</subtitle>
  <link href="https://blog.fqzlr.top/" rel="alternate" type="text/html"/>
  <link href="https://blog.fqzlr.top/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://blog.fqzlr.top/</id>
  <updated>2026-09-05T03:15:00.000Z</updated>
  <entry>
    <title>友链挂了？自动检测+主页截图，GitHub Actions 全搞定</title>
    <link href="https://blog.fqzlr.top/posts/tech/check-flink/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/check-flink/</id>
    <published>2026-07-26T00:00:00.000Z</published>
    <updated>2026-07-26T00:00:00.000Z</updated>
    <summary>博客系列第9期。用 GitHub Actions 自动检测友链可达性、反链存在、主页截图，零服务器零运维，附触发方式详解。</summary>
    <content type="html"><![CDATA[<h2>为什么要做这个</h2>
<p>友链多了之后总有个头疼的问题——对方悄悄撤了你的链接，或者站点直接挂了，你还蒙在鼓里。手动一个个点进去检查？四五十个友链，想想就累。</p>
<p>所以我搞了个自动化方案：<strong>每天定时检测友链状态，每 6 天自动截一张主页图</strong>，结果输出成一个 JSON，博客前端直接拉取展示。全程跑在 GitHub Actions 上，不用服务器，不用花钱。
<img src="https://tu.fqzlr.com/file/fqzlrcom/bPC9KwcO.webp" alt="" /></p>
<h2>参考链接</h2>
<table>
<thead>
<tr>
<th>项目</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/willow-god/check-flink">willow-god/check-flink</a></td>
<td>友链状态检测核心（多线程延迟检测 + 反链检测）</td>
</tr>
<tr>
<td><a href="https://github.com/thun888/Python-WebSite-Screenshot">thun888/Python-WebSite-Screenshot</a></td>
<td>Selenium 主页截图核心</td>
</tr>
<tr>
<td><a href="https://github.com/fqzlr/check-flink">fqzlr/check-flink</a></td>
<td>我的整合版（本文主角）</td>
</tr>
<tr>
<td><a href="https://cfbed.sanyue.de/">cfbed.sanyue.de</a></td>
<td>图床 API 规范（截图上传用）</td>
</tr>
<tr>
<td><a href="https://image.thum.io/">image.thum.io</a></td>
<td>截图兜底服务（Selenium 挂了也不怕）</td>
</tr>
</tbody>
</table>
<h2>实现思路</h2>
<p>把两个开源项目深度整合，最终架构长这样：</p>
<pre><code>博客 friends.json（唯一数据源）
        │
        ▼
┌─ check_links job（每天 2 次）─────────┐
│  多线程请求每个友链                     │
│  统计：延迟 / 失败次数 / 反链存在       │
│  输出 → result.json                    │
└────────────────────────────────────────┘
        │
        ▼
┌─ take_screenshots job（每 6 天）──────┐
│  读取 result.json 中可达的友链          │
│  Selenium 截图 → 上传图床              │
│  失败 → thum.io 兜底                   │
│  截图 URL 写回 result.json             │
└────────────────────────────────────────┘
        │
        ▼
  Vercel 托管 result.json（CDN 加速）
        │
        ▼
  博客前端 fetch → 渲染友链卡片
</code></pre>
<h3>关键文件</h3>
<table>
<thead>
<tr>
<th>文件</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>main.py</code></td>
<td>状态检测 + 反链检测 + 增量合并</td>
</tr>
<tr>
<td><code>screenshot.py</code></td>
<td>Selenium 截图 + 图床上传 + thum.io 兜底</td>
</tr>
<tr>
<td><code>screenshot_runner.py</code></td>
<td>截图入口，从 result.json 读取目标</td>
</tr>
<tr>
<td><code>inject.css</code></td>
<td>截图时注入目标页面（隐藏滚动条/弹窗）</td>
</tr>
<tr>
<td><code>.github/workflows/check_links.yml</code></td>
<td>调度核心：定时 + 手动 + 条件触发</td>
</tr>
</tbody>
</table>
<h3>反链检测怎么做的</h3>
<p>访问对方的友链页面，用正则提取所有真实的 <code>&lt;a href&gt;</code> 链接，解析主机名后跟自己的域名比对。</p>
<p>注意：<strong>只有真实超链接才算反链</strong>。如果对方只是在文章里提了一嘴你的域名（纯文本），不算。之前旧版用字符串匹配，结果把 5 个只是文字提及的站点误判成了有反链，后来重写了这块逻辑。</p>
<h2>怎么部署</h2>
<blockquote>
<p>完整 8 步教程在 <a href="https://github.com/fqzlr/check-flink#-%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B">README</a>，这里挑重点说。</p>
</blockquote>
<h3>第一步：Fork 仓库</h3>
<p>把 <a href="https://github.com/fqzlr/check-flink">fqzlr/check-flink</a> fork 到你自己的 GitHub。</p>
<h3>第二步：配置 Secrets</h3>
<p>在仓库 Settings → Secrets and variables → Actions 里添加：</p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>说明</th>
<th>示例</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>AUTHOR_URL</code></td>
<td>你的域名（反链检测用）</td>
<td><code>fqzlr.com</code></td>
</tr>
<tr>
<td><code>SOURCE_URL</code></td>
<td>友链 JSON 地址</td>
<td><code>https://fqzlr.com/friends.json</code></td>
</tr>
<tr>
<td><code>IMG_API_URL</code></td>
<td>图床上传 API</td>
<td><code>https://tu.xxx.com/api/upload</code></td>
</tr>
<tr>
<td><code>IMG_API_TOKEN</code></td>
<td>图床 API Token</td>
<td><code>your-token</code></td>
</tr>
</tbody>
</table>
<h3>第三步：开启 Actions 写权限</h3>
<p>仓库 Settings → Actions → General → Workflow permissions → 选 <strong>Read and write permissions</strong>。</p>
<p>不然 workflow 没法把 result.json 提交回仓库。</p>
<h3>第四步：准备友链数据源</h3>
<p>你的博客需要暴露一个 JSON 接口，格式类似：</p>
<pre><code>[
  {
    "name": "番茄的博客",
    "link": "https://blog.example.com",
    "avatar": "https://...",
    "linkpage": "https://blog.example.com/friends"
  }
]
</code></pre>
<p>其中 <code>linkpage</code> 是对方的友链页面地址（反链检测会访问这个页面）。</p>
<h3>第五步：准备图床</h3>
<p>截图需要上传到图床。我用的是 <a href="https://github.com/fqzlr/Cloudflare-ImgBed">Cloudflare-ImgBed</a>（博客系列第 4 期讲过），API 兼容 cfbed.sanyue.de 规范。</p>
<h3>第六步：首次触发</h3>
<p>Actions → Run workflow → task 选 <code>both</code> → target_link 留空 → 跑！</p>
<p>首次全量跑一遍，之后日常就自动了。</p>
<h3>第七步：部署到 Vercel</h3>
<p>把仓库导入 Vercel，Output Directory 填 <code>static</code>，得到一个 CDN 地址。博客前端 fetch 这个地址的 <code>result.json</code> 就行。</p>
<h2>怎么触发</h2>
<p>这是我最满意的部分——<strong>灵活到可以只跑一个友链</strong>。</p>
<h3>自动触发（不用管）</h3>
<table>
<thead>
<tr>
<th>时间</th>
<th>执行内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>每天 01:00、13:00</td>
<td>仅状态检测（~1 分钟）</td>
</tr>
<tr>
<td>每 6 天（1/7/13/19/25 号 01:30）</td>
<td>状态检测 + 截图（~10 分钟）</td>
</tr>
</tbody>
</table>
<h3>手动触发（按需）</h3>
<p>进 Actions → Run workflow，有两个参数：</p>
<p><strong>task</strong>（选执行内容）：</p>
<ul>
<li><code>status_only</code>：仅状态检测（默认）</li>
<li><code>screenshots_only</code>：仅截图（用上次检测数据）</li>
<li><code>both</code>：检测 + 截图</li>
</ul>
<p><strong>target_link</strong>（只处理指定友链）：</p>
<ul>
<li>填友链名称或 URL 关键词，如 <code>番茄</code> 或 <code>blog.example.com</code></li>
<li>留空 = 处理全部</li>
</ul>
<h3>常见场景</h3>
<table>
<thead>
<tr>
<th>我想…</th>
<th>task</th>
<th>target_link</th>
</tr>
</thead>
<tbody>
<tr>
<td>日常看看谁挂了</td>
<td><code>status_only</code></td>
<td>留空</td>
</tr>
<tr>
<td>新加了个友链，检测一下</td>
<td><code>status_only</code></td>
<td><code>新友链名字</code></td>
</tr>
<tr>
<td>新加了个友链，检测+截图</td>
<td><code>both</code></td>
<td><code>新友链名字</code></td>
</tr>
<tr>
<td>截图太旧了，全部重截</td>
<td><code>screenshots_only</code></td>
<td>留空</td>
</tr>
<tr>
<td>全量跑一遍</td>
<td><code>both</code></td>
<td>留空</td>
</tr>
</tbody>
</table>
<h3>增量处理是什么</h3>
<p>当你填了 <code>target_link</code>，只有匹配的友链会被重新检测/截图，<strong>其余友链保留上次结果</strong>。不会出现"只跑了一个友链，其他 46 个数据全没了"的情况。<code>total_count</code> 始终不变。</p>
<h2>一些细节</h2>
<h3>截图兜底链</h3>
<pre><code>Selenium 本地截图
  ├─ 成功 → 上传图床 → 图床 URL
  └─ 失败 → thum.io 在线截图（永远可用）
</code></pre>
<p>所以即使 GitHub Actions 环境抽风装不上 Chrome，也不会出现白屏。</p>
<h3>截图频率为什么是 6 天</h3>
<p>Selenium + Chrome 安装要 2-3 分钟，截完全部友链要 5-10 分钟。每天跑太浪费 Actions 配额（每月 2000 分钟），6 天一次刚刚好。</p>
<p>状态检测就轻量多了，纯 requests 请求，1 分钟跑完，所以每天 2 次不心疼。</p>
<h3>数据源只维护一份</h3>
<p>友链配置只在博客侧维护（我的 <code>friends.json</code>），检测仓库自动读取。不用两边同步，加友链只需要在博客加一次。</p>
<h2>最后</h2>
<p>整套方案跑下来零成本：GitHub Actions 免费额度足够，图床用 Cloudflare Workers 免费套餐，Vercel 也是免费的。</p>
<p>如果你也有几十号友链，与其隔三差五手动检查，不如让机器帮你盯着。</p>
<hr />
<p>::github{repo="fqzlr/check-flink"}</p>
<blockquote>
<p>有问题欢迎评论区交流～</p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>全屏壁纸的两种性格：classic 揭幕与 hero 视差的实现细节</title>
    <link href="https://blog.fqzlr.top/posts/tech/fullscreen-hero-layout/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/fullscreen-hero-layout/</id>
    <published>2026-09-05T03:15:00.000Z</published>
    <updated>2026-09-05T03:15:00.000Z</updated>
    <summary>博客系列第16期。全屏壁纸模式下的两种布局：classic 壁纸随页滚动的揭幕式、hero 壁纸固定整屏的视差标题式。附首帧落位防闪烁、z-index 不能为负的原因、视差节流与记忆化写入。</summary>
    <content type="html"><![CDATA[<h2>两种布局，两种性格</h2>
<p>全屏壁纸模式下提供 <code>classic</code> 与 <code>hero</code> 两种布局，靠 <code>&lt;html&gt;</code> 上的 dataset 切换：</p>
<pre><code>&lt;html data-wallpaper-mode="fullscreen" data-fullscreen-layout="classic|hero"&gt;
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>classic</th>
<th>hero</th>
</tr>
</thead>
<tbody>
<tr>
<td>壁纸定位</td>
<td><code>absolute</code>，随页滚动</td>
<td><code>fixed; inset: 0</code>，固定整屏</td>
</tr>
<tr>
<td>首页首屏</td>
<td>整屏壁纸，只留下滑箭头</td>
<td>壁纸 + 居中标题文案</td>
</tr>
<tr>
<td>滚动后</td>
<td>滚过阈值揭出水波纹/渐变</td>
<td>文案上移淡出，壁纸常驻</td>
</tr>
<tr>
<td>非首页</td>
<td>变 <code>max(45vh, 380px)</code> 条带 + 上下文标题</td>
<td>壁纸按 <code>--overlay-blur</code> 虚化铺满</td>
</tr>
</tbody>
</table>
<h2>首帧落位：防闪烁是第一优先级</h2>
<p>壁纸模式与布局都支持访客切换（存 localStorage），如果等 JS 加载后再应用，访客每次进来都会先看到默认布局再「跳」到自己的偏好。解法是把读取逻辑内联进首帧：</p>
<pre><code>// Layout.astro &lt;script is:inline&gt;
const stored = localStorage.getItem('wallpaper-fullscreen-layout');
const fullscreenLayout = stored === 'hero' || stored === 'classic'
	? stored
	: defaultFullscreenLayout; // 站点配置的默认值
document.documentElement.dataset.fullscreenLayout = fullscreenLayout;
</code></pre>
<p><code>define:vars</code> 把站点默认值注入内联脚本，<code>&lt;html&gt;</code> 在渲染任何内容前就带着正确的 dataset——所有布局差异都由 CSS 选择器驱动，不存在布局跳变。</p>
<h2>classic：揭幕式</h2>
<p>首页首屏被壁纸整屏占据（<code>#main-layout</code> 用 <code>top: 100lvh</code> 推下去），首屏只看到一个下滑箭头。滚过 100px 阈值后 <code>body[data-banner-reveal]</code> 点亮，水波纹和底部渐变淡入、箭头淡出——「揭幕」的仪式感。</p>
<h2>hero：视差标题</h2>
<p>壁纸 <code>fixed</code> 铺满、内容滚动其上，标题文案做视差：滚动时文案 <code>translateY(-scrollY)</code> 上移，滚过 50% 视口完全淡出。</p>
<p>两个实现细节：</p>
<ol>
<li><strong>rAF 节流</strong>：scroll 事件只置标记，实际写入在 rAF 里做，一次滚动只算一次布局；</li>
<li><strong>记忆化写入</strong>：首页文案与上下文文案同步处理（后者保留 <code>translate(-50%,-50%)</code> 居中前缀），值没变的帧直接跳过，避免逐帧重复写合成层属性。</li>
</ol>
<p>:::warning[z-index 为什么不能是负数]
壁纸在内容下层，直觉是 <code>z-index: -1</code>。但负值会把舞台画到 <code>body</code> 盒子之下——壁纸里 <code>pointer-events: auto</code> 的轮播链接从此收不到点击。正确姿势是壁纸 <code>z-index: 0</code> + 内容层更高的正层级，保持舞台在文档流内。
:::</p>
<h2>标题文案的排版细节</h2>
<ul>
<li>标题 <code>clamp()</code> 响应式：首页 <code>clamp(2.5rem, 5rem, 5rem)</code>（移动端 2.75rem），非首页降为 <code>clamp(2rem, 4vw, 3rem)</code>；</li>
<li>白字 + <code>text-shadow: 0 2px 12px rgba(0,0,0,0.48)</code>，在任何壁纸上保证对比度；</li>
<li>副标题打字机：<code>data-subtitle-state="typing"</code> 驱动伪元素光标，速度/停顿/循环全部配置化；</li>
<li>非首页描述是「引言框」：半透明暗底圆角 + 首尾引号图标，与参考站 Firefly 的样式对齐；</li>
<li>底部渐变 <code>linear-gradient(to bottom, transparent, var(--page-bg))</code>，高度 <code>clamp(4rem, 12vh, 10rem)</code>，让壁纸到正文有柔和过渡。</li>
</ul>
<h2>比参考站多做的一步</h2>
<p>Firefly 的全屏壁纸只有一种形态；这里把 classic / hero 做成了<strong>访客可切换的布局</strong>，配套切换时的柔和淡出过渡（避免壁纸形态硬切），站点默认值则由 <code>wallpaperMode.fullscreen.layout</code> 配置。</p>
<h2>小结</h2>
<p>全屏壁纸的两种性格：classic 靠「揭示」讲故事，hero 靠「常驻」撑氛围。实现上记住三件事：首帧内联落位防闪烁、壁纸 z-index 别用负数、视差写入要节流加记忆化。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>从零加一个打赏页：配置驱动、二态卡片与页面可用性体系</title>
    <link href="https://blog.fqzlr.top/posts/tech/sponsor-page-from-scratch/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/sponsor-page-from-scratch/</id>
    <published>2026-09-05T03:10:00.000Z</published>
    <updated>2026-09-05T03:10:00.000Z</updated>
    <summary>博客系列第15期。新增 /sponsor/ 打赏页的全过程：扫码/外链二态卡片、打赏者名单网格、内容仓 YAML 配置驱动，以及「页面开关 × 功能开关」双重门控的页面可用性体系。</summary>
    <content type="html"><![CDATA[<h2>需求与形态</h2>
<p>打赏页 <code>/sponsor/</code> 要承载三件事：怎么赏（收款码 / 跳转链接）、谁赏过（打赏者名单）、赏金用哪（用途说明）。整页 SSR 渲染<strong>零水合</strong>——打赏不需要任何客户端交互逻辑，纯静态卡片就够了。</p>
<h2>数据模型：二态卡片</h2>
<p>打赏方式抽象成 <code>SponsorMethod</code>，一个联合类型覆盖两种形态：</p>
<pre><code>type SponsorMethod =
	| { type: "qrcode"; name: string; image: string }
	| { type: "url"; name: string; url: string };
</code></pre>
<ul>
<li><code>qrcode</code> → 扫码卡：图片<strong>强制白底 + 0.75rem 内边距</strong>。这是内容性例外——微信收款码是黑块码，深色模式下若透出页面底色就扫不出来了，注释里专门写明这里不许跟暗色主题；</li>
<li><code>url</code> → 外链卡：主色「前往打赏」按钮，hover <code>brightness(1.08)</code>、按下 <code>scale(0.97)</code>。</li>
</ul>
<p>两者缺一个字段的直接在 <code>resolveSponsorMethods()</code> 里过滤掉，半残数据不渲染。</p>
<h2>配置驱动 + 内容仓管理</h2>
<p>全部配置收在内容仓 <code>sponsor.yaml</code>，经 <code>withUserConfig("sponsor")</code> 覆盖主题默认（默认 <code>enable: false</code>）：</p>
<pre><code>enable: true
methods:
  - type: qrcode
    name: 微信
    image: /images/sponsor/wechat.png
  - type: qrcode
    name: 支付宝
    image: /images/sponsor/alipay.png
donors:
  - name: 某位小伙伴
    amount: 50
    date: 2026-08-15
</code></pre>
<p>图片路径解析三态：<code>/</code> 开头走 <code>url()</code> 适配 base path，<code>http(s):</code>/<code>data:</code> 原样，其余走构建期 <code>resolveAsset()</code>。收款码图片放内容仓 <code>public/images/sponsor/</code>——收款码属于站点内容，跟着内容仓走。</p>
<h2>打赏者名单</h2>
<p><code>SponsorDonor { name, amount?, date?, avatar? }</code> 渲染成 <code>auto-fill minmax(13rem, 1fr)</code> 网格，细节：</p>
<ul>
<li>无头像取<strong>首字符占位</strong>（CSS 变量 <code>--initial</code> 喂给伪元素）；</li>
<li>日期用 <code>Intl.DateTimeFormat(siteConfig.lang)</code> 按站点语言本地化；</li>
<li>金额 <code>font-variant-numeric: tabular-nums</code> 等宽数字 + 主色——纵向看一列金额不会跳。</li>
</ul>
<h2>关键设计：页面可用性双重门控</h2>
<p>新页面最容易漏的是「关掉之后呢」。这个主题有一套统一的页面可用性体系：</p>
<pre><code>// 页面可用 = 页面开关 × 功能开关
siteConfig.pages.sponsor &amp;&amp; sponsorConfig.enable
</code></pre>
<p>任一为 <code>false</code>：路由跳 <code>/404/</code>、顶栏与移动抽屉的导航入口自动隐藏、组件零 DOM。两层开关各管一层——<code>pages.sponsor</code> 是「这个页面要不要存在」，<code>sponsorConfig.enable</code> 是「打赏功能本身开不开」。</p>
<p>:::tip[先写关闭态]
加新页面时先写 <code>enable: false</code> 的分支（404 + 导航隐藏 + 零 DOM），再写内容。关闭态是发布到公网的兜底路径，比功能本身更值得先验证。
:::</p>
<h2>视觉收尾</h2>
<p>圆角卡 + 描边沿用全站 M3E 令牌；用途说明做成 <code>primary-container</code> 底色的胶囊提示条；爱心 badge 是主色圆角方块图标——整页只有一个主色重点（打赏按钮），视线动线：方式 → 名单 → 按钮。</p>
<h2>小结</h2>
<p>打赏页 200 行内搞定：二态卡片数据模型、内容仓配置驱动、SSR 零水合。真正值得复用的是<strong>页面可用性体系</strong>——任何新页面接进来，路由 404、导航隐藏、零 DOM 三件事自动成立。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>关于页改造：一张 MDX 卡片承载四个交互组件的内容复刻</title>
    <link href="https://blog.fqzlr.top/posts/tech/about-page-mdx-components/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/about-page-mdx-components/</id>
    <published>2026-09-05T03:05:00.000Z</published>
    <updated>2026-09-05T03:05:00.000Z</updated>
    <summary>博客系列第14期。关于页不做多卡片拼盘，而是一张 MDX 卡片承载正文，人物悬浮卡、技术栈手风琴、魔改时间线、品牌色社交链接以 not-prose 区块嵌入正文流，附 MDX 内嵌组件的几个坑。</summary>
    <content type="html"><![CDATA[<h2>先定结构：一张卡片，正文流优先</h2>
<p>关于页常见做法是「卡片拼盘」——个人信息一张卡、技能一张卡、时间线一张卡，各管各的。我参考了 <a href="https://github.com/MmzMing/my-blog">MmzMing/my-blog</a> 的起步思路和 <a href="https://github.com/CuteLeaf/Firefly">Firefly</a> 的卡片风格后，选择了相反的结构：</p>
<blockquote>
<p><strong>整页一张 Card，MDX 正文是主角</strong>，四个交互组件以 <code>not-prose</code> 区块嵌入正文流。</p>
</blockquote>
<pre><code>&lt;!-- about.astro --&gt;
&lt;Card radius="l"&gt;
	&lt;Markdown&gt;
		{renderedMdx}
	&lt;/Markdown&gt;
&lt;/Card&gt;
</code></pre>
<p>好处是写作体验和普通文章完全一致——想加一段自述就直接写 Markdown，组件只是正文里的「插入物」，顺序、位置随时调整。</p>
<h2>组件一：行内人物悬浮卡</h2>
<p>正文里提到某个人时，触发词带主色虚线下划线，悬停浮出带头像的小卡片。实现要点：</p>
<ul>
<li>触发器是 <code>&lt;span tabindex="0" role="tooltip"&gt;</code>——<strong>键盘可聚焦</strong>，<code>hover</code> 和 <code>focus-within</code> 双通道触发；</li>
<li>浮层淡入 + 上移 + 小三角指向，纯 CSS 过渡；</li>
<li>头像必须用纯 <code>&lt;img&gt;</code> 而非组件——MDX 内嵌组件会被 Markdown 解析器的 <code>&lt;p&gt;</code> 拆散，块级元素会破坏段落结构。</li>
</ul>
<p>人物卡的紫色板与友链页的站长卡片同源，跨页面身份一致。</p>
<h2>组件二：技术栈手风琴</h2>
<p>按分组折叠的技术栈卡片，展开用 <code>grid-template-rows: 0fr → 1fr</code> 过渡（比 max-height 优雅，不需要猜内容高度）：</p>
<pre><code>&lt;div data-tech-accordion="open|closed"&gt;...&lt;/div&gt;
</code></pre>
<p>卡片品牌色通过 CSS 变量注入（<code>--tech-color</code> / <code>--tech-color-dark</code>），暗色模式下黑白 logo 自动反色。展开时卡片按 <code>animation-delay: calc(0.12s + var(--i) * 0.045s)</code> 逐张入场——<code>--i</code> 是索引变量，一行 CSS 写出 stagger。</p>
<h2>组件三：魔改时间线</h2>
<p>三列 grid：<code>"date rail card"</code>——日期在左，中间一条竖轨，卡片在右。</p>
<ul>
<li>轨道竖线 <code>scaleY</code> 生长动画，与节点圆点 stagger 同步；</li>
<li>圆点 hover 时脉冲扩散；</li>
<li>内容（换主题、加功能、改域名……）维护在内容仓 <code>data/about.ts</code>，与技能/项目页同一套数据契约。</li>
</ul>
<h2>组件四：品牌色社交链接</h2>
<p>2×2 网格，每个链接 hover 时边框和底色转向品牌色。品牌色不写死在组件里，而是由一个映射组件（github / bilibili / qq / email / rss / twitter）驱动 <code>--about-social-brand</code> 变量——加新平台只需加一条映射。</p>
<h2>正文末尾：GitHub 卡片</h2>
<p>正文直接用主题的指令语法挂仓库卡片：</p>
<pre><code>::github{repo="MmzMing/my-blog"}
</code></pre>
<p>致谢对象（起步启蒙的 my-blog、交互参考的 Firefly）以卡片形式放在正文流里，比光秃秃的链接体面。</p>
<p>:::warning[MDX 内嵌组件的两个坑]
一、块级组件嵌入段落会被 <code>&lt;p&gt;</code> 拆散，组件要么用行内元素要么放在段落之间；二、组件内不要依赖父级 prose 样式，一律加 <code>not-prose</code> 自带样式体系，否则 typography 插件的样式会污染组件内部。
:::</p>
<h2>小结</h2>
<p>关于页的改造原则：<strong>内容为主角、组件为插入物</strong>。MDX 保证写作自由度，四个组件（人物卡 / 手风琴 / 时间线 / 社交链接）各自独立、样式自包含，数据全部收在内容仓——改内容不用碰主题代码。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>友链页面设计：二态卡片、粒子特效与死链坟场的三层玩法</title>
    <link href="https://blog.fqzlr.top/posts/tech/friends-page-design/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/friends-page-design/</id>
    <published>2026-09-05T03:00:00.000Z</published>
    <updated>2026-09-05T03:00:00.000Z</updated>
    <summary>博客系列第13期。友链页不止是链接列表：M3E 卡片体系统一视觉、站长/推荐二态卡片配 canvas 星芒流星、死链检测的暂存区与墓碑墙设计，附悬浮预览的 transform 陷阱。</summary>
    <content type="html"><![CDATA[<h2>卡片体系统一：先定语言再谈花样</h2>
<p>友链页最容易做乱——每个卡片一个样式，堆起来就是杂货铺。我的做法是先给<strong>所有卡片类页面定同一套视觉语言</strong>，友链、项目、技能、关于页共用：</p>
<pre><code>.friend-card
	border-radius: var(--shape-corner-l)
	border: 1px solid var(--outline-variant)
	background: var(--card-bg)
</code></pre>
<p>横向布局：56px 圆角头像 + 标题/描述/标签信息区，网格用 <code>repeat(auto-fill, minmax(15rem, 1fr))</code>——15rem 是实测门槛，保证 1024px 宽度下稳定三列。</p>
<p>hover 是整套语言的「呼吸」：边框、标题、底色一起转向主色（底色用 <code>color-mix(in oklab, var(--primary) 5%, var(--card-bg))</code>），阴影升到 <code>--m3e-elevation-1</code>，右上角一个 <code>arrow-outward</code> 箭头从 <code>translate(-0.25rem, 0.25rem)</code> 浮现，头像轻微 <code>scale(1.05)</code>。所有卡片同频呼吸，页面就「齐」了。</p>
<h2>二态卡片：站长紫与推荐金</h2>
<p>70 个友链里总要有层级。设计了两种特殊状态：</p>
<table>
<thead>
<tr>
<th>状态</th>
<th>配色</th>
<th>装饰</th>
<th>粒子</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--owner</code> 站长</td>
<td>紫 <code>#8d7cff / #ebe8ff / #5c49c7</code></td>
<td>小房子 mask SVG</td>
<td>四角星芒 <code>auroraFlow</code></td>
</tr>
<tr>
<td><code>--featured</code> 推荐</td>
<td>金 <code>#f4bb2e / #fff0a2 / #956100</code></td>
<td>大星星 mask SVG</td>
<td>五角星流星 <code>starFlow</code></td>
</tr>
</tbody>
</table>
<p>两个色板刻意与评论系统（Waline）的站长标识同源，暗色模式另有独立色板。粒子的绘制是同一个工具函数换参数：</p>
<pre><code>traceRoundedStar({ points: 5, innerScale: 0.48 }) // → 五角星
traceRoundedStar({ points: 4, innerScale: 0.36 }) // → 四角星芒
</code></pre>
<p>:::tip[特效的工程化底线]
canvas 粒子用 Svelte action 挂载，<code>IntersectionObserver</code> 监测卡片离开视口就暂停 rAF；<code>prefers-reduced-motion</code> 时干脆不创建 canvas，只留一层 CSS 扫带光感。特效必须「离场即停」。
:::</p>
<h2>死链检测：暂存区与墓碑墙</h2>
<p>友链页最大的维护痛点是死链。方案是检测体系 + 分区降级：</p>
<ul>
<li>检测结果按 <code>data-status="success | slow | warn | timeout"</code> 点亮状态徽标；</li>
<li>结果缓存进 localStorage（键 <code>shirone:friend-check-result</code>），先渲染缓存再后台刷新；</li>
<li>失败 1-6 次进入<strong>暂存区</strong>（<code>pendingZone</code>），可能只是对方临时抽风；</li>
<li>失败 7 次以上进<strong>墓碑墙</strong>（<code>graveyardZone</code>）——头像变灰排成一堵墙，既不冤枉也不装瞎。</li>
</ul>
<h2>悬浮预览与一个 transform 陷阱</h2>
<p>鼠标悬停卡片时弹出站点截图预览。截图地址存在 <code>data-siteshot</code> 上，预览浮层用 <code>portalToBody</code> 挂到 <code>body</code> 下。</p>
<p>:::warning[为什么要 portal]
页面入场动画 <code>onload-animation</code> 会在祖先元素上残留 <code>transform</code>。CSS 规定 <code>transform</code> 非 <code>none</code> 时，后代 <code>position: fixed</code> 的包含块不再是视口而是该祖先——浮层的定位会整体错位。挂到 <code>body</code> 下绕开整个祖先链。
:::</p>
<h2>小结</h2>
<p>友链页的三层结构：<strong>统一卡片语言</strong>保下限、<strong>二态卡片 + 粒子</strong>做出识别度、<strong>死链检测分区</strong>解决长期维护。数据结构与 Mizuki 同款，迁移友链数据零成本。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>给博客播放器加歌词：LRC 解析、三处消费与 Meting 透传</title>
    <link href="https://blog.fqzlr.top/posts/tech/blog-music-lyrics-lrc/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/blog-music-lyrics-lrc/</id>
    <published>2026-09-05T02:10:00.000Z</published>
    <updated>2026-09-05T02:10:00.000Z</updated>
    <summary>博客系列第12期。为博客音乐播放器接入歌词：LRC 解析工具、内容仓歌词资源管理、面板歌词 / 悬浮歌词条 / 胶囊歌词三处消费，以及 Meting 在线平台的歌词透传。</summary>
    <content type="html"><![CDATA[<h2>目标形态</h2>
<p>接入完成后，歌词在三个地方消费同一份数据：</p>
<ol>
<li><strong>侧栏播放器的歌词面板</strong>——逐行滚动、点击行跳转进度；</li>
<li><strong>悬浮歌词条</strong>——屏幕下缘一条始终可见的当前歌词；</li>
<li><strong>悬浮控件的胶囊歌词</strong>——收起形态下唱片旁边的单行歌词。</li>
</ol>
<p>三处共用一个解析结果和一个「当前行」计算，播放逻辑零侵入。</p>
<h2>LRC 解析器</h2>
<p>LRC 本质是「时间标签 + 文本」的行式格式。解析器只做一件事：把 <code>[mm:ss.xx]</code> 抽出来换算成秒并排序。</p>
<pre><code>export interface LrcLine {
	time: number; // 秒
	text: string;
}

export function parseLrc(raw: string): LrcLine[] {
	const lines: LrcLine[] = [];
	for (const line of raw.split(/\r?\n/)) {
		// 一行可能有多个时间标签（合并重复句），全部展开
		const times = [...line.matchAll(/\[(\d+):(\d+)(?:\.(\d+))?\]/g)];
		if (times.length === 0) continue;
		const text = line.replace(/\[(\d+):(\d+)(?:\.(\d+))?\]/g, "").trim();
		for (const m of times) {
			const time = Number(m[1]) * 60 + Number(m[2]) + Number(`0.${m[3] ?? "0"}`);
			lines.push({ time, text });
		}
	}
	return lines.sort((a, b) =&gt; a.time - b.time);
}
</code></pre>
<p>「当前行」用一次线性扫描就够了——歌词通常不超过百行，没必要二分：</p>
<pre><code>export function currentLrcIndexAt(lines: LrcLine[], t: number): number {
	let index = -1;
	for (let i = 0; i &lt; lines.length; i++) {
		if (lines[i].time &lt;= t) index = i;
		else break;
	}
	return index;
}
</code></pre>
<p>:::tip[元数据标签直接忽略]
<code>[ti:]</code>、<code>[ar:]</code>、<code>[offset:]</code> 这类 ID 标签不匹配时间正则，会被第一道 <code>continue</code> 自然过滤；<code>offset</code> 补偿极少用到，遇到再支持也不迟。
:::</p>
<h2>歌词资源放在内容仓</h2>
<p>歌词文件放在内容仓 <code>public/assets/music/lrc/</code> 下，与曲目在 <code>data/music.ts</code> 里对接：</p>
<pre><code>export const musicTracks = [
	{
		id: "koufue",
		title: "口笛で愛は歌えない",
		artist: "Dazbee",
		cover: "/assets/music/cover/dazbee.webp",
		src: "/assets/music/audio/koufue.mp3",
		// 歌词：远端 .lrc 地址或内嵌 LRC 文本均可
		lrc: "/assets/music/lrc/koufue.lrc",
	},
	// ...
];
</code></pre>
<p>歌词内容的获取是<strong>懒加载</strong>的：面板或歌词条第一次需要显示时才 <code>fetch</code>，解析结果按曲目缓存——不听歌的访客不会为歌词付出任何请求。</p>
<h2>Meting 平台的歌词透传</h2>
<p>本地曲目之外，Meting（在线音乐平台聚合）模式同样支持歌词：接口返回的 <code>lrc</code> 字段原样透传给同一个解析器。这样无论曲目来自本地还是在线平台，下游三处消费逻辑完全一致。</p>
<pre><code>// Meting 曲目解析时保留 lrc 字段
if (metingLrc) {
	track.lrc = metingLrc; // 透传，下游统一懒加载解析
}
</code></pre>
<h2>三处消费的取舍</h2>
<table>
<thead>
<tr>
<th>消费位置</th>
<th>交互</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr>
<td>歌词面板</td>
<td>自动滚动跟随 + 点击跳转 + 手动滚动暂停 3s</td>
<td>功能最全</td>
</tr>
<tr>
<td>悬浮歌词条</td>
<td>只显示当前行</td>
<td>常驻屏幕下缘，无滚动</td>
</tr>
<tr>
<td>胶囊歌词</td>
<td>只显示当前行，超长省略</td>
<td>嵌在悬浮控件里</td>
</tr>
</tbody>
</table>
<p>自动滚动跟随的实现要点：歌词行变化时 <code>scrollTo</code> 目标行到容器垂直居中；用户手动滚动（<code>wheel</code> / <code>touchstart</code>）后暂停跟随 3 秒再恢复，避免「人在看上一句、视图被拽走」的对抗。</p>
<p>:::details[空歌词的降级链]
<code>无 lrc 配置 → 显示「暂无歌词」占位 → 相关按钮隐藏</code>。歌词加载失败同理，占位文案区分「加载中」和「没有歌词」，不留空白区域。
:::</p>
<h2>收尾</h2>
<p>整个歌词功能约 200 行：一个解析器、一个当前行计算、三处消费、一次懒加载。配置上 <code>showLyrics: false</code> 一行可以让全部歌词 UI 零 DOM；每首歌想不加歌词，不写 <code>lrc</code> 字段就行。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>把显示设置面板做成「全量配置化」：显隐开关 + 访客默认值两层设计</title>
    <link href="https://blog.fqzlr.top/posts/tech/display-settings-full-config/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/display-settings-full-config/</id>
    <published>2026-09-05T02:05:00.000Z</published>
    <updated>2026-09-05T02:05:00.000Z</updated>
    <summary>博客系列第11期。主题的显示设置面板有十几个选项，但站点配置只暴露了一半：本篇补齐显隐开关全覆盖，并把每个选项的访客默认值也收进 YAML——站长想要 Hero 布局默认展示，改一行配置就行。</summary>
    <content type="html"><![CDATA[<h2>问题：面板上的选项，配置里只有一半</h2>
<p>主题的显示设置面板（外观 / 壁纸 / 特效三个 Tab）里有十几个访客可切换的选项：配色九宫格、页面背景模式、全屏布局、覆盖透明参数、水波纹、渐变过渡、樱花特效……但翻配置文件会发现：</p>
<ol>
<li><strong>显隐配置不全</strong>——<code>displaySettings</code> 只列了 6 个开关，樱花特效和横幅相关的 4 个开关不受控；</li>
<li><strong>默认值配置不全</strong>——比如「全屏壁纸的默认布局是经典还是 Hero」，配置文件里根本没有这一项。</li>
</ol>
<p>访客的选择会存到浏览器里覆盖站点默认，但<strong>首次访问看到什么、没动过设置的访客看到什么，由站点默认值决定</strong>。这个默认值理应归站长管。</p>
<h2>第一层：显隐开关全覆盖</h2>
<p><code>displaySettings</code> 补齐面板上的每一个选项：</p>
<pre><code>displaySettings:
  colorStyle: true     # 配色风格 9 宫格
  colorSpec: true      # 配色规范（2021/2025）切换
  wallpaperMode: true  # 页面背景模式切换
  layoutMode: true     # 文章列表布局（列表/网格）
  reduceMotion: true   # 减少动效
  texture: true        # 背景纹理
  effects: true        # 樱花特效开关        ← 新增
  bannerTitle: true    # 首页壁纸标题开关    ← 新增
  bannerCarousel: true # 壁纸轮播开关        ← 新增
  bannerWaves: true    # 水波纹动画开关      ← 新增
  bannerGradient: true # 渐变过渡开关        ← 新增
</code></pre>
<p>面板侧用「<strong>与逻辑</strong>」决定渲染与否：</p>
<pre><code>{#if siteConfig.banner.carousel.enable &amp;&amp; displayConfig.bannerCarousel}
	&lt;div class="m3-toggle-row"&gt;...&lt;/div&gt;
{/if}
</code></pre>
<p>:::tip[显隐与功能是两层]
<code>bannerCarousel: true</code> 只代表「面板里允许出现这个开关」；如果功能本身 <code>carousel.enable: false</code>（轮播压根没启用），开关依然不渲染。两层各管各的，组合语义清晰。
:::</p>
<h2>第二层：访客默认值收进 YAML</h2>
<p>盘点所有面板选项的默认值来源后，把它们分成三类：</p>
<h3>已经可配，补文档即可</h3>
<p>全屏布局的默认值在类型里早就存在（<code>wallpaperMode.fullscreen.layout</code>），只是配置文件没暴露。现在写进 <code>site.yaml</code>：</p>
<pre><code>wallpaperMode:
  defaultMode: banner   # 站点默认背景模式
  fullscreen:
    layout: classic     # classic 内容紧随导航栏 | hero 首屏整屏展示壁纸
  overlay:
    opacity: 0.8        # 覆盖透明默认参数
    blur: 10
    cardOpacity: 0.6
</code></pre>
<h3>新增配置域</h3>
<p>「渐变过渡」的默认值原来硬编码在工具函数里：</p>
<pre><code>// 之前：写死
export function getDefaultBannerGradientEnabled(): boolean {
	return true;
}

// 现在：读配置
export function getDefaultBannerGradientEnabled(): boolean {
	return siteConfig.banner.gradient?.enable ?? true;
}
</code></pre>
<p>类型上给 <code>banner</code> 加一个可选域，配置合并规则天然兼容旧配置（没写就是 <code>true</code>，行为不变）。</p>
<h3>无需新配置的</h3>
<p>配色三件套（色相 / 风格 / 规范）、背景纹理预设与浓度、文章列表布局默认值，原本就有配置入口，这次只是把文档补齐。</p>
<h2>验证方式</h2>
<p>改配置验证默认值是否生效时，要<strong>清掉 localStorage 模拟首次访问</strong>——访客偏好优先于站点默认，不清的话改了也「看不到变化」：</p>
<pre><code>await page.evaluate(() =&gt; localStorage.clear());
await page.reload();
// 读取 &lt;html&gt; 的 dataset 验证
document.documentElement.dataset.fullscreenLayout; // "classic" | "hero"
</code></pre>
<p>把配置临时改成 <code>hero</code> → 同步 → 重启 → 清存储访问，确认 <code>data-fullscreen-layout="hero"</code> 生效后改回。</p>
<h2>小结</h2>
<ul>
<li>显隐开关管「<strong>面板上允许出现什么</strong>」，默认值管「<strong>没动过设置的访客看到什么</strong>」，两层正交；</li>
<li>新增配置域一律用可选字段 + <code>??</code> 兜底，旧配置零迁移成本；</li>
<li>「零额外负担」原则不变：开关关掉的选项直接不输出 DOM。</li>
</ul>
<p>至此，面板上<strong>每一个</strong>选项：能不能出现、默认是什么，都可以在内容仓的 YAML 里一行配置解决。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>给博客左下角的音乐播放器做三态变形：唱片 / 歌词胶囊 / 完整控件</title>
    <link href="https://blog.fqzlr.top/posts/tech/music-dock-three-states/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/music-dock-three-states/</id>
    <published>2026-09-05T02:00:00.000Z</published>
    <updated>2026-09-05T02:00:00.000Z</updated>
    <summary>博客系列第10期。参考 Firefly 的三态形态机，把左下角悬浮音乐播放器从「唱片 + 弹出面板」重构成 disc / pill / bar 三态一体 dock，附拖动顺滑与指针事件的两个实战坑。</summary>
    <content type="html"><![CDATA[<h2>为什么要重构</h2>
<p>博客左下角原本的悬浮音乐播放器是「唱片 + 点击弹出大面板」的结构。功能是够用的，但两个形态之间的视觉关联很弱——唱片在左下角，面板飘在上方，看起来像两个不相干的组件。</p>
<p>参考 <a href="https://github.com/CuteLeaf/Firefly">Firefly</a> 主题的做法后，我把它重构成了<strong>三态一体的 dock</strong>：三种形态共享同一棵 DOM 树，唱片本身就是展开形态的一部分，只是位置和周边表面随形态过渡。</p>
<h2>三态形态机</h2>
<pre><code>type DockShape = "disc" | "pill" | "bar";
</code></pre>
<table>
<thead>
<tr>
<th>形态</th>
<th>内容</th>
<th>什么时候出现</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>disc</code></td>
<td>只有一张可拖动的唱片</td>
<td>未播放的静置状态</td>
</tr>
<tr>
<td><code>pill</code></td>
<td>唱片 + 歌词胶囊</td>
<td>播放中、鼠标不在控件上</td>
</tr>
<tr>
<td><code>bar</code></td>
<td>信息层 + 工具栏 + 唱片骑缝</td>
<td>鼠标悬停，或点击唱片</td>
</tr>
</tbody>
</table>
<p>形态转移规则（照搬参考实现的约定）：</p>
<ul>
<li>未播放：<code>disc ⇄ bar</code>，点击唱片切换，点击控件外收回唱片；</li>
<li>播放中：静置形态由歌词开关决定（开 → <code>pill</code>，关 → <code>disc</code>）；</li>
<li>鼠标进入控件任意组件 → 无条件展开 <code>bar</code>；离开 160ms 后收回静置形态；</li>
<li>暂停时 <code>pill</code> 收回 <code>bar</code>。</li>
</ul>
<pre><code>/** 播放中静置形态：歌词开关打开为胶囊，关闭为唱片 */
function dockRestingShape(): DockShape {
	return dockLyricsOn ? "pill" : "disc";
}

// 播放状态变化时同步形态
$effect(() =&gt; {
	if (!playing) {
		if (dockShape === "pill") dockShape = "bar";
	} else if (dockShape !== "bar" &amp;&amp; dockShape !== dockRestingShape()) {
		dockShape = dockHover ? "bar" : dockRestingShape();
	}
});
</code></pre>
<p>:::tip[为什么用形态机而不是多个组件]
三种形态共享同一份播放状态和 DOM，如果拆成三个组件来回挂载/卸载，动画过渡、播放进度、拖动位置都要跨组件同步，状态一多必然漏。形态机只改一个 <code>dataset</code>，剩下的全部交给 CSS 过渡。
:::</p>
<h2>bar 态的分层表面</h2>
<p>展开形态不是一张大卡片，而是<strong>三层独立表面</strong>叠出来的：</p>
<pre><code>┌──────────────────────┐
│ 列表 | 歌词（Tab）     │ ← 面板：dock 上方独立一层
│ 封面缩略图 + 当前项高亮 │
├──────────────────────┤
│  标题                 │ ← 信息层（两侧内缩的卡片）
│  艺术家  0:52 ─●─ 4:07│
│ (唱片) ⏮ ▶ ⏭ 🔊      │ ← 工具栏胶囊，唱片骑在接缝上
└──────────────────────┘
</code></pre>
<p>展开/收起的编排是关键：信息层用 <code>grid-template-rows: 0fr → 1fr</code> 过渡高度，工具栏从 <code>scale(0.72)</code> 淡入，面板延迟 120ms 再上浮——三层错峰出现，比整体缩放高级得多。</p>
<p>唱片在三态间的位移也全靠 CSS 过渡：<code>disc</code> 态贴容器左下，<code>pill</code> 态垂直居中进胶囊，<code>bar</code> 态用 <code>bottom: calc(var(--fp-bar-h) - var(--fp-disc) / 2)</code> 让中心正好骑在信息层与工具栏的接缝上。</p>
<h2>拖动顺滑：两个实战坑</h2>
<h3>坑一：响应式更新导致拖动跟手</h3>
<p>最初每次 <code>pointermove</code> 都更新 Svelte 的 <code>$state</code>，结果拖动明显滞后——每次状态更新都走一遍组件 diff 再写回 style，指针已经走出去几十像素了。</p>
<p>解法是<strong>拖动中直接写 DOM，抬起时才提交状态</strong>：</p>
<pre><code>function onDiscPointerMove(event: PointerEvent): void {
	if (!discDragging || event.pointerId !== discPointerId) return;
	const dx = event.clientX - discDragStart.px;
	const dy = event.clientY - dragStart.py;
	if (!discMoved &amp;&amp; Math.abs(dx) &lt; 4 &amp;&amp; Math.abs(dy) &lt; 4) return;
	discMoved = true;
	// 拖动中直接写 DOM transform 绕过响应式渲染保证跟手
	const next = clampDiscPos(discDragStart.x + dx, discDragStart.y + dy);
	if (discEl) {
		discEl.style.transform = `translate(${next.x}px, ${next.y}px)`;
	}
	discPos = next;
}
</code></pre>
<p>配合 <code>will-change: transform</code>，实测逐帧采样最大滞后 0px。</p>
<h3>坑二：封面图片的原生拖拽打断指针事件</h3>
<p>展开态拖动时前一步正常、之后事件全部消失。用事件监听排查，抓到了这条链：</p>
<pre><code>dragstart → pointercancel → lostpointercapture
</code></pre>
<p>唱片的封面是 <code>&lt;img&gt;</code>，浏览器对它启动<strong>原生图片拖拽</strong>时会取消正在进行的指针拖动。修复三连：</p>
<pre><code>&lt;div ondragstart={(e) =&gt; e.preventDefault()} ...&gt;
	&lt;img draggable="false" ... /&gt;
</code></pre>
<pre><code>&amp;__disc img
	-webkit-user-drag: none
	user-select: none
</code></pre>
<p>:::warning[记住这个组合]
只要在实现自定义拖拽的元素里放了 <code>&lt;img&gt;</code>，就必须同时处理 <code>dragstart</code> 和 <code>pointer capture</code> 的关系，否则真机上会随机出现「拖一半掉落」。
:::</p>
<h2>歌词开关的互斥联动</h2>
<p>控件上的歌词按钮控制的是<strong>控件自身的胶囊歌词</strong>，和屏幕上的悬浮歌词条是互斥的——开一个自动关另一个，双向联动且都持久化到 localStorage：</p>
<pre><code>function toggleDockLyrics(): void {
	dockLyricsOn = !dockLyricsOn;
	localStorage.setItem(DOCK_LYRICS_KEY, dockLyricsOn ? "true" : "false");
	setFloatingOn(!dockLyricsOn); // 互斥：开此关彼
}
</code></pre>
<p>胶囊里的文本优先显示当前歌词行，没有活动行时回退到「曲名 - 歌手」。</p>
<h2>收尾</h2>
<p>重构完成后：收起是一张会转的唱片，播放中变成「唱片 + 歌词胶囊」，鼠标靠近展开完整控件，移开自动收回；位置可拖动且跨会话记忆；点击外部整体折叠。所有形态共享同一个播放引擎，互斥联动全部落在 UI 层，播放逻辑零侵入。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>维特根斯坦：语言的边界就是世界的边界</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/wittgenstein-language-limits/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/wittgenstein-language-limits/</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>他一生只出版了一本哲学书，却在书末写下&quot;对于不可言说之物，必须保持沉默&quot;。这句话不是认输，是划界。</summary>
    <content type="html"><![CDATA[<p>1921 年，《逻辑哲学论》出版。全书只有七条主命题，正文七十多页，却试图一次性解决全部哲学问题。最后一条只有一行：</p>
<blockquote>
<p>对于不可言说之物，必须保持沉默（Wovon man nicht sprechen kann, darüber muss man schweigen）。</p>
</blockquote>
<p>写下它的人叫路德维希·维特根斯坦。此后他离开哲学界近十年，去奥地利乡村当小学教师，给姐姐设计过一栋极简主义宅邸，做过修道院园丁。他一生只出版了这一本书。</p>
<h2>世界是事实的总和</h2>
<p>《逻辑哲学论》从一句冷淡的断言开始："世界就是所发生的一切事实。"注意，是<strong>事实</strong>，不是"事物"。一只茶杯不是世界的一部分，"茶杯放在桌上"这个事实才是——世界的结构，就是事实之间的逻辑结构。</p>
<p>语言如何连接世界？维特根斯坦的答案是<strong>图像论</strong>：命题是事实的图像，词语对应对象，句子的逻辑结构与事实的逻辑结构同构。"猫在垫子上"之所以有意义，是因为它能画出事态的边界——符合为真，不符为假。</p>
<p>由此他推出那个著名的界限：<strong>语言的界限，就是世界的界限</strong>（Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt）。我的语言能到达哪里，我的世界就延伸到哪里。</p>
<h2>那么哲学还剩什么？</h2>
<p>如果命题只能言说事实，那么伦理、美学、人生意义、生死——这些价值领域，无法被画成事实的图像，也就<strong>不可言说</strong>。它们不在世界之内，而在世界的"边缘之外"。</p>
<p>于是哲学的任务被彻底改写：哲学不是一套理论，而是一种<strong>澄清活动</strong>——把可说的说清楚，从而显示哪些问题根本不是问题，只是语言的误用。历史上无数形而上学争吵，在维特根斯坦看来都是"在语言休假时抓住了语言的把柄"。</p>
<p>:::tip
"不可说"不等于"不存在"或"不重要"。恰恰相反——维特根斯坦在给出版商的信里说，这本书的观点是伦理学的，而伦理学不可言说，所以这本书本身是在"从内部为不可说者划界"。沉默，是敬意而非放弃。
:::</p>
<h2>后期的他推翻了前期的他</h2>
<p>十几年后，维特根斯坦重返剑桥，亲手拆掉了自己的图像论。《哲学研究》开篇引用奥古斯丁的语言观，然后逐条批判——等于清算年轻时的自己。</p>
<p>新的洞见是：<strong>语言的意义不在对应，而在使用</strong>。"游戏"没有统一定义，"水！"在不同场合是请求、警告或回答——词像工具，在"语言游戏"中获得生命。他留下那句被引用最多的总结：</p>
<blockquote>
<p>一个词的意义，是它在语言中的使用。</p>
</blockquote>
<p>前后期看似对立，内核却是同一件事：<strong>哲学问题多生于我们对自己语言的误解</strong>。前期划出可说的界限，后期诊断误用如何发生。</p>
<h2>有点用的结尾</h2>
<p>维特根斯坦的价值不在于教我们说什么，而在于教我们<strong>在哪里闭嘴、在何处多想一步</strong>：</p>
<ul>
<li>争论不下时，先检查双方是不是在用同一个词说不同的事；</li>
<li>被"成功学大词"裹挟时，问问它在具体使用中指什么；</li>
<li>面对无法言说的体验——爱、美、死——不必强行降维成口号，保持沉默也是一种诚实。</li>
</ul>
<p>语言的边界即世界的边界。扩大语言，就是扩大世界；而对真正不可说的，敬它一方沉默。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>《罪与罚》：拉斯柯尔尼科夫的救赎之路</title>
    <link href="https://blog.fqzlr.top/posts/reading/reading-dostoevsky-crime-punishment/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/reading/reading-dostoevsky-crime-punishment/</id>
    <published>2026-08-31T00:00:00.000Z</published>
    <updated>2026-08-31T00:00:00.000Z</updated>
    <summary>一个大学生为验证&quot;超人理论&quot;杀死放高利贷的老太婆，从此开始了精神上的凌迟。陀思妥耶夫斯基用一部小说证明：没有哲学能豁免良心。</summary>
    <content type="html"><![CDATA[<p>《罪与罚》的犯罪发生在第二部开头之前，几乎没有任何悬念——大学生拉斯柯尔尼科夫用斧头杀死了放高利贷的老太婆，顺带杀死了撞见现场的无辜的丽扎韦塔。</p>
<p>凶手是谁，读者从第一页就知道。陀思妥耶夫斯基感兴趣的从来不是"谁做的"，而是<strong>一个人做完这件事之后，灵魂会发生什么</strong>。</p>
<h2>超人理论的陷阱</h2>
<p>拉斯柯尔尼科夫不是穷凶极恶之徒。他聪明、敏感，因贫困辍学，蜷缩在彼得堡一间棺材般的阁楼里。他写了一篇文章，把人分成两类：<strong>绝大多数是"材料"，注定服从；极少数是"超人"，为了推进世界，有权跨越血腥</strong>。拿破仑踏过尸山血海，世人却奉他为伟人——凭什么？</p>
<p>杀死老太婆，是他给自己的实验：你到底是有权跨越的人，还是一只虱子？</p>
<p>:::tip
注意陀思妥耶夫斯基的写作顺序：罪在书名前半，罚在书名后半——但"罚"不是法律判决。拉斯柯尔尼科夫几乎没被怀疑，真正的刑罚在他举起斧头的那一刻就开始了：发烧、谵妄、无法与任何人亲近、听不得别人提那件事。<strong>良心这台仪器，比警察精确一万倍</strong>。
:::</p>
<h2>最漫长的凌迟</h2>
<p>小说的心理描写恐怖如斯。杀人后的拉斯柯尔尼科夫病了几天，起来后干的每一件事都在把自己推向暴露：重返凶案现场按门铃，在警局听到凶案讨论当场晕倒——<strong>他身体里有个部分在疯狂地寻求惩罚</strong>。</p>
<p>他以为自己能扛住理论与人心的裂缝，却发现两样东西撑不住：</p>
<p>一是<strong>亲情</strong>。母亲来信的字里行间全是自我牺牲的爱，妹妹杜尼娅为了供他读书准备嫁给自己厌恶的人。他可以对自己是虱子满不在乎，却无法容忍自己是"为了虱子式的人生而杀人"。</p>
<p>二是<strong>偶然</strong>。他杀丽扎韦塔纯属意外——而她恰恰是最温顺、最无辜的人。理论可以筛选"该死之人"，斧头落下去却不管这些。</p>
<h2>索尼娅：救赎不是逻辑题</h2>
<p>小说真正的转折不是破案，而是拉斯柯尔尼科夫遇到了索尼娅——为养活全家被迫卖身的女孩。</p>
<p>这个被全世界踩进泥里的人，读着拉撒路复活的福音段落，声音发抖，眼里有光。拉斯柯尔尼科夫向她坦白罪行，并骄傲地为自己的理论辩护：我杀的不过是只虱子。索尼娅的回答粉碎了一切诡辩：</p>
<blockquote>
<p>你杀了她，也就把自己交给了黑暗……没有比你更不幸的人了。</p>
</blockquote>
<p>她没有跟他辩论，只做了一件事：让他去十字路口亲吻大地，向全世界承认"我杀了人"。<strong>救赎的第一步不是辩护，是坦白</strong>——把那个靠理论堆起来的、傲慢的自己拆掉。</p>
<h2>西伯利亚的黎明</h2>
<p>结尾，拉斯柯尔尼科夫在流放地病了一场，然后在一个春天的清晨走到河边。索尼娅坐在对岸。那一刻，"他们都想起了很多事"，他扑倒在她的脚下，亲吻着，流泪了。</p>
<p>陀思妥耶夫斯基写道：<strong>一个人重新开始的故事，也许需要另写一本书来讲述</strong>。小说在此止步，但不难读出作者的意思——复活不是理论推翻，是爱战胜了算术。</p>
<h2>一百多年后为什么还要读它</h2>
<p>因为"超人理论"从未过时：只要还有人相信自己是例外、相信目的可以为手段辩护、相信他人的痛苦是可以计算的代价，拉斯柯尔尼科夫的故事就会一直上演。</p>
<p>陀思妥耶夫斯基的答案朴素到近乎固执：<strong>人不是拿来分类的材料；任何以"类"的名义犯下的罪，都由具体的良心一寸寸偿还</strong>。</p>
<p>斧头落下的那一刻很简单，难的是之后的每一个夜晚。这就是《罪与罚》用七百页证明的事。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="读书感悟"/>
  </entry>
  <entry>
    <title>萨特：存在先于本质，你的选择定义你是谁</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/sartre-existence-precedes-essence/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/sartre-existence-precedes-essence/</id>
    <published>2026-08-30T00:00:00.000Z</published>
    <updated>2026-08-30T00:00:00.000Z</updated>
    <summary>裁纸刀先有设计图后有实物，人却恰恰相反——萨特用这个对比，把一整个世纪的自由与焦虑装进了六个字。</summary>
    <content type="html"><![CDATA[<p>1945 年，巴黎，圣日耳曼大道。萨特在一家俱乐部的演讲后来结集为《存在主义是一种人道主义》，开场不久他就抛出了那句纲领：</p>
<blockquote>
<p>存在先于本质（l'existence précède l'essence）。</p>
</blockquote>
<h2>裁纸刀与人</h2>
<p>萨特举例：裁纸刀在被制造之前，它的"本质"——用途、形状、工艺——已经作为设计图存在于工匠脑中。<strong>先有本质，后有存在</strong>。传统神学对人的理解与此同构：人是上帝的造物，每个人带着预先给定的使命来到世上，"人性"就是那张设计图。</p>
<p>但如果上帝不存在呢？萨特说，那就至少有一个存在物在设计图之前就已存在——<strong>人</strong>。人先被抛到世界上，先存在、先出现、先登场，然后通过自己的选择与行动，把自己塑造成是什么。</p>
<p>你没有预先写好的剧本。你的人生不是"活出那个本来的自己"，而是<strong>通过一个个选择，正在书写那个自己</strong>。</p>
<h2>自由是被判处的</h2>
<p>这话听起来令人振奋，萨特却用了一个沉重的词：人是"被判处自由的"（condamné à être libre）。</p>
<p>判处，意味着无法上诉。你可以逃避选择——但逃避本身也是一种选择；你可以求助权威、随大流——把选择权让渡出去，仍然是你的选择。萨特把这种无处可逃的感受命名为<strong>焦虑（angoisse）</strong>：站在路口，你知道身后没有护栏，你做的每一个决定都在替全人类示范"人可以是什么样"。</p>
<p>与焦虑相伴的是<strong>自欺（mauvaise foi）</strong>。它的经典配方是："我没办法，我是被逼的""我天性如此""大家都这样"。萨特说，人当然有处境、有出身、有身体，但<strong>在处境与行动之间，永远横着你的选择</strong>。把自己看成一件被决定的物品，正是为了逃避"我在自由地选择"这份重担。</p>
<p>:::tip
分辨自欺有个简单的试金石：把"我不得不"换成"我选择了"再说一遍。"我选择了继续这份工作"——哪怕理由充分，一旦这样表述，你就拿回了所有权，也拿回了重新审视它的可能。
:::</p>
<h2>责任，及它的分量</h2>
<p>自由的反面不是束缚，是责任。既然一切选择是你的，那么<strong>你的人生成为什么样子，账只能记在你头上</strong>。萨特拒绝一切借口——出身、时代、性格、潜意识，在"我如何回应"这个环节上全部失效。</p>
<p>这近乎冷酷。但萨特认为这恰恰是人道主义所在：因为人是自己成就自己的，所以<strong>人永远可以重新开始</strong>。懦夫不是天生的懦夫，是他把自己造就成懦夫；而既然是造就，就同样可以造就成英雄。</p>
<h2>一个不提供安慰的哲学</h2>
<p>存在主义从不保证"一切都会好"。世界没有内置的意义，未来没有预设的剧本，他人与你一样自由——这些都不会改变。</p>
<p>它唯一承诺的是：<strong>在任何一个醒来的清晨，你面前都摆着重新选择的机会</strong>。这份自由重得让人焦虑，但转念一想，世间还有比这更好的礼物吗？</p>
<p>你正在成为你自己。此刻，就在成为。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>王阳明心学：知而不行，只是未知</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/wangyangming-zhixingheyi/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/wangyangming-zhixingheyi/</id>
    <published>2026-08-28T00:00:00.000Z</published>
    <updated>2026-08-28T00:00:00.000Z</updated>
    <summary>「懂得很多道理，却依然过不好这一生」——王阳明五百年前就诊断出了这个病的病灶：你以为你懂了，其实你没懂。</summary>
    <content type="html"><![CDATA[<p>明正德三年，贵州龙场。一个被廷杖、下狱、贬谪到瘴疠之地的中年人，睡在石棺里，日夜端坐澄默。某个深夜，他忽然从梦中跃起，随从们都吓了一跳——他悟了。</p>
<p>这就是"龙场悟道"。那个人叫王守仁，号阳明。他悟出的道理只有一句：</p>
<blockquote>
<p>圣人之道，吾性自足，向之求理于事物者误也。</p>
</blockquote>
<h2>心即理</h2>
<p>在朱熹的官方学统里，真理（理）散落在万事万物之中，人要"格物致知"，一件一件地研究、穷究。王阳明年轻时真信过这个：对着庭院里的竹子"格"了七天，格到呕血，什么也没格出来。</p>
<p>龙场的绝境让他想通了：<strong>如果理在心外，那么它永远与人隔着一层；如果人心本身就是理的源泉，那么成圣之路就不必外求</strong>。孝顺的道理不在父母身上，在你见父母时自然涌起的那颗心上——心即是理。</p>
<p>这个转向的颠覆性在于：它把道德的裁判权从经典、权威、外部标准，收回到了每个人自己的心中。</p>
<h2>知行合一</h2>
<p>但心学真正击中现代人的，是"知行合一"四个字。</p>
<p>我们习惯把"知道"和"做到"分开：知道要早睡，知道要读书，知道要孝敬父母……做不到时叹一句"懂得很多道理，却依然过不好这一生"。</p>
<p>王阳明的诊断是：<strong>这不叫知道。</strong></p>
<blockquote>
<p>未有知而不行者。知而不行，只是未知。</p>
</blockquote>
<p>你说你知道吸烟有害却戒不掉——你只是"听说过"有害，你的心并没有真正把它当作自己的真理。真正的知，像"知痛必已自痛了才知痛"，是带着体认的、必然发为行动的知。<strong>知是行之始，行是知之成</strong>：知的瞬间，行动已经在路上；行动没有发生，说明那个"知"只是记忆。</p>
<p>:::tip
这个标准其实很残酷也很解放：它取消了"我懂但做不到"这个借口。做不到，就诚实地承认自己还没懂，然后回到事上继续磨。
:::</p>
<h2>致良知</h2>
<p>晚年王阳明把全部学说凝成两个字：<strong>致良知</strong>。</p>
<p>良知不是高深的东西——见孺子入井自然会惊惧，闻恶臭自然会厌恶，这种不虑而知、不学而能的判断力，人人本有。问题在于它常被私欲遮蔽，像明镜蒙尘。所谓修行，不是往心里添加什么，只是<strong>在每一件事上把良知的判断落实</strong>——这就是"致"。</p>
<p>他有一句诗说得极好：</p>
<blockquote>
<p>抛却自家无尽藏，沿门持钵效贫儿。</p>
</blockquote>
<p>真理的宝藏本来就在你家里，你却端着钵去外面挨门乞讨。</p>
<h2>事上磨练</h2>
<p>有人问：静坐时心很好，一遇事就乱，怎么办？王阳明答：人须在事上磨练做功夫，乃有益。"若只好静，遇事便乱，终无长进。"</p>
<p>心学从来不是玄想，它是行动哲学：<strong>在你此刻正在做的这件具体的事上，诚实面对良知的判断，然后去做</strong>。仅此而已，难就难在仅此而已。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>《围城》：城外的人想冲进去，城里的人想逃出来</title>
    <link href="https://blog.fqzlr.top/posts/reading/reading-qianzhongshu-fortress/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/reading/reading-qianzhongshu-fortress/</id>
    <published>2026-08-27T00:00:00.000Z</published>
    <updated>2026-08-27T00:00:00.000Z</updated>
    <summary>钱钟书写的是 1930 年代的留学生与知识分子的婚姻围城，读起来却句句像在写今天的我们。世上有多少笑，是给方鸿渐们预备的。</summary>
    <content type="html"><![CDATA[<p>《围城》里最著名的比喻，借一个法国谚语道出：</p>
<blockquote>
<p>婚姻是一座围城，城外的人想冲进去，城里的人想逃出来。</p>
</blockquote>
<p>但读完全书会发现，围住的何止婚姻——职业是围城，学历是围城，连人生本身也是一座围城。钱钟书用一支毒舌的笔，把这座城砌得让人笑出声，又让人笑不下去。</p>
<h2>方鸿渐：一个"体面的失败者"</h2>
<p>方鸿渐这个人物，是现代小说里最让中国读者坐立难安的男主角之一。</p>
<p>他不算坏人：有点良知，有点才华，对唐晓芙的爱是真的，对赵辛楣的义气也是真的。但他有个致命的特质——<strong>对一切都不够认真，唯独对逃避这件事格外认真</strong>。留学混日子买张假文凭，谓之"体面"；对苏文纨态度暧昧不敢拒绝，谓之"不忍"；与孙柔嘉稀里糊涂订婚，谓之"顺其自然"。</p>
<p>他的人生由一连串"不算主动的选择"构成，然后被这些选择推着走进围城。</p>
<p>:::tip
钱钟书的刻薄在文学史上排得上号。全书金句密度极高，比如评留学生的文凭："这一张文凭，仿佛有亚当、夏娃下身那片树叶的功用，可以遮羞包丑。"再刻薄的讽刺，扎到的都是真问题。
:::</p>
<h2>婚姻的围城：琐碎如何杀死爱情</h2>
<p>方鸿渐与孙柔嘉的婚姻线，是中国文学里最冷静的婚姻解剖。</p>
<p>他们不是没有过温情——订婚时也甜过，旅途中也互相扶持过。但婚后，每一次争吵都从鸡毛蒜皮长成唇枪舌剑：一份工作、一位亲戚、一句口误，都能升级成"你们全家"式的总攻。<strong>两个人都太聪明了，聪明到永远能精准打击对方最痛的地方</strong>。</p>
<p>钱钟书的洞察是：婚姻的敌人不是外遇不是贫穷，而是<strong>两个自我防御机制的对峙</strong>。孙柔嘉的姑妈、方家的老规矩、各自的旧账，都是往城墙里添的砖。到最后，夫妻俩都明白这样下去没有好结果，但谁也不肯先放下武器——城就这么越砌越厚。</p>
<h2>城里的我们</h2>
<p>重读《围城》，最惊悚的是发现它根本没过时：</p>
<ul>
<li>"水课"、混文凭、学历焦虑——方鸿渐的克莱登大学从未倒闭；</li>
<li>"大厂围城"：城外的人挤破头想进去，城里的人在工位上刷着"逃离北上广"；</li>
<li>婚恋市场上条件与条件的讨价还价，与苏文纨、唐晓芙、孙柔嘉之间的周旋如出一辙。</li>
</ul>
<p>杨绛说过，《围城》写的是"围城心态"：<strong>人永远在追求与厌倦之间打转，得到了便不珍惜，得不到的才最美</strong>。这大概是刻在人性里的东西，钱钟书只是把它看穿了，又用最幽默的方式说了出来。</p>
<h2>笑完之后</h2>
<p>《围城》结尾，方鸿渐和孙柔嘉大吵后各自摔门，深夜里他昏昏睡去，那只祖传的老钟"当当"打起来，钟声里报的是几个小时前——"这个时间落伍的计时机无意中包涵对人生的讽刺和感伤，深于一切语言、一切啼笑"。</p>
<p>钱钟书没有给方鸿渐安排觉醒，也没有给围城开门。他只是让钟声敲给所有读者听：<strong>看，你的城，你打算怎么进，怎么出？</strong></p>
<p>能问出这个问题的人，也许已经比方鸿渐多了一分自由。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="读书感悟"/>
  </entry>
  <entry>
    <title>斯多葛主义：能控制的与不能控制的</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/stoicism-dichotomy-of-control/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/stoicism-dichotomy-of-control/</id>
    <published>2026-08-26T00:00:00.000Z</published>
    <updated>2026-08-26T00:00:00.000Z</updated>
    <summary>两千年前的古罗马哲学，如今是美国海军学员的必读教材、心理治疗的灵感来源。斯多葛主义到底说了什么？</summary>
    <content type="html"><![CDATA[<p>爱比克泰德出生时是奴隶，腿有残疾，一生清贫。但他的课堂开在了罗马城的中心，学生里有未来的皇帝马可·奥勒留。他留下的《手册》开篇第一句，是整座斯多葛大厦的地基：</p>
<blockquote>
<p>有些事物取决于我们，有些事物不取决于我们。</p>
</blockquote>
<h2>控制二分法</h2>
<p>取决于我们的：判断、意愿、欲求、厌恶——一句话，<strong>我们自己的意见与行动</strong>。它们天然自由，不受任何外力剥夺。</p>
<p>不取决于我们的：身体、财产、名声、职位——一切<strong>外在结果</strong>。它们本质上是脆弱的、他者的，随时可能被拿走。</p>
<p>斯多葛的全部实践，就建立在这个二分之上：把全部注意力投在可控之事上，对外界结果保持一种清醒的超然。塞涅卡的表述更直观——命运所给予的，本来就不是"我们的"，只是"借用的"。它在原处时就该做好归还的准备。</p>
<h2>消极想象</h2>
<p>斯多葛有一项著名的心理练习：<strong>premeditatio malorum</strong>，预先想象坏事的发生。</p>
<p>这不是悲观，恰恰是防波堤。提前想象失去财产、失去职位、失去亲人，你会发现自己此刻拥有的东西从"理所当然"变回"尚未失去的礼物"。<strong>失去的痛苦，大部分来自"本该属于我"的错觉；拆掉这个错觉，痛苦就失去了支点</strong>。</p>
<p>现代心理学发现，这正对应着"享乐适应"的解药：人对拥有之物会迅速麻木，而刻意练习想象失去，能持续唤醒对当下的珍视。</p>
<h2>理性之外的执行工具</h2>
<p>斯多葛不是空谈，他们发明了许多具体的"操作系统"：</p>
<ul>
<li><strong>晚间复盘</strong>：塞涅卡每天睡前问自己三个问题——今天我做了什么？哪里做得不好？哪里没做完？</li>
<li><strong>角色伦理</strong>：马可·奥勒留提醒自己，人这一生要演好自己的角色——无论剧本是荣是辱，演好它，而不是挑剧本。</li>
<li><strong>当下之锚</strong>：过去不可改，未来不可控，人真正拥有的只有此刻的判断与行动。《沉思录》里反复出现同一个提醒：把自己拉回当下。</li>
</ul>
<p>:::tip
常见误解：斯多葛主义不是压抑情绪的"冷血哲学"。它承认愤怒、悲伤是人性，只是拒绝让它们替你做决定。prohairesis（道德抉择）永远在你手里——这正是奴隶爱比克泰德比皇帝马可更富有的地方。
:::</p>
<h2>为什么它今天突然翻红</h2>
<p>因为现代人的焦虑，几乎全部源于把不可控之物当成了可控：试图控制他人的评价、市场的波动、时代的风向，唯独不修炼自己的判断。</p>
<p>美国海军把《手册》列入学员读物，认知行为疗法（CBT）公开承认源自爱比克泰德——都是因为同一种发现：<strong>痛苦不直接来自事件，而来自我们对事件的判断</strong>。这句两千年前的唯物，如今有大量临床证据。</p>
<p>你无法决定风往哪吹，但可以调整帆。这句被用滥的话，斯多葛学派用一生践行过——并且证明了它真的可行。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>康德的道德律：头顶的星空与心中的律令</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/kant-moral-law/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/kant-moral-law/</id>
    <published>2026-08-24T00:00:00.000Z</published>
    <updated>2026-08-24T00:00:00.000Z</updated>
    <summary>如果人人都这样做，世界还能运转吗？康德用一个朴素的问题，给&quot;道德&quot;装上了理性的地基。</summary>
    <content type="html"><![CDATA[<p>康德墓志铭上的那句话广为流传：</p>
<blockquote>
<p>有两样东西，我们愈经常愈持久地加以思索，它们就愈使心灵充满始终新鲜不断增长的景仰和敬畏：我头上的星空和我心中的道德法则。</p>
</blockquote>
<p>星空是自然律，道德法则是什么？康德用一生回答了这个问题。</p>
<h2>好的意志是一切的起点</h2>
<p>《道德形而上学奠基》开篇断言：<strong>在这个世界上，唯一无条件善的东西，只有一颗好的意志</strong>。聪明可以是作恶的利器，勇敢可以是犯罪的资本，唯有"出于义务而行动"的意志，本身即为善，不因结果成败而增减。</p>
<p>一个濒死倒闭的店主坚持童叟无欺，与一个生意兴隆的店主坚持诚信，谁的道德含量更高？康德的答案可能让人意外：前者。因为后者诚实可能只是因为诚实有利可图，而前者的诚实与利益完全相反，只剩下义务本身。</p>
<p>这个思想实验的锋利之处在于，它把道德从结果那里收回来，交还给<strong>动机</strong>。</p>
<h2>绝对命令</h2>
<p>那么，义务的内容如何确定？康德给出了著名的<strong>绝对命令（kategorischer Imperativ）</strong>，其中最可操作的表述是：</p>
<blockquote>
<p>要只按照你同时能够愿意它成为一条普遍法则的那个准则去行动。</p>
</blockquote>
<p>翻译成日常语言就是行动前的一个自检：**如果人人都这样做，世界还能运转吗？**如果人人撒谎，"承诺"这个词将彻底失效——所以撒谎无法成为普遍法则，撒谎因此是错的。</p>
<p>注意，这个检验不需要引用任何宗教、习俗或权威，纯粹依靠理性自身。这就是康德所谓"启蒙"的底气：<strong>人是自己为自己立法的</strong>。</p>
<h2>人是目的，不是手段</h2>
<p>绝对命令还有第二个表述，可能是伦理学史上最重要的一句话：</p>
<blockquote>
<p>无论是对你自己还是对他人，你要始终把人当作目的，而永远不要仅仅当作手段。</p>
</blockquote>
<p>欺诈之所以错，是因为它把被骗的人贬低为达成私利的工具；剥削之所以错，是因为它把劳动力从"人"压缩成"耗材"。这个原则如今写进了现代伦理学、人权宣言甚至科技伦理的讨论里——人工智能伦理的讨论中"不应把人简化为数据"的诉求，追到源头，仍是康德的回声。</p>
<p>:::tip
康德并不天真。他知道现实中道德常常"吃亏"，他的回答是：道德本来就不是投资，不存在回报率。它的价值在行动的那一刻已经完成。
:::</p>
<h2>规矩铸就的自由</h2>
<p>很多人觉得康德道德是刻板的死规矩，与他"自由"的理念矛盾。恰恰相反——康德认为<strong>任性不是自由，被欲望推着走恰恰是被奴役</strong>。真正的自由，是服从自己理性为自己立的法。</p>
<p>就像乐手：随心所欲乱弹不是自由，精通乐理之后的挥洒才是。道德律令不是自由的枷锁，而是自由的乐谱。</p>
<p>仰望星空，我们知道自己渺小；省察心中的律令，我们知道自己并不卑微。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>《月亮与六便士》：满地都是六便士，他抬头看见了月亮</title>
    <link href="https://blog.fqzlr.top/posts/reading/reading-maugham-moon-sixpence/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/reading/reading-maugham-moon-sixpence/</id>
    <published>2026-08-23T00:00:00.000Z</published>
    <updated>2026-08-23T00:00:00.000Z</updated>
    <summary>一个四十岁的证券经纪人，抛下体面生活去画画，最后死在孤岛上。毛姆没有告诉你答案，只递给你一道选择题。</summary>
    <content type="html"><![CDATA[<p>《月亮与六便士》的原型是画家高更，但毛姆笔下的查尔斯·思特里克兰德比高更更冷、更狠——也更纯粹。</p>
<p>伦敦证券经纪人，四十岁，有房有妻有儿女。某天他毫无征兆地离家出走，家人以为他带着情妇私奔，结果发现他住在巴黎一家破旅馆里，穷困潦倒，只为了一件事：<strong>画画</strong>。没有人觉得他有天赋——包括他自己都不在乎有没有人觉得。</p>
<h2>他不是在追求梦想，是被梦想劫持</h2>
<p>这本书常被简化成"追梦鸡汤"，但毛姆写的根本不是励志故事。</p>
<p>思特里克兰德对世俗毫无留恋是事实，可他同样冷酷到近乎非人：在巴黎病重时被朋友 Dirk 救回家，病好后却引诱其妻勃朗什，又弃之如敝屣，直接导致她服毒身亡。他对此的反应不是愧疚，而是认为勃朗什"精神不健全，自杀完全合理"。</p>
<p>毛姆显然没打算把他塑造成值得效仿的人。思特里克兰德更像<strong>一种力量的载体</strong>：他自述被某种东西"扼住了喉咙"，不画就会疯。他不是选择了艺术，他是被艺术选择了。</p>
<p>:::tip
月亮与六便士的隐喻：六便士是英国最小的硬币，是地上的、现实的、人人弯腰可拾的；月亮是远方的、理想的、抬头才能看见的。这本书的残酷在于——它不劝你选月亮，它只是问：你敢不敢承认，你其实只是不敢选？
:::</p>
<h2>塔希提岛：文明的出走</h2>
<p>小说后半段，思特里克兰德流落塔希提岛，娶了当地女子爱塔，在椰林深处的小屋作画，染上麻风病，双目失明，在墙壁上画出毕生杰作——一幅伊甸园般的壁画——然后留下遗言：<strong>烧掉房子，连同壁画</strong>。</p>
<p>他一生从未想出版、展出、卖掉任何一幅画。画完，即完成。</p>
<p>这一笔是全书的题眼。如果艺术的终点不是被观看，那创作到底是什么？毛姆借思特里克兰德给出了答案：画画之于他，类似于一场必须进行的自我倾倒——他把命运塞进他体内的东西倒出来，倒在墙上，然后就可以死了。<strong>过程中的全部痛苦与狂喜，已经自足</strong>。</p>
<h2>我们为什么不抬头</h2>
<p>读这本书最不舒服的地方，不是思特里克兰德的冷酷，而是它照出的我们自己的借口。</p>
<p>"等我有钱了""等孩子大了""等我退休了"——我们熟练地推迟一切真正想做的事，用"责任"包装怯懦。毛姆并不谴责六便士，书里的德克善良温暖，是另一种圆满。他只是用思特里克兰德这团烈火，逼每个读者诚实面对自己：</p>
<p><strong>你是真的热爱脚下的生活，还是只是不敢抬头看一眼月亮？</strong></p>
<p>这个问题没有标准答案。但这本书值得读的原因是：它让"被选择过的生活"重新变成一个选项。哪怕最终你依然弯腰拾起六便士——你也知道自己拾起的是什么，而这，已经和从前不一样了。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="读书感悟"/>
  </entry>
  <entry>
    <title>尼采：凝视深渊时，别弄丢了自己</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/nietzsche-gaze-into-abyss/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/nietzsche-gaze-into-abyss/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>「上帝死了」不是胜利宣言，而是一声警报。尼采想说的从来不是毁灭，而是：旧价值倒塌之后，你要如何成为自己。</summary>
    <content type="html"><![CDATA[<p>"当你凝视深渊时，深渊也在凝视你。"这句被引用到泛滥的话，出自尼采《善恶的彼岸》。但多数人忽略了它的前半句：</p>
<blockquote>
<p>与恶龙缠斗过久，自身亦成为恶龙。</p>
</blockquote>
<p>两句合起来才是完整的警告：<strong>对抗会重塑对抗者</strong>。而这份警觉，贯穿了尼采全部的哲学。</p>
<h2>"上帝死了"意味着什么</h2>
<p>《快乐的科学》里，尼采讲了一个寓言：一个疯子大白天提着灯笼在集市上找上帝，众人哄笑。疯子喊道：上帝死了！是我们杀死了他！</p>
<p>这不是无神论的欢呼。尼采的意思是：欧洲人赖以安身立命的基督教价值体系，已经在科学、理性与历史批判中事实性崩塌了。问题在于，<strong>人们杀死了上帝，却还不知道自己失去了什么</strong>——那个曾经给"你为什么而活"提供标准答案的东西，没有了。</p>
<p>价值的真空，才是真正的危机。而大多数人的应对，是用别的东西填补空洞：国家、金钱、大众意见、"时代浪潮"。</p>
<h2>末人与超人</h2>
<p>《查拉图斯特拉如是说》里，尼采区分了两种人。</p>
<p><strong>末人（der letzte Mensch）</strong>：没有渴望，眨着眼睛享受微小舒适，"我们发明了幸福"——他们回避一切艰难与崇高，把安全与娱乐当作生活的全部。</p>
<p><strong>超人（Übermensch）</strong>：不是肌肉发达的强者，而是<strong>在自己内部完成价值重估的人</strong>——旧的神圣性倒塌后，他不逃向新的偶像，而是自己为自己立法，把大地当作意义发生的场所。</p>
<p>超人不是一个生物学概念，而是一个任务：成为你自己的过程。</p>
<h2>精神三变</h2>
<p>查拉图斯特拉说，精神要经历三次变形：先变成<strong>骆驼</strong>，背负"你应当"的重担，跪地承受一切传统；再变成<strong>狮子</strong>，对旧价值咆哮"我要"，夺得自由；最后变成<strong>孩子</strong>——"孩子是天真与遗忘，一个新的开始，一个游戏，一个自转的轮"。</p>
<p>:::tip
狮子只能否定，只有孩子才能创造。尼采哲学的落点不是批判，而是创造：真正的成熟，是把"应该"换成"我愿意"，然后像孩子一样，不带怨气地开始。
:::</p>
<h2>深渊与热爱命运</h2>
<p>尼采一生疾病缠身、孤独漂泊、著作滞销，最后精神崩溃。但他的哲学底色却不是怨毒，而是 <strong>amor fati——热爱命运</strong>：不仅接受发生在自己身上的一切，而且希望它"不仅如此，还要更好"——把苦难当作自我锻造的一部分。</p>
<p>这才是那句深渊格言的正确用法：凝视深渊，是为了不变成深渊；对抗世界，是为了不被世界同化。而在这一切之后，依然对自己的人生说"是"。</p>
<p>正如他借查拉图斯特拉之口所说的一切，最终指向同一个问题——</p>
<p><strong>你现在所做的一切，是为你自己的生命增重，还是减重？</strong></p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>西西弗斯是幸福的：加缪与荒诞的英雄</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/camus-sisyphus-happy/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/camus-sisyphus-happy/</id>
    <published>2026-08-20T00:00:00.000Z</published>
    <updated>2026-08-20T00:00:00.000Z</updated>
    <summary>推石上山的西西弗斯，是希腊神话里最悲催的凡人，却被加缪写成了&quot;应当想象的幸福者&quot;。荒诞不是终点，反抗才是。</summary>
    <content type="html"><![CDATA[<p>希腊神话里，西西弗斯因触怒诸神，被判处永恒的刑罚：把巨石推上山顶，石头随即滚落，他再推，再滚落，永无止境。</p>
<p>这大概是关于"徒劳"最完美的意象。但加缪偏偏在《西西弗斯神话》的开头写下一句惊人之语：</p>
<blockquote>
<p>真正严肃的哲学问题只有一个：自杀。</p>
</blockquote>
<h2>荒诞是什么</h2>
<p>加缪说的"荒诞"，不是生活离奇搞笑，而是指一种<strong>断裂</strong>：人天生渴望意义、渴望秩序、渴望理解世界，而世界却用它的沉默、偶然与不回应来面对我们。意义的需求，与意义的缺席，两者相遇，荒诞诞生了。</p>
<p>荒诞不在人这一端，也不在世界那一端，而在两者之间那根绷断的绳子。</p>
<p>面对荒诞，加缪认为有三种反应：</p>
<ol>
<li><strong>肉体自杀</strong>——承认生活被荒诞压垮，这是逃避问题而非解决问题；</li>
<li><strong>哲学自杀</strong>——跳向宗教或某种宏大体系，用现成的意义把裂缝糊上，这同样是取消问题；</li>
<li><strong>反抗</strong>——留在荒诞之中，不逃避、不屈服，睁着眼睛生活。</li>
</ol>
<p>他选第三条路。</p>
<h2>应当想象的西西弗斯是幸福的</h2>
<p>于是加缪把目光投向了那个推石头的人。他的分析有惊人的翻转：</p>
<p>当西西弗斯看着石头滚落、转身走下山去再开始新一轮的时候，加缪说，<strong>那走下山的时光，恰恰是意识的时刻</strong>。诸神以为无尽的重复是最重的刑罚，他们错在没想到：当西西弗斯清楚地知道自己的命运，并且说"好，就这样继续"时，他的命运已经不再能支配他了。</p>
<blockquote>
<p>攀登山顶的奋斗本身，足以充实人的心灵。应当想象的西西弗斯是幸福的。</p>
</blockquote>
<p>这句话的分量在于：西西弗斯的幸福不取决于石头是否到达山顶——它永远到不了——而取决于他与自己命运的关系发生了翻转。<strong>被判处的东西，被他自己没收了。</strong></p>
<p>:::tip
加缪的反抗不是胜利，是姿态：不抱幻想，也不自弃。用他自己的话说，是"在冰底下听见火"，是清醒之下的激情。
:::</p>
<h2>我们每个人手里都有一块石头</h2>
<p>上班、通勤、洗碗、重复……现代生活里到处是西西弗斯式的循环。加缪并不打算美化这种重复，他只是提出一个更锋利的问题：<strong>当结果注定无法圆满时，你选择如何度过过程？</strong></p>
<p>荒诞是不可消除的底色，但人可以在底色上作画。认真把今天的石头推好，明天的石头也推好，同时清楚地知道这一切不通往任何"最终圆满"——这不是认命，这是把裁判权从命运手里抢回来。</p>
<p>石头会滚下去。而推石头的人，可以幸福。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>《百年孤独》：孤独是宿命，也是镜子</title>
    <link href="https://blog.fqzlr.top/posts/reading/reading-marquez-hundred-years/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/reading/reading-marquez-hundred-years/</id>
    <published>2026-08-19T00:00:00.000Z</published>
    <updated>2026-08-19T00:00:00.000Z</updated>
    <summary>马尔克斯用马孔多百年兴衰告诉我们的，不是魔幻有多神奇，而是孤独有多真实。读完全书，你会重新看待自己的孤独。</summary>
    <content type="html"><![CDATA[<p>《百年孤独》的开头被公认为文学史上最伟大的开场之一：</p>
<blockquote>
<p>多年以后，面对行刑队，奥雷里亚诺·布恩迪亚上校将会回想起父亲带他去见识冰块的那个遥远的下午。</p>
</blockquote>
<p>一句话容纳了未来、现在与过去三个时间维度——这是整部小说的时间观：<strong>在马孔多，时间不是直线，而是一个旋转的圆</strong>。</p>
<h2>重复的名字，重复的命运</h2>
<p>读这本书最大的障碍是名字：一代代的奥雷里亚诺、阿尔卡蒂奥、雷梅黛丝，读着读着就晕了。但读到一半你会明白，<strong>马尔克斯就是要你晕</strong>——名字的重复暗示命运的重复：叫奥雷里亚诺的内向孤僻，叫阿尔卡蒂奥的冲动莽撞，女人留下来支撑家园，男人出走或者疯狂。</p>
<p>布恩迪亚家族七代人，每个人都以不同的方式逃跑、挣扎、燃烧，最后都回到同一个终点：孤独。</p>
<p>何塞·阿尔卡蒂奥·布恩迪亚痴迷炼金术与科学，被绑在栗树下疯癫至死；奥雷里亚诺上校发动三十二场战争全部失败，晚年把自己关在作坊里反复熔铸小金鱼，做好化掉，化掉再做；阿玛兰妲一生织着自己的裹尸布，织了拆，拆了织……</p>
<p><strong>这些循环的动作，是孤独的形状</strong>：人被困在自己的世界里，用无意义的重复抵御无法言说的空虚。</p>
<h2>魔幻是外衣，现实是内核</h2>
<p>很多人被"魔幻现实主义"的名头吸引：飞上天空的雷梅黛丝、下了四年十一个月零两天的雨、死去的鬼魂在房间里游荡。</p>
<p>但马尔克斯说得清楚：他写的全是现实。拉美的历史本来就是魔幻的——香蕉公司屠杀三千工人，官方宣布"什么都没发生"，一夜之间从记忆里抹去；殖民者、独裁者、外国资本轮番登场。<strong>魔幻不是逃离现实的技巧，而是讲述拉美现实唯一诚实的语法</strong>。</p>
<p>:::tip
全书最刺痛的一笔：马孔多人集体患上失眠症，继而失忆，只好给万物贴上标签——"这是牛，每天要挤奶"——甚至在入口处立牌子：上帝存在。当一个民族开始遗忘，连"存在"都需要提醒时，历史就成了魔幻。
:::</p>
<h2>孤独的反面是什么</h2>
<p>羊皮卷的最后一句，是全书唯一一次直接的宣判：</p>
<blockquote>
<p>遭受百年孤独的家族，注定不会在大地上第二次出现。</p>
</blockquote>
<p>马尔克斯解释过，这句话的本意不是诅咒，而是一种希望：<strong>布恩迪亚家族的悲剧源于他们不懂爱</strong>。何塞·阿尔卡蒂奥与乌尔苏拉是表兄妹成婚，害怕生出长猪尾巴的孩子；此后七代人，所有的结合几乎都隔着误解、乱伦的阴影与封闭的傲慢——直到最后一代真的生下了长猪尾巴的孩子，家族应验毁灭。</p>
<p>孤独的本质，是爱的无能；是每个人固守自己的壳，宁可在小金鱼的熔铸里打转，也不肯向他人敞开。</p>
<h2>我们为什么要读它</h2>
<p>合上书你会发现，布恩迪亚家族并不遥远。</p>
<p>深夜刷着手机不肯睡去的我们，房间里堆满舍不得扔的旧物的我们，把心事讲给树洞账号也不肯告诉枕边人的我们——<strong>谁不是在做自己的小金鱼呢</strong>。</p>
<p>马尔克斯没有给出解药，他只是把镜子擦得足够亮：看见自己的孤独，是打破它的第一步。毕竟，能被写尽的孤独，就已经不再完全是孤独了。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="读书感悟"/>
  </entry>
  <entry>
    <title>庄子的逍遥：无用之用，方为大用</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/zhuangzi-wuyong-zhiyong/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/zhuangzi-wuyong-zhiyong/</id>
    <published>2026-08-18T00:00:00.000Z</published>
    <updated>2026-08-18T00:00:00.000Z</updated>
    <summary>惠子嫌大葫芦没用，庄子说他&quot;拙于用大&quot;。两千年前的这场辩论，治好了多少现代人的&quot;有用焦虑&quot;。</summary>
    <content type="html"><![CDATA[<p>《逍遥游》的结尾，庄子和老朋友惠子吵了一架，议题听起来很日常：<strong>一个巨大的葫芦有没有用</strong>。</p>
<h2>大葫芦的两种命运</h2>
<p>惠子说：魏王送我大葫芦种子，我种出来的葫芦容积有五石之大。用来盛水，脆得提不起来；剖开做瓢，又大得没地方放。这东西不是不大，但我认为它没用，所以把它砸了。</p>
<p>庄子叹了口气，说你真是"拙于用大"。他有宋国人善于做不龟手之药的例子：同样的药方，有人世世代代靠它漂洗丝絮，有人却靠它获得封地——<strong>同样的东西，用的格局不同，价值天差地别</strong>。然后他说：你现在有五石容量的大葫芦，为什么不系在腰间，浮游于江湖之上，反而担心它太大没处放？可见你的心还是"有蓬之心"——长满了杂草，塞得太满。</p>
<h2>无用之树</h2>
<p>类似的故事还有一则。惠子说有棵大树，木匠看都不看——疙里疙瘩做不了梁，歪歪扭扭做不了家具，纯粹是"无用"之材。</p>
<p>庄子说：你怎么不把它种在"无何有之乡，广莫之野"，让人悠闲地躺在树下呢？它不会遭斧头砍伐，也长不成任何人期待的样子，<strong>正因为它无用，才没有人来砍它，它才能长得如此巨大，庇荫路人</strong>。</p>
<blockquote>
<p>人皆知有用之用，而莫知无用之用也。</p>
</blockquote>
<h2>被效率绑架的现代人</h2>
<p>这个故事之所以今天读来仍觉刺痛，是因为我们活在一个万物皆要"有用"的时代：</p>
<ul>
<li>读书要有用，最好是"干货"；</li>
<li>休息要有用，最好能"自我提升"；</li>
<li>连发呆都要怀疑自己是不是在浪费时间。</li>
</ul>
<p>我们把生命切割成一个又一个可度量的产出，像惠子量那五石之瓢。而庄子的提醒是：<strong>当你用"有用"丈量一切时，被丈量最狠的，是你自己的生命</strong>。</p>
<p>:::tip
"无用"的阅读、无目的的散步、不为什么的发呆，恰恰是心灵免于"蓬之心"的通风口。庄子不是教人躺平，他是提醒我们：工具的价值在工具之外，人的价值更在"用途"之外。
:::</p>
<h2>逍遥不是逃避</h2>
<p>有人批评庄子消极。但细读《逍遥游》会发现，"逍遥"的前提是"无待"——不依赖外物来定义自己。大鹏要靠风，列子要靠御风，都还有所"待"；真正的逍遥，是不把自我价值抵押给任何外部尺度。</p>
<p>在一个人人被 KPI、排名、点赞数追着跑的时代，这种"不待"几乎是一种温柔的反叛。葫芦不必做瓢，树不必成材，人也不必活成别人期待的样子。</p>
<p>浮游于江湖之上，本来就不需要什么理由。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>柏拉图的洞穴寓言：我们看到的&quot;真实&quot;是真实的吗</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/platos-cave-allegory/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/platos-cave-allegory/</id>
    <published>2026-08-16T00:00:00.000Z</published>
    <updated>2026-08-16T00:00:00.000Z</updated>
    <summary>一群终身被锁在洞穴里的人，把墙上的影子当作全部世界。《理想国》里这个寓言，是对信息茧房最古老的预言。</summary>
    <content type="html"><![CDATA[<p>《理想国》第七卷开头，柏拉图让苏格拉底描述了一个奇特的场景。</p>
<h2>洞穴里的囚徒</h2>
<p>想象一个地下洞穴，一群人从出生起就被锁链锁住脖子与双腿，只能面朝洞壁，无法回头。他们身后有一堆火，火与囚徒之间有人举着各种器物走过，火光把这些器物的影子投在囚徒面前的墙壁上。</p>
<p>对囚徒们来说，<strong>墙上的影子就是全部的世界</strong>。他们给影子命名、研究影子的规律，谁最擅长预测下一个影子会出现什么，谁就会被尊为智者。</p>
<p>直到有一天，一个囚徒挣脱了锁链，转过身，走出洞穴。</p>
<h2>上升的痛苦</h2>
<p>这个过程并不美好。转身时，火光刺痛他的眼睛；走出洞口时，阳光几乎让他失明。他需要漫长的时间才能适应，才能先是看清影子、水中倒影，然后是事物本身，最后——直视太阳。</p>
<p>当他明白真相后，回到洞穴想告诉同伴们：你们看到的一切都只是影子。结果呢？同伴们觉得他眼睛被弄坏了、脑子烧坏了。如果他坚持解放他们，甚至要带他们出去，他们会杀了他。</p>
<p>苏格拉底讲完这个故事，停顿了一下。听众们都知道，苏格拉底本人就是那个回到洞穴的人，而雅典的法庭就是那群囚徒。</p>
<p>:::tip
柏拉图用洞穴比喻人的无知状态：感官世界只是"理念世界"的影子，哲学教育就是灵魂从阴影转向光明的"转向"。这不是往脑子里灌知识，而是让整个灵魂转过身来。
:::</p>
<h2>每个时代都有自己的洞穴</h2>
<p>柏拉图写的是两千多年前的雅典，但这个寓言读起来却像是为今天准备的：</p>
<ul>
<li><strong>信息茧房</strong>是现代版的洞穴：算法投喂给我们的内容，就像火光投在墙上的影子，精心挑选、恰好合胃口；</li>
<li><strong>刻板印象</strong>是影子：我们凭影子给人贴标签，却懒得起身看看真实的人；</li>
<li>而"走出洞穴的人被当作疯子"，不正是每个时代说真话者的共同命运吗？</li>
</ul>
<h2>直视太阳需要练习</h2>
<p>寓言的结尾常被忽略：走出洞穴的人回到洞穴后，眼睛已经不适应黑暗了。柏拉图借此提醒我们，看见真实是有代价的——你会与旧的舒适决裂，会被旧日的同伴嘲笑甚至敌视。</p>
<p>但柏拉图依然认为这是值得的。因为囚徒的"智慧"建立在影子上，风一吹就散；而见过太阳的人，哪怕再回到黑暗，心里也始终有一处光源。</p>
<p>认识你自己的洞穴，是走出它的第一步。问题只是：你确定现在看到的，不是影子吗？</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>《活着》：福贵失去了一切，却依然在活着</title>
    <link href="https://blog.fqzlr.top/posts/reading/reading-yuhua-to-live/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/reading/reading-yuhua-to-live/</id>
    <published>2026-08-15T00:00:00.000Z</published>
    <updated>2026-08-15T00:00:00.000Z</updated>
    <summary>余华的《活着》是一本让人不敢翻开的小说，也是一本让人放不下的小说。读完才明白，&quot;活着&quot;两个字本身就是全部意义。</summary>
    <content type="html"><![CDATA[<p>第一次读《活着》的人，大多会有同一个动作：合上书，沉默很久。</p>
<p>余华用不到十三万字的篇幅，写尽了一个叫福贵的男人的一生：地主家的败家子，输光百亩田产气死父亲，此后被拉壮丁、挨饥荒，眼睁睁看着儿子、女儿、妻子、女婿、外孙一个个死去。到了小说结尾，陪伴他的只剩一头同样叫"福贵"的老牛。</p>
<h2>从"少爷"到"人"的坠落与站起</h2>
<p>年轻时的福贵是标准的败家子：嫖、赌、在妓女背上招摇过市，甚至对怀着孕的家珍动手。龙设下赌局骗光他家产的那一刻，读者大概都觉得：这个人完了，活该。</p>
<p>但余华的笔法恰恰在此处转折——<strong>福贵的人生是从破产那天才真正开始的</strong>。脱下绸缎的败家子，租种五亩地，学会耕作，学会了心疼妻子。命运拿走他的家产，却还给他一个"人"。</p>
<p>:::tip
余华说自己写的是"人对苦难的承受能力，对世界乐观的态度"。《活着》里没有控诉，福贵讲述往事时甚至带着笑意——这种平静比嚎哭更让人心碎，也更有力量。
:::</p>
<h2>死亡的密度与活着的韧性</h2>
<p>有庆的死最令人无法接受——那个每天赤脚跑几十里上学、把鞋子攥在手里舍不得穿的男孩，死于给县长夫人献血，被抽干了血。凤霞难产而死，二喜被水泥板夹死，苦根吃豆子撑死……</p>
<p>每一次，读者都以为这是最深的谷底，余华都能再往下挖一层。而福贵每一次埋葬亲人后，都得继续下地、做饭、活着。</p>
<p>小说的惊人之处在于：它不煽情。死亡被叙述得平静、克制，像福贵蹲在田埂上跟陌生人闲聊。<strong>正是这种不动声色，让"活着"本身显出金属般的重量</strong>。</p>
<h2>"活着"在中国话里的力量</h2>
<p>余华在韩文版自序里写过一句话，值得反复咀嚼：</p>
<blockquote>
<p>"活着"在我们中国的语言里充满了力量，它的力量不是来自于喊叫，也不是来自于进攻，而是忍受，去忍受生命赋予我们的责任，去忍受现实给予我们的幸福和苦难、无聊和平庸。</p>
</blockquote>
<p>英语里"活着"（to live）是动词，而汉语的"活着"里有一种<strong>持续的、不问缘由的坚韧</strong>。福贵不是英雄，他从头到尾没有对抗过命运——他只是不肯死，只是把日子过下去。而在这个"只是"里，藏着中国人最深的生命哲学。</p>
<h2>读完之后</h2>
<p>《活着》最反常识的地方是：它写满死亡，却是一首生的赞歌。</p>
<p>我们总以为活着需要理由——为了成功、为了被人记住、为了某个目标。福贵把所有这些理由都失去了，他还在活着，而且活得坦坦荡荡。这提示了一种可能：<strong>活着不需要向任何东西交代，活着本身就是它的理由</strong>。</p>
<p>就像小说结尾，老人和老牛在暮色里远去，粗哑的嗓子唱道："少年去游荡，中年想掘藏，老年做和尚。"</p>
<p>炊烟在田野上袅袅升起。日子还在继续。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="读书感悟"/>
  </entry>
  <entry>
    <title>认识你自己：苏格拉底与未经省察的人生</title>
    <link href="https://blog.fqzlr.top/posts/philosophy/know-thyself-socrates/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/philosophy/know-thyself-socrates/</id>
    <published>2026-08-14T00:00:00.000Z</published>
    <updated>2026-08-14T00:00:00.000Z</updated>
    <summary>苏格拉底说&quot;未经省察的人生不值得过&quot;。这句两千多年前的箴言，为什么在今天依然刺痛我们？</summary>
    <content type="html"><![CDATA[<p>德尔斐神庙的门楣上刻着一句箴言：<strong>认识你自己</strong>。苏格拉底把它变成了自己一生的注脚。</p>
<h2>牛虻的使命</h2>
<p>苏格拉底一生没有留下任何著作，我们今天所知道的他，几乎全部来自柏拉图的对话录。他光着脚走在雅典的集市上，拉住路人就开始提问：什么是正义？什么是勇敢？什么是美德？</p>
<p>他不是想要难为谁。他自比一只"牛虻"，雅典是一匹太大而迟钝的马，需要牛虻不停地叮咬才能保持清醒。他的提问方式后来被称为"苏格拉底方法"——不给出答案，只通过连续的反问，让对方发现自己原以为知道的东西，其实经不起推敲。</p>
<h2>未经省察的人生不值得过</h2>
<p>在受审时，苏格拉底说出了那句著名的话：</p>
<blockquote>
<p>未经省察的人生不值得过。</p>
</blockquote>
<p>这句话的力量在于，它把哲学从天上拉回了人间。哲学不再是关于星辰和神谕的学问，而是每个人对自己生活的盘问：我为什么这样活？我所相信的东西，是我自己想明白的，还是别人塞给我的？</p>
<p>:::tip
省察不等于否定。苏格拉底从不宣称自己有答案，他说自己唯一知道的，就是自己一无所知。这种"知无知"的态度，恰恰是智慧的开端。
:::</p>
<h2>毒酒与坚持</h2>
<p>公元前 399 年，雅典法庭以"腐蚀青年、亵渎神明"的罪名判处苏格拉底死刑。他本有机会逃走，朋友克力同已经买通了狱卒，他却拒绝了。</p>
<p>他的理由很"较真"：一个人既然生活在一座城邦中，享受了城邦的庇护，就等于和城邦签订了契约。城邦判他死，他若逃走，就是在毁坏契约本身。如果人人都因判决不合己意而践踏法律，城邦将无法存在。</p>
<p>于是他当着朋友们的面，平静地饮下毒堇汁。那一年，他已经七十岁。</p>
<h2>我们为什么还需要他</h2>
<p>两千四百年过去了，我们生活在一个答案泛滥的时代——短视频告诉我们该怎样生活，算法告诉我们该相信什么。省察反而成了最稀缺的能力。</p>
<p>苏格拉底留给我们的不是任何一套学说，而是一种姿态：<strong>对习以为常的一切，保留追问的权利</strong>。当你开始问"我为什么要这样活"的时候，你已经在过一种值得过的生活了。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="哲学随想"/>
  </entry>
  <entry>
    <title>Git 详细使用方法：从第一次提交到团队协作</title>
    <link href="https://blog.fqzlr.top/posts/tech/git-guide/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/git-guide/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>从安装配置到分支协作，把日常开发需要的 Git 命令一次讲清楚，附常见翻车现场的救援方法。</summary>
    <content type="html"><![CDATA[<p>Git 是分布式版本控制系统：每个人的电脑上都有一份完整的历史记录，离线也能提交，最后再和远端同步。这篇按"装好 → 日常 → 分支 → 协作 → 救援"的顺序把常用操作串一遍。</p>
<h2>安装与首次配置</h2>
<p>Windows 直接装 <a href="https://git-scm.com/download/win">Git for Windows</a>，macOS 用 <code>brew install git</code>。装完先配置身份，这两行会写进每一个提交：</p>
<pre><code>git config --global user.name "你的名字"
git config --global user.email "you@example.com"
</code></pre>
<p>推荐顺手设置的几项：</p>
<pre><code>git config --global init.defaultBranch main   # 新仓库默认 main 分支
git config --global core.autocrlf input       # Windows 建议 true，避免换行符混乱
git config --global core.editor "code --wait" # 用 VS Code 写提交信息
</code></pre>
<p>:::tip
<code>git config --global --list</code> 可以随时检查当前配置；单个仓库内的局部配置（去掉 <code>--global</code>）优先级更高。
:::</p>
<h2>日常三板斧</h2>
<p>绝大多数时间你只需要这五个命令：</p>
<pre><code>git status            # 看当前状态：改了什么、暂存了什么
git add 文件名         # 把改动放进暂存区；git add . 表示全部
git commit -m "fix: 修复首页翻页"   # 提交暂存区的内容
git log --oneline     # 查看精简历史
git diff              # 看还没暂存的改动；git diff --staged 看已暂存的
</code></pre>
<p>提交信息建议遵循约定式提交（Conventional Commits）：<code>type(scope): subject</code>，常用的 type 有 <code>feat</code>（新功能）、<code>fix</code>（修复）、<code>docs</code>（文档）、<code>refactor</code>（重构）、<code>chore</code>（杂务）。半年后翻历史时，你会感谢当初写清楚的你。</p>
<h2>远程仓库</h2>
<pre><code>git clone https://github.com/user/repo.git   # 克隆远端仓库
git remote -v                                # 查看远端地址
git pull                                     # 拉取并合并远端更新
git push                                     # 推送本地提交
git push -u origin main                      # 首次推送并建立跟踪关系
</code></pre>
<p><code>pull</code> 等于 <code>fetch</code> + <code>merge</code>：先下载远端新提交，再合进当前分支。如果只想下载不合并（先看看别人改了什么），单独用 <code>git fetch</code>。</p>
<h2>分支与合并</h2>
<p>分支是指向某个提交的可移动指针，创建和切换都是瞬间完成的：</p>
<pre><code>git branch                    # 列出本地分支
git switch -c feat/comments   # 创建并切换到新分支（-c 表示创建）
git switch main               # 切回主分支
git merge feat/comments       # 把功能分支合入当前分支
git branch -d feat/comments   # 合并后删除分支
</code></pre>
<p>合并时如果两个人改了同一处代码，会产生冲突。文件里会出现这样的标记：</p>
<pre><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
你这边的改动
=======
对方那边的改动
&gt;&gt;&gt;&gt;&gt;&gt;&gt; feat/comments
</code></pre>
<p>手动编辑成想要的样子，删掉标记，然后 <code>git add</code> 该文件并 <code>git commit</code>，冲突就解决了。</p>
<h2>撤销操作：先想清楚再动手</h2>
<table>
<thead>
<tr>
<th>场景</th>
<th>命令</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>丢弃工作区改动</td>
<td><code>git restore 文件名</code></td>
<td>危险，改动无法找回</td>
</tr>
<tr>
<td>把文件移出暂存区</td>
<td><code>git restore --staged 文件名</code></td>
<td>只取消暂存，不丢内容</td>
</tr>
<tr>
<td>修改最后一次提交</td>
<td><code>git commit --amend</code></td>
<td>可补文件、改提交信息</td>
</tr>
<tr>
<td>回退到某次提交</td>
<td><code>git reset --soft HEAD~1</code></td>
<td>保留改动在暂存区</td>
</tr>
<tr>
<td>撤销某次提交</td>
<td><code>git revert &lt;hash&gt;</code></td>
<td>生成反向提交，安全</td>
</tr>
</tbody>
</table>
<p>:::warning
<code>git reset --hard</code> 和 <code>git push --force</code> 是新手两大事故来源。已经推送到远端的公共分支，用 <code>revert</code> 而不是 <code>reset</code>；确实需要强推时，用 <code>--force-with-lease</code> 至少能避免覆盖别人的提交。
:::</p>
<h2>.gitignore：让仓库只装该装的</h2>
<p>在仓库根目录建 <code>.gitignore</code> 文件，一行一个规则：</p>
<pre><code>node_modules/      # 依赖，pnpm install 能装回来
dist/              # 构建产物
.env               # 密钥和环境变量，绝对不能进仓库
.DS_Store          # 系统杂碎
</code></pre>
<p>已经被跟踪的文件不受 <code>.gitignore</code> 影响，需要先 <code>git rm --cached 文件名</code> 取消跟踪。</p>
<h2>一张速查表</h2>
<pre><code>工作区 --add--&gt; 暂存区 --commit--&gt; 本地仓库 --push--&gt; 远端
        git restore &lt;----        git reset &lt;----
                     git diff         git diff --staged
</code></pre>
<p>记住数据在这四个区域之间的流动方向，大部分疑问就能自己想明白。剩下的交给 <code>git help &lt;命令&gt;</code>——Git 的官方手册就藏在这个命令背后。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>Shirone 设置指南：把博客调成你的样子</title>
    <link href="https://blog.fqzlr.top/posts/tech/shirone-config/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/shirone-config/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>站点身份、个人资料、导航侧栏、动态配色——Shirone 的配置体系一次理清，理解覆盖规则后配置再也不是玄学。</summary>
    <content type="html"><![CDATA[<p>Shirone 的所有配置都带完整中文注释，跟着注释改基本不会错。这篇讲清楚配置文件在哪、谁覆盖谁、以及最值得先改的几个文件。</p>
<h2>两层配置：默认值与覆盖层</h2>
<p>主题源码的 <code>src/config/*.ts</code> 是全部配置的<strong>默认值</strong>，每个文件都有中文注释说明每个字段的含义。而采用双仓模式（内容分离）时，内容仓的 <code>config/*.yaml</code> 是<strong>覆盖层</strong>——同名文件里的字段会覆盖主题默认值，没写的字段自动继承默认。</p>
<pre><code>shirone-content-fq/config/site.yaml    ← 你只写要改的字段
shirone-fq/src/config/siteConfig.ts    ← 其余字段继承这里的默认
</code></pre>
<p>:::tip
这套设计的好处：主题升级不会冲掉你的个性化配置，配置仓库也能保持精简。改完 YAML 后跑一次 <code>pnpm content:sync</code>，它会编译成 <code>src/user/user-config.ts</code> 生效。
:::</p>
<h2>最先改的三个文件</h2>
<h3>1. site.yaml —— 站点身份</h3>
<p>站点标题、网址、语言、主题色都在这里：</p>
<pre><code>title: Fqzlr的博客        # 浏览器标签页与页脚显示的名字
siteUrl: https://blog.fqzlr.top/
locale: zh_CN             # 界面语言，支持 10 种
primaryHue: 165           # 主题色相（HCT 色彩空间的色相值）
</code></pre>
<p><code>primaryHue</code> 是动态配色的核心：改一个数字，整站按钮、链接、高亮的颜色都会跟着变。可以多试几个值，0 是红、120 是绿、220 附近偏蓝、165 是本站选的青绿。</p>
<h3>2. profile.yaml —— 你是谁</h3>
<pre><code>name: Fqzlr
avatar: https://q1.qlogo.cn/g?b=qq&amp;nk=QQ号&amp;s=640
slogan: 躬身入局，挺膺负责
socialLinks:              # 社交链接，图标需要主题已安装的图标集
  - name: GitHub
    url: https://github.com/你的ID
</code></pre>
<p>头像可以是外链，也可以放进内容仓 <code>assets/images/</code> 后用相对路径引用。</p>
<h3>3. nav-bar.yaml / sidebar.yaml —— 导航与侧栏</h3>
<ul>
<li><code>nav-bar.yaml</code> 控制顶部导航显示哪些页面（首页、归档、友链、瞬间、番剧……），每个条目可单独开关</li>
<li><code>sidebar.yaml</code> 控制侧栏布局：单栏还是双栏、每个页面显示哪些挂件（个人卡片、公告、最新文章、标签云等），<code>pages</code> 过滤器能精确到"只在文章页显示目录挂件"这种粒度</li>
</ul>
<h2>按需开关功能</h2>
<p>Shirone 遵循"零额外负担"原则：<strong>关闭的功能零请求、零 DOM、零代码体积</strong>。所以放心把用不到的功能关掉：</p>
<table>
<thead>
<tr>
<th>配置文件</th>
<th>控制什么</th>
<th>关掉后</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>comment.yaml</code></td>
<td>评论区（Twikoo / Waline 等）</td>
<td>文章页不渲染评论、不发任何外部请求</td>
</tr>
<tr>
<td><code>music.yaml</code></td>
<td>侧栏音乐播放器</td>
<td>完全不出现在页面里</td>
</tr>
<tr>
<td><code>umami.yaml</code></td>
<td>Umami 访问统计</td>
<td>不加载统计脚本</td>
</tr>
<tr>
<td><code>anime.yaml</code></td>
<td>番剧页</td>
<td>导航与页面都不生成</td>
</tr>
</tbody>
</table>
<h2>其他常用项速览</h2>
<ul>
<li><strong>font.yaml</strong>：正文字体、代码字体、字体来源（本地 / CDN）</li>
<li><strong>post-list.yaml</strong>：文章列表分页数量、列表/网格布局切换</li>
<li><strong>article.yaml</strong>：文章页的更新提示、延伸阅读、分享开关</li>
<li><strong>license.yaml</strong>：文章底部的版权协议（CC BY-NC-SA 之类）</li>
<li><strong>footer.yaml</strong>：页脚开关；自定义 HTML（比如备案号）放内容仓 <code>config/footer.html</code></li>
<li><strong>permalink.yaml</strong>：文章 URL 结构，上线前定好，之后别再改（会断旧链接）</li>
</ul>
<p>:::warning
站点上线后就不建议再改 <code>siteUrl</code> 和 permalink 结构：前者影响 RSS 与 sitemap 生成的链接，后者会让搜索引擎和已分享的链接 404。
:::</p>
<h2>改完之后</h2>
<p>配置文件是构建时读取的，dev 模式下多数改动会热更新；如果发现没生效，重启 <code>pnpm dev</code>，仍不行就清一下缓存：</p>
<pre><code>pnpm content:sync    # 先确保覆盖层编译进主题
</code></pre>
<p>然后清 <code>node_modules/.vite</code> 与 <code>.astro</code> 目录再重启，这一招能解决九成"改了没反应"的问题。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>从零安装 Shirone 博客：两条路线任你选</title>
    <link href="https://blog.fqzlr.top/posts/tech/shirone-install/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/shirone-install/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>无论你想直接用主题源码建站，还是安装 shirones npm 包，这篇安装指南都能带你跑通第一步。</summary>
    <content type="html"><![CDATA[<p>本站由 <a href="https://github.com/LyraVoid/Shirone">Shirone</a> 主题驱动——一个基于 Astro 7、Svelte 5 与 Tailwind CSS 4 的 Material 3 Expressive 博客主题。这篇记录两种安装路线：直接克隆主题源码，或安装 npm 包。两种方式跑起来后效果一致，区别在于后续升级的姿势。</p>
<h2>环境要求</h2>
<ul>
<li><strong>Node.js 22.12 或更高</strong>：去 <a href="https://nodejs.org/">nodejs.org</a> 下载 LTS 版本，装完用 <code>node -v</code> 验证</li>
<li><strong>pnpm 9</strong>：推荐用 corepack 启用（Node 自带，不用另装）：<code>corepack enable</code></li>
<li>一个 GitHub 账号（用于存放代码与内容仓库）</li>
</ul>
<p>:::tip
Windows 下如果 PowerShell 报"禁止运行脚本"，所有命令的 <code>pnpm</code> 换成 <code>pnpm.cmd</code>、<code>npx</code> 换成 <code>npx.cmd</code> 即可绕过执行策略问题。
:::</p>
<h2>路线一：克隆主题源码（推荐折腾党）</h2>
<p>适合想深度定制、参与主题开发的人，本站就是这么跑的。</p>
<pre><code>git clone https://github.com/LyraVoid/Shirone.git my-blog
cd my-blog
pnpm install
pnpm dev
</code></pre>
<p>打开 <code>http://localhost:4321</code> 就能看到带示例内容的完整站点。常用脚本还有：</p>
<pre><code>pnpm build    # 构建静态站点到 dist/
pnpm preview  # 本地预览构建产物
npx astro check   # .astro 文件类型检查
</code></pre>
<h2>路线二：安装 npm 包（推荐省心党）</h2>
<p>适合只想写文章、不碰主题源码的人。在一个空目录里：</p>
<pre><code>pnpm init
pnpm install shirones
pnpm exec shirone init
</code></pre>
<p><code>init</code> 命令会生成 <code>astro.config.mjs</code>、最小配置目录和内容骨架，之后同样用 <code>pnpm dev</code> 启动。主题升级只需 <code>pnpm update shirones</code>，你的配置和内容完全不受影响。</p>
<h2>内容与配置分离（双仓模式）</h2>
<p>Shirone 官方提供 <a href="https://github.com/LyraVoid/Shirone-Content">Shirone-Content</a> 内容模板仓库，把"文章 + 媒体 + 配置覆盖"放进独立仓库。这是本站采用的模式：</p>
<ol>
<li>Fork 或克隆 Shirone-Content 到自己的账号下（建议设为<strong>私有</strong>，毕竟里面的草稿和友链数据是你自己的）</li>
<li>在主题仓库根目录创建 <code>shirone.content.json</code>，指向你的内容仓：</li>
</ol>
<pre><code>{
  "source": {
    "type": "git",
    "remote": "https://github.com/你的用户名/你的内容仓.git",
    "branch": "main"
  }
}
</code></pre>
<ol>
<li>运行 <code>pnpm content:sync</code>，内容会被物化到主题的 <code>src/content/</code>、<code>src/data/</code> 等目录</li>
<li><code>pnpm content:status</code> 检查连接状态，<code>pnpm content:validate</code> 校验内容合法性</li>
</ol>
<p>:::details 也可以用本地路径
如果内容仓就在本机（比如 <code>../my-content</code>），可以写一个 <code>.env</code> 文件：</p>
<pre><code>CONTENT_DIR=../my-content
</code></pre>
<p>本地路径优先级高于 <code>shirone.content.json</code> 里的 git 源，适合改动频繁的写作期。
:::</p>
<h2>部署</h2>
<p><code>pnpm build</code> 产出的 <code>dist/</code> 是纯静态文件，扔到哪都能跑：</p>
<ul>
<li><strong>Vercel / Netlify</strong>：导入 GitHub 仓库，构建命令 <code>pnpm build</code>，输出目录 <code>dist</code>，之后 push 即自动部署</li>
<li><strong>Cloudflare Pages</strong>：同样支持，注意 Node 版本选 22+</li>
<li><strong>自己的服务器</strong>：nginx 指向 <code>dist/</code> 目录即可</li>
</ul>
<h2>装完之后</h2>
<p>站点能跑起来只是开始。接下来建议按顺序做三件事：改站点身份（标题、头像、社交链接），删掉示例文章换成自己的内容，然后配置导航和侧栏。这些正是后面两篇文章的主题——设置篇和使用篇，见本站分类目录。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>Shirone 使用手册：写作与日常运营</title>
    <link href="https://blog.fqzlr.top/posts/tech/shirone-usage/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/shirone-usage/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>文章怎么写、瞬间怎么发、友链时间线网址导航怎么维护——本站内容系统的日常操作都在这里。</summary>
    <content type="html"><![CDATA[<p>安装与设置完成后，剩下的就是内容工作了。这篇覆盖本站内容系统的日常操作：写文章、发瞬间、维护数据页面，以及好用的 Markdown 扩展语法。</p>
<h2>写一篇文章</h2>
<p>文章放在内容仓 <code>content/posts/</code> 下，Markdown 即可，也支持 MDX。最小 frontmatter 长这样：</p>
<pre><code>---
title: 我的第一篇文章
published: 2026-08-10
description: 一句话摘要，会显示在列表和分享卡片里
tags: [随笔]
category: 生活
---
</code></pre>
<p>frontmatter 之后就是正文。几条本站约定：</p>
<ul>
<li><strong>子目录归类</strong>：文章按主题放进子文件夹，如 <code>posts/tech/</code>、<code>posts/philosophy/</code>，URL 会带上目录名（<code>/posts/tech/xxx/</code>）</li>
<li><strong>category 用大类</strong>（如"技术教程"），细分交给 tags，标签页会自动聚合</li>
<li><strong>草稿</strong>：frontmatter 加一行 <code>draft: true</code>，文章只进构建不进正式列表</li>
<li>想新建文章时，主题仓的 <code>pnpm new-post 文件名</code> 可以生成骨架，再挪进对应子目录</li>
</ul>
<h2>Markdown 扩展语法</h2>
<p>除了标准 Markdown，Shirone 还内置几组扩展，写教程尤其好用：</p>
<p>提示块（admonition），支持 <code>tip</code> / <code>warning</code> / <code>details</code> 等类型，<code>details</code> 默认折叠：</p>
<pre><code>:::tip[小贴士]
这是提示内容
:::
</code></pre>
<p>此外还有 Mermaid 流程图、数学公式、代码块增强（复制按钮、行高亮）、图片画廊，直接按各自的标准语法写即可，无需配置。</p>
<h2>发瞬间（动态）</h2>
<p>瞬间是轻量的短内容，类似说说。文件放 <code>content/moments/</code>，命名 <code>YYYY-MM-DD-标识.md</code>：</p>
<pre><code>---
content: 网站换新背景图了
date: 2026-08-08 21:30:00
location: 河南-郑州
images:
  - src: https://tu.fqzlr.com/bg.webp
    alt: 新背景图
---
</code></pre>
<p>带 <code>images</code> 就是图文瞬间，纯文字则只写 <code>content</code>。侧栏的"瞬间"页面按时间倒序展示。</p>
<h2>维护数据页面</h2>
<p>几类"列表型"页面由内容仓 <code>data/</code> 下的 TypeScript 文件驱动，改完同样 <code>pnpm content:sync</code> 生效：</p>
<table>
<thead>
<tr>
<th>文件</th>
<th>页面</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>friends.ts</code></td>
<td>友链</td>
<td>每条含名称、头像、链接、描述</td>
</tr>
<tr>
<td><code>timeline.ts</code></td>
<td>时间线</td>
<td>按年份记录事件，<code>milestone</code> 标记重点节点</td>
</tr>
<tr>
<td><code>compass.ts</code></td>
<td>网址导航</td>
<td>分组的网址收藏，icon 可用图标名或图片 URL</td>
</tr>
<tr>
<td><code>projects.ts</code></td>
<td>项目展示</td>
<td>名称、封面、技术栈、链接</td>
</tr>
</tbody>
</table>
<h2>双仓日常节奏</h2>
<p>采用内容分离模式后，我的日常流程是：</p>
<pre><code># 内容仓里写作（.env 指向本地内容仓时同步是实时的）
pnpm content:sync      # 物化最新内容到主题
pnpm dev               # 本地预览
pnpm content:validate  # 发布前校验
git push               # 内容仓与主题仓各自推送
</code></pre>
<p>内容仓 push 之后，如果部署流水线绑定的是主题仓，记得主题仓也要触发一次构建才能带上最新内容。</p>
<h2>搜索与 SEO</h2>
<p>站点自带 Pagefind 全文搜索（<code>pnpm build</code> 时自动建立索引，本地 <code>pnpm dev</code> 下搜不到是正常的，构建后才有）；RSS 与 sitemap 默认输出，配合 <code>site.yaml</code> 里的正式网址，搜索引擎收录无需额外操作。</p>
<p>:::tip
写作遇到"改了没生效"，先分清是内容还是配置：内容问题重启 dev，配置问题先 <code>content:sync</code> 再重启。更多排查思路见设置篇的结尾。
:::</p>
<p>到这里，安装、设置、使用三篇就齐了。建站只是开始，持续写下去才是博客的意义。</p>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>评论区也能发图了：Waline 接入图床 + 按页面路径自动归类</title>
    <link href="https://blog.fqzlr.top/posts/tech/waline-imgbed/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/waline-imgbed/</id>
    <published>2026-07-26T00:00:00.000Z</published>
    <updated>2026-07-26T00:00:00.000Z</updated>
    <summary>博客系列第10期。给 Waline 评论系统接上自建图床，评论贴图自动上传，还会按当前页面路径归档到对应文件夹，Token 走环境变量不裸奔。</summary>
    <content type="html"><![CDATA[<h2>为什么要做这个</h2>
<p>Waline 评论系统默认只支持填图片 URL，访客想发张图得自己找地方传完再贴链接——基本等于没人会发图。</p>
<p>但接上</p>
<p>[[博客相关/04-搭建个人图床]]</p>
<p>里搭的 Cloudflare-ImgBed 之后，评论区直接支持<strong>粘贴/选择图片自动上传</strong>，体验完全不一样。更进一步，我还让它根据当前页面路径<strong>自动归档到对应文件夹</strong>，图床后台不会乱成一锅粥。</p>
<h2>实现思路</h2>
<pre><code>graph TD
    A[访客在评论区粘贴图片] --&gt; B[Waline imageUploader 拦截]
    B --&gt; C[解析当前页面 URL 路径]
    C --&gt; D[生成 uploadFolder 参数]
    D --&gt; E[POST 图床 /upload?uploadFolder=xxx]
    E --&gt; F[图床返回图片 URL]
    F --&gt; G[URL 自动插入评论正文]
</code></pre>
<p>核心就一件事：Waline 提供了 <code>imageUploader</code> 配置项，传一个函数进去，函数负责把图片文件传到图床、返回 URL，剩下的插入评论正文 Waline 自己搞定。</p>
<p>官方文档：<a href="https://waline.js.org/cookbook/customize/upload-image.html">Waline 自定义图片上传</a></p>
<h2>准备工作</h2>
<table>
<thead>
<tr>
<th>东西</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>自建图床</td>
<td>Cloudflare-ImgBed，[[博客相关/04-搭建个人图床|第 4 期]]讲过怎么搭</td>
</tr>
<tr>
<td>图床 API Token</td>
<td>图床管理面板 → Token 管理 → 创建，权限勾选 <code>upload</code></td>
</tr>
<tr>
<td>Waline 已接入</td>
<td>评论系统跑起来了才有意义，迁移教程见 [[博客相关/Twikoo 评论完整迁移 Waline 教程/Twikoo 评论完整迁移 Waline 教程]]</td>
</tr>
</tbody>
</table>
<h2>第一步：配置文件加入图床入口</h2>
<p>在 <code>src/config/commentConfig.ts</code> 的 waline 配置里加两项：</p>
<pre><code>waline: {
  serverURL: "https://waline.fqzlr.com",
  lang: "zh-CN",
  // ... 其他配置

  // ===== 评论图片上传（图床接入） =====
  // 图床上传地址（/upload 端点）
  imageUploadURL: "https://tu.fqzlr.com/upload",
  // 图床 API Token —— 不要硬编码！走环境变量
  imageUploadToken: import.meta.env?.PUBLIC_IMG_UPLOAD_TOKEN || "",
},
</code></pre>
<blockquote>
<p>[!IMPORTANT] Token 安全
Token 绝对不要直接写在代码里提交到仓库。通过环境变量注入：</p>
<ul>
<li><strong>Vercel</strong>：Settings → Environment Variables → 添加 <code>PUBLIC_IMG_UPLOAD_TOKEN</code></li>
<li><strong>本地开发</strong>：项目根目录 <code>.env</code> 文件添加 <code>PUBLIC_IMG_UPLOAD_TOKEN=imgbed_xxxxx</code></li>
</ul>
</blockquote>
<p>两项都填了才启用上传，任一留空自动禁用——这个判断逻辑后面会写到。</p>
<h2>第二步：补全类型声明</h2>
<p>Astro + TypeScript 项目，新加的配置项和环境变量都要声明类型，不然编辑器会报错。</p>
<p><strong>配置类型</strong>（<code>src/types/config.ts</code>）：</p>
<pre><code>waline?: {
  serverURL: string;
  lang?: string;
  login?: "enable" | "force" | "disable";
  visitorCount?: boolean;
  emoji?: string[];
  imageUploadURL?: string;   // ← 新增
  imageUploadToken?: string; // ← 新增
};
</code></pre>
<p><strong>环境变量类型</strong>（<code>src/env.d.ts</code>）：</p>
<pre><code>interface ImportMetaEnv {
  readonly PUBLIC_IMG_UPLOAD_TOKEN: string; // ← 新增
}
</code></pre>
<h2>第三步：实现 imageUploader</h2>
<p>这是核心部分，在 <code>src/components/comment/Waline.astro</code> 里完成。</p>
<h3>注入配置到客户端</h3>
<p>Astro 组件的 frontmatter 是服务端代码，<code>imageUploader</code> 需要在浏览器里跑，所以用 <code>define:vars</code> 把配置注入到内联脚本：</p>
<pre><code>---
const imageUploadURL = commentConfig.waline?.imageUploadURL || "";
const imageUploadToken = commentConfig.waline?.imageUploadToken || "";
---
&lt;script type="module" is:inline define:vars={{ config, imageUploadURL, imageUploadToken }}&gt;
  import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';
  // ...
&lt;/script&gt;
</code></pre>
<h3>上传函数本体</h3>
<pre><code>if (imageUploadURL &amp;&amp; imageUploadToken) {
  config.imageUploader = (file) =&gt; {
    var folder = getUploadFolder();
    var uploadURL = imageUploadURL + '?uploadFolder=' + encodeURIComponent(folder);

    var formData = new FormData();
    formData.append('file', file);
    return fetch(uploadURL, {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer ' + imageUploadToken,
        'Accept': 'application/json',
      },
      body: formData,
    })
      .then((resp) =&gt; {
        if (!resp.ok) throw new Error('图片上传失败: ' + resp.status);
        return resp.json();
      })
      .then((data) =&gt; {
        // 解析响应，拿到图片 URL（下面细说）
        return url;
      });
  };
}
</code></pre>
<p>几个要点：</p>
<ul>
<li><strong>条件启用</strong>：<code>imageUploadURL &amp;&amp; imageUploadToken</code> 都成立才挂载 <code>imageUploader</code>，否则 Waline 走默认行为（不允许发图）</li>
<li><strong>Bearer 认证</strong>：cfbed 规范用 <code>Authorization: Bearer {token}</code> 请求头</li>
<li><strong>FormData</strong>：字段名必须是 <code>file</code>，这是 cfbed API 的约定</li>
</ul>
<h2>亮点：按页面路径自动归类</h2>
<p>这是我觉得最实用的部分——不同页面的评论图片，自动传到图床的不同文件夹。</p>
<h3>归类规则</h3>
<pre><code>function getUploadFolder() {
  var segments = window.location.pathname
    .split('/')
    .filter(function (s) { return s.length &gt; 0; });
  if (segments.length === 0) return 'fqzlrcom/comments';
  var parts = segments.slice(0, 2);
  return 'fqzlrcom/' + parts.join('/');
}
</code></pre>
<p>规则很简单：</p>
<ol>
<li>前缀统一为 <code>fqzlrcom/</code>（域名标识，图床里一眼就知道是哪个站的图）</li>
<li>取当前页面路径的<strong>前 2 段</strong>作为子文件夹</li>
<li>超出的段直接截断，不无限建目录</li>
<li>根路径 <code>/</code> 或解析失败时，回退到 <code>fqzlrcom/comments</code></li>
</ol>
<h3>实际效果</h3>
<table>
<thead>
<tr>
<th>评论所在页面</th>
<th>路径段</th>
<th>上传文件夹</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/friends/</code></td>
<td><code>friends</code></td>
<td><code>fqzlrcom/friends</code></td>
</tr>
<tr>
<td><code>/dynamic/</code></td>
<td><code>dynamic</code></td>
<td><code>fqzlrcom/dynamic</code></td>
</tr>
<tr>
<td><code>/posts/blog/check-flink/</code></td>
<td><code>posts</code> <code>blog</code> <code>check-flink</code></td>
<td><code>fqzlrcom/posts/blog</code>（截断第 3 段）</td>
</tr>
<tr>
<td><code>/</code></td>
<td>无</td>
<td><code>fqzlrcom/comments</code>（回退）</td>
</tr>
</tbody>
</table>
<p>为什么最多取 2 段？文章路径 <code>/posts/博客相关/xxx/</code> 如果全取，文件夹会深到三四层，图床后台点进去找图很痛苦。2 段刚好能区分「哪个板块 + 哪个子分类」，够用了。</p>
<h3>传给图床</h3>
<p>cfbed API 通过 query 参数 <code>uploadFolder</code> 接收文件夹路径：</p>
<pre><code>POST https://tu.fqzlr.com/upload?uploadFolder=fqzlrcom%2Ffriends
</code></pre>
<p>记得 <code>encodeURIComponent</code>，路径里的 <code>/</code> 需要编码。</p>
<h2>响应解析：兼容多种格式</h2>
<p>cfbed 不同版本/配置的响应格式不完全一样，所以解析逻辑做了多路兼容：</p>
<pre><code>var url = '';
if (Array.isArray(data) &amp;&amp; data[0]) {
  // 格式一：[{ src, publicUrl }]
  url = data[0].publicUrl || data[0].src || '';
} else if (data?.data?.links?.url) {
  // 格式二：{ data: { links: { url } } }
  url = data.data.links.url;
} else if (data?.src) {
  // 格式三：{ src }
  url = data.src;
}
if (!url) throw new Error('图床响应格式异常');
</code></pre>
<p>还有一个容易踩的坑：<strong><code>publicUrl</code> 未配置时，<code>src</code> 是相对路径</strong>（如 <code>/file/xxx.jpg</code>），直接插入评论会 404。需要拼接图床域名：</p>
<pre><code>if (url.startsWith('/')) {
  url = new URL(imageUploadURL).origin + url;
}
</code></pre>
<p><code>new URL(imageUploadURL).origin</code> 从上传地址里提取协议 + 域名（<code>https://tu.fqzlr.com</code>），不用额外硬编码域名配置。</p>
<h2>完整文件改动清单</h2>
<table>
<thead>
<tr>
<th>文件</th>
<th>改动</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>src/config/commentConfig.ts</code></td>
<td>新增 <code>imageUploadURL</code> / <code>imageUploadToken</code> 配置项</td>
</tr>
<tr>
<td><code>src/types/config.ts</code></td>
<td>waline 类型补充两个可选字段</td>
</tr>
<tr>
<td><code>src/env.d.ts</code></td>
<td>声明 <code>PUBLIC_IMG_UPLOAD_TOKEN</code> 环境变量类型</td>
</tr>
<tr>
<td><code>src/components/comment/Waline.astro</code></td>
<td>实现 <code>getUploadFolder</code> + <code>imageUploader</code></td>
</tr>
</tbody>
</table>
<p>四个文件，改动量不大，但评论区从此支持发图了。</p>
<h2>常见问题</h2>
<blockquote>
<p>[!WARNING] 图片上传失败怎么排查</p>
<ol>
<li>打开浏览器 DevTools → Network，看 <code>/upload</code> 请求的状态码</li>
<li><strong>401</strong>：Token 错了或权限没勾 upload</li>
<li><strong>404</strong>：<code>imageUploadURL</code> 地址写错了，确认是 <code>/upload</code> 端点</li>
<li><strong>响应格式异常</strong>：图床版本不同响应结构可能不一样，对照上面的三路解析逻辑检查</li>
</ol>
</blockquote>
<blockquote>
<p>[!TIP] 本地测试
<code>.env</code> 里配好 <code>PUBLIC_IMG_UPLOAD_TOKEN</code> 后跑 <code>pnpm dev</code>，在任意有评论区的页面粘贴图片即可测试。上传成功的话，图片 URL 会自动出现在评论输入框里。</p>
</blockquote>
<h2>最后</h2>
<p>整个方案零额外成本：图床跑在 Cloudflare Workers 免费套餐上，Token 走环境变量不泄露，文件夹自动归类让图床后台始终清爽。</p>
<p>如果你也在用 Waline + Cloudflare-ImgBed，这套配置抄过去改改域名就能用。</p>
<hr />
<blockquote>
<p>图床搭建教程：[[博客相关/04-搭建个人图床]]</p>
<p>Waline 官方图片上传文档：<a href="https://waline.js.org/cookbook/customize/upload-image.html">waline.js.org/cookbook/customize/upload-image.html</a></p>
<p>cfbed API 规范：<a href="https://cfbed.sanyue.de/api/upload.html">cfbed.sanyue.de</a></p>
<p>有问题欢迎评论区交流～（对，就是本文接好的那个评论区）</p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>Twikoo 评论一键迁移 Waline（双向互转）</title>
    <link href="https://blog.fqzlr.top/posts/tech/twikoo-to-waline/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/twikoo-to-waline/</id>
    <published>2026-07-18T00:00:00.000Z</published>
    <updated>2026-07-18T00:00:00.000Z</updated>
    <summary>适用于 Firefly Astro 博客，Twikoo ↔ Waline 评论数据双向转换、导入全流程，修复路径尾斜杠匹配失败、时间格式 500 错误、回复层级丢失等问题</summary>
    <content type="html"><![CDATA[<h2>一、迁移前置说明</h2>
<p>本文档适配基于 Firefly 主题的 Astro 静态博客，完整覆盖 <strong>Twikoo ↔ Waline</strong> 评论数据双向转换全流程，解决四大高频痛点：</p>
<ol>
<li>转换后的时间戳格式非法，导入时 Waline 后台报 <code>500: invalid input syntax for type timestamp</code>；</li>
<li>Waline 后台导入提示成功，但前台页面完全不显示评论（路径尾斜杠不匹配）；</li>
<li>转换后父子回复全部平铺，多层嵌套回复层级直接丢失；</li>
<li>Twikoo 与 Waline 互转时缺少双向支持，无法反向恢复。</li>
</ol>
<h3>前置准备清单</h3>
<ol>
<li>已部署完成 Waline 服务（示例地址：<code>https://waline.fqzlr.com</code>）；</li>
<li>Firefly 博客页面已挂载 Waline 前端评论组件；</li>
<li>可正常登录 Twikoo 管理后台导出原始评论 JSON；</li>
<li>浏览器推荐 Chrome / Edge。</li>
</ol>
<h2>二、第一步：导出原始评论数据</h2>
<h3>Twikoo 导出</h3>
<ol>
<li>进入 Twikoo 管理后台；</li>
<li>找到「数据导出」，导出全部评论为 <strong>纯 JSON 数组</strong>，保存为 <code>.json</code> 文件。</li>
</ol>
<h3>Waline 导出</h3>
<ol>
<li>进入 Waline 管理后台 <code>https://waline.fqzlr.com/ui</code>；</li>
<li>左侧菜单「导入导出」→ 导出全部数据，保存为 <code>.json</code> 文件。</li>
</ol>
<blockquote>
<p>重要：Twikoo 导出每条评论需包含 <code>_id</code>、<code>pid</code>、<code>rid</code> 字段；Waline 导出需包含 <code>data.Comment</code>、<code>data.Users</code>、<code>data.Counter</code> 三表。</p>
</blockquote>
<h2>三、第二步：双向转换工具（自动修复路径尾斜杠 + 保留嵌套回复）</h2>
<h3>工具核心能力</h3>
<blockquote>
<p>[!IMPORTANT]
本工具针对实际迁移踩坑场景做了以下修复和增强：</p>
<ol>
<li><strong>修复时间格式 500 错误</strong>：<code>toISOString()</code> 已自带 3 位毫秒（如 <code>2026-04-25T11:23:19.928Z</code>），不再拼接多余的 <code>.000</code>，避免产生非法格式 <code>2026-04-25T11:23:19.928.000Z</code>；</li>
<li><strong>自动补路径尾斜杠</strong>：所有 <code>url</code> 末尾自动追加 <code>/</code>，与 Firefly 前台路径格式（<code>/friends/</code>）完全一致；</li>
<li><strong>完整还原嵌套回复</strong>：Twikoo <code>_id</code> ↔ Waline <code>objectId</code> 建立完整映射表，<code>pid</code>/<code>rid</code> 精准关联父子评论；</li>
<li><strong>1:1 对齐 Waline 数据表</strong>：补齐 <code>Comment</code>/<code>Counter</code>/<code>Users</code> 三表及所有必填字段；</li>
<li><strong>支持双向互转</strong>：Twikoo → Waline 和 Waline → Twikoo 均可一键转换；</li>
<li><strong>本地离线运算</strong>：所有数据在浏览器端处理，不上传任何服务器。</li>
</ol>
</blockquote>
<h3>内嵌交互式转换工具</h3>
<blockquote>
<p>[!TIP]
旧博客提供该转换工具的在线版：<a href="https://blog.fqzlr.com/posts/tech/twikoo-to-waline/">Twikoo ↔ Waline 在线转换工具</a>，本页保留完整工具源代码，可复制保存为 HTML 文件离线使用。</p>
</blockquote>
<p>新建文本文件，复制下方全部代码，保存为 <code>twikoo-to-waline.html</code>，双击用浏览器打开即可离线使用：</p>
<pre><code>&lt;!DOCTYPE html&gt;
&lt;html lang="zh-CN"&gt;
&lt;head&gt;
&lt;meta charset="UTF-8"&gt;
&lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
&lt;title&gt;Twikoo ↔ Waline 评论数据双向转换工具&lt;/title&gt;
&lt;style&gt;
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f6fa; color: #2c3e50; min-height: 100vh; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 32px; }
.header h1 { font-size: 22px; font-weight: 600; }
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
.card { background: #fff; border-radius: 10px; padding: 24px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.step-label { display: inline-block; background: #667eea; color: #fff; font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 10px; margin-right: 8px; }
.card h3 { font-size: 16px; margin-bottom: 16px; display: flex; align-items: center; }
.mode-switch { display: flex; gap: 0; margin-bottom: 20px; border-radius: 8px; overflow: hidden; border: 2px solid #667eea; }
.mode-btn { flex: 1; padding: 10px 16px; border: none; font-size: 14px; font-weight: 600; cursor: pointer; background: #fff; color: #667eea; transition: all 0.2s; }
.mode-btn.active { background: #667eea; color: #fff; }
.mode-btn:hover:not(.active) { background: #f0f2ff; }
.drop-zone { border: 2px dashed #d0d5dd; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.2s; margin-bottom: 8px; }
.drop-zone:hover, .drop-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.drop-zone p { color: #888; font-size: 14px; }
.drop-zone p strong { color: #667eea; }
.file-hint { font-size: 12px; color: #aaa; margin-top: 4px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.stat-box { background: #f8f9fc; border-radius: 8px; padding: 12px; text-align: center; }
.stat-box .num { font-size: 26px; font-weight: 700; color: #667eea; }
.stat-box .label { font-size: 11px; color: #888; margin-top: 3px; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.btn { padding: 8px 18px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; font-weight: 500; transition: all 0.2s; }
.btn-primary { background: #667eea; color: #fff; }
.btn-primary:hover { background: #5a6fd6; }
.btn-secondary { background: #e8eaed; color: #333; }
.btn-secondary:hover { background: #d8dbe0; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover { background: #219a52; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.comment-list { max-height: 500px; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; }
.comment-item { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; display: flex; gap: 10px; align-items: flex-start; }
.comment-item:last-child { border-bottom: none; }
.comment-item .oid { background: #667eea; color: #fff; font-size: 10px; min-width: 24px; height: 20px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.comment-item .indent { color: #aaa; font-size: 12px; margin-right: 4px; }
.comment-item.level-1 { padding-left: 44px; }
.comment-item.level-2 { padding-left: 66px; }
.comment-item.level-3 { padding-left: 88px; }
.comment-info { flex: 1; min-width: 0; }
.comment-info .author { font-weight: 600; font-size: 13px; }
.comment-info .meta { font-size: 11px; color: #999; margin-top: 2px; }
.comment-info .text { font-size: 13px; margin-top: 3px; color: #444; word-break: break-all; }
.log { background: #1e1e1e; color: #d4d4d4; padding: 12px 16px; border-radius: 8px; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 12px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; }
.log .ok { color: #6a9955; }
.log .warn { color: #dcdcaa; }
.log .err { color: #f44747; }
.note { font-size: 12px; color: #999; background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 10px 14px; margin-top: 12px; line-height: 1.6; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class="header"&gt;
  &lt;h1&gt;Twikoo ↔ Waline 评论数据双向转换工具&lt;/h1&gt;
  &lt;p&gt;保留留言回复层级关系 · 支持双向互转 · 纯浏览器端运行，数据不上传&lt;/p&gt;
&lt;/div&gt;

&lt;div class="container"&gt;

  &lt;!-- 方向选择 --&gt;
  &lt;div class="card"&gt;
    &lt;h3&gt;&lt;span class="step-label"&gt;1&lt;/span&gt; 选择转换方向&lt;/h3&gt;
    &lt;div class="mode-switch"&gt;
      &lt;button class="mode-btn active" id="modeTW" onclick="setMode('tw2wl')"&gt;Twikoo → Waline&lt;/button&gt;
      &lt;button class="mode-btn" id="modeWT" onclick="setMode('wl2tw')"&gt;Waline → Twikoo&lt;/button&gt;
    &lt;/div&gt;
    &lt;div id="modeDesc" style="font-size:13px;color:#666;"&gt;将 Twikoo 导出的 JSON 数组转换为 Waline 导入格式（含 Comment / Users / Counter 三表）&lt;/div&gt;
  &lt;/div&gt;

  &lt;!-- 加载 --&gt;
  &lt;div class="card"&gt;
    &lt;h3&gt;&lt;span class="step-label"&gt;2&lt;/span&gt; 加载源文件&lt;/h3&gt;
    &lt;div class="drop-zone" id="dropZone"&gt;
      &lt;p id="dropText"&gt;拖拽 &lt;strong&gt;twikoo-comment.json&lt;/strong&gt; 到此处&lt;br&gt;或 &lt;strong&gt;点击选择文件&lt;/strong&gt;&lt;/p&gt;
      &lt;input type="file" id="fileInput" accept=".json" style="display:none"&gt;
    &lt;/div&gt;
    &lt;div class="file-hint" id="fileHint"&gt;Twikoo 导出文件通常是一个 JSON 数组，Waline 导出文件是一个含 data.Comment 的对象&lt;/div&gt;
    &lt;div id="fileInfo" style="margin-top:8px;font-size:13px;color:#888;"&gt;&lt;/div&gt;
  &lt;/div&gt;

  &lt;!-- 统计 --&gt;
  &lt;div class="card" id="statsCard" style="display:none"&gt;
    &lt;h3&gt;数据概览&lt;/h3&gt;
    &lt;div class="stats" id="statsGrid"&gt;&lt;/div&gt;
  &lt;/div&gt;

  &lt;!-- 转换 --&gt;
  &lt;div class="card" id="convertCard" style="display:none"&gt;
    &lt;h3&gt;&lt;span class="step-label"&gt;3&lt;/span&gt; 转换 &amp; 预览&lt;/h3&gt;
    &lt;div class="toolbar"&gt;
      &lt;button class="btn btn-primary" id="btnConvert"&gt;执行转换&lt;/button&gt;
      &lt;button class="btn btn-success" id="btnDownload" disabled&gt;下载结果 JSON&lt;/button&gt;
      &lt;button class="btn btn-secondary" id="btnPreview" disabled&gt;预览评论列表&lt;/button&gt;
    &lt;/div&gt;
    &lt;div class="log" id="logArea" style="display:none;"&gt;&lt;/div&gt;
    &lt;div class="comment-list" id="previewList" style="display:none;margin-top:12px;"&gt;&lt;/div&gt;
    &lt;div class="note" id="noteArea" style="display:none;"&gt;&lt;/div&gt;
  &lt;/div&gt;

&lt;/div&gt;

&lt;script&gt;
// ==================== 状态 ====================
let currentMode = 'tw2wl'; // 'tw2wl' | 'wl2tw'
let sourceData = null;
let outputData = null;
let convertLog = [];
let convertStats = null;

// ==================== UUID 生成 ====================
function generateUUID() {
  return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c =&gt; {
    const r = Math.random() * 16 | 0;
    return (c === 'x' ? r : (r &amp; 0x3 | 0x8)).toString(16);
  });
}

// ==================== 工具函数 ====================
function stripHtml(html) {
  const div = document.createElement('div');
  div.innerHTML = html;
  return div.textContent || div.innerText || '';
}

function tsToISO(ms) {
  return new Date(ms).toISOString();
}

function isoToTs(iso) {
  return new Date(iso).getTime();
}

function normalizeUrl(url) {
  if (!url) return '/';
  return url.endsWith('/') ? url : url + '/';
}

function wrapHtml(text) {
  if (!text || text.trim() === '') return '';
  return '&lt;p&gt;' + text.replace(/&amp;/g, '&amp;amp;').replace(/&lt;/g, '&amp;lt;').replace(/&gt;/g, '&amp;gt;').replace(/\\n/g, '&lt;/p&gt;\\n&lt;p&gt;') + '&lt;/p&gt;\\n';
}

function parseIPRegion(ipRegion) {
  if (!ipRegion) return '';
  const parts = ipRegion.split('|');
  return [parts[0], parts[2], parts[3]].filter(Boolean).join(' ');
}

// ==================== 核心：Twikoo → Waline ====================
function convertTW2WL(data) {
  const comments = Array.isArray(data) ? data : (data.data || data.comments || data);
  if (!Array.isArray(comments) || comments.length === 0) {
    throw new Error('未识别到评论数组');
  }

  const log = [];
  log.push('[Twikoo → Waline] 开始转换...');
  log.push(`源评论数: ${comments.length}`);

  // 分配数字 objectId
  let nextId = 1;
  const idMap = {}; // twikoo _id → waline objectId
  for (const c of comments) {
    idMap[c._id] = nextId++;
  }

  // Users 按邮箱去重
  const userMap = {};
  const users = [];
  let uidCounter = 0;
  const usedEmails = new Set();

  for (const c of comments) {
    const email = (c.mail || '').trim().toLowerCase();
    if (email &amp;&amp; !usedEmails.has(email)) {
      usedEmails.add(email);
      uidCounter++;
      userMap[email] = uidCounter;
      users.push({
        objectId: uidCounter,
        display_name: c.nick || '',
        email: email,
        password: '',
        type: c.master === 1 ? 'administrator' : 'guest',
        label: c.master === 1 ? '主理人' : null,
        url: c.link || null,
        avatar: c.avatar || null,
        github: null, twitter: null, facebook: null, google: null,
        weibo: null, qq: null, oidc: null, huawei: null, "2fa": null,
        createdAt: tsToISO(c.created),
        updatedAt: tsToISO(c.updated || c.created)
      });
    }
  }
  log.push(`去重用户: ${users.length}`);

  // Counter 按 url 去重
  const urlCountMap = {};
  for (const c of comments) {
    const url = normalizeUrl(c.url);
    urlCountMap[url] = (urlCountMap[url] || 0) + 1;
  }
  const counters = [];
  let cidCounter = 0;
  for (const [url, count] of Object.entries(urlCountMap)) {
    cidCounter++;
    counters.push({
      objectId: cidCounter, url, time: count,
      reaction0: null, reaction1: null, reaction2: null,
      reaction3: null, reaction4: null, reaction5: null,
      reaction6: null, reaction7: null, reaction8: null,
      createdAt: tsToISO(Date.now()),
      updatedAt: tsToISO(Date.now())
    });
  }

  // 转换 Comment
  const walineComments = [];
  let topCount = 0, replyCount = 0, orphanCount = 0;

  for (const c of comments) {
    const email = (c.mail || '').trim().toLowerCase();
    const userId = userMap[email] || 0;
    let pid = null, rid = null;

    if (c.pid &amp;&amp; c.pid.trim() !== '') {
      pid = idMap[c.pid] || null;
      if (pid === null) { orphanCount++; log.push(`⚠ 孤儿回复: ${c._id} → 父 ${c.pid} 不存在`); }
    }
    if (c.rid &amp;&amp; c.rid.trim() !== '') {
      rid = idMap[c.rid] || null;
    }
    if (pid !== null &amp;&amp; rid === null) rid = pid;

    pid === null ? topCount++ : replyCount++;

    walineComments.push({
      objectId: idMap[c._id],
      user_id: userId,
      comment: stripHtml(c.comment || ''),
      ip: c.ip || '',
      link: c.link || null,
      mail: c.mail || '',
      nick: c.nick || '',
      pid, rid,
      sticky: c.top === 1 ? 1 : null,
      status: c.isSpam ? 'spam' : 'approved',
      like: null,
      ua: c.ua || '',
      url: normalizeUrl(c.url),
      insertedAt: tsToISO(c.created),
      createdAt: tsToISO(c.created),
      updatedAt: tsToISO(c.updated || c.created)
    });
  }

  log.push(`顶级: ${topCount} · 回复: ${replyCount} · 孤儿: ${orphanCount}`);

  return {
    result: {
      __version: '1.41.3', type: 'waline', version: 1, time: Date.now(),
      tables: ['Comment', 'Counter', 'Users'],
      data: { Comment: walineComments, Counter: counters, Users: users }
    },
    log, stats: { total: comments.length, top: topCount, reply: replyCount, users: users.length, urls: counters.length, orphan: orphanCount }
  };
}

// ==================== 核心：Waline → Twikoo ====================
function convertWL2TW(data) {
  const log = [];
  log.push('[Waline → Twikoo] 开始转换...');

  // 提取数据
  let walineObj = data;
  if (Array.isArray(data)) {
    // 可能是单纯的 Comment 数组
    walineObj = { data: { Comment: data, Users: [], Counter: [] } };
  }

  const wlComments = walineObj?.data?.Comment || walineObj?.Comment || walineObj?.comments || [];
  const wlUsers = walineObj?.data?.Users || walineObj?.Users || [];
  const wlCounters = walineObj?.data?.Counter || walineObj?.Counter || [];

  if (!Array.isArray(wlComments) || wlComments.length === 0) {
    throw new Error('未识别到 Waline Comment 数据');
  }

  log.push(`源评论数: ${wlComments.length}`);
  log.push(`源用户数: ${wlUsers.length}`);
  log.push(`源计数器: ${wlCounters.length}`);

  // 构建 user_id → 用户信息 的映射
  const userInfoMap = {};
  for (const u of wlUsers) {
    userInfoMap[u.objectId] = u;
  }

  // 分配 Twikoo 的 _id（UUID）
  const idMap = {}; // waline objectId → twikoo _id
  for (const c of wlComments) {
    idMap[c.objectId] = generateUUID();
  }
  log.push(`生成 UUID _id: ${Object.keys(idMap).length} 个`);

  // 构建 url → 页面信息映射（用于补充 href）
  const urlInfoMap = {};
  for (const ct of wlCounters) {
    if (ct.url) urlInfoMap[ct.url] = ct;
  }

  // 转换
  const twikooComments = [];
  let topCount = 0, replyCount = 0, orphanCount = 0;

  for (const c of wlComments) {
    const userInfo = userInfoMap[c.user_id] || {};
    const email = c.mail || userInfo.email || '';
    const nick = c.nick || userInfo.display_name || '';

    let pid = '';
    let rid = '';

    if (c.pid != null) {
      pid = idMap[c.pid] || '';
      if (pid === '') { orphanCount++; log.push(`⚠ 孤儿回复: objectId=${c.objectId} → 父 ${c.pid} 不存在`); }
    }
    if (c.rid != null) {
      rid = idMap[c.rid] || '';
    }

    pid ? replyCount++ : topCount++;

    const created = typeof c.createdAt === 'number' ? c.createdAt : isoToTs(c.createdAt || c.insertedAt || new Date().toISOString());
    const updated = typeof c.updatedAt === 'number' ? c.updatedAt : isoToTs(c.updatedAt || c.createdAt || new Date().toISOString());

    // 构建 ipRegion（近似还原）
    // Waline 没有 ipRegion 字段，只能留空或尝试从其他字段推断
    const ipRegion = '';

    twikooComments.push({
      _id: idMap[c.objectId],
      uid: '',
      nick: nick,
      mail: email,
      mailMd5: '',
      link: c.link || userInfo.url || '',
      ua: c.ua || '',
      ip: c.ip || '',
      ipRegion: ipRegion,
      master: (userInfo.type === 'administrator') ? 1 : 0,
      url: c.url || '/',
      href: c.url ? ('https://fqzlr.com' + c.url + '/') : '',
      comment: wrapHtml(c.comment || ''),
      pid: pid,
      rid: rid,
      isSpam: c.status === 'spam' ? 1 : 0,
      created: created,
      updated: updated,
      like: c.like ? JSON.stringify(c.like) : '[]',
      top: c.sticky ? 1 : 0,
      avatar: userInfo.avatar || ''
    });
  }

  log.push(`顶级: ${topCount} · 回复: ${replyCount} · 孤儿: ${orphanCount}`);

  return {
    result: twikooComments,
    log, stats: { total: twikooComments.length, top: topCount, reply: replyCount, users: wlUsers.length, orphan: orphanCount }
  };
}

// ==================== UI ====================
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const statsCard = document.getElementById('statsCard');
const statsGrid = document.getElementById('statsGrid');
const convertCard = document.getElementById('convertCard');
const btnConvert = document.getElementById('btnConvert');
const btnDownload = document.getElementById('btnDownload');
const btnPreview = document.getElementById('btnPreview');
const logArea = document.getElementById('logArea');
const previewList = document.getElementById('previewList');
const noteArea = document.getElementById('noteArea');
const dropText = document.getElementById('dropText');
const fileHint = document.getElementById('fileHint');
const modeDesc = document.getElementById('modeDesc');

function setMode(mode) {
  currentMode = mode;
  document.getElementById('modeTW').classList.toggle('active', mode === 'tw2wl');
  document.getElementById('modeWT').classList.toggle('active', mode === 'wl2tw');

  if (mode === 'tw2wl') {
    modeDesc.textContent = '将 Twikoo 导出的 JSON 数组转换为 Waline 导入格式（含 Comment / Users / Counter 三表）';
    dropText.innerHTML = '拖拽 &lt;strong&gt;twikoo-comment.json&lt;/strong&gt; 到此处&lt;br&gt;或 &lt;strong&gt;点击选择文件&lt;/strong&gt;';
    fileHint.textContent = 'Twikoo 导出文件通常是一个 JSON 数组，每个元素是一条评论';
  } else {
    modeDesc.textContent = '将 Waline 导出的 JSON 对象转换为 Twikoo 评论数组格式（含层级关系）';
    dropText.innerHTML = '拖拽 &lt;strong&gt;waline-export.json&lt;/strong&gt; 到此处&lt;br&gt;或 &lt;strong&gt;点击选择文件&lt;/strong&gt;';
    fileHint.textContent = 'Waline 导出文件是一个含 data.Comment / data.Users / data.Counter 的 JSON 对象';
  }

  // 重置
  sourceData = null;
  outputData = null;
  convertLog = [];
  convertStats = null;
  fileInfo.textContent = '';
  statsCard.style.display = 'none';
  convertCard.style.display = 'none';
  logArea.style.display = 'none';
  previewList.style.display = 'none';
  noteArea.style.display = 'none';
  btnDownload.disabled = true;
  btnPreview.disabled = true;
}

dropZone.addEventListener('click', () =&gt; fileInput.click());
dropZone.addEventListener('dragover', e =&gt; { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () =&gt; dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e =&gt; {
  e.preventDefault();
  dropZone.classList.remove('dragover');
  const file = e.dataTransfer.files[0];
  if (file) loadFile(file);
});
fileInput.addEventListener('change', e =&gt; {
  const file = e.target.files[0];
  if (file) loadFile(file);
});

function loadFile(file) {
  fileInfo.textContent = `已选择: ${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
  const reader = new FileReader();
  reader.onload = function() {
    try {
      sourceData = JSON.parse(reader.result);

      let arr;
      if (currentMode === 'tw2wl') {
        arr = Array.isArray(sourceData) ? sourceData : (sourceData.data || sourceData.comments || []);
      } else {
        arr = sourceData?.data?.Comment || sourceData?.Comment || sourceData?.comments || [];
      }

      statsCard.style.display = 'block';
      convertCard.style.display = 'block';

      const topArr = currentMode === 'tw2wl'
        ? arr.filter(c =&gt; !c.pid || c.pid === '')
        : arr.filter(c =&gt; c.pid == null);
      const replyArr = currentMode === 'tw2wl'
        ? arr.filter(c =&gt; c.pid &amp;&amp; c.pid !== '')
        : arr.filter(c =&gt; c.pid != null);
      const userCount = currentMode === 'tw2wl'
        ? new Set(arr.map(c =&gt; c.mail?.trim().toLowerCase()).filter(Boolean)).size
        : (sourceData?.data?.Users || sourceData?.Users || []).length || '未知';

      statsGrid.innerHTML = `
        &lt;div class="stat-box"&gt;&lt;div class="num"&gt;${arr.length}&lt;/div&gt;&lt;div class="label"&gt;评论总数&lt;/div&gt;&lt;/div&gt;
        &lt;div class="stat-box"&gt;&lt;div class="num"&gt;${topArr.length}&lt;/div&gt;&lt;div class="label"&gt;顶级评论&lt;/div&gt;&lt;/div&gt;
        &lt;div class="stat-box"&gt;&lt;div class="num"&gt;${replyArr.length}&lt;/div&gt;&lt;div class="label"&gt;回复评论&lt;/div&gt;&lt;/div&gt;
        &lt;div class="stat-box"&gt;&lt;div class="num"&gt;${userCount}&lt;/div&gt;&lt;div class="label"&gt;关联用户&lt;/div&gt;&lt;/div&gt;
      `;

      logArea.style.display = 'none';
      previewList.style.display = 'none';
      noteArea.style.display = 'none';
      btnDownload.disabled = true;
      btnPreview.disabled = true;
    } catch (err) {
      fileInfo.innerHTML = `&lt;span style="color:#e74c3c"&gt;JSON 解析失败: ${err.message}&lt;/span&gt;`;
    }
  };
  reader.readAsText(file);
}

btnConvert.addEventListener('click', () =&gt; {
  if (!sourceData) return alert('请先加载源 JSON 文件');
  try {
    let result;
    if (currentMode === 'tw2wl') {
      result = convertTW2WL(sourceData);
    } else {
      result = convertWL2TW(sourceData);
    }
    outputData = result.result;
    convertLog = result.log;
    convertStats = result.stats;

    logArea.style.display = 'block';
    logArea.innerHTML = convertLog.map(l =&gt; {
      if (l.startsWith('⚠')) return `&lt;span class="warn"&gt;${l}&lt;/span&gt;`;
      if (l.startsWith('❌')) return `&lt;span class="err"&gt;${l}&lt;/span&gt;`;
      return `&lt;span class="ok"&gt;${l}&lt;/span&gt;`;
    }).join('\\n');

    btnDownload.disabled = false;
    btnPreview.disabled = false;
    previewList.style.display = 'none';

    // 显示注意事项
    if (currentMode === 'wl2tw') {
      noteArea.style.display = 'block';
      noteArea.innerHTML = `
        &lt;strong&gt;注意：&lt;/strong&gt;Waline 原始数据不包含 &lt;code&gt;uid&lt;/code&gt;、&lt;code&gt;mailMd5&lt;/code&gt;、&lt;code&gt;ipRegion&lt;/code&gt;、&lt;code&gt;href&lt;/code&gt; 等字段，转换后这些字段将为空或使用默认值。&lt;br&gt;
        建议导入 Twikoo 后手动补充站点域名等配置。
      `;
    } else {
      noteArea.style.display = 'block';
      noteArea.innerHTML = `
        &lt;strong&gt;注意：&lt;/strong&gt;转换后的 Waline JSON 可直接通过 Waline 管理后台"导入数据"功能导入。&lt;br&gt;
        评论中的 HTML 标签已去除，纯文本保留。回复层级关系通过 &lt;code&gt;pid&lt;/code&gt;/&lt;code&gt;rid&lt;/code&gt; 数字 ID 保留。
      `;
    }
  } catch (err) {
    alert('转换失败: ' + err.message);
  }
});

btnDownload.addEventListener('click', () =&gt; {
  if (!outputData) return;
  const filename = currentMode === 'tw2wl' ? 'waline-import.json' : 'twikoo-export.json';
  const blob = new Blob([JSON.stringify(outputData, null, '\\t')], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename; a.click();
  URL.revokeObjectURL(url);
});

btnPreview.addEventListener('click', () =&gt; {
  if (!outputData) return;

  let comments;
  if (currentMode === 'tw2wl') {
    comments = outputData.data.Comment;
  } else {
    comments = outputData;
  }

  previewList.style.display = 'block';

  // 构建映射 + 计算层级
  const commentMap = {};
  for (const c of comments) {
    const id = currentMode === 'tw2wl' ? c.objectId : c._id;
    commentMap[id] = c;
  }

  function getPid(c) {
    if (currentMode === 'tw2wl') return c.pid;
    return c.pid || null;
  }

  function getId(c) {
    if (currentMode === 'tw2wl') return c.objectId;
    return c._id;
  }

  function getLevel(comment) {
    let level = 0;
    let current = comment;
    const pid = getPid(current);
    if (pid &amp;&amp; commentMap[pid]) {
      level++;
      current = commentMap[pid];
      let safety = 0;
      while (safety &lt; 50) {
        const p = getPid(current);
        if (p &amp;&amp; commentMap[p]) {
          level++;
          current = commentMap[p];
        } else break;
        safety++;
      }
    }
    return level;
  }

  function getNick(c) { return c.nick || '匿名'; }
  function getText(c) { const t = c.comment || ''; return t.length &gt; 100 ? t.slice(0, 100) + '...' : (t || '(空)'); }
  function getUrl(c) { return c.url || '/'; }
  function getTime(c) {
    const t = c.createdAt || c.created || '';
    if (typeof t === 'number') return new Date(t).toISOString().slice(0, 10);
    return String(t).slice(0, 10);
  }
  function getStatus(c) { return currentMode === 'tw2wl' ? c.status : (c.isSpam ? 'spam' : 'approved'); }
  function getDisplayId(c) { return currentMode === 'tw2wl' ? c.objectId : c._id.slice(0, 8); }
  function getPidDisplay(c) {
    if (currentMode === 'tw2wl') return c.pid ? ' → 回复 #' + c.pid : '';
    return c.pid ? ' → 回复 ' + c.pid.slice(0, 8) : '';
  }

  // 保持原始顺序
  previewList.innerHTML = comments.map(c =&gt; {
    const level = getLevel(c);
    const indent = '└ '.repeat(Math.min(level, 10));
    return `
      &lt;div class="comment-item level-${Math.min(level, 3)}"&gt;
        &lt;div class="oid"&gt;${getDisplayId(c)}&lt;/div&gt;
        &lt;div class="comment-info"&gt;
          &lt;div class="author"&gt;${indent}${getNick(c)}${getPidDisplay(c)}&lt;/div&gt;
          &lt;div class="meta"&gt;${getUrl(c)} · ${getTime(c)} · ${getStatus(c)}&lt;/div&gt;
          &lt;div class="text"&gt;${getText(c)}&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    `;
  }).join('');
});
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3>转换工具操作步骤</h3>
<h4>Twikoo → Waline 方向</h4>
<ol>
<li>
<p>在上方内嵌工具中，顶部切换按钮选择「Twikoo → Waline」；</p>
</li>
<li>
<p>导入数据，二选一：</p>
<ul>
<li>粘贴 Twikoo 完整 JSON 数组到输入框；</li>
<li>点击「上传本地 JSON」，选中导出的 Twikoo 备份文件；</li>
</ul>
</li>
<li>
<p>（可选）路径前缀批量替换：填写旧/新路径前缀后点击「一键替换全部 url」，替换后自动补尾斜杠；</p>
</li>
<li>
<p>点击「开始转换」；</p>
</li>
<li>
<p>预览校验：</p>
<ul>
<li>每条评论 <code>"url": "/xxx/"</code> 末尾已自动补 <code>/</code>；</li>
<li>子评论 <code>pid</code> 为数字 ID，代表父子关联正常；</li>
</ul>
</li>
<li>
<p>点击「下载结果 JSON」保存 <code>waline-import.json</code>。</p>
</li>
</ol>
<h4>Waline → Twikoo 方向（反向恢复）</h4>
<ol>
<li>顶部切换按钮选择「Waline → Twikoo」；</li>
<li>粘贴 Waline 导出的完整 JSON 对象；</li>
<li>点击「开始转换」→ 下载 <code>twikoo-export.json</code>；</li>
<li>注意：Waline 原始数据不含 <code>uid</code>、<code>mailMd5</code>、<code>ipRegion</code>、<code>href</code> 等 Twikoo 特有字段，转换后这些字段为空或默认值。</li>
</ol>
<h2>四、第三步：Waline 后台导入转换后的评论文件</h2>
<ol>
<li>访问 Waline 管理后台：<code>https://waline.fqzlr.com/ui</code>；</li>
<li>左侧菜单栏打开「导入导出」页面；</li>
<li>
<blockquote>
<p>[!WARNING]
导入操作会覆盖现有评论，<strong>务必先导出当前 Waline 评论备份</strong>，防止数据丢失；</p>
</blockquote>
</li>
<li>上传刚下载的 <code>waline-import.json</code>，点击执行导入；</li>
<li>导入完成后，<code>Ctrl + F5</code> 强制刷新后台页面，即可在评论列表看到全部迁移历史评论，嵌套回复正常分层展示。</li>
</ol>
<h2>五、第四步：Firefly 博客前台验证评论展示</h2>
<h3>路径匹配原理</h3>
<p>转换工具已自动为所有评论 <code>url</code> 末尾添加 <code>/</code>，Waline 使用精确字符串匹配页面路径，和 Firefly 前台手动提交评论的路径格式完全一致。</p>
<ol>
<li>打开博客任意文章页面，示例：<code>fqzlr.com/friends/</code>；</li>
<li><code>Ctrl + F5</code> 强制清除浏览器缓存刷新页面；</li>
<li>页面加载全部迁移历史评论，子评论正常缩进嵌套展示。</li>
</ol>
<h3>前台无评论 / 层级错乱排查方案</h3>
<ol>
<li>F12 打开浏览器开发者工具，切换到 Network 面板；</li>
<li>筛选 Waline API 请求，查看请求参数 <code>path</code> 是否为带尾斜杠格式（<code>/friends/</code>）；</li>
<li>进入 Waline 后台查看单条评论详情：
<ul>
<li>若 <code>url</code> 缺少末尾 <code>/</code>：重新转换，确认路径已自动补斜杠；</li>
<li>若子评论 <code>pid</code> 为 null：原始 Twikoo 数据缺失 <code>_id</code>/<code>pid</code>/<code>rid</code>，清理脏数据后重新导出转换；</li>
</ul>
</li>
<li>确认导入流程无报错、未清空原有评论数据。</li>
</ol>
<h2>六、高频踩坑问题汇总</h2>
<h3>坑1：导入时报 500 错误 <code>invalid input syntax for type timestamp</code></h3>
<ul>
<li>具体报错：<code>500: invalid input syntax for type timestamp: "2026-04-25T11:23:19.928.000Z"</code></li>
<li>根因：旧版转换工具的 <code>tsToISO</code> 使用了 <code>toISOString().replace('Z', '.000Z')</code>，当毫秒本身已有 3 位时（如 <code>.928Z</code>），替换后变成 <code>.928.000Z</code>，PostgreSQL 无法解析双重小数点格式；</li>
<li>解决方案：使用本文新版转换工具（v2），<code>new Date(ms).toISOString()</code> 直接输出标准 ISO 8601 格式，无需额外拼接。</li>
</ul>
<h3>坑2：导入成功，后台可见评论，Firefly 前台页面空白</h3>
<ul>
<li>根因：路径末尾斜杠不匹配（<code>/friends</code> vs <code>/friends/</code>）；</li>
<li>解决方案：使用本文新版转换工具，转换时自动为所有 <code>url</code> 补尾斜杠，清空旧评论后重新导入。</li>
</ul>
<h3>坑3：导入提示成功，但 Waline 后台无任何评论</h3>
<ul>
<li>原因1：转换 JSON 缺失 <code>createdAt</code>/<code>user_id</code>/<code>like</code> 数据库必填字段；</li>
<li>原因2：外层 JSON 结构缺少 <code>Counter</code>、<code>Users</code> 空数组；</li>
<li>解决方案：使用本文完整版转换工具，已补齐全部标准字段与外层结构。</li>
</ul>
<h3>坑4：所有回复全部平铺，没有嵌套层级</h3>
<ul>
<li>根因：转换工具未做 Twikoo <code>_id</code> 与 Waline <code>objectId</code> ID 映射，父子评论 pid 关联断裂；</li>
<li>解决方案：使用本文修复层级回复的新版转换器，完整还原多级回复关系。</li>
</ul>
<h3>坑5：转换工具下载按钮点击无响应</h3>
<ul>
<li>解决方案：更换 Chrome / Edge 浏览器，关闭浏览器弹窗拦截功能。</li>
</ul>
<h3>坑6：评论图片、换行、Emoji 丢失乱码</h3>
<ul>
<li>解决方案：工具内置 UTF-8 编码处理，Twikoo→Waline 方向会去除 HTML 标签保留纯文本。</li>
</ul>
<h2>七、兜底备用方案（后台导入解析失败时）</h2>
<p>若 Waline 后台导入接口持续丢失数据、层级错乱，可直接写入数据库绕过解析器：</p>
<ol>
<li>打开转换后的 <code>waline-import.json</code>，复制 <code>data.Comment</code> 数组内全部评论对象；</li>
<li>进入数据库管理面板（MySQL / SQLite / LeanCloud）；</li>
<li>找到 <code>wl_Comment</code> 数据表，批量插入复制的评论数组；</li>
<li>直接写入数据库不会丢失任何评论数据、完整保留回复层级。</li>
</ol>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>小白怎么二改博客？使用国产 Codex 阿里的 Qoder、字节的 Trae 来一键修改</title>
    <link href="https://blog.fqzlr.top/posts/tech/ai-tools-customize-blog/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/ai-tools-customize-blog/</id>
    <published>2026-06-28T00:00:00.000Z</published>
    <updated>2026-06-28T00:00:00.000Z</updated>
    <summary>博客系列第8期。编程小白也能改博客，用阿里 Qoder 和字节 Trae 两款国产 AI 工具，描述需求就能自动修改代码。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV131TM6FEPJ&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>不会代码也能改博客</h2>
<p>很多人搭好博客之后想改点东西——换个颜色、加个功能、调一下布局——但一看代码就懵了。这期教你用 AI 工具来干这事，你只需要用中文描述想要什么效果，AI 帮你改代码。</p>
<p>这里用两款国产工具：<strong>阿里 Qoder</strong> 和 <strong>字节 Trae</strong>。都是免费的，对新手很友好。</p>
<h2>工具介绍</h2>
<h3>阿里 Qoder</h3>
<p><a href="https://qoder.com.cn">Qoder</a> 是阿里推出的 AI 编程助手，擅长整体性的代码生成和重构。你可以把它理解为一个能读懂你项目代码的 AI 助手，告诉它要改什么，它直接帮你改好。</p>
<p>适合场景：整体重构、添加新功能模块、大范围样式调整。</p>
<h3>字节 Trae</h3>
<p><a href="https://www.trae.cn/ide/download">Trae</a> 是字节跳动做的 AI IDE（代码编辑器），内置了 AI 辅助编程能力。它更擅长细节优化和错误修复，可以一边写代码一边让 AI 帮忙。</p>
<p>适合场景：细节调整、修 bug、局部优化。</p>
<h3>对比</h3>
<table>
<thead>
<tr>
<th></th>
<th>Qoder</th>
<th>Trae</th>
</tr>
</thead>
<tbody>
<tr>
<td>开发商</td>
<td>阿里巴巴</td>
<td>字节跳动</td>
</tr>
<tr>
<td>擅长</td>
<td>整体重构、功能添加</td>
<td>细节优化、错误修复</td>
</tr>
<tr>
<td>使用方式</td>
<td>描述需求 -&gt; 生成方案</td>
<td>编辑器内实时辅助</td>
</tr>
<tr>
<td>上手难度</td>
<td>简单</td>
<td>简单</td>
</tr>
</tbody>
</table>
<p>两个都可以试试，看哪个更合你习惯。</p>
<h2>操作流程</h2>
<p>整个流程其实很简单：</p>
<h3>1. 安装工具</h3>
<p>去官网下载安装就行，Qoder 和 Trae 都有 Windows 版本。</p>
<h3>2. 导入博客项目</h3>
<p>把你在 GitHub 上的博客仓库克隆到本地，然后用工具打开项目文件夹。</p>
<h3>3. 描述你的需求</h3>
<p>直接用中文告诉 AI 你想改什么。比如：</p>
<ul>
<li>"把首页的文章卡片改成圆角阴影样式"</li>
<li>"在导航栏加一个关于我的页面链接"</li>
<li>"把代码块的主题换成深色风格"</li>
<li>"给文章页加一个目录导航"</li>
</ul>
<p>描述得越具体，AI 改出来的效果越接近你的预期。</p>
<h3>4. AI 生成方案并应用</h3>
<p>AI 会分析你的项目代码，生成修改方案。你确认后它直接改文件。</p>
<h3>5. 预览效果</h3>
<p>本地跑 <code>pnpm dev</code> 看效果，不满意就继续让 AI 调。</p>
<h3>6. 满意后推送</h3>
<pre><code>git add .
git commit -m "修改xxx样式"
git push
</code></pre>
<h2>常见修改场景</h2>
<p>这些是 AI 工具比较擅长的：</p>
<p><strong>样式调整</strong>：颜色、字体、间距、圆角、阴影这些视觉相关的改动，AI 处理得很好。</p>
<p><strong>添加功能</strong>：搜索框、评论区、分享按钮、暗色模式切换等常见功能，基本一句话就能加上。</p>
<p><strong>响应式适配</strong>：让博客在手机上也好看，AI 能帮你写媒体查询。</p>
<p><strong>SEO 优化</strong>：让 AI 帮你优化 meta 标签、文章结构。</p>
<h2>注意事项</h2>
<p><strong>改之前先备份。</strong> 虽然 Git 本身就有版本记录，但保险起见，大改之前先 commit 一次当前状态。</p>
<p><strong>小步测试。</strong> 不要一次让 AI 改太多东西，改一点测一点，出问题容易定位。</p>
<p><strong>了解基本概念。</strong> 虽然不用会写代码，但知道 HTML 是结构、CSS 是样式这种基本概念，和 AI 沟通会更顺畅。</p>
<h2>参考博客</h2>
<p>视频里展示的几个改得很好看的博客：</p>
<ul>
<li>mmzhiku 大佬：<a href="https://tblog.mmzhiku.xyz">tblog.mmzhiku.xyz</a></li>
<li>团子和蛋糕大佬：<a href="https://blog.tsh520.cn">blog.tsh520.cn</a></li>
<li>我的博客：<a href="https://www.fqzlr.com">fqzlr.com</a></li>
</ul>
<p>可以去参考一下别人的改法，找到自己喜欢的风格再动手。</p>
<blockquote>
<p>视频链接：<a href="https://www.bilibili.com/video/BV131TM6FEPJ">BV131TM6FEPJ</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>用 Obsidian 丝滑的写博客，分享一个轻量化的笔记博客</title>
    <link href="https://blog.fqzlr.top/posts/tech/obsidian-lightweight-note-blog/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/obsidian-lightweight-note-blog/</id>
    <published>2026-05-27T00:00:00.000Z</published>
    <updated>2026-05-27T00:00:00.000Z</updated>
    <summary>博客系列番外。分享一个轻量化的笔记博客方案，用 Obsidian 直接写，直接发布，适合不想折腾的人。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV1eUG16nErs&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>这个方案适合谁</h2>
<p>前面几期讲了完整的 Firefly 博客搭建流程，有些人可能觉得还是有点复杂。这期分享一个更轻量的方案——直接用 Obsidian 当博客后端，生成一个简洁的笔记站点。</p>
<p>如果你只是想有个地方放笔记、分享知识，不需要花里胡哨的设计，这个方案很适合。</p>
<h2>效果展示</h2>
<p>演示站点：<a href="https://bj.fqzlr.com">bj.fqzlr.com</a></p>
<p>整体风格非常简洁，就是笔记列表 + 文章内容的形式。没有复杂的导航、没有炫酷的动效，就是安安静静展示内容。</p>
<h2>和完整博客的区别</h2>
<table>
<thead>
<tr>
<th></th>
<th>完整博客（Firefly）</th>
<th>轻量化笔记博客</th>
</tr>
</thead>
<tbody>
<tr>
<td>功能</td>
<td>完整博客功能</td>
<td>纯笔记展示</td>
</tr>
<tr>
<td>自定义</td>
<td>高度可定制</td>
<td>开箱即用</td>
</tr>
<tr>
<td>配置复杂度</td>
<td>需要配不少东西</td>
<td>基本不用配</td>
</tr>
<tr>
<td>适合场景</td>
<td>个人品牌、内容运营</td>
<td>个人笔记、知识分享</td>
</tr>
<tr>
<td>维护成本</td>
<td>偶尔需要更新</td>
<td>几乎不用管</td>
</tr>
</tbody>
</table>
<h2>核心思路</h2>
<p>其实和上一期讲的 Obsidian 联动方案类似，区别在于：</p>
<ul>
<li>不需要完整的 Firefly 主题，用更轻量的 Astro 模板</li>
<li>Obsidian 里写笔记，通过插件同步到 Astro 项目</li>
<li>部署方式和之前一样用 Cloudflare</li>
</ul>
<p>最大的区别是<strong>简单</strong>。不用折腾主题配置、不用管各种插件，装上就能用。</p>
<h2>快速上手</h2>
<p>如果你已经有 Obsidian 和 Astro 环境：</p>
<ol>
<li>创建一个轻量的 Astro 项目</li>
<li>配置 Astro Composer 插件（参考上一期）</li>
<li>部署到 Cloudflare</li>
</ol>
<p>如果你还没有这些，建议先看前面的教程把基础环境搭好，再回来做这个就很快了。</p>
<h2>一些使用建议</h2>
<p><strong>笔记组织</strong>：在 Obsidian 里建一个专门的文件夹放要发布的笔记，和日常笔记分开管理。</p>
<p><strong>写作习惯</strong>：不用每篇都追求完美，笔记嘛，记录想法就好。积累多了自然有价值。</p>
<p><strong>定期整理</strong>：隔一段时间回顾一下，把好的笔记优化一下，过时的删掉或归档。</p>
<blockquote>
<p>演示站点：<a href="https://bj.fqzlr.com">bj.fqzlr.com</a></p>
<p>视频链接：<a href="https://www.bilibili.com/video/BV1eUG16nErs">BV1eUG16nErs</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>Obsidian 笔记软件联动 Mizuki &amp; Firefly Astro 等个人博客，优雅的利用图床写文章</title>
    <link href="https://blog.fqzlr.top/posts/tech/obsidian-astro-imgbed-workflow/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/obsidian-astro-imgbed-workflow/</id>
    <published>2026-05-18T00:00:00.000Z</published>
    <updated>2026-05-18T00:00:00.000Z</updated>
    <summary>博客系列第7期。用 Obsidian 写笔记，通过 Astro Composer 插件自动转换为博客内容，配合图床优雅发布。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV1jWLH6cERQ&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>这套工作流是什么</h2>
<p>简单说就是：<strong>用 Obsidian 写笔记 -&gt; 自动转成博客文章格式 -&gt; 推送到 GitHub -&gt; 博客自动更新</strong>。</p>
<p>写文章的人不需要关心部署、构建这些事，只管在 Obsidian 里写就行。整个流程打通之后，写作体验非常丝滑。</p>
<p>这个方案适用于所有基于 Astro 的博客主题，包括 Mizuki、Firefly 等。</p>
<h2>前置条件</h2>
<ul>
<li>已经有一个 Astro 博客（参考前面几期教程）</li>
<li>安装了 Obsidian</li>
<li>有一个自建图床（参考第5期图床教程）</li>
<li>会基本的 Git 操作</li>
</ul>
<h2>核心：安装 Astro Composer 插件</h2>
<p>这个插件是 Obsidian 和 Astro 博客之间的桥梁，它能把 Obsidian 笔记自动转换成 Astro 能识别的 Markdown 格式。</p>
<h3>安装步骤</h3>
<p>Astro Composer 还没上架社区插件库，需要通过 BRAT 安装：</p>
<ol>
<li>先在 Obsidian 社区插件里搜索安装 <strong>BRAT</strong></li>
<li>启用 BRAT 后，打开 BRAT 设置</li>
<li>点 <strong>Add a beta plugin</strong></li>
<li>输入仓库地址：<code>https://github.com/astro-modular/astro-composer</code></li>
<li>点 Add Plugin，等安装完启用</li>
</ol>
<h3>配置插件</h3>
<p>安装后进入 Astro Composer 设置，有几个关键配置项：</p>
<p><strong>Posts Folder</strong> — Obsidian 里放博客文章的文件夹</p>
<p>比如设成 <code>Blog/Posts</code>，以后所有要发到博客的笔记都放在这个文件夹下。</p>
<p><strong>Astro Content Path</strong> — 本地 Astro 项目的 <code>src/content</code> 目录路径</p>
<p>比如 <code>D:/MyBlog/Firefly/src/content</code>，插件会把转换后的内容同步到这个目录。</p>
<p><strong>Link Base Path</strong> — 博客的文章访问路径前缀</p>
<p>比如填 <code>/blog</code>，插件会自动把 Obsidian 内部链接转成博客友好的格式。</p>
<p><strong>Creation Mode</strong> — 文章存储结构</p>
<p>推荐选 <code>Folder-based with index.md</code>，每篇文章一个文件夹，里面放 <code>index.md</code>。</p>
<p><strong>Draft Management</strong> — 草稿管理</p>
<p>建议启用 <code>Underscore Prefix</code>，草稿文件会自动加下划线前缀（如 <code>_my-draft.md</code>），Astro 会忽略这些文件不发布。</p>
<h2>配合图床使用</h2>
<p>写文章的时候图片怎么办？这就是图床派上用场的时候了。</p>
<ol>
<li>在 Obsidian 里插入图片</li>
<li>图片自动上传到你的 Cloudflare 图床</li>
<li>文章里的图片链接自动替换成图床 URL</li>
<li>推送到 GitHub 后，博客上的图片直接加载图床链接</li>
</ol>
<p>这样图片不占项目仓库空间，加载速度也快。</p>
<h2>日常写作流程</h2>
<p>配好之后日常使用就三步：</p>
<ol>
<li>打开 Obsidian，在 Posts Folder 里新建笔记，正常写</li>
<li>写完后用 Git 推送代码到 GitHub</li>
<li>Cloudflare 自动构建，博客更新</li>
</ol>
<p>整个过程不需要手动去管 Astro 的文件格式、Frontmatter 这些，Astro Composer 都帮你处理好了。</p>
<h2>进阶：配合 Git 插件自动同步</h2>
<p>Obsidian 有个 <strong>Obsidian Git</strong> 插件，可以设置定时自动 commit + push。配好之后你连 Git 命令都不用打，写完笔记保存，Obsidian 自动帮你推到 GitHub。</p>
<p>这样就实现了真正的「写完即发布」。</p>
<blockquote>
<p>视频链接：<a href="https://www.bilibili.com/video/BV1jWLH6cERQ">BV1jWLH6cERQ</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>fuwari 博客主题 Mizuki 和 Firefly 对比，以及好玩的主页和笔记博客分享</title>
    <link href="https://blog.fqzlr.top/posts/tech/mizuki-vs-firefly-themes/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/mizuki-vs-firefly-themes/</id>
    <published>2026-05-10T00:00:00.000Z</published>
    <updated>2026-05-10T00:00:00.000Z</updated>
    <summary>博客系列第6期。对比 fuwari 框架下两个好看的主题 Mizuki 和 Firefly，顺便分享一些好玩的主页和笔记博客玩法。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV15n5n6nEKU&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>fuwari 框架简介</h2>
<p><a href="https://github.com/fuwari">fuwari</a> 是一个基于 Astro 的静态博客框架体系，下面有多个主题可选。它的特点是部署方式统一（都是 Astro 生态），但不同主题的视觉风格差异很大，适合不同需求的人。</p>
<p>这期视频主要对比其中两个主题：<strong>Mizuki</strong> 和 <strong>Firefly</strong>。</p>
<h2>Mizuki vs Firefly</h2>
<h3>Mizuki</h3>
<p>Mizuki 更偏「笔记/知识库」风格，界面简洁清爽，适合喜欢极简风的人。它的特点是：</p>
<ul>
<li>布局紧凑，信息密度高</li>
<li>适合做个人笔记、知识分享</li>
<li>首页展示文章列表，一目了然</li>
<li>整体风格偏学术/技术博客</li>
</ul>
<p>如果你主要想拿来写技术笔记、做知识库，Mizuki 很合适。</p>
<h3>Firefly</h3>
<p>Firefly 更偏「个人博客/主页」风格，视觉设计更丰富，可玩性更高。它的特点是：</p>
<ul>
<li>界面更现代，动效和排版更讲究</li>
<li>支持更多自定义配置</li>
<li>社区活跃，文档齐全</li>
<li>适合做个人品牌展示</li>
</ul>
<p>如果你想打造一个有设计感的个人博客，Firefly 是更好的选择。</p>
<h3>怎么选</h3>
<table>
<thead>
<tr>
<th></th>
<th>Mizuki</th>
<th>Firefly</th>
</tr>
</thead>
<tbody>
<tr>
<td>风格</td>
<td>极简笔记</td>
<td>设计感博客</td>
</tr>
<tr>
<td>适合场景</td>
<td>知识库、技术笔记</td>
<td>个人品牌、内容展示</td>
</tr>
<tr>
<td>上手难度</td>
<td>简单</td>
<td>稍复杂但文档全</td>
</tr>
<tr>
<td>自定义程度</td>
<td>中等</td>
<td>较高</td>
</tr>
</tbody>
</table>
<p>我的建议：如果你纠结，选 Firefly。它的文档更完善，社区更大，遇到问题更容易找到解决方案。而且后面几期教程都是基于 Firefly 讲的。</p>
<h2>好玩的主页玩法</h2>
<p>除了传统博客，fuwari 生态还可以做很多有趣的东西：</p>
<p><strong>个人主页</strong>：不做博客，就做一个简洁的个人介绍页，放你的联系方式、项目链接、技能展示。</p>
<p><strong>笔记站</strong>：把 Obsidian 的笔记同步到博客，变成一个在线知识库。</p>
<p><strong>项目展示</strong>：用来展示你的开源项目或者作品集。</p>
<h2>友链</h2>
<p>视频里提到了友链功能。友链就是和其他博主互换链接，互相导流。Firefly 主题自带友链页面，你可以在 <code>src/content/</code> 下创建友链数据文件，格式大概是：</p>
<pre><code>name: 博主名
link: https://他们的博客地址
avatar: 头像链接
description: 一句话介绍
</code></pre>
<p>也可以加一个友链自助申请按钮，让访客自己提交。这个在后面的文章里有专门讲（<a href="https://fqzlr.com/posts/blog/firefly-link-button/">Firefly 友链自助申请</a>）。</p>
<blockquote>
<p>欢迎来加友链：<a href="https://fqzlr.com/friends">fqzlr.com/friends</a></p>
</blockquote>
<h2>相关资源</h2>
<ul>
<li>Firefly 源码：<a href="https://github.com/CuteLeaf/Firefly">github.com/CuteLeaf/Firefly</a></li>
<li>作者博客：<a href="https://fqzlr.com">fqzlr.com</a></li>
</ul>
<blockquote>
<p>视频链接：<a href="https://www.bilibili.com/video/BV15n5n6nEKU">BV15n5n6nEKU</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>不需要服务器，0成本，搭建属于自己的个人图床</title>
    <link href="https://blog.fqzlr.top/posts/tech/personal-img-bed/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/personal-img-bed/</id>
    <published>2026-05-07T00:00:00.000Z</published>
    <updated>2026-05-07T00:00:00.000Z</updated>
    <summary>博客系列第5期。用 Cloudflare Workers 部署专属图床 Cloudflare-ImgBed，免费、全球加速、数据自己掌控。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV1MFRtBbE9B&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>什么是图床</h2>
<p>简单说就是一个专门存图片、能生成图片链接的服务。写博客、发公众号、做文档的时候经常会用到，有了图床就不用把图片塞在项目里占空间，也不用担心某个平台的图片链接失效。</p>
<p>市面上免费图床不少，但第三方图床总有跑路风险。自己搭一个最安心，而且用 Cloudflare 的方案完全免费。</p>
<h2>为什么选 Cloudflare-ImgBed</h2>
<p>这个项目（<a href="https://github.com/MarSeventh/CloudFlare-ImgBed">github.com/MarSeventh/CloudFlare-ImgBed</a>）支持多种存储方式（R2、KV、D1、Telegram），部署在 Cloudflare Workers 上，全球 CDN 加速，而且支持通过 GitHub Actions 自动更新。</p>
<p>核心优势：零成本、无服务器、全球加速、数据可控。</p>
<h2>准备工作</h2>
<p>需要这些东西，后面一步步拿：</p>
<ul>
<li>GitHub 账号</li>
<li>Cloudflare 账号（免费注册）</li>
<li>Cloudflare Account ID</li>
<li>Cloudflare API Token</li>
<li>KV 或 D1 数据库（二选一）</li>
</ul>
<p>不需要装任何软件，全程浏览器操作。</p>
<h2>部署步骤</h2>
<h3>第一步：Fork 项目</h3>
<p>打开 <a href="https://github.com/MarSeventh/CloudFlare-ImgBed">CloudFlare-ImgBed</a> 仓库，点右上角 Fork。</p>
<h3>第二步：获取 Cloudflare 信息</h3>
<p><strong>拿 Account ID 和 API Token：</strong></p>
<ol>
<li>登录 <a href="https://dash.cloudflare.com/">Cloudflare Dashboard</a></li>
<li>右上角头像 -&gt; <strong>我的个人资料</strong> -&gt; <strong>API 令牌</strong></li>
<li>创建令牌，选模板「编辑 Cloudflare Workers」，一路下一步</li>
<li>生成的 Token <strong>立刻复制保存</strong>，它只显示一次</li>
<li>回到 Dashboard 首页，右侧复制 Account ID</li>
</ol>
<p><strong>创建数据库（KV 和 D1 二选一）：</strong></p>
<p>方案 A - KV（读写更快）：</p>
<ol>
<li>存储和数据库 -&gt; Workers KV -&gt; 创建</li>
<li>名称填 <code>img_url</code></li>
<li>复制命名空间 ID</li>
</ol>
<p>方案 B - D1（免费额度更高）：</p>
<ol>
<li>存储和数据库 -&gt; D1 SQL 数据库 -&gt; 创建</li>
<li>名称填 <code>img_d1</code></li>
<li>复制数据库 ID</li>
<li>进入控制台，执行项目提供的 <code>init.sql</code> 初始化</li>
</ol>
<p><strong>可选：创建 R2 存储桶</strong>（用来存图片）</p>
<p>存储和数据库 -&gt; R2 对象存储 -&gt; 创建，名称随意。</p>
<h3>第三步：配置 GitHub Secrets</h3>
<p>进入你 Fork 的仓库 -&gt; Settings -&gt; Secrets and variables -&gt; Actions -&gt; New repository secret，依次添加：</p>
<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>值</th>
<th>必填</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>CLOUDFLARE_API_TOKEN</code></td>
<td>你的 API Token</td>
<td>是</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_ID</code></td>
<td>你的 Account ID</td>
<td>是</td>
</tr>
<tr>
<td><code>KV_NAMESPACE_ID</code></td>
<td>KV 命名空间 ID</td>
<td>KV/D1 二选一</td>
</tr>
<tr>
<td><code>D1_DATABASE_ID</code></td>
<td>D1 数据库 ID</td>
<td>KV/D1 二选一</td>
</tr>
<tr>
<td><code>R2_BUCKET_NAME</code></td>
<td>R2 存储桶名</td>
<td>可选</td>
</tr>
</tbody>
</table>
<p>注意一定要用 <strong>Secrets</strong> 而不是 Variables，Variables 是公开的。</p>
<h3>第四步：部署</h3>
<ol>
<li>进入仓库 Actions 页面</li>
<li>选择 <strong>Deploy to Cloudflare Workers</strong></li>
<li>点 <strong>Run workflow</strong>，分支选 main</li>
<li>等 1-3 分钟</li>
</ol>
<p>看到绿色勾就部署成功了。</p>
<p>如果想以后自动同步上游更新，可以启用 <strong>Upstream Sync</strong> workflow，以后上游有新版本会自动同步并重新部署。</p>
<h2>部署后</h2>
<p>访问 Worker 地址就能使用图床了。不过第一次使用需要先在后台管理面板配置存储渠道（R2 或 Telegram），配好之后才能正常上传图片。</p>
<h2>常见问题</h2>
<ul>
<li><strong>部署失败</strong>：检查 Secrets 有没有填错，Token 权限对不对</li>
<li><strong>无法上传图片</strong>：还没配置存储渠道，去后台绑定 R2 或 Telegram</li>
<li><strong>链接打不开</strong>：确认 Worker 已启用，名称和子域名拼写正确</li>
</ul>
<blockquote>
<p>文字版教程：<a href="https://fqzlr.com/posts/blog/img-bed/">fqzlr.com/posts/blog/img-bed</a></p>
<p>视频链接：<a href="https://www.bilibili.com/video/BV1MFRtBbE9B">BV1MFRtBbE9B</a></p>
<p>项目地址：<a href="https://github.com/MarSeventh/CloudFlare-ImgBed">github.com/MarSeventh/CloudFlare-ImgBed</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>博客部署教程补充之 Cloudflare IP优选及文章编写发布</title>
    <link href="https://blog.fqzlr.top/posts/tech/cloudflare-ip-optimize-and-writing/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/cloudflare-ip-optimize-and-writing/</id>
    <published>2026-05-03T00:00:00.000Z</published>
    <updated>2026-05-03T00:00:00.000Z</updated>
    <summary>博客系列第4期。教你做 Cloudflare IP 优选加速国内访问，以及 Astro 博客的 Markdown 文章编写规范。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV1Hk9fBTE6H&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>为什么要做 IP 优选</h2>
<p>Cloudflare 的默认节点对国内访问不太友好，有时候打开很慢。IP 优选的原理是找到一个国内延迟最低的 Cloudflare 节点，把你的域名解析到这个节点上，访问速度会有明显提升。</p>
<p>做完之后体感差别很大，建议部署好博客之后就做这一步。</p>
<h2>IP 优选三步走</h2>
<h3>第一步：添加路由规则</h3>
<ol>
<li>登录 Cloudflare，进入你博客域名的控制台</li>
<li>左侧菜单找到 <strong>规则（Rules）</strong></li>
<li>点 <strong>添加路由</strong>，地址填 <code>你的域名/*</code>（比如 <code>blog.example.com/*</code>）</li>
<li>保存</li>
</ol>
<p>这条规则的作用是让博客的所有页面和静态资源都走优选节点。</p>
<h3>第二步：配置 DNS 优选解析</h3>
<ol>
<li>左侧菜单进入 <strong>DNS</strong> -&gt; <strong>记录</strong></li>
<li>点 <strong>添加记录</strong>：
<ul>
<li>类型：A 记录或 CNAME 都行</li>
<li>名称：随便起个前缀，比如 <code>speed</code></li>
<li>目标：填优选节点地址（格式 <code>任意内容.cf.090227.xyz</code>）</li>
<li><strong>重点：关闭代理（把云朵图标点灰）</strong>，不然优选不生效</li>
</ul>
</li>
<li>保存</li>
</ol>
<h3>第三步：把主域名指向优选节点</h3>
<ol>
<li>再添加一条 DNS 记录：
<ul>
<li>名称：填你博客域名的前缀（比如 <code>blog</code>，根域名就填 <code>@</code>）</li>
<li>目标：填上一步创建的记录名（比如 <code>speed.你的域名.com</code>）</li>
<li>代理状态：同样关闭</li>
</ul>
</li>
<li>保存，等 1-3 分钟生效</li>
</ol>
<p>刷新博客试试，应该能感觉到速度提升。</p>
<p><strong>原理简述</strong>：博客域名 -&gt; 优选地址 -&gt; Cloudflare 自动选最快的国内节点。Astro 生成的都是纯静态文件（HTML/CSS/图片），配合 CDN 加速效果很好。</p>
<h2>文章编写规范</h2>
<p>博客部署好了，接下来就是写文章。Firefly 用 Markdown（<code>.md</code>）或 MDX（<code>.mdx</code>）格式，功能很丰富。</p>
<h3>文章放哪</h3>
<p>所有文章放在 <code>src/content/posts/</code> 目录下，可以建子目录来组织。图片等资源放在同目录或 <code>src/assets/</code> 下。文件名建议用英文连字符格式（如 <code>cloudflare-ip-optimize.md</code>），避免中文路径问题。</p>
<h3>Frontmatter 必须写</h3>
<p>每篇文章顶部用 <code>---</code> 包裹 YAML 元数据：</p>
<pre><code>---
title: "文章标题"           # 必填
published: 2026-05-03       # 必填，发布日期
updated: 2026-05-04         # 可选，更新日期
description: "文章简介"      # 可选，首页卡片显示
image:                      # 可选，封面图（相对路径/网络链接/随机图API都行）
tags:
  - 标签1
  - 标签2
category: 分类名             # 可选
draft: false                # true 则隐藏不显示
pinned: false               # true 则置顶
slug: my-custom-url         # 可选，自定义URL路径
author: 作者名               # 可选
comment: true               # 可选，是否开启评论
---
</code></pre>
<p>各字段说明：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>必填</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>title</code></td>
<td>是</td>
<td>文章标题</td>
</tr>
<tr>
<td><code>published</code></td>
<td>是</td>
<td>发布日期，格式 <code>YYYY-MM-DD</code></td>
</tr>
<tr>
<td><code>updated</code></td>
<td>否</td>
<td>更新日期</td>
</tr>
<tr>
<td><code>description</code></td>
<td>否</td>
<td>首页卡片显示的简介</td>
</tr>
<tr>
<td><code>image</code></td>
<td>否</td>
<td>封面图，支持相对路径、绝对路径、网络链接、随机图 API</td>
</tr>
<tr>
<td><code>tags</code></td>
<td>否</td>
<td>标签列表</td>
</tr>
<tr>
<td><code>category</code></td>
<td>否</td>
<td>分类</td>
</tr>
<tr>
<td><code>draft</code></td>
<td>否</td>
<td><code>true</code> 为草稿不显示，<code>false</code> 正式发布</td>
</tr>
<tr>
<td><code>pinned</code></td>
<td>否</td>
<td><code>true</code> 置顶显示</td>
</tr>
<tr>
<td><code>slug</code></td>
<td>否</td>
<td>自定义 URL 路径，建议英文连字符</td>
</tr>
<tr>
<td><code>author</code></td>
<td>否</td>
<td>作者名</td>
</tr>
<tr>
<td><code>comment</code></td>
<td>否</td>
<td>是否开启评论，默认 true</td>
</tr>
<tr>
<td><code>licenseName</code></td>
<td>否</td>
<td>许可证名称（如 CC BY-NC-SA 4.0）</td>
</tr>
<tr>
<td><code>password</code></td>
<td>否</td>
<td>文章密码，设置后需输入密码才能查看</td>
</tr>
</tbody>
</table>
<h3>数学公式（KaTeX）</h3>
<p>主题内置 KaTeX，直接用就行。</p>
<p>行内公式用单 <code>$</code>：<code>$E = mc^2$</code></p>
<p>块级公式用双 <code>$$</code>：</p>
<pre><code>$$
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
$$
</code></pre>
<h3>Mermaid 图表</h3>
<p>用 <code>mermaid</code> 代码块即可渲染各种图表：</p>
<pre><code>```mermaid
graph TD
    A[开始] --&gt; B{条件}
    B --&gt;|是| C[执行]
    B --&gt;|否| D[结束]
```
</code></pre>
<p>还支持时序图、甘特图、饼图、类图、状态图等。</p>
<h3>提醒框</h3>
<p>用 GitHub 风格的语法，很直观：</p>
<pre><code>&gt; [!NOTE]
&gt; 普通提示

&gt; [!TIP]
&gt; 小技巧

&gt; [!WARNING]
&gt; 注意

&gt; [!CAUTION]
&gt; 危险操作
</code></pre>
<h3>嵌入视频</h3>
<p>直接粘贴 iframe：</p>
<pre><code>&lt;iframe src="//player.bilibili.com/player.html?bvid=BV1xxxxx"
  scrolling="no" border="0" frameborder="no"
  framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;
</code></pre>
<h3>GitHub 仓库卡片</h3>
<pre><code>::github{repo="CuteLeaf/Firefly"}
</code></pre>
<h3>编辑器选择</h3>
<p>两个推荐：</p>
<ul>
<li><strong>VS Code</strong>：适合想折腾项目配置的人，对 Astro 项目支持好</li>
<li><strong>Obsidian</strong>：适合只想安静写文章的人，可视化编辑，不用记语法（后面有专门的教程讲怎么联动）</li>
</ul>
<h2>写完之后</h2>
<p>文章写好后：</p>
<pre><code>git add .
git commit -m "新增文章：xxx"
git push
</code></pre>
<p>推到 GitHub 后 Cloudflare 自动构建上线，刷新博客就能看到了。</p>
<blockquote>
<p>官方文档：<a href="https://docs-firefly.cuteleaf.cn/zh/guide/writing.html">docs-firefly.cuteleaf.cn/zh/guide/writing</a></p>
<p>文字版：<a href="https://fqzlr.com/posts/blog/ip/">fqzlr.com/posts/blog/ip</a></p>
<p>视频链接：<a href="https://www.bilibili.com/video/BV1Hk9fBTE6H">BV1Hk9fBTE6H</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>不用服务器，零成本搭建一个自己的个人博客</title>
    <link href="https://blog.fqzlr.top/posts/tech/zero-cost-blog-setup/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/zero-cost-blog-setup/</id>
    <published>2026-05-02T00:00:00.000Z</published>
    <updated>2026-05-02T00:00:00.000Z</updated>
    <summary>博客系列第3期。从零开始搭建基于 Firefly (Astro) 的个人博客，使用 Cloudflare 免费部署，不用买服务器。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV1hX9XBKEhm&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<ul>
<li>根据Firefly 官方文档，涵盖全流程操作，代码可直接复制，小白可按步完成，附关键避坑提示。
<strong>说明</strong>：基于 Windows 系统；命令可直接执行。</li>
</ul>
<h1>第一篇：前置准备｜安装必备环境</h1>
<h2>一、前言</h2>
<p>Firefly 是 Astro 静态博客主题，本文是作者的搭建笔记，为以后回溯搭建过程，为后续操作筑牢基础。</p>
<h2>二、必备软件清单</h2>
<ul>
<li><a href="https://nodejs.org/">Node.js</a>：核心环境，<strong>版本 ≥22</strong></li>
<li>pnpm</li>
<li>Git：用于拉取源码、提交代码</li>
<li>VS Code：推荐编辑器，用于修改配置、写文章</li>
</ul>
<h2>三、分步安装教程</h2>
<h3>1. 安装 Node.js</h3>
<ol>
<li>访问 <a href="https://nodejs.org/">https://nodejs.org/</a>，下载 <strong>LTS 版</strong>。</li>
<li><img src="https://tu.fqzlr.com/file/blog/wenzhang/1777551551531_image.png" alt="image.png|528" /></li>
</ol>
<p><img src="https://tu.fqzlr.com/file/blog/wenzhang/1777551944095_image.png" alt="image.png|534" /></p>
<p>终端执行 <code>node -v</code>、<code>npm -v</code>，输出版本号即成功。
<img src="https://tu.fqzlr.com/file/blog/wenzhang/1777696257686_image.png" alt="image.png" />
<strong>注意</strong>：安装后重启终端，无效则重启电脑。</p>
<h3>2. 安装 pnpm</h3>
<ol>
<li>终端执行：<code>npm install -g pnpm</code></li>
<li>执行 <code>pnpm -v</code> 输出版本号即成功，权限不足用管理员终端。</li>
</ol>
<p><img src="https://tu.fqzlr.com/file/blog/wenzhang/1777696287079_image.png" alt="image.png" /></p>
<p><strong>注意</strong>：网络超时切换手机热点。</p>
<h3>3. 安装 Git</h3>
<p>1.访问 <a href="https://git-scm.com/download/win">https://git-scm.com/download/win</a>，下载最新版并默认安装。
<img src="https://tu.fqzlr.com/file/blog/wenzhang/1777696402220_image.png" alt="image.png" />
2. 一直下一步就行了，全程默认安装。</p>
<h3>4. 安装 VS Code（可选）</h3>
<p>1.访问 <a href="https://code.visualstudio.com/">https://code.visualstudio.com/</a>，下载并安装，勾选“创建桌面快捷方式”和“将 Code 加入 PATH”。</p>
<p><img src="https://tu.fqzlr.com/file/blog/wenzhang/1777696460697_image.png" alt="image.png" /></p>
<p>推荐安装中文插件。</p>
<p><img src="https://tu.fqzlr.com/file/blog/wenzhang/1777696528164_image.png" alt="image.png" /></p>
<h2>四、常见环境问题排查</h2>
<ul>
<li>node 命令无效 → 重新安装并勾选“Add to PATH”，重启终端/电脑。</li>
<li>pnpm 权限不足 → 用管理员终端执行命令。</li>
<li>git 无反应 → 重启电脑，无效则重装。</li>
</ul>
<h1>第二篇：源码托管｜Fork Firefly 官方仓库</h1>
<h2>一、前言</h2>
<p>通过Fork官方仓库，获得自己的独立仓库，可自由修改内容、同步官方更新，后续推送代码到该仓库，Cloudflare会自动触发构建部署。</p>
<h2>二、前置准备</h2>
<ul>
<li>已完成第一篇环境安装（Node ≥22、pnpm、Git、VS Code）</li>
<li>已注册并登录 <a href="%5Bhttps://github.com/"><strong>GitHub 账号</strong></a></li>
</ul>
<h2>三、Fork 操作步骤（1分钟完成）</h2>
<ol>
<li>
<p>打开Firefly官方仓库：<a href="https://github.com/CuteLeaf/Firefly">https://github.com/CuteLeaf/Firefly</a></p>
</li>
<li>
<p>点击页面右上角的「Fork」按钮（绿色/灰色，位置显眼）。</p>
</li>
<li>
<p>等待3-5秒，页面自动跳转，此时你已拥有「自己的Firefly仓库」（仓库地址：https://github.com/你的GitHub用户名/Firefly）。</p>
</li>
</ol>
<p><strong>关键说明</strong>：Fork后的仓库归你所有，修改内容不会影响官方仓库，后续可一键同步官方更新，适合长期维护。</p>
<h1>第三篇：本地搭建｜克隆仓库 + 本地预览调试</h1>
<h2>一、前言</h2>
<p>将你Fork后的仓库克隆到本地，安装依赖后启动本地服务，用于预览修改效果（仅本地查看，无需打包，推送代码后Cloudflare自动部署）。</p>
<h2>二、前置准备</h2>
<ul>
<li>已完成前两篇操作（环境安装、Fork仓库）</li>
<li>新建空文件夹：路径<strong>无中文、无空格、无特殊字符</strong>（示例：D:\blog）</li>
</ul>
<h2>三、分步操作教程</h2>
<hr />
<h2>四、克隆你自己的仓库到本地</h2>
<ol>
<li>进入 <strong>你的 Fork 仓库</strong> 页面</li>
<li>点击 <strong>Code</strong> → 复制 <strong>HTTPS 地址</strong></li>
<li>执行克隆（把地址换成你自己的）：
运行</li>
</ol>
<pre><code>git clone https://github.com/你的用户名/Firefly.git
</code></pre>
<ol>
<li>进入项目目录：</li>
</ol>
<pre><code>cd Firefly
</code></pre>
<hr />
<h3>2. 安装本地依赖（仅用于本地预览）</h3>
<ol>
<li>安装依赖（用 pnpm）
运行</li>
</ol>
<pre><code>pnpm install
</code></pre>
<h3>3. 启动本地预览服务</h3>
<pre><code>pnpm dev
</code></pre>
<ol>
<li>
<p>等待10-30秒，终端显示访问地址：    <a href="http://localhost:4321">http://localhost:4321</a></p>
</li>
<li>
<p>打开浏览器输入该地址，看到Firefly默认首页，即本地搭建成功。</p>
</li>
</ol>
<h3>4. 本地简单调试</h3>
<p>修改配置 / 文章 → <strong>保存自动刷新</strong></p>
<ol>
<li>在项目根目录创建<code>wrangler.toml</code>：  Cloudflare Workers部署需要</li>
</ol>
<pre><code>name = "firefly"
compatibility_date = "YYYY-MM-DD" # 更为今日

[assets]
directory = "./dist"

[vars]
NODE_VERSION = "22"
</code></pre>
<p>2.简单修改站点信息。</p>
<ul>
<li>停止服务：<code>Ctrl + C</code></li>
<li>重新启动：<code>pnpm dev</code></li>
<li>本地仅用于预览，无需执行任何打包命令（Cloudflare会自动打包）。</li>
</ul>
<h3>5. 本地开发完成后</h3>
<p>1.先配置本地 Git 身份</p>
<ul>
<li>打开电脑的<strong>终端（Mac/Linux）</strong> 或 <strong>Git Bash/CMD（Windows）</strong>，执行 2 条命令：</li>
</ul>
<pre><code># 1. 设置你的GitHub用户名（就是你GitHub主页的用户名）
git config --global user.name "你的GitHub用户名"

# 2. 设置你的GitHub绑定邮箱（就是你注册GitHub用的邮箱）
git config --global user.email "你的GitHub邮箱"
</code></pre>
<ul>
<li>检测是否成功</li>
</ul>
<pre><code>git config --global user.name
git config --global user.email
</code></pre>
<pre><code>能显示出你填的信息，就说明绑定好了。
</code></pre>
<ol>
<li>推送到 GitHub：</li>
</ol>
<pre><code>git add . 
git commit -m "更新内容" 
git push
</code></pre>
<h1>第四篇：部署配置｜Cloudflare 关联GitHub自动构建</h1>
<h2>一、前言</h2>
<p>这是核心部署步骤，关联你的GitHub仓库后，后续只要推送代码到GitHub，Cloudflare会自动执行「安装依赖→打包→上线」，全程无需手动操作。</p>
<h2>二、前置准备</h2>
<ul>
<li>
<p>已完成前几篇操作（环境安装、Fork仓库、本地搭建，简单修改，上传到github）</p>
</li>
<li>
<p>已注册并登录Cloudflare账号（无账号可注册：<a href="https://dash.cloudflare.com/">https://dash.cloudflare.com/</a>）</p>
</li>
<li>
<p>你的GitHub仓库已包含完整Firefly源码</p>
</li>
</ul>
<h2>三、分步配置教程</h2>
<h3>1. 新建 Cloudflare Worker 应用</h3>
<ol>
<li><strong>登录 Cloudflare 控制台</strong> 打开浏览器访问官方控制台：<a href="https://dash.cloudflare.com/">https://dash.cloudflare.com/</a>，输入账号密码完成登录。</li>
<li><strong>进入 Workers &amp; Pages 页面</strong> 登录后，在左侧菜单栏找到并点击 <strong>Workers 和 Pages</strong>（英文对应：Workers &amp; Pages），进入应用管理页面。</li>
<li><strong>创建应用程序</strong> 在页面右上角，点击 <strong>创建应用程序</strong>（英文对应：Create application），进入应用创建流程。</li>
<li><strong>关联 GitHub 代码仓库</strong> 在创建页面中，选择 <strong>连接到 Git（Connect Git）</strong>，然后选中 <strong>GitHub</strong>，按照页面提示完成授权，允许 Cloudflare 访问你的 GitHub 账号。</li>
<li><strong>选择目标仓库</strong> 授权完成后，系统会列出你的 GitHub 所有仓库，从中选中需要部署到 Cloudflare Worker 的代码仓库（如 Firefly 仓库）。</li>
<li><strong>配置构建设置</strong> ：</li>
</ol>
<ul>
<li><strong>Build command</strong>: <code>pnpm build</code></li>
<li><strong>Deploy command</strong>: <code>npx wrangler deploy</code></li>
</ul>
<ol>
<li><strong>发起首次部署</strong> 配置完成后，点击页面底部的 <strong>部署（Deploy）</strong>，启动首次自动部署流程。</li>
<li><strong>等待自动构建完成</strong> Cloudflare 会自动执行三个操作：拉取 GitHub 仓库代码 → 执行构建命令 → 将项目部署至 Workers 服务器，耐心等待即可。。</li>
</ol>
<h3>2. 验证自动部署是否成功</h3>
<ol>
<li>
<p>当构建状态显示“成功”后，点击 Worker 项目顶部的 <strong>临时域名</strong>（格式为：<code>xxx.workers.dev</code>）。</p>
</li>
<li>
<p>打开浏览器访问该临时域名，若页面展示效果与本地预览的博客首页完全一致，说明 Cloudflare Worker 与 GitHub 自动部署配置成功。</p>
</li>
</ol>
<h2>第五篇：绑定域名（Spaceship 平台域名适配）</h2>
<p>完成 Cloudflare Worker 自动部署后，默认使用 <code>xxx.workers.dev</code> 临时域名访问，为了提升专业性和记忆性，我们将你在 <a href="https://www.spaceship.com/zh/auth/?returnUrl=/launchpad/">Spaceship 平台</a> 注册的域名，绑定到 Worker 应用，全程适配博客框架，步骤清晰无冗余。</p>
<h3>一、绑定前准备（必做）</h3>
<ol>
<li><strong>确认域名状态</strong>：登录 <a href="https://www.spaceship.com/zh/auth/?returnUrl=/launchpad/">Spaceship 控制台</a>，确认你的域名状态为“正常”，未被锁定、未过期，且已完成实名认证（若有要求），避免因域名异常导致绑定失败。</li>
</ol>
<h3>二、Step 1：在 Cloudflare 中添加自定义域名</h3>
<ol>
<li>
<p>登录 Cloudflare 控制台，进入之前创建的 Worker 项目主页（可通过左侧「Workers 和 Pages」找到对应项目）。</p>
</li>
<li>
<p>在项目主页左侧导航栏，点击 <strong>触发器</strong>（英文：Triggers）选项卡，找到「自定义域」模块，点击 <strong>添加自定义域</strong>。</p>
</li>
<li>
<p>在弹出的输入框中，填写你在 Spaceship 平台的域名（如 <code>yourdomain.com</code> 或 <code>www.yourdomain.com</code>），点击 <strong>添加</strong>。</p>
</li>
<li>
<p>添加后，Cloudflare 会自动生成对应的 DNS 解析记录（CNAME 记录），记录下该 CNAME 记录的「目标值」（通常为你的 Worker 临时域名），后续需在 Spaceship 平台配置该解析。</p>
</li>
</ol>
<h3>三、Step 2：在 Spaceship 平台配置 DNS 解析（核心步骤）</h3>
<p>这一步是关键，需在你的域名所在平台（Spaceship）修改 DNS 解析，将域名指向 Cloudflare Worker，具体操作如下：</p>
<ol>
<li>
<p><strong>登录 Spaceship 控制台</strong>：打开浏览器，访问 <a href="https://www.spaceship.com/zh/auth/?returnUrl=/launchpad/">https://www.spaceship.com/zh/auth/?returnUrl=%2Flaunchpad%2F</a>，输入你的账号密码完成登录（若已登录可直接进入）。</p>
</li>
<li>
<p><strong>进入域名管理页面</strong>：登录后，点击顶部「Launchpad」（控制台），找到「Domain Manager」（域名管理）模块，在域名列表中找到你要绑定的域名，点击该域名进入详情页。</p>
</li>
<li>
<p><strong>进入 DNS 解析设置</strong>：在域名详情页中，找到「DNS」选项（或「DNS 解析」「解析设置」），点击进入 DNS 解析配置页面，删除当前无关的解析记录（避免冲突）。</p>
</li>
<li>
<p><strong>添加 CNAME 解析记录</strong>：点击「添加记录」（或「新增解析」），按以下参数填写（适配 Cloudflare Worker 绑定）：</p>
<ol>
<li>
<p>记录类型：选择 <strong>CNAME</strong>（核心类型，用于将域名指向 Worker 临时域名，适配 IP 动态变化场景）；</p>
</li>
<li>
<p>主机记录：填写 <code>@</code>（代表主域名，如 <code>yourdomain.com</code>）或 <code>www</code>（代表带 www 前缀的域名，如 <code>www.yourdomain.com</code>，建议两者都添加）；</p>
</li>
<li>
<p>记录值：粘贴 Step 1 中 Cloudflare 生成的 CNAME 目标值（即 Worker 临时域名，如<code>xxx.workers.dev</code>），确保填写无误；</p>
</li>
<li>
<p>TTL（缓存时间）：设置为 10~60 分钟（数值越小，解析生效越快，新手可直接默认）；</p>
</li>
</ol>
</li>
<li>
<p>点击「保存」，完成 DNS 解析配置，此时 Spaceship 平台的域名解析已指向 Cloudflare Worker。</p>
</li>
</ol>
<h3>四、Step 4：验证域名绑定是否成功</h3>
<p>DNS 解析生效时间通常为 10 分钟~24 小时（新域名生效较快，老域名可能需等待更久），可通过以下两种方式验证绑定结果：</p>
<ol>
<li>
<p><strong>浏览器直接访问</strong>：打开浏览器，输入你绑定的域名（如 <code>yourdomain.com</code>），若能正常显示博客首页，且地址栏有 HTTPS 小锁标志，说明绑定成功；若提示“无法访问”，请耐心等待解析生效，或检查解析记录填写是否正确。</p>
</li>
<li>
<p><strong>在线工具查询</strong>：使用 DNS 解析查询工具（如 DNSChecker.org），输入你的域名，查询 CNAME 记录，若查询结果与你配置的 Worker 临时域名一致，说明解析已生效，绑定成功。</p>
</li>
</ol>
<p>###五、域名绑定常见问题排查</p>
<ul>
<li>
<p><strong>解析配置后无法访问</strong>：检查 Spaceship 平台的 CNAME 记录值是否与 Cloudflare 生成的目标值一致，TTL 设置是否过小，若无误，耐心等待解析生效（最长 24 小时）；同时确认 Cloudflare 中自定义域已添加成功。</p>
</li>
<li>
<p><strong>浏览器提示“不安全”</strong>：检查 Cloudflare 的 SSL/TLS 加密模式是否设置正确，证书是否已生效，若未生效，等待几分钟后刷新页面重试。</p>
</li>
<li>
<p><strong>提示“域名已被占用”</strong>：确认该域名未被其他 Cloudflare 项目绑定，若已绑定，先删除原有绑定，再重新添加。</p>
</li>
<li>
<p><strong>DNS 解析修改无效</strong>：检查 Spaceship 平台的域名 DNS 服务器是否为默认服务器，若已修改为第三方 DNS，需先改回默认或同步解析记录至对应 DNS 服务器。</p>
</li>
</ul>
<h2>写在最后</h2>
<p>整个过程不需要写代码，核心操作就是 Fork 仓库、改配置、Cloudflare 关联 GitHub。部署完成后，写文章就是在 <code>src/content/posts/</code> 目录下新建 Markdown 文件，push 到 GitHub 就自动上线。</p>
<blockquote>
<p>文字版教程：<a href="https://fqzlr.com/posts/firefly-set/windows-firefly/">fqzlr.com/posts/firefly-set/windows-firefly</a></p>
<p>官方文档：<a href="https://docs-firefly.cuteleaf.cn/zh/guide/getting-started.html">docs-firefly.cuteleaf.cn</a></p>
<p>视频链接：<a href="https://www.bilibili.com/video/BV1hX9XBKEhm">BV1hX9XBKEhm</a></p>
<p>Firefly 源码：<a href="https://github.com/CuteLeaf/Firefly">github.com/CuteLeaf/Firefly</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>域名注册教程，最低一年只需要6元，com域名优惠价一年20元</title>
    <link href="https://blog.fqzlr.top/posts/tech/domain-registration-tutorial/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/domain-registration-tutorial/</id>
    <published>2026-04-21T00:00:00.000Z</published>
    <updated>2026-04-21T00:00:00.000Z</updated>
    <summary>博客系列第1期。手把手教你在 Spaceship 平台注册便宜域名，最低6元/年，com域名也只要20元左右。</summary>
    <content type="html"><![CDATA[<p>&lt;iframe width="100%" height="468"
src="//player.bilibili.com/player.html?bvid=BV1BEdCBsEEw&amp;p=1&amp;autoplay=0"
scrolling="no" border="0" frameborder="no"
framespacing="0" allowfullscreen="true"&gt;
&lt;/iframe&gt;</p>
<h2>为什么选 Spaceship</h2>
<p>Spaceship 是 Namecheap 旗下的域名注册平台，经常有新用户优惠，价格比国内大多数平台便宜不少。<code>.com</code> 域名首年大概 20 元左右，部分冷门后缀最低能到 6 元/年。</p>
<p>对比一下常见平台的价格：阿里云/腾讯云 <code>.com</code> 首年大概 55-69 元，续费也差不多。Spaceship 的价格优势很明显。</p>
<h2>注册流程</h2>
<h3>1. 注册账号</h3>
<p>打开 <a href="https://www.spaceship.com/">spaceship.com</a>，右上角注册账号，用邮箱注册就行。</p>
<h3>2. 搜索域名</h3>
<p>首页搜索框输入你想要的域名，比如 <code>myblog</code>，系统会列出所有可用后缀和对应价格。</p>
<p>几个建议：</p>
<ul>
<li>优先选 <code>.com</code>，认知度最高</li>
<li>预算有限可以选 <code>.xyz</code>、<code>.site</code>、<code>.online</code> 这类，首年很便宜</li>
<li>域名尽量短，好记，避免拼音太长</li>
<li>注意看<strong>续费价格</strong>，有些首年便宜续费贵</li>
</ul>
<h3>3. 加入购物车并结算</h3>
<p>选好域名后加入购物车，结算时注意：</p>
<ul>
<li><strong>WHOIS 隐私保护</strong>：Spaceship 默认免费赠送，不用额外花钱</li>
<li><strong>取消不必要的附加服务</strong>：默认可能会勾选一些付费服务，按需取消</li>
<li>支付方式支持信用卡、PayPal、支付宝等</li>
</ul>
<h3>4. 完成购买</h3>
<p>付款成功后，域名会出现在你的控制台。进入 Domain Manager 就能看到管理页面。</p>
<h2>几个要注意的坑</h2>
<p><strong>续费价格不等于首年价格。</strong> 买之前看一眼续费价，有些 <code>.xyz</code> 首年 6 块，续费可能要 60+。</p>
<p><strong>域名转出有限制。</strong> 注册后 60 天内不能转出，不过正常使用不受影响。</p>
<p><strong>实名认证。</strong> Spaceship 是国外平台，不需要国内的实名认证流程，但如果后续要绑定国内服务器，则需要完成备案。我们后面搭建博客用的是 Cloudflare，流程相对简单。</p>
<h2>后续步骤</h2>
<p>域名注册好了之后，后面搭建博客时会用到。主要是在 Cloudflare 里把域名解析到博客地址，这个在后面的部署教程里会详细讲。</p>
<blockquote>
<p>视频链接：<a href="https://www.bilibili.com/video/BV1BEdCBsEEw">BV1BEdCBsEEw</a></p>
</blockquote>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
  <entry>
    <title>怎么使用obsidian 来编写 astro 博客</title>
    <link href="https://blog.fqzlr.top/posts/tech/obsidian-astro/" rel="alternate" type="text/html"/>
    <id>https://blog.fqzlr.top/posts/tech/obsidian-astro/</id>
    <published>2026-04-18T00:00:00.000Z</published>
    <updated>2026-04-18T00:00:00.000Z</updated>
    <summary>本教程专为「Obsidian 撰写笔记」+「Astro Composer 插件转换笔记为 Astro 博客内容」+「Git 插件同步到 GitHub」的 workflow 设计，全程无需复杂命令行，纯 Obsidian 内操作（少量 GitHub 后台配置），适合新手快速上手，解决“Obsidian 写文→Astro 发布→GitHub 托管”的全流程同步问题。</summary>
    <content type="html"><![CDATA[<h2>tle: "怎么使用obsidian 来编写 astro 博客"
published: 2026-04-18
description: "本教程专为「Obsidian 撰写笔记」+「Astro Composer 插件转换笔记为 Astro 博客内容」+「Git 插件同步到 GitHub」的 workflow 设计，全程无需复杂命令行，纯 Obsidian 内操作（少量 GitHub 后台配置），适合新手快速上手，解决“Obsidian 写文→Astro 发布→GitHub 托管”的全流程同步问题。"
image: "https://tu.682000.xyz/file/blog/wenzhang/1776516130544_image.png"
tags: [Obsidian, astro]
category: 技术教程</h2>
<p>Obsidian + Astro Composer + Git 插件 同步 Astro 博客到 GitHub 完整教程</p>
<p>前置前提（必做）：</p>
<ul>
<li>
<p>已安装 Obsidian 客户端，且启用「第三方插件」（设置 → 第三方插件 → 关闭“安全模式”）。</p>
</li>
<li>
<p>已创建 Astro 博客项目（本地需有 Astro 项目文件夹，可通过 <code>pnpm create astro@latest</code> 快速创建，选择 blog 模板即可），并熟悉 Astro 项目的 <code>src/content</code> 目录（用于存放博客文章）。</p>
</li>
<li>
<p>已注册 GitHub 账号，且创建了用于托管 Astro 博客的仓库（建议仓库名与 Astro 博客项目名一致，若要部署 GitHub Pages，仓库名可设为 <code>用户名.github.io</code>）。</p>
</li>
<li>
<p>已安装所需插件：Astro Composer（Obsidian 插件）、Git（Obsidian 插件，常用 Obsidian Git 或 Git Vault/Folder Sync，本教程以 Obsidian Git 为例，适配多数用户需求）。</p>
</li>
</ul>
<h2>第一步：安装并配置 Astro Composer 插件（核心：Obsidian 笔记转 Astro 内容）</h2>
<p>Astro Composer 是 Obsidian 与 Astro 博客无缝衔接的关键插件，可自动将 Obsidian 笔记转换为 Astro 支持的 Markdown 格式（含自动处理链接、标准化属性、管理草稿等），目前该插件暂未上架 Obsidian 社区插件库，需通过 BRAT 插件安装或手动安装，推荐 BRAT 安装（更便捷，支持自动更新）。</p>
<h3>1.1 安装 Astro Composer 插件</h3>
<ol>
<li>
<p>先安装 BRAT 插件：Obsidian 内 → 设置 → 第三方插件 → 浏览社区插件 → 搜索「BRAT」→ 安装并启用。</p>
</li>
<li>
<p>启用 BRAT 后，打开 BRAT 设置 → 点击「Add a beta plugin」→ 输入 Astro Composer 插件仓库地址（<code>https://github.com/astro-modular/astro-composer</code>）→ 点击「Add Plugin」，等待安装完成后启用插件。</p>
</li>
</ol>
<h3>1.2 配置 Astro Composer 插件</h3>
<p>配置核心目的：让插件知道「Obsidian 笔记存放位置」和「Astro 项目的内容目录位置」，实现笔记自动转换后同步到 Astro 项目中。</p>
<ol>
<li>
<p>打开 Obsidian 设置 → 找到「Astro Composer」插件，进入配置页面。
<img src="https://tu.682000.xyz/file/blog/wenzhang/1776517996753_image.png" alt="image.png" />
<img src="https://tu.682000.xyz/file/blog/wenzhang/1776518008379_image.png" alt="image.png" />
<img src="https://tu.682000.xyz/file/blog/wenzhang/1776518031103_image.png" alt="image.png" /></p>
</li>
<li>
<p>核心配置项（必设）：</p>
<ol>
<li>
<p>Posts Folder：设置 Obsidian 中用于存放博客文章的文件夹（例如 <code>Blog/Posts</code>），后续所有要发布的笔记都放在这个文件夹下。</p>
</li>
<li>
<p>Astro Content Path：设置本地 Astro 项目的<code>src/content</code> 目录路径（例如 <code>D:/MyBlog/astro-blog/src/content</code>），插件会自动将转换后的笔记同步到这个目录。</p>
</li>
<li>
<p>Link Base Path：设置链接基准路径，填写 Astro 博客的文章访问路径（例如 <code>/blog</code>），插件会自动将 Obsidian 内部链接（ wikilinks 或 Markdown 内部链接）转换为 Astro 友好的链接格式，避免发布后链接失效。</p>
</li>
<li>
<p>Creation Mode：选择文章存储结构，推荐「Folder-based with index.md」（每个文章对应一个文件夹，内部包含 index.md 文件），适配多数 Astro 博客模板。</p>
</li>
<li>
<p>Draft Management：启用「Underscore Prefix」，插件会为草稿笔记添加下划线前缀（例如 <code>_my-draft.md</code>），Astro 会自动隐藏这些草稿，避免误发布。</p>
</li>
</ol>
</li>
<li>
<p>可选配置（优化体验）：</p>
<ol>
<li>
<p>Property Standardization：设置笔记属性模板（例如标题、日期、标签等），启用「Standardize Properties」命令，可自动为笔记添加缺失的属性，保持 Astro 文章格式统一。</p>
</li>
<li>
<p>Rename Post Command：启用后，修改笔记的「title」属性，插件会自动将笔记文件名/文件夹名更新为 kebab-case 格式（例如“我的第一篇博客”→「my-first-blog」），适配 Astro 的路由规则。</p>
</li>
</ol>
</li>
<li>
<p>配置完成后，点击「Save Settings」，重启插件（Obsidian 重启或禁用再启用），确保配置生效。</p>
</li>
</ol>
]]></content>
    <author><name>fqzlr</name></author>
    <category term="技术教程"/>
  </entry>
</feed>
